packet-tracer-skill 0.2.2 → 0.3.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 (66) hide show
  1. package/CHANGELOG.md +424 -42
  2. package/README.md +535 -250
  3. package/SKILL.md +337 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/automation-controller-proof.md +35 -0
  6. package/docs/curated-donor-registry.md +11 -0
  7. package/docs/generate-ready-pilot-design.md +30 -0
  8. package/docs/github-launch-ops-0.2.3.md +37 -0
  9. package/docs/github-metadata.md +6 -4
  10. package/docs/hero-demo-plan.md +1 -1
  11. package/docs/home-iot-donor-proof.md +4 -4
  12. package/docs/industrial-programming-proof.md +48 -0
  13. package/docs/ipv4-routing-management-proof.md +37 -0
  14. package/docs/l2-resiliency-bgp-proof.md +60 -0
  15. package/docs/l2-security-qos-proof.md +59 -0
  16. package/docs/packet-tracer-feature-gap-atlas.md +174 -17
  17. package/docs/post-launch-follow-up.md +9 -5
  18. package/docs/proof-readiness-dashboard.md +69 -0
  19. package/docs/publish-preview-roadmap.md +6 -5
  20. package/docs/release-checklist.md +27 -13
  21. package/docs/release-notes-0.2.2.md +1 -1
  22. package/docs/release-notes-0.2.3.md +59 -0
  23. package/docs/release-notes-0.2.4.md +20 -0
  24. package/docs/runtime-truth.md +33 -8
  25. package/docs/security-edge-deepening-proof.md +65 -0
  26. package/docs/voice-collaboration-proof.md +38 -0
  27. package/docs/wan-security-donor-proof.md +20 -3
  28. package/examples/README.md +98 -69
  29. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  30. package/examples/gallery.md +94 -6
  31. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  32. package/examples/index.json +932 -4
  33. package/examples/local-sample-evidence.json +24 -0
  34. package/examples/proof-cards.json +117 -0
  35. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  36. package/package.json +60 -44
  37. package/pytest.ini +9 -0
  38. package/references/packettracer-feature-atlas.json +67 -17
  39. package/references/packettracer-sample-catalog.json +45287 -4525
  40. package/references/packettracer-sample-catalog.md +599 -259
  41. package/references/proof-readiness-candidates.json +352 -0
  42. package/scripts/build_examples_index.py +228 -35
  43. package/scripts/build_sample_catalog.py +24 -44
  44. package/scripts/corpus_runner.py +430 -0
  45. package/scripts/coverage_matrix.py +1842 -1319
  46. package/scripts/donor_cache.py +354 -0
  47. package/scripts/donor_diagnostics.py +3 -1
  48. package/scripts/feature_atlas.py +65 -1
  49. package/scripts/generate_pkt.py +8762 -4070
  50. package/scripts/intent_parser.py +2242 -1138
  51. package/scripts/local_donors.py +340 -0
  52. package/scripts/packet_tracer_env.py +846 -391
  53. package/scripts/pkt_annotate.py +218 -0
  54. package/scripts/pkt_codec.py +420 -181
  55. package/scripts/pkt_editor.py +2405 -1226
  56. package/scripts/pkt_transformer.py +1072 -727
  57. package/scripts/pkt_verify.py +461 -0
  58. package/scripts/remote_search.py +197 -21
  59. package/scripts/runtime_doctor.py +80 -29
  60. package/scripts/sample_catalog.py +1372 -1195
  61. package/scripts/twofish_diagnostics.py +48 -31
  62. package/scripts/usage_ledger.py +218 -0
  63. package/scripts/vendor/README.md +44 -37
  64. package/scripts/vendor/twofish_pure.py +321 -0
  65. package/scripts/workspace_repair.py +548 -508
  66. package/templates/pt900/donors/README.md +15 -0
