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.
- package/CHANGELOG.md +424 -42
- package/README.md +535 -250
- package/SKILL.md +337 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/automation-controller-proof.md +35 -0
- package/docs/curated-donor-registry.md +11 -0
- package/docs/generate-ready-pilot-design.md +30 -0
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/industrial-programming-proof.md +48 -0
- package/docs/ipv4-routing-management-proof.md +37 -0
- package/docs/l2-resiliency-bgp-proof.md +60 -0
- package/docs/l2-security-qos-proof.md +59 -0
- package/docs/packet-tracer-feature-gap-atlas.md +174 -17
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +27 -13
- package/docs/release-notes-0.2.2.md +1 -1
- package/docs/release-notes-0.2.3.md +59 -0
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +65 -0
- package/docs/voice-collaboration-proof.md +38 -0
- package/docs/wan-security-donor-proof.md +20 -3
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +60 -44
- package/pytest.ini +9 -0
- package/references/packettracer-feature-atlas.json +67 -17
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +24 -44
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1319
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/feature_atlas.py +65 -1
- package/scripts/generate_pkt.py +8762 -4070
- package/scripts/intent_parser.py +2242 -1138
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2405 -1226
- package/scripts/pkt_transformer.py +1072 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/remote_search.py +197 -21
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1372 -1195
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- package/templates/pt900/donors/README.md +15 -0
|
@@ -1,391 +1,846 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Path(r"C:\Program Files
|
|
15
|
-
Path(r"C:\Program Files
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Path
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Path("/
|
|
26
|
-
Path
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Path.home() / "
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Path("01 Networking") / "
|
|
39
|
-
Path("01 Networking") / "
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if
|
|
157
|
-
return
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if
|
|
301
|
-
return
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|