@@ -1,391 +1,846 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import platform
5
- from pathlib import Path
6
- import xml.etree.ElementTree as ET
7
- from dataclasses import dataclass
8
-
9
-
10
- DEFAULT_INSTALL_CANDIDATES_BY_OS = {
11
- "Windows": [
12
- Path(r"C:\Program Files\Cisco Packet Tracer 9.0.0"),
13
- Path(r"C:\Program Files\Cisco Packet Tracer"),
14
- Path(r"C:\Program Files (x86)\Cisco Packet Tracer 9.0.0"),
15
- Path(r"C:\Program Files (x86)\Cisco Packet Tracer"),
16
- ],
17
- "Darwin": [
18
- Path("/Applications/Cisco Packet Tracer.app/Contents/Resources"),
19
- Path("/Applications/Packet Tracer.app/Contents/Resources"),
20
- Path.home() / "Applications" / "Cisco Packet Tracer.app" / "Contents" / "Resources",
21
- ],
22
- "Linux": [
23
- Path("/opt/pt"),
24
- Path("/opt/packettracer"),
25
- Path("/usr/local/packettracer"),
26
- Path.home() / "packettracer",
27
- ],
28
- }
29
- DEFAULT_PACKET_TRACER_TARGET_VERSION = "9.0.0.0810"
30
- DEFAULT_DONOR_FALLBACKS = [
31
- Path.home() / "Downloads",
32
- Path.home() / "Documents",
33
- Path.home() / "Desktop",
34
- ]
35
- DEFAULT_SAMPLE_DONOR_FILES = [
36
- Path("01 Networking") / "FTP" / "FTP.pkt",
37
- Path("01 Networking") / "HTTPS" / "HTTPS.pkt",
38
- Path("01 Networking") / "DNS" / "Multilevel_DNS.pkt",
39
- Path("01 Networking") / "DHCP" / "dhcp_snooping_trusted_untrusted_gigabit_ports.pkt",
40
- ]
41
-
42
-
43
- @dataclass(frozen=True)
44
- class CompatibilityDonorDetails:
45
- target_version: str
46
- resolved_path: Path | None
47
- donor_version: str | None
48
- donor_source: str | None
49
- status: str
50
- blocking_reason: str
51
- candidate_paths: list[tuple[str, Path]]
52
-
53
-
54
- def _existing_path(raw: str | None) -> Path | None:
55
- if not raw:
56
- return None
57
- path = Path(raw).expanduser()
58
- return path if path.exists() else None
59
-
60
-
61
- def _host_os() -> str:
62
- return platform.system()
63
-
64
-
65
- def default_install_candidates(host_os: str | None = None) -> list[Path]:
66
- return DEFAULT_INSTALL_CANDIDATES_BY_OS.get(host_os or _host_os(), [])
67
-
68
-
69
- def default_executable_candidates(root: Path, host_os: str | None = None) -> list[Path]:
70
- system = host_os or _host_os()
71
- if system == "Windows":
72
- return [root / "bin" / "PacketTracer.exe", root / "PacketTracer.exe"]
73
- if system == "Darwin":
74
- return [
75
- root / "bin" / "PacketTracer",
76
- root / "Packet Tracer",
77
- root / "MacOS" / "Packet Tracer",
78
- ]
79
- if system == "Linux":
80
- return [
81
- root / "bin" / "PacketTracer",
82
- root / "bin" / "packettracer",
83
- root / "PacketTracer",
84
- root / "packettracer",
85
- ]
86
- return [root / "bin" / "PacketTracer", root / "PacketTracer"]
87
-
88
-
89
- def default_saves_candidates(root: Path, host_os: str | None = None) -> list[Path]:
90
- system = host_os or _host_os()
91
- candidates = [root / "saves"]
92
- if system == "Darwin":
93
- candidates.extend(
94
- [
95
- root / "Contents" / "Resources" / "saves",
96
- root.parent / "Resources" / "saves",
97
- ]
98
- )
99
- elif system == "Linux":
100
- candidates.extend(
101
- [
102
- root / "resources" / "saves",
103
- root.parent / "saves",
104
- ]
105
- )
106
- return candidates
107
-
108
-
109
- def detect_packet_tracer_layout(root: Path, host_os: str | None = None) -> str:
110
- system = host_os or _host_os()
111
- if system == "Windows":
112
- if (root / "bin" / "PacketTracer.exe").exists() or (root / "PacketTracer.exe").exists():
113
- return "windows_install_root"
114
- normalized_parts = [part.lower() for part in root.parts if part]
115
- if any(part.startswith("cisco packet tracer") or part == "packet tracer" for part in normalized_parts):
116
- return "windows_install_root"
117
- if system == "Darwin":
118
- if "Contents/Resources" in root.as_posix():
119
- return "macos_app_bundle_resources"
120
- if ".app" in root.as_posix():
121
- return "macos_app_bundle"
122
- if system == "Linux":
123
- if (root / "bin" / "packettracer").exists() or (root / "bin" / "PacketTracer").exists():
124
- return "linux_install_root"
125
- return "custom"
126
-
127
-
128
- def recommended_packet_tracer_root(host_os: str | None = None) -> Path | None:
129
- candidates = default_install_candidates(host_os)
130
- return candidates[0] if candidates else None
131
-
132
-
133
- def recommended_packet_tracer_saves_root(host_os: str | None = None) -> Path | None:
134
- root = recommended_packet_tracer_root(host_os)
135
- if root is None:
136
- return None
137
- candidates = default_saves_candidates(root, host_os)
138
- return candidates[0] if candidates else None
139
-
140
-
141
- def get_packet_tracer_root() -> Path | None:
142
- env_root = _existing_path(os.getenv("PACKET_TRACER_ROOT"))
143
- if env_root is not None:
144
- return env_root
145
- for candidate in default_install_candidates():
146
- if candidate.exists():
147
- return candidate
148
- return None
149
-
150
-
151
- def get_packet_tracer_saves_root() -> Path | None:
152
- env_saves = _existing_path(os.getenv("PACKET_TRACER_SAVES_ROOT"))
153
- if env_saves is not None:
154
- return env_saves
155
- root = get_packet_tracer_root()
156
- if root is None:
157
- return None
158
- for candidate in default_saves_candidates(root):
159
- if candidate.exists():
160
- return candidate
161
- return None
162
-
163
-
164
- def get_packet_tracer_exe() -> Path | None:
165
- env_exe = _existing_path(os.getenv("PACKET_TRACER_EXE"))
166
- if env_exe is not None:
167
- return env_exe
168
- root = get_packet_tracer_root()
169
- if root is None:
170
- return None
171
- for candidate in default_executable_candidates(root):
172
- if candidate.exists():
173
- return candidate
174
- return None
175
-
176
-
177
- def require_packet_tracer_saves_root() -> Path:
178
- saves = get_packet_tracer_saves_root()
179
- if saves is None:
180
- raise FileNotFoundError(
181
- "Packet Tracer sample saves were not found. Set PACKET_TRACER_SAVES_ROOT or PACKET_TRACER_ROOT."
182
- )
183
- return saves
184
-
185
-
186
- def require_packet_tracer_exe() -> Path:
187
- exe = get_packet_tracer_exe()
188
- if exe is None:
189
- raise FileNotFoundError(
190
- "Packet Tracer executable was not found. Set PACKET_TRACER_EXE or PACKET_TRACER_ROOT."
191
- )
192
- return exe
193
-
194
-
195
- def resolve_sample_path(relative_path: str) -> Path:
196
- return require_packet_tracer_saves_root() / relative_path
197
-
198
-
199
- def get_packet_tracer_target_version() -> str:
200
- return os.getenv("PACKET_TRACER_TARGET_VERSION", DEFAULT_PACKET_TRACER_TARGET_VERSION)
201
-
202
-
203
- def _pkt_version(pkt_path: Path) -> str | None:
204
- try:
205
- from pkt_codec import decode_pkt_modern
206
-
207
- root = ET.fromstring(decode_pkt_modern(pkt_path.read_bytes()))
208
- except Exception:
209
- return None
210
- return root.findtext("./VERSION")
211
-
212
-
213
- def _candidate_pkt_files(directory: Path, source: str) -> list[tuple[str, Path]]:
214
- if not directory.exists() or not directory.is_dir():
215
- return []
216
- candidates = sorted(
217
- (path for path in directory.glob("*.pkt") if path.is_file()),
218
- key=lambda path: (path.stat().st_mtime, path.name.lower()),
219
- reverse=True,
220
- )
221
- return [(source, candidate) for candidate in candidates]
222
-
223
-
224
- def _candidate_pkt_files_recursive(directory: Path, source: str, limit: int = 12) -> list[tuple[str, Path]]:
225
- if not directory.exists() or not directory.is_dir():
226
- return []
227
- candidates = sorted(
228
- (path for path in directory.rglob("*.pkt") if path.is_file()),
229
- key=lambda path: (path.stat().st_mtime, path.name.lower()),
230
- reverse=True,
231
- )
232
- return [(source, candidate) for candidate in candidates[:limit]]
233
-
234
-
235
- def list_packet_tracer_compatibility_donor_candidates() -> list[tuple[str, Path]]:
236
- candidates: list[tuple[str, Path]] = []
237
- seen: set[str] = set()
238
-
239
- env_donor = os.getenv("PACKET_TRACER_COMPAT_DONOR")
240
- if env_donor:
241
- env_path = Path(env_donor).expanduser()
242
- seen.add(str(env_path).lower())
243
- candidates.append(("env", env_path))
244
-
245
- for directory in DEFAULT_DONOR_FALLBACKS:
246
- for source, candidate in _candidate_pkt_files(directory, f"auto:{directory.name.lower()}"):
247
- key = str(candidate).lower()
248
- if key in seen:
249
- continue
250
- seen.add(key)
251
- candidates.append((source, candidate))
252
-
253
- saves_root = get_packet_tracer_saves_root()
254
- if saves_root is not None:
255
- for relative_path in DEFAULT_SAMPLE_DONOR_FILES:
256
- candidate = saves_root / relative_path
257
- key = str(candidate).lower()
258
- if key in seen:
259
- continue
260
- seen.add(key)
261
- candidates.append(("auto:packet-tracer-saves", candidate))
262
- for source, candidate in _candidate_pkt_files_recursive(saves_root, "auto:packet-tracer-saves-scan"):
263
- key = str(candidate).lower()
264
- if key in seen:
265
- continue
266
- seen.add(key)
267
- candidates.append((source, candidate))
268
-
269
- return candidates
270
-
271
-
272
- def inspect_packet_tracer_compatibility_donor() -> CompatibilityDonorDetails:
273
- target_version = get_packet_tracer_target_version()
274
- candidates = list_packet_tracer_compatibility_donor_candidates()
275
- env_donor = os.getenv("PACKET_TRACER_COMPAT_DONOR")
276
-
277
- if env_donor:
278
- env_path = Path(env_donor).expanduser()
279
- if not env_path.exists():
280
- return CompatibilityDonorDetails(
281
- target_version=target_version,
282
- resolved_path=None,
283
- donor_version=None,
284
- donor_source="env",
285
- status="missing",
286
- blocking_reason=f"set but missing: {env_path}",
287
- candidate_paths=candidates,
288
- )
289
- if env_path.suffix.lower() != ".pkt":
290
- return CompatibilityDonorDetails(
291
- target_version=target_version,
292
- resolved_path=None,
293
- donor_version=None,
294
- donor_source="env",
295
- status="invalid_extension",
296
- blocking_reason=f"compatibility donor must be a .pkt file: {env_path}",
297
- candidate_paths=candidates,
298
- )
299
- donor_version = _pkt_version(env_path)
300
- if donor_version is None:
301
- return CompatibilityDonorDetails(
302
- target_version=target_version,
303
- resolved_path=None,
304
- donor_version=None,
305
- donor_source="env",
306
- status="decode_error",
307
- blocking_reason=f"could not decode donor version: {env_path}",
308
- candidate_paths=candidates,
309
- )
310
- if donor_version != target_version:
311
- return CompatibilityDonorDetails(
312
- target_version=target_version,
313
- resolved_path=None,
314
- donor_version=donor_version,
315
- donor_source="env",
316
- status="version_mismatch",
317
- blocking_reason=f"{env_path} is version {donor_version}; expected {target_version}",
318
- candidate_paths=candidates,
319
- )
320
- return CompatibilityDonorDetails(
321
- target_version=target_version,
322
- resolved_path=env_path,
323
- donor_version=donor_version,
324
- donor_source="env",
325
- status="ok",
326
- blocking_reason="",
327
- candidate_paths=candidates,
328
- )
329
-
330
- decode_failures = 0
331
- wrong_version_count = 0
332
- for source, candidate in candidates:
333
- if not candidate.exists() or candidate.suffix.lower() != ".pkt":
334
- continue
335
- donor_version = _pkt_version(candidate)
336
- if donor_version is None:
337
- decode_failures += 1
338
- continue
339
- if donor_version != target_version:
340
- wrong_version_count += 1
341
- continue
342
- return CompatibilityDonorDetails(
343
- target_version=target_version,
344
- resolved_path=candidate,
345
- donor_version=donor_version,
346
- donor_source=source,
347
- status="ok",
348
- blocking_reason="",
349
- candidate_paths=candidates,
350
- )
351
-
352
- if candidates:
353
- if decode_failures == len(candidates):
354
- reason = (
355
- "donor candidates were found, but none could be decoded. "
356
- "Check the local Twofish bridge and Python 3.14 runtime."
357
- )
358
- elif wrong_version_count > 0:
359
- reason = f"no Packet Tracer {target_version} donor was found among the discovered local candidates"
360
- else:
361
- reason = f"no compatible Packet Tracer {target_version} donor was found"
362
- else:
363
- reason = (
364
- "no donor candidates were discovered. Set PACKET_TRACER_COMPAT_DONOR "
365
- "or place a working 9.0 donor lab in Downloads, Documents, Desktop, or Packet Tracer saves."
366
- )
367
-
368
- return CompatibilityDonorDetails(
369
- target_version=target_version,
370
- resolved_path=None,
371
- donor_version=None,
372
- donor_source=None,
373
- status="missing",
374
- blocking_reason=reason,
375
- candidate_paths=candidates,
376
- )
377
-
378
-
379
- def get_packet_tracer_compatibility_donor() -> Path | None:
380
- details = inspect_packet_tracer_compatibility_donor()
381
- return details.resolved_path if details.status == "ok" else None
382
-
383
-
384
- def require_packet_tracer_compatibility_donor() -> Path:
385
- details = inspect_packet_tracer_compatibility_donor()
386
- if details.status != "ok" or details.resolved_path is None:
387
- raise FileNotFoundError(
388
- "Packet Tracer 9.0 compatibility donor was not found. "
389
- f"{details.blocking_reason}"
390
- )
391
- return details.resolved_path
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import platform
6
+ from pathlib import Path
7
+ import xml.etree.ElementTree as ET
8
+ from dataclasses import dataclass
9
+ from functools import lru_cache
10
+
11
+
12
+ DEFAULT_INSTALL_CANDIDATES_BY_OS = {
13
+ "Windows": [
14
+ Path(r"C:\Program Files\Cisco Packet Tracer 9.0.0"),
15
+ Path(r"C:\Program Files\Cisco Packet Tracer"),
16
+ Path(r"C:\Program Files (x86)\Cisco Packet Tracer 9.0.0"),
17
+ Path(r"C:\Program Files (x86)\Cisco Packet Tracer"),
18
+ ],
19
+ "Darwin": [
20
+ Path("/Applications/Cisco Packet Tracer.app/Contents/Resources"),
21
+ Path("/Applications/Packet Tracer.app/Contents/Resources"),
22
+ Path.home() / "Applications" / "Cisco Packet Tracer.app" / "Contents" / "Resources",
23
+ ],
24
+ "Linux": [
25
+ Path("/opt/pt"),
26
+ Path("/opt/packettracer"),
27
+ Path("/usr/local/packettracer"),
28
+ Path.home() / "packettracer",
29
+ ],
30
+ }
31
+ DEFAULT_PACKET_TRACER_TARGET_VERSION = "9.0.0.0810"
32
+ DEFAULT_DONOR_FALLBACKS = [
33
+ Path.home() / "Downloads",
34
+ Path.home() / "Documents",
35
+ Path.home() / "Desktop",
36
+ ]
37
+ DEFAULT_SAMPLE_DONOR_FILES = [
38
+ Path("01 Networking") / "FTP" / "FTP.pkt",
39
+ Path("01 Networking") / "HTTPS" / "HTTPS.pkt",
40
+ Path("01 Networking") / "DNS" / "Multilevel_DNS.pkt",
41
+ Path("01 Networking") / "DHCP" / "dhcp_snooping_trusted_untrusted_gigabit_ports.pkt",
42
+ ]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class CompatibilityDonorDetails:
47
+ target_version: str
48
+ resolved_path: Path | None
49
+ donor_version: str | None
50
+ donor_source: str | None
51
+ status: str
52
+ blocking_reason: str
53
+ candidate_paths: list[tuple[str, Path]]
54
+ compatibility_tier: str = ""
55
+
56
+
57
+ # --- donor version compatibility -------------------------------------------
58
+ #
59
+ # Packet Tracer `<VERSION>` strings look like `major.minor.patch.build`, and the
60
+ # build field is *not* a schema identifier: it changes with every point release
61
+ # and with every re-save. Requiring an exact build match therefore rejects
62
+ # essentially the whole donor corpus. Of the 292 sample saves that ship with
63
+ # Packet Tracer 9.0.0, none carry `9.0.0.0810` — 48 are 9.0.0.x with other
64
+ # builds, and the rest span 5.x through 8.x.
65
+ #
66
+ # Tiers are ordered from strictest to loosest. A policy names the loosest tier
67
+ # that is still acceptable.
68
+
69
+ COMPATIBILITY_TIERS = ("exact", "same_minor", "same_major", "upgradeable", "incompatible")
70
+
71
+ # Measured against a running Packet Tracer 9.0.0.0810, one file at a time:
72
+ #
73
+ # 6.2.0.0000 opens 8.0.0.0000 opens (original, re-encoded, relabelled)
74
+ # 9.0.0.0000 opens 9.0.0.0172 opens
75
+ # 9.0.0.4178 opens 9.0.0.9999 opens
76
+ # 9.1.0.0000 REFUSED 99.9.9.9999 REFUSED
77
+ #
78
+ # So the gate is an ordering on the first three fields, and the build field is
79
+ # ignored entirely. Anything at or below the installed release opens; anything
80
+ # above it does not.
81
+ #
82
+ # An earlier reading of the same symptom concluded that the build had to match
83
+ # exactly and that none of the bundled Cisco samples could serve as a donor.
84
+ # That was inferred from generated files failing to open -- which they did, for
85
+ # unrelated reasons -- and never tested against an untouched sample. The control
86
+ # that settles it: the *original* 8.0.0.0000 sample opens, and a file whose
87
+ # version was relabelled to a nonexistent build is refused, so the bridge does
88
+ # report refusals rather than silently succeeding.
89
+ #
90
+ # Packet Tracer ships hundreds of labs under its own `saves/`, all at or below
91
+ # the installed release, so a fresh install needs no downloaded donor at all.
92
+ #
93
+ # This default stayed at "exact" while two questions were open, and both are now
94
+ # answered by measurement rather than argument:
95
+ #
96
+ # * a lab *generated* from an older bundled donor was indeed refused -- but
97
+ # for naming an interface the router does not have, not for its version.
98
+ # With the finished lab checked against its own hardware, such a lab opens
99
+ # and its hosts ping each other with 0% loss;
100
+ # * loosening the default changed which donor gets picked and exposed real
101
+ # defects at scale -- cloned devices sharing one MAC, interfaces carrying
102
+ # two cables. Those are fixed, and 40 hosts across 22 switches now come out
103
+ # with zero of either.
104
+ #
105
+ # Gate for changing this back or further: the corpus must run clean under the
106
+ # policy (30/31 generated, 0 unexpected) and a lab built from a bundled sample
107
+ # must open *and* carry traffic. Both hold.
108
+ DEFAULT_DONOR_POLICY = "upgradeable"
109
+
110
+ # Packet Tracer reliably upgrades saves from this major version onward on open.
111
+ MINIMUM_UPGRADEABLE_MAJOR = 6
112
+
113
+
114
+ def _release_fields(version: str | None) -> tuple[int, int, int] | None:
115
+ """The major.minor.patch a version names, padded, or None if unreadable."""
116
+ fields = _version_fields(version)
117
+ if not fields:
118
+ return None
119
+ padded = (*fields, 0, 0, 0)[:3]
120
+ return (padded[0], padded[1], padded[2])
121
+
122
+
123
+ def donor_opens_in_target(donor_version: str | None, target_version: str | None = None) -> bool:
124
+ """Whether Packet Tracer will open a lab carrying `donor_version`.
125
+
126
+ The rule is measured, not assumed: the donor's major.minor.patch must not
127
+ exceed the installed release. The fourth field -- the build -- is ignored,
128
+ which is why a 9.0.0.9999 lab opens on a 9.0.0.0810 install.
129
+ """
130
+ target_version = target_version or get_packet_tracer_target_version()
131
+ donor = _release_fields(donor_version)
132
+ target = _release_fields(target_version)
133
+ if donor is None or target is None:
134
+ return False
135
+ return donor <= target
136
+
137
+
138
+ def _version_fields(version: str | None) -> tuple[int, ...]:
139
+ if not version:
140
+ return ()
141
+ fields: list[int] = []
142
+ for part in str(version).split("."):
143
+ try:
144
+ fields.append(int(part))
145
+ except ValueError:
146
+ break
147
+ return tuple(fields)
148
+
149
+
150
+ def donor_compatibility(donor_version: str | None, target_version: str | None = None) -> str:
151
+ """Classify a donor `<VERSION>` against the target, as a compatibility tier.
152
+
153
+ Returns one of `COMPATIBILITY_TIERS`.
154
+ """
155
+ target_version = target_version or get_packet_tracer_target_version()
156
+ if not donor_version:
157
+ return "incompatible"
158
+ if donor_version == target_version:
159
+ return "exact"
160
+
161
+ # Similarity is not the gate; the ordering is. A 9.1.0 donor resembles a
162
+ # 9.0.0 target more closely than an 8.0.0 one does, yet Packet Tracer opens
163
+ # the 8.0.0 lab and refuses the 9.1.0 lab. Rank only what actually opens.
164
+ if not donor_opens_in_target(donor_version, target_version):
165
+ return "incompatible"
166
+
167
+ donor_fields = _version_fields(donor_version)
168
+ target_fields = _version_fields(target_version)
169
+ if len(donor_fields) < 2 or len(target_fields) < 2:
170
+ return "incompatible"
171
+
172
+ if donor_fields[:2] == target_fields[:2]:
173
+ return "same_minor"
174
+ if donor_fields[0] == target_fields[0]:
175
+ return "same_major"
176
+ if MINIMUM_UPGRADEABLE_MAJOR <= donor_fields[0] < target_fields[0]:
177
+ return "upgradeable"
178
+ return "incompatible"
179
+
180
+
181
+ def get_donor_policy() -> str:
182
+ """The loosest acceptable compatibility tier, from the environment."""
183
+ raw = (os.getenv("PACKET_TRACER_DONOR_POLICY") or "").strip().lower()
184
+ return raw if raw in COMPATIBILITY_TIERS else DEFAULT_DONOR_POLICY
185
+
186
+
187
+ def donor_tier_is_accepted(tier: str, policy: str | None = None) -> bool:
188
+ policy = policy or get_donor_policy()
189
+ if tier not in COMPATIBILITY_TIERS or policy not in COMPATIBILITY_TIERS:
190
+ return False
191
+ if tier == "incompatible":
192
+ return False
193
+ return COMPATIBILITY_TIERS.index(tier) <= COMPATIBILITY_TIERS.index(policy)
194
+
195
+
196
+ def build_is_known(target_version: str | None = None) -> bool:
197
+ """Whether the running Packet Tracer build is known, not just its release.
198
+
199
+ An install directory yields `9.0.0` — a release. Only a lab the install
200
+ saved carries the build (`9.0.0.0810`), and Packet Tracer refuses to open
201
+ anything generated from a donor with a different build.
202
+ """
203
+ # `None` means "detect it"; an empty string means "known to be unknown".
204
+ resolved = get_packet_tracer_target_version() if target_version is None else target_version
205
+ return len(_version_fields(resolved)) >= 4
206
+
207
+
208
+ def save_a_lab_hint() -> str:
209
+ return (
210
+ "Open Packet Tracer, then File > Save As and save any lab (an empty one is fine) "
211
+ "into Documents, Downloads or Desktop. That file carries your exact Packet Tracer "
212
+ "build, which is what generated labs must match."
213
+ )
214
+
215
+
216
+ def describe_donor_rejection(donor_version: str, target_version: str, tier: str, policy: str) -> str:
217
+ if tier == "incompatible":
218
+ return (
219
+ f"version {donor_version} is not compatible with target {target_version} "
220
+ f"(Packet Tracer does not reliably upgrade saves older than "
221
+ f"{MINIMUM_UPGRADEABLE_MAJOR}.x)"
222
+ )
223
+ if policy == "exact":
224
+ # Do not offer a looser policy here. Loosening was measured to produce
225
+ # files Packet Tracer refuses to open, so suggesting it would walk the
226
+ # user into a broken state that looks like progress.
227
+ return (
228
+ f"version {donor_version} does not match your Packet Tracer build {target_version}. "
229
+ "A generated lab must be built from a donor your own Packet Tracer saved. "
230
+ + save_a_lab_hint()
231
+ )
232
+ return (
233
+ f"version {donor_version} is tier '{tier}' against target {target_version}, "
234
+ f"but the active donor policy is '{policy}'. "
235
+ f"Set PACKET_TRACER_DONOR_POLICY={tier} to accept it."
236
+ )
237
+
238
+
239
+ def _existing_path(raw: str | None) -> Path | None:
240
+ if not raw:
241
+ return None
242
+ path = Path(raw).expanduser()
243
+ return path if path.exists() else None
244
+
245
+
246
+ def _host_os() -> str:
247
+ return platform.system()
248
+
249
+
250
+ def default_install_candidates(host_os: str | None = None) -> list[Path]:
251
+ return DEFAULT_INSTALL_CANDIDATES_BY_OS.get(host_os or _host_os(), [])
252
+
253
+
254
+ def default_executable_candidates(root: Path, host_os: str | None = None) -> list[Path]:
255
+ system = host_os or _host_os()
256
+ if system == "Windows":
257
+ return [root / "bin" / "PacketTracer.exe", root / "PacketTracer.exe"]
258
+ if system == "Darwin":
259
+ return [
260
+ root / "bin" / "PacketTracer",
261
+ root / "Packet Tracer",
262
+ root / "MacOS" / "Packet Tracer",
263
+ ]
264
+ if system == "Linux":
265
+ return [
266
+ root / "bin" / "PacketTracer",
267
+ root / "bin" / "packettracer",
268
+ root / "PacketTracer",
269
+ root / "packettracer",
270
+ ]
271
+ return [root / "bin" / "PacketTracer", root / "PacketTracer"]
272
+
273
+
274
+ def default_saves_candidates(root: Path, host_os: str | None = None) -> list[Path]:
275
+ system = host_os or _host_os()
276
+ candidates = [root / "saves"]
277
+ if system == "Darwin":
278
+ candidates.extend(
279
+ [
280
+ root / "Contents" / "Resources" / "saves",
281
+ root.parent / "Resources" / "saves",
282
+ ]
283
+ )
284
+ elif system == "Linux":
285
+ candidates.extend(
286
+ [
287
+ root / "resources" / "saves",
288
+ root.parent / "saves",
289
+ ]
290
+ )
291
+ return candidates
292
+
293
+
294
+ def detect_packet_tracer_layout(root: Path, host_os: str | None = None) -> str:
295
+ system = host_os or _host_os()
296
+ if system == "Windows":
297
+ if (root / "bin" / "PacketTracer.exe").exists() or (root / "PacketTracer.exe").exists():
298
+ return "windows_install_root"
299
+ normalized_parts = [part.lower() for part in root.parts if part]
300
+ if any(part.startswith("cisco packet tracer") or part == "packet tracer" for part in normalized_parts):
301
+ return "windows_install_root"
302
+ if system == "Darwin":
303
+ if "Contents/Resources" in root.as_posix():
304
+ return "macos_app_bundle_resources"
305
+ if ".app" in root.as_posix():
306
+ return "macos_app_bundle"
307
+ if system == "Linux":
308
+ if (root / "bin" / "packettracer").exists() or (root / "bin" / "PacketTracer").exists():
309
+ return "linux_install_root"
310
+ return "custom"
311
+
312
+
313
+ def recommended_packet_tracer_root(host_os: str | None = None) -> Path | None:
314
+ candidates = default_install_candidates(host_os)
315
+ return candidates[0] if candidates else None
316
+
317
+
318
+ def recommended_packet_tracer_saves_root(host_os: str | None = None) -> Path | None:
319
+ root = recommended_packet_tracer_root(host_os)
320
+ if root is None:
321
+ return None
322
+ candidates = default_saves_candidates(root, host_os)
323
+ return candidates[0] if candidates else None
324
+
325
+
326
+ def get_packet_tracer_root() -> Path | None:
327
+ env_root = _existing_path(os.getenv("PACKET_TRACER_ROOT"))
328
+ if env_root is not None:
329
+ return env_root
330
+ for candidate in default_install_candidates():
331
+ if candidate.exists():
332
+ return candidate
333
+ return None
334
+
335
+
336
+ def get_packet_tracer_saves_root() -> Path | None:
337
+ """Resolve a saves tree: explicit override, live install, then the local cache.
338
+
339
+ The cache is shaped like a real `saves/` tree, so it can answer this seam
340
+ directly and every consumer of `resolve_sample_path()` keeps working
341
+ unchanged on a machine with no Packet Tracer installed.
342
+ """
343
+ env_saves = _existing_path(os.getenv("PACKET_TRACER_SAVES_ROOT"))
344
+ if env_saves is not None:
345
+ return env_saves
346
+
347
+ root = get_packet_tracer_root()
348
+ if root is not None:
349
+ for candidate in default_saves_candidates(root):
350
+ if candidate.exists():
351
+ _refresh_donor_cache(candidate)
352
+ return candidate
353
+
354
+ from donor_cache import cache_is_usable, cache_root
355
+
356
+ if cache_is_usable(get_packet_tracer_target_version()):
357
+ return cache_root()
358
+ return None
359
+
360
+
361
+ _DONOR_CACHE_REFRESHED = False
362
+
363
+
364
+ def _refresh_donor_cache(saves_root: Path) -> None:
365
+ """Populate the cache once per process while a live install is available.
366
+
367
+ Failure here is never fatal: the cache improves availability on *other*
368
+ machines and must not break the machine that already works.
369
+ """
370
+ global _DONOR_CACHE_REFRESHED
371
+ if _DONOR_CACHE_REFRESHED:
372
+ return
373
+ _DONOR_CACHE_REFRESHED = True
374
+ try:
375
+ from donor_cache import bootstrap, cache_enabled, cache_is_usable
376
+
377
+ target_version = get_packet_tracer_target_version()
378
+ if not cache_enabled() or cache_is_usable(target_version):
379
+ return
380
+ report = bootstrap(saves_root, target_version=target_version)
381
+ if report.ok:
382
+ print(report.summary())
383
+ except Exception:
384
+ return
385
+
386
+
387
+ def get_packet_tracer_exe() -> Path | None:
388
+ env_exe = _existing_path(os.getenv("PACKET_TRACER_EXE"))
389
+ if env_exe is not None:
390
+ return env_exe
391
+ root = get_packet_tracer_root()
392
+ if root is None:
393
+ return None
394
+ for candidate in default_executable_candidates(root):
395
+ if candidate.exists():
396
+ return candidate
397
+ return None
398
+
399
+
400
+ def require_packet_tracer_saves_root() -> Path:
401
+ saves = get_packet_tracer_saves_root()
402
+ if saves is None:
403
+ from donor_cache import missing_requirements_message
404
+
405
+ raise FileNotFoundError(missing_requirements_message())
406
+ return saves
407
+
408
+
409
+ def require_packet_tracer_exe() -> Path:
410
+ exe = get_packet_tracer_exe()
411
+ if exe is None:
412
+ raise FileNotFoundError(
413
+ "Packet Tracer executable was not found. Set PACKET_TRACER_EXE or PACKET_TRACER_ROOT."
414
+ )
415
+ return exe
416
+
417
+
418
+ def resolve_sample_path(relative_path: str) -> Path:
419
+ return require_packet_tracer_saves_root() / relative_path
420
+
421
+
422
+ def _version_from_install_root(root: Path) -> str | None:
423
+ """Read a Packet Tracer version out of the install directory name.
424
+
425
+ Cisco names the install folder after the release, e.g.
426
+ `Cisco Packet Tracer 9.0.0`. That gives major.minor.patch and nothing more.
427
+
428
+ The build field is deliberately omitted rather than padded with `0000`.
429
+ Inventing a build would make bundled samples that happen to carry
430
+ `9.0.0.0000` compare as `exact`, outranking the user's own saves written by
431
+ the actual installed binary. A three-field target can never match `exact`,
432
+ so every donor lands on `same_minor` and is ranked on real criteria instead.
433
+ """
434
+ match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", root.name)
435
+ if not match:
436
+ return None
437
+ major, minor, patch = match.group(1), match.group(2), match.group(3) or "0"
438
+ return f"{major}.{minor}.{patch}"
439
+
440
+
441
+ def _build_from_executable(release_version: str = "") -> str | None:
442
+ """Read the running build straight out of the Packet Tracer binary.
443
+
444
+ This is the authoritative source and it needs nothing from the user. The
445
+ binary's `FileVersion` resource carries the same four-field string Packet
446
+ Tracer stamps into every lab it saves — measured on 9.0.0: the executable
447
+ reports `9.0.0.0810`, exactly what its saves contain.
448
+
449
+ Reading it here removes the one-time bootstrap the skill used to require.
450
+ Before this, the build could only be learned from a lab the install had
451
+ already written, so a user who had never saved anything got a three-field
452
+ release (`9.0.0`) that no donor can match under the `exact` policy -- not
453
+ even a genuine lab written by that very install.
454
+
455
+ Windows only for now: the version resource is a PE feature, and no
456
+ equivalent has been measured on the Linux and macOS builds. Returns None
457
+ there so the local-save path still runs.
458
+ """
459
+ if platform.system() != "Windows":
460
+ return None
461
+ exe = get_packet_tracer_exe()
462
+ if exe is None:
463
+ return None
464
+
465
+ try:
466
+ import ctypes
467
+ from ctypes import wintypes
468
+
469
+ version_dll = ctypes.WinDLL("version")
470
+ path = str(exe)
471
+ size = version_dll.GetFileVersionInfoSizeW(path, None)
472
+ if not size:
473
+ return None
474
+ buffer = ctypes.create_string_buffer(size)
475
+ if not version_dll.GetFileVersionInfoW(path, 0, size, buffer):
476
+ return None
477
+
478
+ value = ctypes.c_void_p()
479
+ length = wintypes.UINT()
480
+ # The language/codepage of the string table is not fixed, so ask the
481
+ # file which translations it actually carries instead of guessing.
482
+ if not version_dll.VerQueryValueW(
483
+ buffer, r"\VarFileInfo\Translation", ctypes.byref(value), ctypes.byref(length)
484
+ ) or not length.value:
485
+ return None
486
+ language, codepage = ctypes.cast(
487
+ value, ctypes.POINTER(wintypes.WORD * 2)
488
+ ).contents[:]
489
+
490
+ if not version_dll.VerQueryValueW(
491
+ buffer,
492
+ rf"\StringFileInfo\{language:04x}{codepage:04x}\FileVersion",
493
+ ctypes.byref(value),
494
+ ctypes.byref(length),
495
+ ) or not length.value:
496
+ return None
497
+ detected = ctypes.wstring_at(value.value, length.value).strip().rstrip("\x00").strip()
498
+ except (OSError, AttributeError, ValueError):
499
+ return None
500
+
501
+ # Only a full four-field build is useful; anything shorter tells us no more
502
+ # than the install directory name already did.
503
+ fields = _version_fields(detected)
504
+ if len(fields) < 4:
505
+ return None
506
+ if release_version:
507
+ release_fields = _version_fields(release_version)[:2]
508
+ if len(release_fields) == 2 and fields[:2] != release_fields:
509
+ # The binary disagrees with the directory it sits in; trust neither.
510
+ return None
511
+ return detected
512
+
513
+
514
+ def _build_from_local_saves(release_version: str) -> str | None:
515
+ """Find the running build by reading a lab this Packet Tracer saved.
516
+
517
+ Only files the local install wrote carry its build number. Bundled Cisco
518
+ samples do not: they ship as `9.0.0.0000` and friends, which is precisely
519
+ the value Packet Tracer then refuses to open.
520
+ """
521
+ release_fields = _version_fields(release_version)[:2]
522
+ if len(release_fields) < 2:
523
+ return None
524
+
525
+ best: tuple[tuple[int, ...], str] | None = None
526
+ for directory in DEFAULT_DONOR_FALLBACKS:
527
+ if not directory.exists():
528
+ continue
529
+ try:
530
+ candidates = sorted(directory.glob("*.pkt"))[:12]
531
+ except OSError:
532
+ continue
533
+ for candidate in candidates:
534
+ version = _pkt_version(candidate)
535
+ fields = _version_fields(version)
536
+ if len(fields) < 4 or fields[:2] != release_fields:
537
+ continue
538
+ if best is None or fields > best[0]:
539
+ best = (fields, str(version))
540
+ return best[1] if best else None
541
+
542
+
543
+ def detect_packet_tracer_target_version() -> tuple[str, str]:
544
+ """Resolve the version to target, and say where it came from.
545
+
546
+ Order: explicit env override, then the installed Packet Tracer, then the
547
+ resolved compatibility donor's own `<VERSION>`, then the built-in default.
548
+ This is what removes the hard requirement for one specific Packet Tracer
549
+ build: the skill follows whatever release is actually installed.
550
+ """
551
+ override = os.getenv("PACKET_TRACER_TARGET_VERSION")
552
+ if override:
553
+ return override, "env"
554
+
555
+ root = get_packet_tracer_root()
556
+ if root is not None:
557
+ detected = _version_from_install_root(root)
558
+ if detected:
559
+ # The directory name gives major.minor.patch but not the build, and
560
+ # Packet Tracer refuses to open a file whose <VERSION> build differs
561
+ # from its own: "This file requires 9.0.0.0000. Your current version
562
+ # is 9.0.0.0810."
563
+ #
564
+ # The binary itself knows its build, so ask it first: that works on a
565
+ # machine where nothing has ever been saved. Labs this install wrote
566
+ # carry the same string and cover the platforms where no version
567
+ # resource is available.
568
+ build = _build_from_executable(detected)
569
+ if build:
570
+ return build, "executable"
571
+ build = _build_from_local_saves(detected)
572
+ if build:
573
+ return build, "local_save"
574
+ return detected, "install_root"
575
+
576
+ env_donor = _existing_path(os.getenv("PACKET_TRACER_COMPAT_DONOR"))
577
+ if env_donor is not None and env_donor.suffix.lower() == ".pkt":
578
+ donor_version = _pkt_version(env_donor)
579
+ if donor_version:
580
+ return donor_version, "compat_donor"
581
+
582
+ return DEFAULT_PACKET_TRACER_TARGET_VERSION, "default"
583
+
584
+
585
+ def get_packet_tracer_target_version() -> str:
586
+ return detect_packet_tracer_target_version()[0]
587
+
588
+
589
+ _VERSION_PATTERN = re.compile(rb"<VERSION>([^<]*)</VERSION>")
590
+
591
+
592
+ @lru_cache(maxsize=1024)
593
+ def _pkt_version_cached(path_key: str, size: int, mtime_ns: int) -> str | None:
594
+ """Read `<VERSION>` from a `.pkt`, keyed on the file's identity.
595
+
596
+ Donor scanning asks the same files for their version many times per run, and
597
+ a full decode of a multi-megabyte lab costs seconds. Reading only the header
598
+ is constant-time; caching removes the repeats. The size and mtime are part
599
+ of the key so an edited file is re-read rather than served stale.
600
+ """
601
+ from pkt_codec import decode_pkt_auto, peek_pkt_header
602
+
603
+ raw = Path(path_key).read_bytes()
604
+ try:
605
+ match = _VERSION_PATTERN.search(peek_pkt_header(raw))
606
+ if match:
607
+ return match.group(1).decode("utf-8", "replace")
608
+ except Exception:
609
+ pass
610
+
611
+ # The peek only models the modern container. Fall back to a full decode,
612
+ # which also handles pre-Twofish 5.x saves, rather than reporting the file
613
+ # as unreadable.
614
+ try:
615
+ xml, _ = decode_pkt_auto(raw)
616
+ return ET.fromstring(xml).findtext("./VERSION")
617
+ except Exception:
618
+ return None
619
+
620
+
621
+ def _pkt_version(pkt_path: Path) -> str | None:
622
+ try:
623
+ stat = pkt_path.stat()
624
+ except OSError:
625
+ return None
626
+ return _pkt_version_cached(str(pkt_path), stat.st_size, stat.st_mtime_ns)
627
+
628
+
629
+ def _candidate_pkt_files(directory: Path, source: str) -> list[tuple[str, Path]]:
630
+ if not directory.exists() or not directory.is_dir():
631
+ return []
632
+ candidates = sorted(
633
+ (path for path in directory.glob("*.pkt") if path.is_file()),
634
+ key=lambda path: (path.stat().st_mtime, path.name.lower()),
635
+ reverse=True,
636
+ )
637
+ return [(source, candidate) for candidate in candidates]
638
+
639
+
640
+ def _candidate_pkt_files_recursive(directory: Path, source: str, limit: int = 12) -> list[tuple[str, Path]]:
641
+ if not directory.exists() or not directory.is_dir():
642
+ return []
643
+ candidates = sorted(
644
+ (path for path in directory.rglob("*.pkt") if path.is_file()),
645
+ key=lambda path: (path.stat().st_mtime, path.name.lower()),
646
+ reverse=True,
647
+ )
648
+ return [(source, candidate) for candidate in candidates[:limit]]
649
+
650
+
651
+ def list_packet_tracer_compatibility_donor_candidates() -> list[tuple[str, Path]]:
652
+ candidates: list[tuple[str, Path]] = []
653
+ seen: set[str] = set()
654
+
655
+ env_donor = os.getenv("PACKET_TRACER_COMPAT_DONOR")
656
+ if env_donor:
657
+ env_path = Path(env_donor).expanduser()
658
+ seen.add(str(env_path).lower())
659
+ candidates.append(("env", env_path))
660
+
661
+ for directory in DEFAULT_DONOR_FALLBACKS:
662
+ for source, candidate in _candidate_pkt_files(directory, f"auto:{directory.name.lower()}"):
663
+ key = str(candidate).lower()
664
+ if key in seen:
665
+ continue
666
+ seen.add(key)
667
+ candidates.append((source, candidate))
668
+
669
+ saves_root = get_packet_tracer_saves_root()
670
+ if saves_root is not None:
671
+ for relative_path in DEFAULT_SAMPLE_DONOR_FILES:
672
+ candidate = saves_root / relative_path
673
+ key = str(candidate).lower()
674
+ if key in seen:
675
+ continue
676
+ seen.add(key)
677
+ candidates.append(("auto:packet-tracer-saves", candidate))
678
+ for source, candidate in _candidate_pkt_files_recursive(saves_root, "auto:packet-tracer-saves-scan"):
679
+ key = str(candidate).lower()
680
+ if key in seen:
681
+ continue
682
+ seen.add(key)
683
+ candidates.append((source, candidate))
684
+
685
+ return candidates
686
+
687
+
688
+ def inspect_packet_tracer_compatibility_donor() -> CompatibilityDonorDetails:
689
+ target_version = get_packet_tracer_target_version()
690
+ candidates = list_packet_tracer_compatibility_donor_candidates()
691
+ env_donor = os.getenv("PACKET_TRACER_COMPAT_DONOR")
692
+
693
+ if env_donor:
694
+ env_path = Path(env_donor).expanduser()
695
+ if not env_path.exists():
696
+ return CompatibilityDonorDetails(
697
+ target_version=target_version,
698
+ resolved_path=None,
699
+ donor_version=None,
700
+ donor_source="env",
701
+ status="missing",
702
+ blocking_reason=f"set but missing: {env_path}",
703
+ candidate_paths=candidates,
704
+ )
705
+ if env_path.suffix.lower() != ".pkt":
706
+ return CompatibilityDonorDetails(
707
+ target_version=target_version,
708
+ resolved_path=None,
709
+ donor_version=None,
710
+ donor_source="env",
711
+ status="invalid_extension",
712
+ blocking_reason=f"compatibility donor must be a .pkt file: {env_path}",
713
+ candidate_paths=candidates,
714
+ )
715
+ donor_version = _pkt_version(env_path)
716
+ if donor_version is None:
717
+ return CompatibilityDonorDetails(
718
+ target_version=target_version,
719
+ resolved_path=None,
720
+ donor_version=None,
721
+ donor_source="env",
722
+ status="decode_error",
723
+ blocking_reason=f"could not decode donor version: {env_path}",
724
+ candidate_paths=candidates,
725
+ )
726
+ policy = get_donor_policy()
727
+ tier = donor_compatibility(donor_version, target_version)
728
+ if not donor_tier_is_accepted(tier, policy):
729
+ return CompatibilityDonorDetails(
730
+ target_version=target_version,
731
+ resolved_path=None,
732
+ donor_version=donor_version,
733
+ donor_source="env",
734
+ status="version_mismatch",
735
+ blocking_reason=(
736
+ f"{env_path}: "
737
+ + describe_donor_rejection(donor_version, target_version, tier, policy)
738
+ ),
739
+ candidate_paths=candidates,
740
+ compatibility_tier=tier,
741
+ )
742
+ return CompatibilityDonorDetails(
743
+ target_version=target_version,
744
+ resolved_path=env_path,
745
+ donor_version=donor_version,
746
+ donor_source="env",
747
+ status="ok",
748
+ blocking_reason="",
749
+ candidate_paths=candidates,
750
+ compatibility_tier=tier,
751
+ )
752
+
753
+ policy = get_donor_policy()
754
+ decode_failures = 0
755
+ wrong_version_count = 0
756
+ # Prefer the strictest tier available rather than the first candidate that
757
+ # merely passes policy, so an exact-build donor always beats a same_minor one.
758
+ best: tuple[int, str, Path, str] | None = None
759
+ for source, candidate in candidates:
760
+ if not candidate.exists() or candidate.suffix.lower() != ".pkt":
761
+ continue
762
+ donor_version = _pkt_version(candidate)
763
+ if donor_version is None:
764
+ decode_failures += 1
765
+ continue
766
+ tier = donor_compatibility(donor_version, target_version)
767
+ if not donor_tier_is_accepted(tier, policy):
768
+ wrong_version_count += 1
769
+ continue
770
+ rank = COMPATIBILITY_TIERS.index(tier)
771
+ if best is None or rank < best[0]:
772
+ best = (rank, donor_version, candidate, source)
773
+ if rank == 0:
774
+ break
775
+
776
+ if best is not None:
777
+ _, donor_version, candidate, source = best
778
+ return CompatibilityDonorDetails(
779
+ target_version=target_version,
780
+ resolved_path=candidate,
781
+ donor_version=donor_version,
782
+ donor_source=source,
783
+ status="ok",
784
+ blocking_reason="",
785
+ candidate_paths=candidates,
786
+ compatibility_tier=COMPATIBILITY_TIERS[best[0]],
787
+ )
788
+
789
+ if candidates:
790
+ if decode_failures == len(candidates):
791
+ reason = (
792
+ "donor candidates were found, but none could be decoded. "
793
+ "They may use an unsupported Packet Tracer container variant."
794
+ )
795
+ elif wrong_version_count > 0:
796
+ reason = (
797
+ f"no donor compatible with {target_version} under the '{policy}' policy was found "
798
+ f"among {wrong_version_count} discovered local candidates. "
799
+ )
800
+ if policy == "exact":
801
+ # Never offer a looser policy under `exact`. Loosening was
802
+ # measured to produce files Packet Tracer refuses to open, so it
803
+ # walks the user into a broken state that looks like progress.
804
+ # The bundled Cisco samples are exactly this trap: there are
805
+ # dozens of them, all carrying a build the local install rejects.
806
+ reason += (
807
+ "Bundled Cisco samples do not qualify -- they ship with a different "
808
+ "build, and a lab generated from one is refused on open. " + save_a_lab_hint()
809
+ )
810
+ else:
811
+ reason += (
812
+ "Set PACKET_TRACER_DONOR_POLICY to a looser tier "
813
+ f"({', '.join(COMPATIBILITY_TIERS[:-1])}) to widen the search."
814
+ )
815
+ else:
816
+ reason = f"no compatible Packet Tracer {target_version} donor was found"
817
+ else:
818
+ reason = (
819
+ "no donor candidates were discovered. Set PACKET_TRACER_COMPAT_DONOR "
820
+ "or place a working 9.0 donor lab in Downloads, Documents, Desktop, or Packet Tracer saves."
821
+ )
822
+
823
+ return CompatibilityDonorDetails(
824
+ target_version=target_version,
825
+ resolved_path=None,
826
+ donor_version=None,
827
+ donor_source=None,
828
+ status="missing",
829
+ blocking_reason=reason,
830
+ candidate_paths=candidates,
831
+ )
832
+
833
+
834
+ def get_packet_tracer_compatibility_donor() -> Path | None:
835
+ details = inspect_packet_tracer_compatibility_donor()
836
+ return details.resolved_path if details.status == "ok" else None
837
+
838
+
839
+ def require_packet_tracer_compatibility_donor() -> Path:
840
+ details = inspect_packet_tracer_compatibility_donor()
841
+ if details.status != "ok" or details.resolved_path is None:
842
+ raise FileNotFoundError(
843
+ "Packet Tracer 9.0 compatibility donor was not found. "
844
+ f"{details.blocking_reason}"
845
+ )
846
+ return details.resolved_path