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,1195 +1,1372 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from functools import lru_cache
5
- from dataclasses import dataclass, field
6
- from pathlib import Path
7
- from typing import Any
8
- import xml.etree.ElementTree as ET
9
-
10
- from packet_tracer_env import get_packet_tracer_saves_root, get_packet_tracer_target_version
11
- from pkt_codec import decode_pkt_modern
12
- from workspace_repair import inspect_workspace_integrity
13
-
14
- SCRIPT_DIR = Path(__file__).resolve().parent
15
- SKILL_ROOT = SCRIPT_DIR.parent
16
- DEFAULT_CATALOG_JSON = SKILL_ROOT / "references" / "packettracer-sample-catalog.json"
17
- DEFAULT_CATALOG_MD = SKILL_ROOT / "references" / "packettracer-sample-catalog.md"
18
- DEFAULT_CURATED_DONOR_REGISTRY = SKILL_ROOT / "references" / "curated-donor-registry.json"
19
-
20
- CAPABILITY_KEYWORDS = {
21
- "vlan": ["vlan", "trunk", "access"],
22
- "trunk": ["trunk"],
23
- "access_port": ["access"],
24
- "dhcp_pool": ["dhcp", "reservation", "apipa"],
25
- "router_dhcp": ["dhcp", "reservation", "apipa"],
26
- "server_dhcp": ["dhcp", "reservation", "apipa"],
27
- "dhcpv6_stateful": ["dhcpv6", "stateful_dhcpv6"],
28
- "dhcpv6_stateless": ["stateless_dhcpv6"],
29
- "ipv6_slaac": ["slaac"],
30
- "ipv6_prefix_delegation": ["prefix_delegation"],
31
- "ipv6_dns_aaaa": ["aaaa"],
32
- "ipv6_tunneling": ["ipv6ip", "ipv6 tunneling"],
33
- "isatap": ["isatap"],
34
- "dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
35
- "ospf": ["ospf"],
36
- "ospfv3": ["ospfv3", "ipv6_ospf"],
37
- "eigrp": ["eigrp"],
38
- "eigrp_ipv6": ["ipv6_eigrp"],
39
- "rip": ["rip"],
40
- "ripng": ["ripng", "ipv6 rip"],
41
- "hsrp": ["hsrp"],
42
- "nat": ["nat"],
43
- "acl": ["acl", "access-list"],
44
- "dai": ["dai", "dynamic arp inspection"],
45
- "dot1x": ["dot1x", "802.1x", "port-based nac"],
46
- "lldp": ["lldp"],
47
- "rep": ["rep_"],
48
- "snmp": ["snmp"],
49
- "netflow": ["netflow"],
50
- "span": ["span", "rspan"],
51
- "qos": ["qos"],
52
- "port_security": ["port security", "port-security"],
53
- "vpn": ["vpn", "ipsec", "gre"],
54
- "ipsec": ["ipsec", "ike"],
55
- "gre": ["gre"],
56
- "ppp": ["ppp", "chap", "pap"],
57
- "wan": ["wan", "cloud", "serial", "pppoe", "ppp"],
58
- "security_edge": ["asa", "firewall", "security appliance"],
59
- "asa_acl_nat": ["asa_acl_nat"],
60
- "asa_service_policy": ["asa_service_policy", "service policy"],
61
- "clientless_vpn": ["clientless_vpn"],
62
- "cbac": ["cbac"],
63
- "zfw": ["zfw", "zone based firewall"],
64
- "sniffer": ["sniffer"],
65
- "multilayer_switching": ["multilayer", "layer 3", "3560", "svi"],
66
- "wireless": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep", "5g", "bluetooth", "cellular"],
67
- "wireless_ap": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep"],
68
- "wireless_client": ["wireless", "wifi", "tablet", "smartphone", "laptop"],
69
- "wlc": ["wlc", "wireless lan controller"],
70
- "wpa_enterprise": ["wpa_radius", "wpa2_enterprise"],
71
- "wep": ["wep"],
72
- "guest_wifi": ["guest"],
73
- "beamforming": ["beamforming"],
74
- "meraki": ["meraki"],
75
- "cellular_5g": ["5g", "cellular", "cell tower"],
76
- "bluetooth": ["bluetooth"],
77
- "network_controller": ["network controller", "netcon"],
78
- "python_programming": ["python"],
79
- "javascript_programming": ["javascript"],
80
- "blockly_programming": ["blockly"],
81
- "tcp_udp_app": ["tcp_test_app", "udp_test_app"],
82
- "vm_iox": ["iox", "uploading_and_running_vm"],
83
- "voip": ["voip"],
84
- "ip_phone": ["ip phone", "phone"],
85
- "call_manager": ["call manager", "telephony", "voip"],
86
- "linksys_voice": ["voip linksys"],
87
- "mqtt": ["mqtt"],
88
- "real_http": ["real-http", "real http"],
89
- "real_websocket": ["real-websocket", "real websocket"],
90
- "visual_scripting": ["visual-scripting", "visual scripting"],
91
- "ptp": ["ptp"],
92
- "profinet": ["profinet"],
93
- "l2nat": ["l2nat"],
94
- "cyberobserver": ["cyberobserver"],
95
- "industrial_firewall": ["isa3000", "industrial cybersecurity"],
96
- "coaxial": ["coaxial"],
97
- "cable_dsl": ["cable modem", "dsl modem"],
98
- "central_office": ["centraloffice", "central office"],
99
- "cell_tower": ["cell tower"],
100
- "power_distribution": ["power distribution"],
101
- "hot_swappable": ["hotswappable", "hot swappable"],
102
- "ios_license": ["ios_15", "license"],
103
- "ntp": ["ntp"],
104
- "dns": ["dns"],
105
- "server_dns": ["dns"],
106
- "server_http": ["http", "https", "websocket"],
107
- "server_ftp": ["ftp", "tftp"],
108
- "ftp_http_https": ["ftp", "http", "https", "websocket"],
109
- "iot": ["iot", "sensor", "led", "arduino", "mqtt", "environment"],
110
- "switching": ["switch", "switching", "lldp", "rep"],
111
- "router_on_a_stick": ["router-on-a-stick", "dot1q"],
112
- "host_server": ["server", "pc", "client", "host"],
113
- "management_vlan": ["management", "vlan99", "telnet"],
114
- "telnet": ["telnet", "terminal server"],
115
- "tablet": ["tablet", "pda"],
116
- "printer": ["printer"],
117
- }
118
-
119
- REPORT_ONLY_CAPABILITIES = {
120
- "ipv6_slaac",
121
- "dhcpv6_stateful",
122
- "dhcpv6_stateless",
123
- "ipv6_prefix_delegation",
124
- "ipv6_dns_aaaa",
125
- "ipv6_tunneling",
126
- "isatap",
127
- "ospfv3",
128
- "eigrp_ipv6",
129
- "ripng",
130
- "hsrp",
131
- "dhcp_snooping",
132
- "dai",
133
- "dot1x",
134
- "lldp",
135
- "rep",
136
- "snmp",
137
- "netflow",
138
- "span",
139
- "qos",
140
- "port_security",
141
- "asa_acl_nat",
142
- "asa_service_policy",
143
- "clientless_vpn",
144
- "cbac",
145
- "zfw",
146
- "sniffer",
147
- "wlc",
148
- "wpa_enterprise",
149
- "wep",
150
- "guest_wifi",
151
- "beamforming",
152
- "meraki",
153
- "cellular_5g",
154
- "bluetooth",
155
- "network_controller",
156
- "python_programming",
157
- "javascript_programming",
158
- "blockly_programming",
159
- "tcp_udp_app",
160
- "vm_iox",
161
- "voip",
162
- "ip_phone",
163
- "call_manager",
164
- "linksys_voice",
165
- "mqtt",
166
- "real_http",
167
- "real_websocket",
168
- "visual_scripting",
169
- "ptp",
170
- "profinet",
171
- "l2nat",
172
- "cyberobserver",
173
- "industrial_firewall",
174
- "coaxial",
175
- "cable_dsl",
176
- "central_office",
177
- "cell_tower",
178
- "power_distribution",
179
- "hot_swappable",
180
- "ios_license",
181
- }
182
-
183
- TYPE_NORMALIZATION = {
184
- "pc": "PC",
185
- "pc-pt": "PC",
186
- "server": "Server",
187
- "server-pt": "Server",
188
- "router": "Router",
189
- "switch": "Switch",
190
- "multilayerswitch": "MultiLayerSwitch",
191
- "layer3switch": "MultiLayerSwitch",
192
- "wirelessrouter": "WirelessRouter",
193
- "wirelessrouternewgeneration": "WirelessRouter",
194
- "homegateway": "WirelessRouter",
195
- "wirelesslancontroller": "LightWeightAccessPoint",
196
- "lightweightaccesspoint": "LightWeightAccessPoint",
197
- "accesspoint": "LightWeightAccessPoint",
198
- "pda": "Tablet",
199
- "tabletpc": "Tablet",
200
- "tablet": "Tablet",
201
- "laptop": "Laptop",
202
- "printer": "Printer",
203
- "smartphone": "Smartphone",
204
- "mcucomponent": "IoT",
205
- "networkcontroller": "NetworkController",
206
- "centralofficeserver": "CentralOfficeServer",
207
- "celltower": "CellTower",
208
- }
209
-
210
- LICENSE_FILENAMES = [
211
- "LICENSE",
212
- "LICENSE.txt",
213
- "LICENSE.md",
214
- "COPYING",
215
- "COPYING.txt",
216
- "NOTICE",
217
- ]
218
-
219
-
220
- @dataclass
221
- class SampleDescriptor:
222
- path: str
223
- relative_path: str
224
- version: str
225
- device_count: int
226
- link_count: int
227
- devices: list[dict[str, Any]]
228
- links: list[dict[str, Any]]
229
- capability_tags: list[str]
230
- topology_tags: list[str]
231
- preferred_roles: list[str]
232
- trust_level: str = "trusted"
233
- origin: str = "cisco-local"
234
- role: str = "primary"
235
- prototype_eligible: bool = True
236
- license_or_permission: str = "local-cisco"
237
- promotion_status: str = "validated_primary"
238
- validation_status: str = "validated"
239
- packet_tracer_version: str = ""
240
- wireless_mode_tags: list[str] = field(default_factory=list)
241
- donor_eligible: bool = True
242
- device_families: list[str] = field(default_factory=list)
243
- model_families: list[str] = field(default_factory=list)
244
- port_media_types: list[str] = field(default_factory=list)
245
- service_support: list[str] = field(default_factory=list)
246
- iot_roles: list[str] = field(default_factory=list)
247
- runtime_features: list[str] = field(default_factory=list)
248
- donor_graph_fingerprint: dict[str, Any] = field(default_factory=dict)
249
- apply_safety_level: str = "reference-known"
250
- promotion_evidence: list[str] = field(default_factory=list)
251
- validated_edit_capabilities: list[str] = field(default_factory=list)
252
- acceptance_notes: list[str] = field(default_factory=list)
253
- archetype_tags: list[str] = field(default_factory=list)
254
- acceptance_fixtures: list[str] = field(default_factory=list)
255
- provenance: str = ""
256
- workspace_validation_status: str = ""
257
- evidence_source: str = "inferred"
258
-
259
- def normalized_device_counts(self) -> dict[str, int]:
260
- counts: dict[str, int] = {}
261
- for device in self.devices:
262
- normalized = normalize_device_type(device.get("type", ""))
263
- if normalized:
264
- counts[normalized] = counts.get(normalized, 0) + 1
265
- return counts
266
-
267
-
268
- @dataclass
269
- class SampleCandidate:
270
- sample: SampleDescriptor
271
- capability_score: int
272
- topology_score: int
273
- total_score: int
274
- reasons: list[str]
275
-
276
-
277
- @dataclass
278
- class ReferencePattern:
279
- relative_path: str
280
- origin: str
281
- capability_tags: list[str]
282
- topology_tags: list[str]
283
- device_summary: dict[str, int]
284
- wireless_mode_tags: list[str] = field(default_factory=list)
285
-
286
-
287
- @dataclass
288
- class DonorValidationResult:
289
- packet_tracer_version: str
290
- validation_status: str
291
- promotion_status: str
292
- donor_eligible: bool
293
- issues: list[str]
294
-
295
-
296
- @dataclass
297
- class CuratedDonorRecord:
298
- sample: SampleDescriptor
299
- validation: DonorValidationResult
300
-
301
-
302
- def _normalize_registry_key(value: str) -> str:
303
- return value.strip().replace("\\", "/").lower()
304
-
305
-
306
- @lru_cache(maxsize=4)
307
- def _load_curated_donor_registry_cached(path_str: str) -> dict[str, dict[str, Any]]:
308
- path = Path(path_str)
309
- if not path.exists():
310
- return {}
311
- payload = json.loads(path.read_text(encoding="utf-8"))
312
- donors = list(payload.get("donors", [])) if isinstance(payload, dict) else []
313
- registry: dict[str, dict[str, Any]] = {}
314
- for item in donors:
315
- if not isinstance(item, dict):
316
- continue
317
- key = _normalize_registry_key(str(item.get("relative_path", "")))
318
- if not key:
319
- continue
320
- registry[key] = dict(item)
321
- return registry
322
-
323
-
324
- def load_curated_donor_registry(path: Path | None = None) -> dict[str, dict[str, Any]]:
325
- return dict(_load_curated_donor_registry_cached(str(path or DEFAULT_CURATED_DONOR_REGISTRY)))
326
-
327
-
328
- def normalize_device_type(raw_type: str) -> str:
329
- key = raw_type.strip().lower().replace(" ", "")
330
- return TYPE_NORMALIZATION.get(key, raw_type.strip())
331
-
332
-
333
- def _normalized_counts_for_item(item: dict[str, Any]) -> dict[str, int]:
334
- counts: dict[str, int] = {}
335
- for device in item.get("devices", []):
336
- normalized = normalize_device_type(device.get("type", ""))
337
- if normalized:
338
- counts[normalized] = counts.get(normalized, 0) + 1
339
- return counts
340
-
341
-
342
- def infer_capability_tags(item: dict[str, Any]) -> list[str]:
343
- tags: set[str] = set()
344
- rel = item.get("relative_path", "").lower().replace("\\", "/")
345
- rel_flat = rel.replace("/", " ")
346
- devices = item.get("devices", [])
347
- raw_types = {str(device.get("type", "")).strip() for device in devices if str(device.get("type", "")).strip()}
348
- normalized_types = [normalize_device_type(device.get("type", "")) for device in devices]
349
- router_count = normalized_types.count("Router")
350
- switch_count = normalized_types.count("Switch")
351
- host_count = sum(1 for dtype in normalized_types if dtype in {"PC", "Server"})
352
-
353
- for tag, words in CAPABILITY_KEYWORDS.items():
354
- if any(word in rel or word in rel_flat for word in words):
355
- tags.add(tag)
356
- if router_count >= 2:
357
- tags.add("multi_router")
358
- if switch_count >= 1:
359
- tags.add("switching")
360
- if host_count >= 1:
361
- tags.add("host_server")
362
- if any(dtype in {"WirelessRouter", "LightWeightAccessPoint"} for dtype in normalized_types):
363
- tags.add("wireless")
364
- tags.add("wireless_ap")
365
- if "HomeGateway" in raw_types or any(dtype == "IoT" for dtype in normalized_types):
366
- tags.add("iot")
367
- if any(dtype in {"ASA", "Security Appliance"} for dtype in normalized_types) or raw_types & {"ASA", "Security Appliance"}:
368
- tags.add("security_edge")
369
- tags.add("acl")
370
- if any(dtype in {"Cloud", "Cable Modem", "Dsl Modem"} for dtype in normalized_types):
371
- tags.add("wan")
372
- if any(dtype == "MultiLayerSwitch" for dtype in normalized_types):
373
- tags.add("multilayer_switching")
374
- if any(dtype in {"Tablet", "Laptop", "Smartphone"} for dtype in normalized_types):
375
- tags.add("wireless_client")
376
- if any(dtype == "Tablet" for dtype in normalized_types):
377
- tags.add("tablet")
378
- if any(dtype == "Printer" for dtype in normalized_types):
379
- tags.add("printer")
380
- if "telnet" in rel or "terminal server" in rel_flat:
381
- tags.add("telnet")
382
- if "management" in rel_flat:
383
- tags.add("management_vlan")
384
- return sorted(tags or {"switching"})
385
-
386
-
387
- def infer_topology_tags(item: dict[str, Any]) -> list[str]:
388
- tags: set[str] = set()
389
- rel = item.get("relative_path", "").lower().replace("\\", "/")
390
- counts = _normalized_counts_for_item(item)
391
- link_count = int(item.get("link_count", 0))
392
- switch_count = counts.get("Switch", 0)
393
- router_count = counts.get("Router", 0)
394
- wireless_count = counts.get("LightWeightAccessPoint", 0) + counts.get("WirelessRouter", 0)
395
- server_count = counts.get("Server", 0)
396
-
397
- if "router_on_a_stick" in item.get("capability_tags", []):
398
- tags.add("router_on_a_stick")
399
- if "acl" in item.get("capability_tags", []):
400
- tags.add("acl_policy")
401
- if wireless_count:
402
- tags.add("wireless_edge")
403
- if server_count:
404
- tags.add("server_services")
405
- if switch_count >= 3 and router_count >= 1:
406
- tags.add("department_lan")
407
- if switch_count >= 2 and router_count >= 1:
408
- tags.add("core_access")
409
- if switch_count >= 2 and link_count >= switch_count:
410
- tags.add("chain")
411
- if router_count == 1 and switch_count <= 1 and wireless_count:
412
- tags.add("small_office")
413
- if "campus" in rel or "department" in rel or "vlan" in rel:
414
- tags.add("department_lan")
415
- return sorted(tags or {"general"})
416
-
417
-
418
- def infer_preferred_roles(item: dict[str, Any]) -> list[str]:
419
- roles: list[str] = []
420
- tags = set(item.get("capability_tags", []))
421
- if "vlan" in tags:
422
- roles.append("preferred_vlan")
423
- if "dhcp_pool" in tags:
424
- roles.append("preferred_dhcp")
425
- if {"ospf", "eigrp", "rip"} & tags:
426
- roles.append("preferred_routing")
427
- if {"nat", "acl", "vpn"} & tags:
428
- roles.append("preferred_security")
429
- if {"vpn", "ipsec", "gre", "ppp", "multilayer_switching"} & tags:
430
- roles.append("preferred_wan")
431
- if "wireless" in tags:
432
- roles.append("preferred_wireless")
433
- if "telnet" in tags or "management_vlan" in tags:
434
- roles.append("preferred_management")
435
- if "server_dns" in tags or "server_dhcp" in tags:
436
- roles.append("preferred_server")
437
- if "iot" in tags:
438
- roles.append("preferred_iot")
439
- if not roles:
440
- roles.append("preferred_general")
441
- return roles
442
-
443
-
444
- def infer_wireless_mode_tags(item: dict[str, Any]) -> list[str]:
445
- normalized_types = [normalize_device_type(device.get("type", "")) for device in item.get("devices", [])]
446
- tags: set[str] = set()
447
- if "LightWeightAccessPoint" in normalized_types:
448
- tags.add("ap_bridge")
449
- if "WirelessRouter" in normalized_types:
450
- tags.add("home_router_edge")
451
- if len(tags) > 1:
452
- tags.add("mixed")
453
- return sorted(tags)
454
-
455
-
456
- def infer_iot_roles(item: dict[str, Any]) -> list[str]:
457
- roles = {str(role).strip() for role in item.get("iot_roles", []) if str(role).strip()}
458
- raw_types = {str(device.get("type", "")).strip() for device in item.get("devices", []) if str(device.get("type", "")).strip()}
459
- normalized_types = {normalize_device_type(device.get("type", "")) for device in item.get("devices", []) if device.get("type")}
460
- families = set(item.get("device_families", []) or infer_device_families(item))
461
- has_thing = bool(raw_types & {"MCUComponent", "Board", "Sensor", "Actuator"}) or "iot devices" in families
462
- has_gateway = "HomeGateway" in raw_types or ("home/wireless routers" in families and has_thing)
463
- has_server = "Server" in normalized_types or "servers" in families
464
- if has_thing:
465
- roles.add("thing")
466
- if has_gateway:
467
- roles.add("gateway")
468
- if has_server:
469
- roles.add("server")
470
- return sorted(roles)
471
-
472
-
473
- def infer_device_families(item: dict[str, Any]) -> list[str]:
474
- family_map = {
475
- "Router": "routers",
476
- "Switch": "switches",
477
- "MultiLayerSwitch": "multilayer switches",
478
- "Server": "servers",
479
- "PC": "end devices",
480
- "Laptop": "end devices",
481
- "Tablet": "end devices",
482
- "Smartphone": "end devices",
483
- "Printer": "end devices",
484
- "LightWeightAccessPoint": "access points",
485
- "WirelessRouter": "home/wireless routers",
486
- "WirelessRouterNewGeneration": "home/wireless routers",
487
- "HomeGateway": "home/wireless routers",
488
- "WirelessLanController": "access points",
489
- "Power Distribution Device": "pt-specific edge/utility devices",
490
- "Cloud": "wan/cloud/dsl/cable devices",
491
- "Cable Modem": "wan/cloud/dsl/cable devices",
492
- "Dsl Modem": "wan/cloud/dsl/cable devices",
493
- "Security Appliance": "security devices",
494
- "ASA": "security devices",
495
- "NetworkController": "network controllers",
496
- "CentralOfficeServer": "wan/cloud/dsl/cable devices",
497
- "CellTower": "wan/cloud/dsl/cable devices",
498
- "IoT": "iot devices",
499
- "Sensor": "iot devices",
500
- "Actuator": "iot devices",
501
- "MCUComponent": "iot devices",
502
- }
503
- families = {
504
- family_map.get(normalize_device_type(device.get("type", "")), "pt-specific edge/utility devices")
505
- for device in item.get("devices", [])
506
- if device.get("type")
507
- }
508
- return sorted(families)
509
-
510
-
511
- def infer_model_families(item: dict[str, Any]) -> list[str]:
512
- return sorted(
513
- {
514
- str(device.get("model", "")).strip()
515
- for device in item.get("devices", [])
516
- if str(device.get("model", "")).strip()
517
- }
518
- )
519
-
520
-
521
- def infer_port_media_types(item: dict[str, Any]) -> list[str]:
522
- media = {
523
- str(link.get("cable_type") or link.get("media") or link.get("type") or "").strip()
524
- for link in item.get("links", [])
525
- if str(link.get("cable_type") or link.get("media") or link.get("type") or "").strip()
526
- }
527
- return sorted(media)
528
-
529
-
530
- def infer_service_support(item: dict[str, Any]) -> list[str]:
531
- tags = set(item.get("capability_tags", []))
532
- services: set[str] = set()
533
- service_map = {
534
- "server_dhcp": "dhcp",
535
- "dhcp_pool": "dhcp",
536
- "dns": "dns",
537
- "server_dns": "dns",
538
- "server_http": "http",
539
- "server_https": "https",
540
- "server_ftp": "ftp",
541
- "server_tftp": "tftp",
542
- "ntp": "ntp",
543
- "server_email": "email",
544
- "server_syslog": "syslog",
545
- "server_aaa": "aaa",
546
- "ftp_http_https": "http",
547
- "telnet": "telnet",
548
- "management_vlan": "management",
549
- }
550
- for tag in tags:
551
- service = service_map.get(tag)
552
- if service:
553
- services.add(service)
554
- return sorted(services)
555
-
556
-
557
- def infer_runtime_features(item: dict[str, Any]) -> list[str]:
558
- features: set[str] = set()
559
- tags = set(item.get("capability_tags", []))
560
- families = set(item.get("device_families", []) or infer_device_families(item))
561
- if item.get("wireless_mode_tags"):
562
- features.add("wireless_runtime")
563
- if item.get("service_support"):
564
- features.add("service_runtime")
565
- if infer_iot_roles(item):
566
- features.add("iot_runtime")
567
- if families & {"wan/cloud/dsl/cable devices"} or tags & {"wan", "ppp"}:
568
- features.add("wan_runtime")
569
- if families & {"security devices"} or tags & {"security_edge", "acl", "nat"}:
570
- features.add("security_runtime")
571
- if tags & {"vpn", "ipsec", "gre"}:
572
- features.add("tunnel_runtime")
573
- if tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
574
- features.add("ipv6_runtime")
575
- if families & {"multilayer switches"} or tags & {"multilayer_switching"}:
576
- features.add("multilayer_runtime")
577
- if item.get("workspace_validation"):
578
- features.add("workspace_validated")
579
- if any(str(link.get("from", "")) and str(link.get("to", "")) for link in item.get("links", [])):
580
- features.add("named_link_graph")
581
- return sorted(features)
582
-
583
-
584
- def infer_archetype_tags(item: dict[str, Any]) -> list[str]:
585
- tags: set[str] = set()
586
- topology = set(item.get("topology_tags", []))
587
- families = set(item.get("device_families", []))
588
- services = set(item.get("service_support", []))
589
- wireless_modes = set(item.get("wireless_mode_tags", []))
590
- iot_roles = set(infer_iot_roles(item))
591
-
592
- if topology & {"chain", "core_access", "department_lan", "router_on_a_stick"} or (
593
- families & {"routers", "switches", "multilayer switches"}
594
- and set(item.get("capability_tags", [])) & {"vlan", "router_on_a_stick", "management_vlan", "telnet"}
595
- ):
596
- tags.add("campus/core")
597
- if services or "servers" in families or "server_services" in topology:
598
- tags.add("service-heavy")
599
- if wireless_modes or families & {"access points", "home/wireless routers"}:
600
- tags.add("wireless-heavy")
601
- if iot_roles or "iot devices" in families or "HomeGateway" in set(item.get("model_families", [])):
602
- tags.add("IoT/home gateway")
603
- if families & {"wan/cloud/dsl/cable devices", "security devices"} or set(item.get("capability_tags", [])) & {
604
- "vpn",
605
- "ipsec",
606
- "gre",
607
- "ppp",
608
- "wan",
609
- "security_edge",
610
- "multilayer_switching",
611
- "nat",
612
- "acl",
613
- }:
614
- tags.add("WAN/security edge")
615
- capability_tags = set(item.get("capability_tags", []))
616
- if capability_tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ipv6_prefix_delegation", "ipv6_dns_aaaa", "ipv6_tunneling", "isatap", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
617
- tags.add("IPv6/routing")
618
- if capability_tags & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
619
- tags.add("L2 security/monitoring")
620
- if capability_tags & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
621
- tags.add("advanced wireless")
622
- if capability_tags & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"} or "network controllers" in families:
623
- tags.add("automation/controller")
624
- if capability_tags & {"voip", "ip_phone", "call_manager", "linksys_voice"}:
625
- tags.add("voice/collaboration")
626
- if capability_tags & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"}:
627
- tags.add("industrial IoT")
628
- return sorted(tags)
629
-
630
-
631
- def infer_donor_graph_fingerprint(item: dict[str, Any]) -> dict[str, Any]:
632
- pairs: list[str] = []
633
- media_types = infer_port_media_types(item)
634
- for link in item.get("links", []):
635
- left = str(link.get("from", "")).strip()
636
- right = str(link.get("to", "")).strip()
637
- if left and right:
638
- pairs.append(" <-> ".join(sorted((left, right))))
639
- return {
640
- "link_count": int(item.get("link_count", 0)),
641
- "named_pairs": sorted(dict.fromkeys(pairs)),
642
- "media_types": media_types,
643
- }
644
-
645
-
646
- def infer_apply_safety_level(item: dict[str, Any]) -> str:
647
- if item.get("origin") == "cisco-local" and item.get("prototype_eligible", True) and str(item.get("version", "")).startswith("9."):
648
- return "acceptance-verified"
649
- if item.get("donor_eligible") and item.get("prototype_eligible", False):
650
- return "safe-open-generate-supported"
651
- if item.get("capability_tags"):
652
- return "config-mutation-supported"
653
- if item.get("devices"):
654
- return "inventory-supported"
655
- return "reference-known"
656
-
657
-
658
- def infer_validated_edit_capabilities(item: dict[str, Any]) -> list[str]:
659
- capabilities: set[str] = {str(tag) for tag in item.get("capability_tags", []) if str(tag).strip()}
660
- inferred_iot_roles = set(infer_iot_roles(item))
661
- families = set(item.get("device_families", []) or infer_device_families(item))
662
- service_capability_map = {
663
- "dhcp": "server_dhcp",
664
- "dns": "server_dns",
665
- "http": "server_http",
666
- "https": "server_https",
667
- "ftp": "server_ftp",
668
- "tftp": "server_tftp",
669
- "ntp": "server_ntp",
670
- "email": "server_email",
671
- "syslog": "server_syslog",
672
- "aaa": "server_aaa",
673
- }
674
- for service in item.get("service_support", []):
675
- mapped = service_capability_map.get(str(service))
676
- if mapped:
677
- capabilities.add(mapped)
678
- if item.get("wireless_mode_tags"):
679
- capabilities.add("wireless_mutation")
680
- if any(device_family == "end devices" for device_family in item.get("device_families", [])):
681
- capabilities.add("end_device_mutation")
682
- if inferred_iot_roles:
683
- capabilities.add("iot")
684
- capabilities.add("iot_registration")
685
- if {"server", "gateway"} & inferred_iot_roles:
686
- capabilities.add("iot_control")
687
- if capabilities & {"vpn", "ipsec", "gre"}:
688
- capabilities.update({"vpn", "ipsec", "gre"})
689
- if capabilities & {"ppp", "wan"} or families & {"wan/cloud/dsl/cable devices"}:
690
- capabilities.add("wan")
691
- if "ppp" in capabilities:
692
- capabilities.add("ppp")
693
- if capabilities & {"acl", "nat", "security_edge"} or families & {"security devices"}:
694
- capabilities.add("security_edge")
695
- if "multilayer switches" in families or "multilayer_switching" in capabilities:
696
- capabilities.add("multilayer_switching")
697
- if item.get("apply_safety_level") == "inventory-supported":
698
- return []
699
- capabilities -= REPORT_ONLY_CAPABILITIES
700
- return sorted(capabilities)
701
-
702
-
703
- def infer_promotion_evidence(item: dict[str, Any]) -> list[str]:
704
- evidence: list[str] = []
705
- version = str(item.get("packet_tracer_version") or item.get("version") or "").strip()
706
- target_version = get_packet_tracer_target_version()
707
- if version and version == target_version:
708
- evidence.append(f"version:{version}")
709
- validation_status = str(item.get("validation_status", "")).strip()
710
- if validation_status:
711
- evidence.append(f"validation:{validation_status}")
712
- promotion_status = str(item.get("promotion_status", "")).strip()
713
- if promotion_status:
714
- evidence.append(f"promotion:{promotion_status}")
715
- if item.get("donor_eligible"):
716
- evidence.append("donor_eligible")
717
- runtime_features = set(item.get("runtime_features", []))
718
- if "workspace_validated" in runtime_features:
719
- evidence.append("workspace_validated")
720
- workspace_validation_status = str(item.get("workspace_validation_status", "")).strip()
721
- if workspace_validation_status:
722
- evidence.append(f"workspace_validation:{workspace_validation_status}")
723
- apply_safety_level = str(item.get("apply_safety_level", "")).strip()
724
- if apply_safety_level:
725
- evidence.append(f"apply_safety:{apply_safety_level}")
726
- validated_edit_capabilities = list(item.get("validated_edit_capabilities", []))
727
- if validated_edit_capabilities:
728
- evidence.append(f"edit_capabilities:{len(validated_edit_capabilities)}")
729
- explicit_fixtures = [str(fixture) for fixture in item.get("acceptance_fixtures", []) if str(fixture).strip()]
730
- for fixture in explicit_fixtures:
731
- evidence.append(f"acceptance_fixture:{fixture}")
732
- archetype_to_fixture = {
733
- "campus/core": "campus",
734
- "service-heavy": "service_heavy",
735
- "IoT/home gateway": "home_iot",
736
- "WAN/security edge": "wan_security_edge",
737
- }
738
- if not explicit_fixtures and promotion_status in {"validated_primary", "validated_compat", "acceptance_verified_curated"}:
739
- for archetype in item.get("archetype_tags", []):
740
- fixture = archetype_to_fixture.get(str(archetype))
741
- if fixture:
742
- evidence.append(f"acceptance_fixture:{fixture}")
743
- provenance = str(item.get("provenance", "")).strip()
744
- if provenance:
745
- evidence.append(f"provenance:{provenance}")
746
- evidence_source = str(item.get("evidence_source", "")).strip()
747
- if evidence_source:
748
- evidence.append(f"evidence_source:{evidence_source}")
749
- return evidence
750
-
751
-
752
- def infer_acceptance_notes(item: dict[str, Any]) -> list[str]:
753
- notes: list[str] = []
754
- workspace_validation = item.get("workspace_validation", {})
755
- if isinstance(workspace_validation, dict):
756
- logical_status = str(workspace_validation.get("logical_status", "")).strip()
757
- physical_status = str(workspace_validation.get("physical_status", "")).strip()
758
- if logical_status or physical_status:
759
- notes.append(f"workspace logical={logical_status or 'unknown'} physical={physical_status or 'unknown'}")
760
- wireless_modes = [str(item) for item in item.get("wireless_mode_tags", []) if str(item).strip()]
761
- if wireless_modes:
762
- notes.append(f"wireless modes: {', '.join(wireless_modes)}")
763
- archetypes = [str(item) for item in item.get("archetype_tags", []) if str(item).strip()]
764
- if archetypes:
765
- notes.append(f"archetypes: {', '.join(archetypes)}")
766
- acceptance_fixtures = [str(item) for item in item.get("acceptance_fixtures", []) if str(item).strip()]
767
- if acceptance_fixtures:
768
- notes.append(f"acceptance fixtures: {', '.join(acceptance_fixtures)}")
769
- provenance = str(item.get("provenance", "")).strip()
770
- if provenance:
771
- notes.append(f"provenance: {provenance}")
772
- return notes
773
-
774
-
775
- def _detect_license_or_permission(root: Path) -> str:
776
- for filename in LICENSE_FILENAMES:
777
- candidate = root / filename
778
- if not candidate.exists():
779
- continue
780
- try:
781
- text = candidate.read_text(encoding="utf-8", errors="ignore").lower()
782
- except Exception:
783
- return f"present:{candidate.name}"
784
- if "mit license" in text:
785
- return "MIT"
786
- if "apache license" in text and "version 2.0" in text:
787
- return "Apache-2.0"
788
- if "bsd" in text:
789
- return "BSD"
790
- if "creative commons" in text and "noncommercial" in text:
791
- return "CC-BY-NC"
792
- if "creative commons" in text:
793
- return "CC"
794
- return f"present:{candidate.name}"
795
- return "unknown"
796
-
797
-
798
- def validate_external_sample_summary(item: dict[str, Any], curated: bool) -> DonorValidationResult:
799
- target_version = get_packet_tracer_target_version()
800
- packet_tracer_version = str(item.get("version", ""))
801
- issues: list[str] = []
802
- if packet_tracer_version != target_version:
803
- issues.append(f"version_mismatch:{packet_tracer_version or 'unknown'}")
804
- if int(item.get("device_count", 0)) < 2:
805
- issues.append("insufficient_devices")
806
- if int(item.get("link_count", 0)) < 1:
807
- issues.append("insufficient_links")
808
- counts = _normalized_counts_for_item(item)
809
- if curated and not any(counts.get(kind, 0) for kind in ("Switch", "Router")):
810
- issues.append("missing_network_core")
811
- workspace_validation = item.get("workspace_validation", {})
812
- if curated and isinstance(workspace_validation, dict):
813
- workspace_mode = str(workspace_validation.get("workspace_mode", ""))
814
- logical_status = str(workspace_validation.get("logical_status", ""))
815
- physical_status = str(workspace_validation.get("physical_status", ""))
816
- blocking_issues = [str(issue) for issue in workspace_validation.get("blocking_issues", []) if str(issue)]
817
- legacy_memaddr_only = (
818
- workspace_mode == "legacy_uuid_physical"
819
- and physical_status == "ok"
820
- and logical_status == "invalid"
821
- and bool(blocking_issues)
822
- and all(issue == "Link device MEM_ADDR references do not match device workspace records" for issue in blocking_issues)
823
- )
824
- if physical_status != "ok":
825
- issues.append("workspace_physical_invalid")
826
- elif logical_status != "ok" and not legacy_memaddr_only:
827
- issues.append("workspace_logical_invalid")
828
- donor_eligible = curated and not issues
829
- validation_status = "validated" if not issues else "blocked"
830
- promotion_status = "validated_curated" if donor_eligible else "reference_only"
831
- return DonorValidationResult(
832
- packet_tracer_version=packet_tracer_version,
833
- validation_status=validation_status,
834
- promotion_status=promotion_status,
835
- donor_eligible=donor_eligible,
836
- issues=issues,
837
- )
838
-
839
-
840
- def _merge_curated_registry_entry(
841
- item: dict[str, Any],
842
- registry_entry: dict[str, Any] | None,
843
- validation: DonorValidationResult,
844
- ) -> dict[str, Any]:
845
- merged = dict(item)
846
- if not registry_entry:
847
- return merged
848
- for field in [
849
- "packet_tracer_version",
850
- "apply_safety_level",
851
- "archetype_tags",
852
- "validated_edit_capabilities",
853
- "acceptance_fixtures",
854
- "acceptance_notes",
855
- "provenance",
856
- ]:
857
- if field in registry_entry:
858
- merged[field] = registry_entry[field]
859
- requested_promotion = str(registry_entry.get("promotion_status", "")).strip()
860
- if requested_promotion and validation.donor_eligible:
861
- merged["promotion_status"] = requested_promotion
862
- if not validation.donor_eligible:
863
- merged["promotion_status"] = "reference_only"
864
- merged["donor_eligible"] = False
865
- if registry_entry:
866
- merged["evidence_source"] = "registry+inferred"
867
- workspace_validation_status = str(registry_entry.get("workspace_validation", "")).strip()
868
- if workspace_validation_status:
869
- merged["workspace_validation_status"] = workspace_validation_status
870
- return merged
871
-
872
-
873
- def _descriptor_from_item(item: dict[str, Any]) -> SampleDescriptor:
874
- return SampleDescriptor(
875
- path=item["path"],
876
- relative_path=item["relative_path"],
877
- version=item.get("version", ""),
878
- device_count=item.get("device_count", 0),
879
- link_count=item.get("link_count", 0),
880
- devices=item.get("devices", []),
881
- links=item.get("links", []),
882
- capability_tags=item.get("capability_tags", []),
883
- topology_tags=item.get("topology_tags", []),
884
- preferred_roles=item.get("preferred_roles", []),
885
- trust_level=item.get("trust_level", "trusted"),
886
- origin=item.get("origin", "cisco-local"),
887
- role=item.get("role", "primary"),
888
- prototype_eligible=bool(item.get("prototype_eligible", True)),
889
- license_or_permission=item.get("license_or_permission", "local-cisco"),
890
- promotion_status=item.get("promotion_status", "validated_primary"),
891
- validation_status=item.get("validation_status", "validated"),
892
- packet_tracer_version=item.get("packet_tracer_version", item.get("version", "")),
893
- wireless_mode_tags=item.get("wireless_mode_tags", []),
894
- donor_eligible=bool(item.get("donor_eligible", item.get("prototype_eligible", True))),
895
- device_families=item.get("device_families", []),
896
- model_families=item.get("model_families", []),
897
- port_media_types=item.get("port_media_types", []),
898
- service_support=item.get("service_support", []),
899
- iot_roles=item.get("iot_roles", []),
900
- runtime_features=item.get("runtime_features", []),
901
- donor_graph_fingerprint=item.get("donor_graph_fingerprint", {}),
902
- apply_safety_level=item.get("apply_safety_level", "reference-known"),
903
- promotion_evidence=item.get("promotion_evidence", []),
904
- validated_edit_capabilities=item.get("validated_edit_capabilities", []),
905
- acceptance_notes=item.get("acceptance_notes", []),
906
- archetype_tags=item.get("archetype_tags", []),
907
- acceptance_fixtures=item.get("acceptance_fixtures", []),
908
- provenance=item.get("provenance", ""),
909
- workspace_validation_status=item.get("workspace_validation_status", ""),
910
- evidence_source=item.get("evidence_source", "inferred"),
911
- )
912
-
913
-
914
- def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
915
- enriched: list[dict[str, Any]] = []
916
- for item in items:
917
- if "error" in item:
918
- enriched.append(item)
919
- continue
920
- new_item = dict(item)
921
- new_item["capability_tags"] = infer_capability_tags(new_item)
922
- new_item["topology_tags"] = infer_topology_tags(new_item)
923
- new_item["preferred_roles"] = infer_preferred_roles(new_item)
924
- new_item["wireless_mode_tags"] = infer_wireless_mode_tags(new_item)
925
- new_item.setdefault("trust_level", "trusted")
926
- new_item.setdefault("origin", "cisco-local")
927
- new_item.setdefault("role", "primary")
928
- new_item.setdefault("prototype_eligible", True)
929
- new_item.setdefault("license_or_permission", "local-cisco")
930
- new_item.setdefault("promotion_status", "validated_primary")
931
- new_item.setdefault("validation_status", "validated")
932
- new_item.setdefault("packet_tracer_version", new_item.get("version", ""))
933
- new_item.setdefault("donor_eligible", bool(new_item.get("prototype_eligible", True)))
934
- new_item.setdefault("device_families", infer_device_families(new_item))
935
- new_item.setdefault("model_families", infer_model_families(new_item))
936
- new_item.setdefault("port_media_types", infer_port_media_types(new_item))
937
- new_item.setdefault("service_support", infer_service_support(new_item))
938
- new_item.setdefault("iot_roles", infer_iot_roles(new_item))
939
- new_item["runtime_features"] = sorted(set(new_item.get("runtime_features", [])) | set(infer_runtime_features(new_item)))
940
- new_item.setdefault("donor_graph_fingerprint", infer_donor_graph_fingerprint(new_item))
941
- new_item.setdefault("apply_safety_level", infer_apply_safety_level(new_item))
942
- new_item.setdefault("archetype_tags", infer_archetype_tags(new_item))
943
- new_item.setdefault("validated_edit_capabilities", infer_validated_edit_capabilities(new_item))
944
- new_item.setdefault("acceptance_fixtures", [])
945
- new_item.setdefault("provenance", "")
946
- new_item.setdefault("promotion_evidence", infer_promotion_evidence(new_item))
947
- new_item.setdefault("acceptance_notes", infer_acceptance_notes(new_item))
948
- new_item.setdefault("workspace_validation_status", "")
949
- new_item.setdefault("evidence_source", "inferred")
950
- enriched.append(new_item)
951
- return enriched
952
-
953
-
954
- @lru_cache(maxsize=8)
955
- def _load_catalog_cached(path_str: str) -> tuple[SampleDescriptor, ...]:
956
- raw_items = json.loads(Path(path_str).read_text(encoding="utf-8"))
957
- items = enrich_catalog_items(raw_items)
958
- saves_root = get_packet_tracer_saves_root()
959
- return tuple(
960
- _descriptor_from_item(
961
- {
962
- **item,
963
- "path": str((saves_root / item["relative_path"]) if saves_root is not None else item.get("path", item["relative_path"])),
964
- }
965
- )
966
- for item in items
967
- if "error" not in item
968
- )
969
-
970
-
971
- def load_catalog(path: Path | None = None) -> list[SampleDescriptor]:
972
- return list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
973
-
974
-
975
- def _summarize_pkt(path: Path, relative_path: str, origin: str, prototype_eligible: bool) -> dict[str, Any]:
976
- xml = decode_pkt_modern(path.read_bytes())
977
- root = ET.fromstring(xml)
978
- from pkt_editor import inventory_root
979
-
980
- inventory = inventory_root(root)
981
- workspace_result = inspect_workspace_integrity(root)
982
- devices = []
983
- indexed_devices = root.findall(".//DEVICES/DEVICE")
984
- index_to_name = {str(index): device.findtext("./ENGINE/NAME", default="") for index, device in enumerate(indexed_devices)}
985
- save_ref_to_name = {
986
- device.findtext("./ENGINE/SAVE_REF_ID", default=""): device.findtext("./ENGINE/NAME", default="")
987
- for device in indexed_devices
988
- if device.findtext("./ENGINE/SAVE_REF_ID", default="")
989
- }
990
- for device in root.findall(".//DEVICES/DEVICE"):
991
- type_node = device.find("./ENGINE/TYPE")
992
- devices.append(
993
- {
994
- "name": device.findtext("./ENGINE/NAME", default=""),
995
- "type": device.findtext("./ENGINE/TYPE", default=""),
996
- "model": type_node.get("model", "") if type_node is not None else "",
997
- }
998
- )
999
- links = []
1000
- for link in root.findall(".//LINKS/LINK"):
1001
- cable = link.find("./CABLE")
1002
- ports = cable.findall("PORT") if cable is not None else []
1003
- from_ref = cable.findtext("FROM", default="") if cable is not None else ""
1004
- to_ref = cable.findtext("TO", default="") if cable is not None else ""
1005
- links.append(
1006
- {
1007
- "from": save_ref_to_name.get(from_ref, index_to_name.get(from_ref, "")),
1008
- "to": save_ref_to_name.get(to_ref, index_to_name.get(to_ref, "")),
1009
- "type": link.findtext("./TYPE", default=""),
1010
- "cable_type": cable.findtext("./TYPE", default="") if cable is not None else "",
1011
- "media": cable.findtext("./TYPE", default="") if cable is not None else "",
1012
- "ports": [port.text or "" for port in ports[:2]],
1013
- }
1014
- )
1015
- service_aliases = {
1016
- "dhcp_server": "dhcp",
1017
- "dns_server": "dns",
1018
- "http_server": "http",
1019
- "https_server": "https",
1020
- "ftp_server": "ftp",
1021
- "tftp_server": "tftp",
1022
- "ntp_server": "ntp",
1023
- "email_server": "email",
1024
- "syslog_server": "syslog",
1025
- "acs_server": "aaa",
1026
- }
1027
- service_support = sorted(
1028
- {
1029
- service_aliases[service]
1030
- for services in inventory.get("services", {}).values()
1031
- for service in services
1032
- if service in service_aliases
1033
- }
1034
- )
1035
- iot_roles = sorted({str(entry.get("role", "")).strip() for entry in inventory.get("iot", {}).values() if str(entry.get("role", "")).strip()})
1036
- return {
1037
- "path": str(path),
1038
- "relative_path": relative_path,
1039
- "version": root.findtext("./VERSION", default=""),
1040
- "device_count": len(devices),
1041
- "link_count": len(links),
1042
- "devices": devices,
1043
- "links": links,
1044
- "origin": origin,
1045
- "trust_level": "reference-only" if origin != "cisco-local" else "trusted",
1046
- "role": "reference" if origin != "cisco-local" else "primary",
1047
- "prototype_eligible": prototype_eligible,
1048
- "service_support": service_support,
1049
- "iot_roles": iot_roles,
1050
- "workspace_validation": {
1051
- "workspace_mode": workspace_result.workspace_mode,
1052
- "logical_status": workspace_result.logical_status,
1053
- "physical_status": workspace_result.physical_status,
1054
- "blocking_issue_count": len(workspace_result.blocking_issues),
1055
- "blocking_issues": workspace_result.blocking_issues,
1056
- },
1057
- }
1058
-
1059
-
1060
- def summarize_pkt_descriptor(
1061
- path: Path,
1062
- relative_path: str | None = None,
1063
- *,
1064
- origin: str = "external-reference",
1065
- prototype_eligible: bool = False,
1066
- trust_level: str | None = None,
1067
- role: str | None = None,
1068
- license_or_permission: str | None = None,
1069
- promotion_status: str | None = None,
1070
- validation_status: str | None = None,
1071
- donor_eligible: bool | None = None,
1072
- ) -> SampleDescriptor:
1073
- item = _summarize_pkt(path, relative_path or path.name, origin, prototype_eligible)
1074
- if trust_level is not None:
1075
- item["trust_level"] = trust_level
1076
- if role is not None:
1077
- item["role"] = role
1078
- if license_or_permission is not None:
1079
- item["license_or_permission"] = license_or_permission
1080
- if promotion_status is not None:
1081
- item["promotion_status"] = promotion_status
1082
- if validation_status is not None:
1083
- item["validation_status"] = validation_status
1084
- if donor_eligible is not None:
1085
- item["donor_eligible"] = donor_eligible
1086
- enriched = enrich_catalog_items([item])[0]
1087
- return _descriptor_from_item(enriched)
1088
-
1089
-
1090
- @lru_cache(maxsize=4)
1091
- def _load_reference_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
1092
- items: list[dict[str, Any]] = []
1093
- for root_str in roots_key:
1094
- root = Path(root_str)
1095
- if not root.exists():
1096
- continue
1097
- for pkt_path in sorted(root.rglob("*.pkt")):
1098
- try:
1099
- rel = str(pkt_path.relative_to(root))
1100
- item = _summarize_pkt(pkt_path, rel, "external-reference", False)
1101
- validation = validate_external_sample_summary(item, curated=False)
1102
- item["license_or_permission"] = _detect_license_or_permission(root)
1103
- item["validation_status"] = validation.validation_status
1104
- item["promotion_status"] = validation.promotion_status
1105
- item["donor_eligible"] = validation.donor_eligible
1106
- items.append(item)
1107
- except Exception:
1108
- continue
1109
- enriched = enrich_catalog_items(items)
1110
- return tuple(_descriptor_from_item(item) for item in enriched)
1111
-
1112
-
1113
- def load_reference_catalog(reference_roots: list[Path] | None = None) -> list[SampleDescriptor]:
1114
- if not reference_roots:
1115
- return []
1116
- roots_key = tuple(sorted(str(path) for path in reference_roots))
1117
- return list(_load_reference_catalog_cached(roots_key))
1118
-
1119
-
1120
- @lru_cache(maxsize=4)
1121
- def _load_curated_donor_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
1122
- items: list[dict[str, Any]] = []
1123
- registry = load_curated_donor_registry()
1124
- for root_str in roots_key:
1125
- root = Path(root_str)
1126
- if not root.exists():
1127
- continue
1128
- license_or_permission = _detect_license_or_permission(root)
1129
- for pkt_path in sorted(root.rglob("*.pkt")):
1130
- try:
1131
- rel = str(pkt_path.relative_to(root))
1132
- item = _summarize_pkt(pkt_path, rel, "external-curated", True)
1133
- validation = validate_external_sample_summary(item, curated=True)
1134
- item["trust_level"] = "curated" if validation.donor_eligible else "reference-only"
1135
- item["role"] = "secondary" if validation.donor_eligible else "reference"
1136
- item["prototype_eligible"] = validation.donor_eligible
1137
- item["license_or_permission"] = license_or_permission
1138
- item["validation_status"] = validation.validation_status
1139
- item["promotion_status"] = validation.promotion_status
1140
- item["packet_tracer_version"] = validation.packet_tracer_version
1141
- item["donor_eligible"] = validation.donor_eligible
1142
- registry_entry = registry.get(_normalize_registry_key(rel)) or registry.get(_normalize_registry_key(pkt_path.name))
1143
- item = _merge_curated_registry_entry(item, registry_entry, validation)
1144
- items.append(item)
1145
- except Exception:
1146
- continue
1147
- enriched = enrich_catalog_items(items)
1148
- return tuple(_descriptor_from_item(item) for item in enriched)
1149
-
1150
-
1151
- def load_curated_donor_catalog(donor_roots: list[Path] | None = None) -> list[SampleDescriptor]:
1152
- if not donor_roots:
1153
- return []
1154
- roots_key = tuple(sorted(str(path) for path in donor_roots))
1155
- return list(_load_curated_donor_catalog_cached(roots_key))
1156
-
1157
-
1158
- def extract_reference_patterns(samples: list[SampleDescriptor]) -> list[ReferencePattern]:
1159
- patterns: list[ReferencePattern] = []
1160
- for sample in samples:
1161
- patterns.append(
1162
- ReferencePattern(
1163
- relative_path=sample.relative_path,
1164
- origin=sample.origin,
1165
- capability_tags=sample.capability_tags,
1166
- topology_tags=sample.topology_tags,
1167
- device_summary=sample.normalized_device_counts(),
1168
- wireless_mode_tags=sample.wireless_mode_tags,
1169
- )
1170
- )
1171
- return patterns
1172
-
1173
-
1174
- def write_catalog_outputs(items: list[dict[str, Any]], json_path: Path | None = None, md_path: Path | None = None) -> None:
1175
- enriched = enrich_catalog_items(items)
1176
- compact_items: list[dict[str, Any]] = []
1177
- for item in enriched:
1178
- saved = dict(item)
1179
- saved.pop("path", None)
1180
- compact_items.append(saved)
1181
- (json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(compact_items, indent=2, ensure_ascii=False), encoding="utf-8")
1182
- root_label = "<PACKET_TRACER_SAVES_ROOT>"
1183
- lines = ["# Packet Tracer Installed Sample Catalog", "", f"Source root: `{root_label}`", ""]
1184
- for item in enriched:
1185
- if "error" in item:
1186
- lines.append(f"- `{item['relative_path']}`")
1187
- lines.append(f" decode error: `{item['error']}`")
1188
- continue
1189
- labels = ", ".join(f"{device['name']} ({device['type']}/{device['model']})" for device in item["devices"])
1190
- lines.append(f"- `{item['relative_path']}`")
1191
- lines.append(f" version: `{item['version']}`, devices: `{item['device_count']}`, links: `{item['link_count']}`")
1192
- lines.append(f" tags: {', '.join(item.get('capability_tags', []))}")
1193
- lines.append(f" topology: {', '.join(item.get('topology_tags', []))}")
1194
- lines.append(f" devices: {labels}")
1195
- (md_path or DEFAULT_CATALOG_MD).write_text("\n".join(lines) + "\n", encoding="utf-8")
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+ import xml.etree.ElementTree as ET
10
+
11
+ from packet_tracer_env import (
12
+ donor_compatibility,
13
+ donor_tier_is_accepted,
14
+ get_packet_tracer_saves_root,
15
+ get_packet_tracer_target_version,
16
+ )
17
+ from pkt_codec import decode_pkt_auto, decode_pkt_modern, parse_pkt_xml
18
+ from workspace_repair import inspect_workspace_integrity
19
+
20
+ SCRIPT_DIR = Path(__file__).resolve().parent
21
+ SKILL_ROOT = SCRIPT_DIR.parent
22
+ DEFAULT_CATALOG_JSON = SKILL_ROOT / "references" / "packettracer-sample-catalog.json"
23
+ DEFAULT_CATALOG_MD = SKILL_ROOT / "references" / "packettracer-sample-catalog.md"
24
+ DEFAULT_CURATED_DONOR_REGISTRY = SKILL_ROOT / "references" / "curated-donor-registry.json"
25
+
26
+ CAPABILITY_KEYWORDS = {
27
+ "vlan": ["vlan", "trunk", "access"],
28
+ "trunk": ["trunk"],
29
+ "access_port": ["access"],
30
+ "dhcp_pool": ["dhcp", "reservation", "apipa"],
31
+ "router_dhcp": ["dhcp", "reservation", "apipa"],
32
+ "server_dhcp": ["dhcp", "reservation", "apipa"],
33
+ "dhcpv6_stateful": ["dhcpv6", "stateful_dhcpv6"],
34
+ "dhcpv6_stateless": ["stateless_dhcpv6"],
35
+ "ipv6_slaac": ["slaac"],
36
+ "ipv6_prefix_delegation": ["prefix_delegation"],
37
+ "ipv6_dns_aaaa": ["aaaa"],
38
+ "ipv6_tunneling": ["ipv6ip", "ipv6 tunneling"],
39
+ "isatap": ["isatap"],
40
+ "dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
41
+ "ospf": ["ospf"],
42
+ "ospfv2": ["ospf", "ospfv2", "single-area ospf"],
43
+ "ospfv3": ["ospfv3", "ipv6_ospf"],
44
+ "eigrp": ["eigrp"],
45
+ "eigrp_ipv4": ["eigrp", "ipv4 eigrp"],
46
+ "eigrp_ipv6": ["ipv6_eigrp"],
47
+ "rip": ["rip"],
48
+ "ripv2": ["ripv2", "rip"],
49
+ "ripng": ["ripng", "ipv6 rip"],
50
+ "hsrp": ["hsrp"],
51
+ "nat": ["nat"],
52
+ "nat_static": ["static nat"],
53
+ "nat_dynamic": ["dynamic nat"],
54
+ "pat": ["pat", "overload"],
55
+ "static_route": ["static route", "ip route"],
56
+ "default_route": ["default route"],
57
+ "dhcp_relay": ["dhcp relay", "helper-address"],
58
+ "ssh_ios": ["ssh"],
59
+ "ntp_ios": ["ntp"],
60
+ "syslog_ios": ["syslog"],
61
+ "acl": ["acl", "access-list"],
62
+ "dai": ["dai", "dynamic arp inspection"],
63
+ "dot1x": ["dot1x", "802.1x", "port-based nac"],
64
+ "lldp": ["lldp"],
65
+ "rep": ["rep_"],
66
+ "snmp": ["snmp"],
67
+ "netflow": ["netflow"],
68
+ "span": ["rspan", "monitor session", "span session"],
69
+ "qos": ["qos"],
70
+ "port_security": ["port security", "port-security"],
71
+ "bgp": ["bgp"],
72
+ "stp": ["stp", "spanning tree", "spanning-tree"],
73
+ "rstp": ["rstp", "rapid-pvst"],
74
+ "etherchannel": ["etherchannel", "port-channel", "channel-group"],
75
+ "lacp": ["lacp"],
76
+ "pagp": ["pagp"],
77
+ "vtp": ["vtp"],
78
+ "dtp": ["dtp"],
79
+ "vpn": ["vpn", "ipsec", "gre"],
80
+ "ipsec": ["ipsec", "ike"],
81
+ "gre": ["gre"],
82
+ "ppp": ["ppp", "chap", "pap"],
83
+ "wan": ["wan", "cloud", "serial", "pppoe", "ppp"],
84
+ "security_edge": ["asa", "firewall", "security appliance"],
85
+ "asa_acl_nat": ["asa_acl_nat"],
86
+ "asa_service_policy": ["asa_service_policy", "service policy"],
87
+ "clientless_vpn": ["clientless_vpn"],
88
+ "cbac": ["cbac"],
89
+ "zfw": ["zfw", "zone based firewall"],
90
+ "sniffer": ["sniffer"],
91
+ "multilayer_switching": ["multilayer", "layer 3", "3560", "svi"],
92
+ "wireless": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep", "5g", "bluetooth", "cellular"],
93
+ "wireless_ap": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep"],
94
+ "wireless_client": ["wireless", "wifi", "tablet", "smartphone", "laptop"],
95
+ "wlc": ["wlc", "wireless lan controller"],
96
+ "wpa_enterprise": ["wpa_radius", "wpa2_enterprise"],
97
+ "wep": ["wep"],
98
+ "guest_wifi": ["guest"],
99
+ "beamforming": ["beamforming"],
100
+ "meraki": ["meraki"],
101
+ "cellular_5g": ["5g", "cellular", "cell tower"],
102
+ "bluetooth": ["bluetooth"],
103
+ "network_controller": ["network controller", "netcon"],
104
+ "python_programming": ["python"],
105
+ "javascript_programming": ["javascript"],
106
+ "blockly_programming": ["blockly"],
107
+ "tcp_udp_app": ["tcp_test_app", "udp_test_app"],
108
+ "vm_iox": ["iox", "uploading_and_running_vm"],
109
+ "voip": ["voip"],
110
+ "ip_phone": ["ip phone", "phone"],
111
+ "call_manager": ["call manager", "telephony", "voip"],
112
+ "linksys_voice": ["voip linksys"],
113
+ "mqtt": ["mqtt"],
114
+ "real_http": ["real-http", "real http"],
115
+ "real_websocket": ["real-websocket", "real websocket"],
116
+ "visual_scripting": ["visual-scripting", "visual scripting"],
117
+ "ptp": ["ptp"],
118
+ "profinet": ["profinet"],
119
+ "l2nat": ["l2nat"],
120
+ "cyberobserver": ["cyberobserver"],
121
+ "industrial_firewall": ["isa3000", "industrial cybersecurity"],
122
+ "coaxial": ["coaxial"],
123
+ "cable_dsl": ["cable modem", "dsl modem"],
124
+ "central_office": ["centraloffice", "central office"],
125
+ "cell_tower": ["cell tower"],
126
+ "power_distribution": ["power distribution"],
127
+ "hot_swappable": ["hotswappable", "hot swappable"],
128
+ "ios_license": ["ios_15", "license"],
129
+ "ntp": ["ntp"],
130
+ "dns": ["dns"],
131
+ "server_dns": ["dns"],
132
+ "server_http": ["http", "https", "websocket"],
133
+ "server_ftp": ["ftp", "tftp"],
134
+ "ftp_http_https": ["ftp", "http", "https", "websocket"],
135
+ "iot": ["iot", "sensor", "led", "arduino", "mqtt", "environment"],
136
+ "switching": ["switch", "switching", "lldp", "rep"],
137
+ "router_on_a_stick": ["router-on-a-stick", "dot1q"],
138
+ "host_server": ["server", "pc", "client", "host"],
139
+ "management_vlan": ["management", "vlan99", "telnet"],
140
+ "telnet": ["telnet", "terminal server"],
141
+ "tablet": ["tablet", "pda"],
142
+ "printer": ["printer"],
143
+ }
144
+
145
+ REPORT_ONLY_CAPABILITIES = {
146
+ "ipv6_slaac",
147
+ "dhcpv6_stateful",
148
+ "dhcpv6_stateless",
149
+ "ipv6_prefix_delegation",
150
+ "ipv6_dns_aaaa",
151
+ "ipv6_tunneling",
152
+ "isatap",
153
+ "ospfv3",
154
+ "eigrp_ipv6",
155
+ "ripng",
156
+ "hsrp",
157
+ "dhcp_snooping",
158
+ "dai",
159
+ "dot1x",
160
+ "lldp",
161
+ "rep",
162
+ "snmp",
163
+ "netflow",
164
+ "span",
165
+ "qos",
166
+ "port_security",
167
+ "bgp",
168
+ "stp",
169
+ "rstp",
170
+ "etherchannel",
171
+ "lacp",
172
+ "pagp",
173
+ "vtp",
174
+ "dtp",
175
+ "ospfv2",
176
+ "eigrp_ipv4",
177
+ "ripv2",
178
+ "static_route",
179
+ "default_route",
180
+ "dhcp_relay",
181
+ "nat_static",
182
+ "nat_dynamic",
183
+ "pat",
184
+ "ssh_ios",
185
+ "ntp_ios",
186
+ "syslog_ios",
187
+ "asa_acl_nat",
188
+ "asa_service_policy",
189
+ "clientless_vpn",
190
+ "cbac",
191
+ "zfw",
192
+ "sniffer",
193
+ "wlc",
194
+ "wpa_enterprise",
195
+ "wep",
196
+ "guest_wifi",
197
+ "beamforming",
198
+ "meraki",
199
+ "cellular_5g",
200
+ "bluetooth",
201
+ "network_controller",
202
+ "python_programming",
203
+ "javascript_programming",
204
+ "blockly_programming",
205
+ "tcp_udp_app",
206
+ "vm_iox",
207
+ "voip",
208
+ "ip_phone",
209
+ "call_manager",
210
+ "linksys_voice",
211
+ "mqtt",
212
+ "real_http",
213
+ "real_websocket",
214
+ "visual_scripting",
215
+ "ptp",
216
+ "profinet",
217
+ "l2nat",
218
+ "cyberobserver",
219
+ "industrial_firewall",
220
+ "coaxial",
221
+ "cable_dsl",
222
+ "central_office",
223
+ "cell_tower",
224
+ "power_distribution",
225
+ "hot_swappable",
226
+ "ios_license",
227
+ }
228
+
229
+ TYPE_NORMALIZATION = {
230
+ "pc": "PC",
231
+ "pc-pt": "PC",
232
+ "server": "Server",
233
+ "server-pt": "Server",
234
+ "router": "Router",
235
+ "switch": "Switch",
236
+ "multilayerswitch": "MultiLayerSwitch",
237
+ "layer3switch": "MultiLayerSwitch",
238
+ "wirelessrouter": "WirelessRouter",
239
+ "wirelessrouternewgeneration": "WirelessRouter",
240
+ "homegateway": "WirelessRouter",
241
+ # A controller is not an access point: it manages them, carries gigabit
242
+ # uplinks rather than a radio, and a prompt asking for one wants that
243
+ # device. Folding it here meant the planner's `WirelessLanController` kind
244
+ # matched nothing in any donor, and `1 wlc 2 access point qur` was refused
245
+ # by the very lab that carries a WLC.
246
+ "wirelesslancontroller": "WirelessLanController",
247
+ "lightweightaccesspoint": "LightWeightAccessPoint",
248
+ "accesspoint": "LightWeightAccessPoint",
249
+ "pda": "Tablet",
250
+ "tabletpc": "Tablet",
251
+ "tablet": "Tablet",
252
+ "laptop": "Laptop",
253
+ "printer": "Printer",
254
+ "smartphone": "Smartphone",
255
+ "mcucomponent": "IoT",
256
+ "networkcontroller": "NetworkController",
257
+ "centralofficeserver": "CentralOfficeServer",
258
+ "celltower": "CellTower",
259
+ }
260
+
261
+ LICENSE_FILENAMES = [
262
+ "LICENSE",
263
+ "LICENSE.txt",
264
+ "LICENSE.md",
265
+ "COPYING",
266
+ "COPYING.txt",
267
+ "NOTICE",
268
+ ]
269
+
270
+
271
+ @dataclass
272
+ class SampleDescriptor:
273
+ path: str
274
+ relative_path: str
275
+ version: str
276
+ device_count: int
277
+ link_count: int
278
+ devices: list[dict[str, Any]]
279
+ links: list[dict[str, Any]]
280
+ capability_tags: list[str]
281
+ topology_tags: list[str]
282
+ preferred_roles: list[str]
283
+ trust_level: str = "trusted"
284
+ origin: str = "cisco-local"
285
+ role: str = "primary"
286
+ prototype_eligible: bool = True
287
+ license_or_permission: str = "local-cisco"
288
+ promotion_status: str = "validated_primary"
289
+ validation_status: str = "validated"
290
+ packet_tracer_version: str = ""
291
+ wireless_mode_tags: list[str] = field(default_factory=list)
292
+ donor_eligible: bool = True
293
+ device_families: list[str] = field(default_factory=list)
294
+ model_families: list[str] = field(default_factory=list)
295
+ port_media_types: list[str] = field(default_factory=list)
296
+ service_support: list[str] = field(default_factory=list)
297
+ iot_roles: list[str] = field(default_factory=list)
298
+ runtime_features: list[str] = field(default_factory=list)
299
+ donor_graph_fingerprint: dict[str, Any] = field(default_factory=dict)
300
+ apply_safety_level: str = "reference-known"
301
+ promotion_evidence: list[str] = field(default_factory=list)
302
+ validated_edit_capabilities: list[str] = field(default_factory=list)
303
+ acceptance_notes: list[str] = field(default_factory=list)
304
+ archetype_tags: list[str] = field(default_factory=list)
305
+ acceptance_fixtures: list[str] = field(default_factory=list)
306
+ provenance: str = ""
307
+ workspace_validation_status: str = ""
308
+ evidence_source: str = "inferred"
309
+
310
+ def normalized_device_counts(self) -> dict[str, int]:
311
+ counts: dict[str, int] = {}
312
+ for device in self.devices:
313
+ normalized = normalize_device_type(device.get("type", ""))
314
+ if normalized:
315
+ counts[normalized] = counts.get(normalized, 0) + 1
316
+ return counts
317
+
318
+
319
+ @dataclass
320
+ class SampleCandidate:
321
+ sample: SampleDescriptor
322
+ capability_score: int
323
+ topology_score: int
324
+ total_score: int
325
+ reasons: list[str]
326
+
327
+
328
+ @dataclass
329
+ class ReferencePattern:
330
+ relative_path: str
331
+ origin: str
332
+ capability_tags: list[str]
333
+ topology_tags: list[str]
334
+ device_summary: dict[str, int]
335
+ wireless_mode_tags: list[str] = field(default_factory=list)
336
+
337
+
338
+ @dataclass
339
+ class DonorValidationResult:
340
+ packet_tracer_version: str
341
+ validation_status: str
342
+ promotion_status: str
343
+ donor_eligible: bool
344
+ issues: list[str]
345
+
346
+
347
+ @dataclass
348
+ class CuratedDonorRecord:
349
+ sample: SampleDescriptor
350
+ validation: DonorValidationResult
351
+
352
+
353
+ def _normalize_registry_key(value: str) -> str:
354
+ return value.strip().replace("\\", "/").lower()
355
+
356
+
357
+ @lru_cache(maxsize=4)
358
+ def _load_curated_donor_registry_cached(path_str: str) -> dict[str, dict[str, Any]]:
359
+ path = Path(path_str)
360
+ if not path.exists():
361
+ return {}
362
+ payload = json.loads(path.read_text(encoding="utf-8"))
363
+ donors = list(payload.get("donors", [])) if isinstance(payload, dict) else []
364
+ registry: dict[str, dict[str, Any]] = {}
365
+ for item in donors:
366
+ if not isinstance(item, dict):
367
+ continue
368
+ key = _normalize_registry_key(str(item.get("relative_path", "")))
369
+ if not key:
370
+ continue
371
+ registry[key] = dict(item)
372
+ return registry
373
+
374
+
375
+ def load_curated_donor_registry(path: Path | None = None) -> dict[str, dict[str, Any]]:
376
+ return dict(_load_curated_donor_registry_cached(str(path or DEFAULT_CURATED_DONOR_REGISTRY)))
377
+
378
+
379
+ def normalize_device_type(raw_type: str) -> str:
380
+ key = raw_type.strip().lower().replace(" ", "")
381
+ return TYPE_NORMALIZATION.get(key, raw_type.strip())
382
+
383
+
384
+ def _normalized_counts_for_item(item: dict[str, Any]) -> dict[str, int]:
385
+ counts: dict[str, int] = {}
386
+ for device in item.get("devices", []):
387
+ normalized = normalize_device_type(device.get("type", ""))
388
+ if normalized:
389
+ counts[normalized] = counts.get(normalized, 0) + 1
390
+ return counts
391
+
392
+
393
+ #
394
+ # Evidence read from device configuration rather than from the file name.
395
+ #
396
+ # Every keyword capability below `CAPABILITY_KEYWORDS` is matched against the
397
+ # path, so a lab called `telnet.pkt` counts as having telnet and a lab whose
398
+ # switches actually carry `line vty` with `transport input telnet` does not.
399
+ # That is how a campus donor with nineteen `line vty` blocks and nineteen
400
+ # `interface Vlan` blocks was credited with no capabilities at all, which in
401
+ # turn refused every management/telnet prompt as "missing critical capability
402
+ # coverage".
403
+ #
404
+ # Only capabilities whose evidence is unambiguous in config text belong here.
405
+ #
406
+ # Measured across all 292 bundled samples, filename credit against real config:
407
+ #
408
+ # capability name-only correct missed
409
+ # rip 5 1 36
410
+ # static_route 0 0 22
411
+ # acl 5 1 18
412
+ # nat 6 4 7
413
+ # eigrp 2 8 4
414
+ # hsrp 3 0 0
415
+ #
416
+ # Both directions are wrong. Every `hsrp` credit was a filename coincidence, and
417
+ # 36 labs that configure RIP were invisible. Config evidence is *added* to the
418
+ # filename tags rather than replacing them: withdrawing credit is a larger
419
+ # change and needs its own measurement, but a capability the config proves is
420
+ # never wrong to record.
421
+ CONFIG_EVIDENCE_PATTERNS: dict[str, tuple[str, ...]] = {
422
+ "telnet": (r"^\s*line vty\b", r"transport input (?:all|telnet)"),
423
+ "management_vlan": (r"^\s*interface vlan\s*\d+", r"^\s*ip default-gateway\b"),
424
+ "ospf": (r"^\s*router ospf\b",),
425
+ "ospfv2": (r"^\s*router ospf\b",),
426
+ "eigrp": (r"^\s*router eigrp\b",),
427
+ "eigrp_ipv4": (r"^\s*router eigrp\b",),
428
+ "rip": (r"^\s*router rip\b",),
429
+ "ripv2": (r"^\s*router rip\b.*|^\s*version 2\b",),
430
+ "bgp": (r"^\s*router bgp\b",),
431
+ "nat": (r"^\s*ip nat\b",),
432
+ "pat": (r"^\s*ip nat inside source list .*overload",),
433
+ "static_route": (r"^\s*ip route\b",),
434
+ "default_route": (r"^\s*ip route 0\.0\.0\.0 0\.0\.0\.0",),
435
+ "acl": (r"^\s*(?:ip )?access-list\b",),
436
+ "port_security": (r"switchport port-security",),
437
+ "etherchannel": (r"^\s*channel-group\b",),
438
+ "hsrp": (r"^\s*standby \d+ ip\b",),
439
+ "vtp": (r"^\s*vtp (?:mode|domain)\b",),
440
+ "stp": (r"^\s*spanning-tree mode\b",),
441
+ "dhcp_relay": (r"^\s*ip helper-address\b",),
442
+ "dhcp_pool": (r"^\s*ip dhcp pool\b",),
443
+ "router_dhcp": (r"^\s*ip dhcp pool\b",),
444
+ "trunk": (r"switchport mode trunk",),
445
+ "access_port": (r"switchport mode access",),
446
+ "router_on_a_stick": (r"^\s*encapsulation dot1q\b",),
447
+ "ssh_ios": (r"^\s*ip ssh\b", r"transport input ssh"),
448
+ "ntp_ios": (r"^\s*ntp server\b",),
449
+ "syslog_ios": (r"^\s*logging (?:host|\d+\.)",),
450
+ "snmp": (r"^\s*snmp-server\b",),
451
+ "lldp": (r"^\s*lldp run\b",),
452
+ "dhcp_snooping": (r"^\s*ip dhcp snooping\b",),
453
+ "dai": (r"^\s*ip arp inspection\b",),
454
+ "dot1x": (r"^\s*dot1x\b",),
455
+ "vlan": (r"^\s*interface vlan\s*\d+", r"switchport access vlan"),
456
+ }
457
+
458
+
459
+ def config_capability_tags(config_text: str) -> set[str]:
460
+ """Capabilities a lab demonstrably configures, not ones its name suggests."""
461
+ if not config_text:
462
+ return set()
463
+ lowered = config_text.lower()
464
+ found: set[str] = set()
465
+ for capability, patterns in CONFIG_EVIDENCE_PATTERNS.items():
466
+ if any(re.search(pattern, lowered, flags=re.MULTILINE) for pattern in patterns):
467
+ found.add(capability)
468
+ return found
469
+
470
+
471
+ def infer_capability_tags(item: dict[str, Any]) -> list[str]:
472
+ tags: set[str] = set(item.get("config_capability_tags") or ())
473
+ rel = item.get("relative_path", "").lower().replace("\\", "/")
474
+ rel_flat = rel.replace("/", " ")
475
+ devices = item.get("devices", [])
476
+ raw_types = {str(device.get("type", "")).strip() for device in devices if str(device.get("type", "")).strip()}
477
+ normalized_types = [normalize_device_type(device.get("type", "")) for device in devices]
478
+ router_count = normalized_types.count("Router")
479
+ switch_count = normalized_types.count("Switch")
480
+ host_count = sum(1 for dtype in normalized_types if dtype in {"PC", "Server"})
481
+
482
+ for tag, words in CAPABILITY_KEYWORDS.items():
483
+ if any(word in rel or word in rel_flat for word in words):
484
+ tags.add(tag)
485
+ if router_count >= 2:
486
+ tags.add("multi_router")
487
+ # Two routers with serial hardware is what a leased line needs: one has
488
+ # nothing to reach. Ranking had no way to tell these apart from any other
489
+ # multi-router lab, so a WAN prompt was answered with firewall samples that
490
+ # could only ever produce the link over copper.
491
+ if (
492
+ sum(
493
+ 1
494
+ for device, dtype in zip(devices, normalized_types)
495
+ if dtype == "Router" and int(device.get("serial_ports") or 0) > 0
496
+ )
497
+ >= 2
498
+ ):
499
+ tags.add("serial_wan")
500
+ tags.add("wan")
501
+ if switch_count >= 1:
502
+ tags.add("switching")
503
+ if host_count >= 1:
504
+ tags.add("host_server")
505
+ if any(dtype in {"WirelessRouter", "LightWeightAccessPoint"} for dtype in normalized_types):
506
+ tags.add("wireless")
507
+ tags.add("wireless_ap")
508
+ if "HomeGateway" in raw_types or any(dtype == "IoT" for dtype in normalized_types):
509
+ tags.add("iot")
510
+ if any(dtype in {"ASA", "Security Appliance"} for dtype in normalized_types) or raw_types & {"ASA", "Security Appliance"}:
511
+ tags.add("security_edge")
512
+ tags.add("acl")
513
+ if any(dtype in {"Cloud", "Cable Modem", "Dsl Modem"} for dtype in normalized_types):
514
+ tags.add("wan")
515
+ if any(dtype == "MultiLayerSwitch" for dtype in normalized_types):
516
+ tags.add("multilayer_switching")
517
+ if any(dtype in {"Tablet", "Laptop", "Smartphone"} for dtype in normalized_types):
518
+ tags.add("wireless_client")
519
+ if any(dtype == "Tablet" for dtype in normalized_types):
520
+ tags.add("tablet")
521
+ if any(dtype == "Printer" for dtype in normalized_types):
522
+ tags.add("printer")
523
+ if "telnet" in rel or "terminal server" in rel_flat:
524
+ tags.add("telnet")
525
+ if "management" in rel_flat:
526
+ tags.add("management_vlan")
527
+ return sorted(tags or {"switching"})
528
+
529
+
530
+ def infer_topology_tags(item: dict[str, Any]) -> list[str]:
531
+ tags: set[str] = set()
532
+ rel = item.get("relative_path", "").lower().replace("\\", "/")
533
+ counts = _normalized_counts_for_item(item)
534
+ link_count = int(item.get("link_count", 0))
535
+ switch_count = counts.get("Switch", 0)
536
+ router_count = counts.get("Router", 0)
537
+ wireless_count = counts.get("LightWeightAccessPoint", 0) + counts.get("WirelessRouter", 0)
538
+ server_count = counts.get("Server", 0)
539
+
540
+ if "router_on_a_stick" in item.get("capability_tags", []):
541
+ tags.add("router_on_a_stick")
542
+ if "acl" in item.get("capability_tags", []):
543
+ tags.add("acl_policy")
544
+ if wireless_count:
545
+ tags.add("wireless_edge")
546
+ if server_count:
547
+ tags.add("server_services")
548
+ if switch_count >= 3 and router_count >= 1:
549
+ tags.add("department_lan")
550
+ if switch_count >= 2 and router_count >= 1:
551
+ tags.add("core_access")
552
+ if switch_count >= 2 and link_count >= switch_count:
553
+ tags.add("chain")
554
+ if router_count == 1 and switch_count <= 1 and wireless_count:
555
+ tags.add("small_office")
556
+ if "campus" in rel or "department" in rel or "vlan" in rel:
557
+ tags.add("department_lan")
558
+ return sorted(tags or {"general"})
559
+
560
+
561
+ def infer_preferred_roles(item: dict[str, Any]) -> list[str]:
562
+ roles: list[str] = []
563
+ tags = set(item.get("capability_tags", []))
564
+ if "vlan" in tags:
565
+ roles.append("preferred_vlan")
566
+ if "dhcp_pool" in tags:
567
+ roles.append("preferred_dhcp")
568
+ if {"ospf", "eigrp", "rip", "bgp", "ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route"} & tags:
569
+ roles.append("preferred_routing")
570
+ if {"dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"} & tags:
571
+ roles.append("preferred_ipv4_management")
572
+ if {"stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"} & tags:
573
+ roles.append("preferred_l2_resiliency")
574
+ if {"nat", "acl", "vpn"} & tags:
575
+ roles.append("preferred_security")
576
+ if {"vpn", "ipsec", "gre", "ppp", "multilayer_switching"} & tags:
577
+ roles.append("preferred_wan")
578
+ if "wireless" in tags:
579
+ roles.append("preferred_wireless")
580
+ if "telnet" in tags or "management_vlan" in tags:
581
+ roles.append("preferred_management")
582
+ if "server_dns" in tags or "server_dhcp" in tags:
583
+ roles.append("preferred_server")
584
+ if "iot" in tags:
585
+ roles.append("preferred_iot")
586
+ if not roles:
587
+ roles.append("preferred_general")
588
+ return roles
589
+
590
+
591
+ def infer_wireless_mode_tags(item: dict[str, Any]) -> list[str]:
592
+ normalized_types = [normalize_device_type(device.get("type", "")) for device in item.get("devices", [])]
593
+ tags: set[str] = set()
594
+ if "LightWeightAccessPoint" in normalized_types:
595
+ tags.add("ap_bridge")
596
+ if "WirelessRouter" in normalized_types:
597
+ tags.add("home_router_edge")
598
+ if len(tags) > 1:
599
+ tags.add("mixed")
600
+ return sorted(tags)
601
+
602
+
603
+ def infer_iot_roles(item: dict[str, Any]) -> list[str]:
604
+ roles = {str(role).strip() for role in item.get("iot_roles", []) if str(role).strip()}
605
+ raw_types = {str(device.get("type", "")).strip() for device in item.get("devices", []) if str(device.get("type", "")).strip()}
606
+ normalized_types = {normalize_device_type(device.get("type", "")) for device in item.get("devices", []) if device.get("type")}
607
+ families = set(item.get("device_families", []) or infer_device_families(item))
608
+ has_thing = bool(raw_types & {"MCUComponent", "Board", "Sensor", "Actuator"}) or "iot devices" in families
609
+ has_gateway = "HomeGateway" in raw_types or ("home/wireless routers" in families and has_thing)
610
+ has_server = "Server" in normalized_types or "servers" in families
611
+ if has_thing:
612
+ roles.add("thing")
613
+ if has_gateway:
614
+ roles.add("gateway")
615
+ if has_server:
616
+ roles.add("server")
617
+ return sorted(roles)
618
+
619
+
620
+ def infer_device_families(item: dict[str, Any]) -> list[str]:
621
+ family_map = {
622
+ "Router": "routers",
623
+ "Switch": "switches",
624
+ "MultiLayerSwitch": "multilayer switches",
625
+ "Server": "servers",
626
+ "PC": "end devices",
627
+ "Laptop": "end devices",
628
+ "Tablet": "end devices",
629
+ "Smartphone": "end devices",
630
+ "Printer": "end devices",
631
+ "IpPhone": "end devices",
632
+ "HomeVoip": "end devices",
633
+ "AnalogPhone": "end devices",
634
+ "LightWeightAccessPoint": "access points",
635
+ "WirelessRouter": "home/wireless routers",
636
+ "WirelessRouterNewGeneration": "home/wireless routers",
637
+ "HomeGateway": "home/wireless routers",
638
+ "WirelessLanController": "access points",
639
+ "Power Distribution Device": "pt-specific edge/utility devices",
640
+ "Cloud": "wan/cloud/dsl/cable devices",
641
+ "Cable Modem": "wan/cloud/dsl/cable devices",
642
+ "Dsl Modem": "wan/cloud/dsl/cable devices",
643
+ "Security Appliance": "security devices",
644
+ "ASA": "security devices",
645
+ "NetworkController": "network controllers",
646
+ "CentralOfficeServer": "wan/cloud/dsl/cable devices",
647
+ "CellTower": "wan/cloud/dsl/cable devices",
648
+ "IoT": "iot devices",
649
+ "Sensor": "iot devices",
650
+ "Actuator": "iot devices",
651
+ "MCUComponent": "iot devices",
652
+ }
653
+ families = {
654
+ family_map.get(normalize_device_type(device.get("type", "")), "pt-specific edge/utility devices")
655
+ for device in item.get("devices", [])
656
+ if device.get("type")
657
+ }
658
+ return sorted(families)
659
+
660
+
661
+ def infer_model_families(item: dict[str, Any]) -> list[str]:
662
+ return sorted(
663
+ {
664
+ str(device.get("model", "")).strip()
665
+ for device in item.get("devices", [])
666
+ if str(device.get("model", "")).strip()
667
+ }
668
+ )
669
+
670
+
671
+ def infer_port_media_types(item: dict[str, Any]) -> list[str]:
672
+ media = {
673
+ str(link.get("cable_type") or link.get("media") or link.get("type") or "").strip()
674
+ for link in item.get("links", [])
675
+ if str(link.get("cable_type") or link.get("media") or link.get("type") or "").strip()
676
+ }
677
+ return sorted(media)
678
+
679
+
680
+ def infer_service_support(item: dict[str, Any]) -> list[str]:
681
+ tags = set(item.get("capability_tags", []))
682
+ services: set[str] = set()
683
+ service_map = {
684
+ "server_dhcp": "dhcp",
685
+ "dhcp_pool": "dhcp",
686
+ "dns": "dns",
687
+ "server_dns": "dns",
688
+ "server_http": "http",
689
+ "server_https": "https",
690
+ "server_ftp": "ftp",
691
+ "server_tftp": "tftp",
692
+ "ntp": "ntp",
693
+ "server_email": "email",
694
+ "server_syslog": "syslog",
695
+ "server_aaa": "aaa",
696
+ "ftp_http_https": "http",
697
+ "telnet": "telnet",
698
+ "management_vlan": "management",
699
+ }
700
+ for tag in tags:
701
+ service = service_map.get(tag)
702
+ if service:
703
+ services.add(service)
704
+ return sorted(services)
705
+
706
+
707
+ def infer_runtime_features(item: dict[str, Any]) -> list[str]:
708
+ features: set[str] = set()
709
+ tags = set(item.get("capability_tags", []))
710
+ families = set(item.get("device_families", []) or infer_device_families(item))
711
+ if item.get("wireless_mode_tags"):
712
+ features.add("wireless_runtime")
713
+ if item.get("service_support"):
714
+ features.add("service_runtime")
715
+ if infer_iot_roles(item):
716
+ features.add("iot_runtime")
717
+ if families & {"wan/cloud/dsl/cable devices"} or tags & {"wan", "ppp"}:
718
+ features.add("wan_runtime")
719
+ if families & {"security devices"} or tags & {"security_edge", "acl", "nat"}:
720
+ features.add("security_runtime")
721
+ if tags & {"vpn", "ipsec", "gre"}:
722
+ features.add("tunnel_runtime")
723
+ if tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
724
+ features.add("ipv6_runtime")
725
+ if tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
726
+ features.add("l2_resiliency_runtime")
727
+ if tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
728
+ features.add("ipv4_routing_management_runtime")
729
+ if families & {"multilayer switches"} or tags & {"multilayer_switching"}:
730
+ features.add("multilayer_runtime")
731
+ if item.get("workspace_validation"):
732
+ features.add("workspace_validated")
733
+ if any(str(link.get("from", "")) and str(link.get("to", "")) for link in item.get("links", [])):
734
+ features.add("named_link_graph")
735
+ return sorted(features)
736
+
737
+
738
+ def infer_archetype_tags(item: dict[str, Any]) -> list[str]:
739
+ tags: set[str] = set()
740
+ topology = set(item.get("topology_tags", []))
741
+ families = set(item.get("device_families", []))
742
+ services = set(item.get("service_support", []))
743
+ wireless_modes = set(item.get("wireless_mode_tags", []))
744
+ iot_roles = set(infer_iot_roles(item))
745
+
746
+ if topology & {"chain", "core_access", "department_lan", "router_on_a_stick"} or (
747
+ families & {"routers", "switches", "multilayer switches"}
748
+ and set(item.get("capability_tags", [])) & {"vlan", "router_on_a_stick", "management_vlan", "telnet"}
749
+ ):
750
+ tags.add("campus/core")
751
+ if services or "servers" in families or "server_services" in topology:
752
+ tags.add("service-heavy")
753
+ if wireless_modes or families & {"access points", "home/wireless routers"}:
754
+ tags.add("wireless-heavy")
755
+ if iot_roles or "iot devices" in families or "HomeGateway" in set(item.get("model_families", [])):
756
+ tags.add("IoT/home gateway")
757
+ if families & {"wan/cloud/dsl/cable devices", "security devices"} or set(item.get("capability_tags", [])) & {
758
+ "vpn",
759
+ "ipsec",
760
+ "gre",
761
+ "ppp",
762
+ "wan",
763
+ "security_edge",
764
+ "multilayer_switching",
765
+ "nat",
766
+ "acl",
767
+ }:
768
+ tags.add("WAN/security edge")
769
+ capability_tags = set(item.get("capability_tags", []))
770
+ if capability_tags & {"ipv6_slaac", "dhcpv6_stateful", "dhcpv6_stateless", "ipv6_prefix_delegation", "ipv6_dns_aaaa", "ipv6_tunneling", "isatap", "ospfv3", "eigrp_ipv6", "ripng", "hsrp"}:
771
+ tags.add("IPv6/routing")
772
+ if capability_tags & {"dhcp_snooping", "dai", "dot1x", "lldp", "rep", "snmp", "netflow", "span", "qos", "port_security"}:
773
+ tags.add("L2 security/monitoring")
774
+ if capability_tags & {"bgp", "stp", "rstp", "etherchannel", "lacp", "pagp", "vtp", "dtp"}:
775
+ tags.add("L2 resiliency/routing")
776
+ if capability_tags & {"ospfv2", "eigrp_ipv4", "ripv2", "static_route", "default_route", "dhcp_relay", "nat_static", "nat_dynamic", "pat", "ssh_ios", "ntp_ios", "syslog_ios"}:
777
+ tags.add("IPv4 routing/management")
778
+ if capability_tags & {"wlc", "wpa_enterprise", "wep", "guest_wifi", "beamforming", "meraki", "cellular_5g", "bluetooth"}:
779
+ tags.add("advanced wireless")
780
+ if capability_tags & {"network_controller", "python_programming", "javascript_programming", "blockly_programming", "tcp_udp_app", "vm_iox"} or "network controllers" in families:
781
+ tags.add("automation/controller")
782
+ if capability_tags & {"voip", "ip_phone", "call_manager", "linksys_voice"}:
783
+ tags.add("voice/collaboration")
784
+ if capability_tags & {"mqtt", "real_http", "real_websocket", "visual_scripting", "ptp", "profinet", "l2nat", "cyberobserver", "industrial_firewall"}:
785
+ tags.add("industrial IoT")
786
+ return sorted(tags)
787
+
788
+
789
+ def infer_donor_graph_fingerprint(item: dict[str, Any]) -> dict[str, Any]:
790
+ pairs: list[str] = []
791
+ media_types = infer_port_media_types(item)
792
+ for link in item.get("links", []):
793
+ left = str(link.get("from", "")).strip()
794
+ right = str(link.get("to", "")).strip()
795
+ if left and right:
796
+ pairs.append(" <-> ".join(sorted((left, right))))
797
+ return {
798
+ "link_count": int(item.get("link_count", 0)),
799
+ "named_pairs": sorted(dict.fromkeys(pairs)),
800
+ "media_types": media_types,
801
+ }
802
+
803
+
804
+ def infer_apply_safety_level(item: dict[str, Any]) -> str:
805
+ if item.get("origin") == "cisco-local" and item.get("prototype_eligible", True) and str(item.get("version", "")).startswith("9."):
806
+ return "acceptance-verified"
807
+ if item.get("donor_eligible") and item.get("prototype_eligible", False):
808
+ return "safe-open-generate-supported"
809
+ if item.get("capability_tags"):
810
+ return "config-mutation-supported"
811
+ if item.get("devices"):
812
+ return "inventory-supported"
813
+ return "reference-known"
814
+
815
+
816
+ def infer_validated_edit_capabilities(item: dict[str, Any]) -> list[str]:
817
+ capabilities: set[str] = {str(tag) for tag in item.get("capability_tags", []) if str(tag).strip()}
818
+ inferred_iot_roles = set(infer_iot_roles(item))
819
+ families = set(item.get("device_families", []) or infer_device_families(item))
820
+ service_capability_map = {
821
+ "dhcp": "server_dhcp",
822
+ "dns": "server_dns",
823
+ "http": "server_http",
824
+ "https": "server_https",
825
+ "ftp": "server_ftp",
826
+ "tftp": "server_tftp",
827
+ "ntp": "server_ntp",
828
+ "email": "server_email",
829
+ "syslog": "server_syslog",
830
+ "aaa": "server_aaa",
831
+ }
832
+ for service in item.get("service_support", []):
833
+ mapped = service_capability_map.get(str(service))
834
+ if mapped:
835
+ capabilities.add(mapped)
836
+ if item.get("wireless_mode_tags"):
837
+ capabilities.add("wireless_mutation")
838
+ if any(device_family == "end devices" for device_family in item.get("device_families", [])):
839
+ capabilities.add("end_device_mutation")
840
+ if inferred_iot_roles:
841
+ capabilities.add("iot")
842
+ capabilities.add("iot_registration")
843
+ if {"server", "gateway"} & inferred_iot_roles:
844
+ capabilities.add("iot_control")
845
+ if capabilities & {"vpn", "ipsec", "gre"}:
846
+ capabilities.update({"vpn", "ipsec", "gre"})
847
+ if capabilities & {"ppp", "wan"} or families & {"wan/cloud/dsl/cable devices"}:
848
+ capabilities.add("wan")
849
+ if "ppp" in capabilities:
850
+ capabilities.add("ppp")
851
+ if capabilities & {"acl", "nat", "security_edge"} or families & {"security devices"}:
852
+ capabilities.add("security_edge")
853
+ if "multilayer switches" in families or "multilayer_switching" in capabilities:
854
+ capabilities.add("multilayer_switching")
855
+ if item.get("apply_safety_level") == "inventory-supported":
856
+ return []
857
+ capabilities -= REPORT_ONLY_CAPABILITIES
858
+ return sorted(capabilities)
859
+
860
+
861
+ def infer_promotion_evidence(item: dict[str, Any]) -> list[str]:
862
+ evidence: list[str] = []
863
+ version = str(item.get("packet_tracer_version") or item.get("version") or "").strip()
864
+ target_version = get_packet_tracer_target_version()
865
+ if version and version == target_version:
866
+ evidence.append(f"version:{version}")
867
+ validation_status = str(item.get("validation_status", "")).strip()
868
+ if validation_status:
869
+ evidence.append(f"validation:{validation_status}")
870
+ promotion_status = str(item.get("promotion_status", "")).strip()
871
+ if promotion_status:
872
+ evidence.append(f"promotion:{promotion_status}")
873
+ if item.get("donor_eligible"):
874
+ evidence.append("donor_eligible")
875
+ runtime_features = set(item.get("runtime_features", []))
876
+ if "workspace_validated" in runtime_features:
877
+ evidence.append("workspace_validated")
878
+ workspace_validation_status = str(item.get("workspace_validation_status", "")).strip()
879
+ if workspace_validation_status:
880
+ evidence.append(f"workspace_validation:{workspace_validation_status}")
881
+ apply_safety_level = str(item.get("apply_safety_level", "")).strip()
882
+ if apply_safety_level:
883
+ evidence.append(f"apply_safety:{apply_safety_level}")
884
+ validated_edit_capabilities = list(item.get("validated_edit_capabilities", []))
885
+ if validated_edit_capabilities:
886
+ evidence.append(f"edit_capabilities:{len(validated_edit_capabilities)}")
887
+ explicit_fixtures = [str(fixture) for fixture in item.get("acceptance_fixtures", []) if str(fixture).strip()]
888
+ for fixture in explicit_fixtures:
889
+ evidence.append(f"acceptance_fixture:{fixture}")
890
+ archetype_to_fixture = {
891
+ "campus/core": "campus",
892
+ "service-heavy": "service_heavy",
893
+ "IoT/home gateway": "home_iot",
894
+ "WAN/security edge": "wan_security_edge",
895
+ }
896
+ if not explicit_fixtures and promotion_status in {"validated_primary", "validated_compat", "acceptance_verified_curated"}:
897
+ for archetype in item.get("archetype_tags", []):
898
+ fixture = archetype_to_fixture.get(str(archetype))
899
+ if fixture:
900
+ evidence.append(f"acceptance_fixture:{fixture}")
901
+ provenance = str(item.get("provenance", "")).strip()
902
+ if provenance:
903
+ evidence.append(f"provenance:{provenance}")
904
+ evidence_source = str(item.get("evidence_source", "")).strip()
905
+ if evidence_source:
906
+ evidence.append(f"evidence_source:{evidence_source}")
907
+ return evidence
908
+
909
+
910
+ def infer_acceptance_notes(item: dict[str, Any]) -> list[str]:
911
+ notes: list[str] = []
912
+ workspace_validation = item.get("workspace_validation", {})
913
+ if isinstance(workspace_validation, dict):
914
+ logical_status = str(workspace_validation.get("logical_status", "")).strip()
915
+ physical_status = str(workspace_validation.get("physical_status", "")).strip()
916
+ if logical_status or physical_status:
917
+ notes.append(f"workspace logical={logical_status or 'unknown'} physical={physical_status or 'unknown'}")
918
+ wireless_modes = [str(item) for item in item.get("wireless_mode_tags", []) if str(item).strip()]
919
+ if wireless_modes:
920
+ notes.append(f"wireless modes: {', '.join(wireless_modes)}")
921
+ archetypes = [str(item) for item in item.get("archetype_tags", []) if str(item).strip()]
922
+ if archetypes:
923
+ notes.append(f"archetypes: {', '.join(archetypes)}")
924
+ acceptance_fixtures = [str(item) for item in item.get("acceptance_fixtures", []) if str(item).strip()]
925
+ if acceptance_fixtures:
926
+ notes.append(f"acceptance fixtures: {', '.join(acceptance_fixtures)}")
927
+ provenance = str(item.get("provenance", "")).strip()
928
+ if provenance:
929
+ notes.append(f"provenance: {provenance}")
930
+ return notes
931
+
932
+
933
+ def _detect_license_or_permission(root: Path) -> str:
934
+ for filename in LICENSE_FILENAMES:
935
+ candidate = root / filename
936
+ if not candidate.exists():
937
+ continue
938
+ try:
939
+ text = candidate.read_text(encoding="utf-8", errors="ignore").lower()
940
+ except Exception:
941
+ return f"present:{candidate.name}"
942
+ if "mit license" in text:
943
+ return "MIT"
944
+ if "apache license" in text and "version 2.0" in text:
945
+ return "Apache-2.0"
946
+ if "bsd" in text:
947
+ return "BSD"
948
+ if "creative commons" in text and "noncommercial" in text:
949
+ return "CC-BY-NC"
950
+ if "creative commons" in text:
951
+ return "CC"
952
+ return f"present:{candidate.name}"
953
+ return "unknown"
954
+
955
+
956
+ def validate_external_sample_summary(item: dict[str, Any], curated: bool) -> DonorValidationResult:
957
+ target_version = get_packet_tracer_target_version()
958
+ packet_tracer_version = str(item.get("version", ""))
959
+ issues: list[str] = []
960
+ # Use the shared compatibility ladder rather than string equality: the build
961
+ # field of a `<VERSION>` is not a schema identifier, and the target may be a
962
+ # three-field version detected from the install directory.
963
+ if not donor_tier_is_accepted(donor_compatibility(packet_tracer_version, target_version)):
964
+ issues.append(f"version_mismatch:{packet_tracer_version or 'unknown'}")
965
+ if int(item.get("device_count", 0)) < 2:
966
+ issues.append("insufficient_devices")
967
+ if int(item.get("link_count", 0)) < 1:
968
+ issues.append("insufficient_links")
969
+ counts = _normalized_counts_for_item(item)
970
+ if curated and not any(counts.get(kind, 0) for kind in ("Switch", "Router")):
971
+ issues.append("missing_network_core")
972
+ workspace_validation = item.get("workspace_validation", {})
973
+ if curated and isinstance(workspace_validation, dict):
974
+ workspace_mode = str(workspace_validation.get("workspace_mode", ""))
975
+ logical_status = str(workspace_validation.get("logical_status", ""))
976
+ physical_status = str(workspace_validation.get("physical_status", ""))
977
+ blocking_issues = [str(issue) for issue in workspace_validation.get("blocking_issues", []) if str(issue)]
978
+ legacy_memaddr_only = (
979
+ workspace_mode == "legacy_uuid_physical"
980
+ and physical_status == "ok"
981
+ and logical_status == "invalid"
982
+ and bool(blocking_issues)
983
+ and all(issue == "Link device MEM_ADDR references do not match device workspace records" for issue in blocking_issues)
984
+ )
985
+ if physical_status != "ok":
986
+ issues.append("workspace_physical_invalid")
987
+ elif logical_status != "ok" and not legacy_memaddr_only:
988
+ issues.append("workspace_logical_invalid")
989
+ donor_eligible = curated and not issues
990
+ validation_status = "validated" if not issues else "blocked"
991
+ promotion_status = "validated_curated" if donor_eligible else "reference_only"
992
+ return DonorValidationResult(
993
+ packet_tracer_version=packet_tracer_version,
994
+ validation_status=validation_status,
995
+ promotion_status=promotion_status,
996
+ donor_eligible=donor_eligible,
997
+ issues=issues,
998
+ )
999
+
1000
+
1001
+ def _merge_curated_registry_entry(
1002
+ item: dict[str, Any],
1003
+ registry_entry: dict[str, Any] | None,
1004
+ validation: DonorValidationResult,
1005
+ ) -> dict[str, Any]:
1006
+ merged = dict(item)
1007
+ if not registry_entry:
1008
+ return merged
1009
+ for field in [
1010
+ "packet_tracer_version",
1011
+ "apply_safety_level",
1012
+ "archetype_tags",
1013
+ "validated_edit_capabilities",
1014
+ "acceptance_fixtures",
1015
+ "acceptance_notes",
1016
+ "provenance",
1017
+ ]:
1018
+ if field in registry_entry:
1019
+ merged[field] = registry_entry[field]
1020
+ requested_promotion = str(registry_entry.get("promotion_status", "")).strip()
1021
+ if requested_promotion and validation.donor_eligible:
1022
+ merged["promotion_status"] = requested_promotion
1023
+ if not validation.donor_eligible:
1024
+ merged["promotion_status"] = "reference_only"
1025
+ merged["donor_eligible"] = False
1026
+ if registry_entry:
1027
+ merged["evidence_source"] = "registry+inferred"
1028
+ workspace_validation_status = str(registry_entry.get("workspace_validation", "")).strip()
1029
+ if workspace_validation_status:
1030
+ merged["workspace_validation_status"] = workspace_validation_status
1031
+ return merged
1032
+
1033
+
1034
+ def _descriptor_from_item(item: dict[str, Any]) -> SampleDescriptor:
1035
+ return SampleDescriptor(
1036
+ path=item["path"],
1037
+ relative_path=item["relative_path"],
1038
+ version=item.get("version", ""),
1039
+ device_count=item.get("device_count", 0),
1040
+ link_count=item.get("link_count", 0),
1041
+ devices=item.get("devices", []),
1042
+ links=item.get("links", []),
1043
+ capability_tags=item.get("capability_tags", []),
1044
+ topology_tags=item.get("topology_tags", []),
1045
+ preferred_roles=item.get("preferred_roles", []),
1046
+ trust_level=item.get("trust_level", "trusted"),
1047
+ origin=item.get("origin", "cisco-local"),
1048
+ role=item.get("role", "primary"),
1049
+ prototype_eligible=bool(item.get("prototype_eligible", True)),
1050
+ license_or_permission=item.get("license_or_permission", "local-cisco"),
1051
+ promotion_status=item.get("promotion_status", "validated_primary"),
1052
+ validation_status=item.get("validation_status", "validated"),
1053
+ packet_tracer_version=item.get("packet_tracer_version", item.get("version", "")),
1054
+ wireless_mode_tags=item.get("wireless_mode_tags", []),
1055
+ donor_eligible=bool(item.get("donor_eligible", item.get("prototype_eligible", True))),
1056
+ device_families=item.get("device_families", []),
1057
+ model_families=item.get("model_families", []),
1058
+ port_media_types=item.get("port_media_types", []),
1059
+ service_support=item.get("service_support", []),
1060
+ iot_roles=item.get("iot_roles", []),
1061
+ runtime_features=item.get("runtime_features", []),
1062
+ donor_graph_fingerprint=item.get("donor_graph_fingerprint", {}),
1063
+ apply_safety_level=item.get("apply_safety_level", "reference-known"),
1064
+ promotion_evidence=item.get("promotion_evidence", []),
1065
+ validated_edit_capabilities=item.get("validated_edit_capabilities", []),
1066
+ acceptance_notes=item.get("acceptance_notes", []),
1067
+ archetype_tags=item.get("archetype_tags", []),
1068
+ acceptance_fixtures=item.get("acceptance_fixtures", []),
1069
+ provenance=item.get("provenance", ""),
1070
+ workspace_validation_status=item.get("workspace_validation_status", ""),
1071
+ evidence_source=item.get("evidence_source", "inferred"),
1072
+ )
1073
+
1074
+
1075
+ def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
1076
+ enriched: list[dict[str, Any]] = []
1077
+ for item in items:
1078
+ if "error" in item:
1079
+ enriched.append(item)
1080
+ continue
1081
+ new_item = dict(item)
1082
+ new_item["capability_tags"] = infer_capability_tags(new_item)
1083
+ new_item["topology_tags"] = infer_topology_tags(new_item)
1084
+ new_item["preferred_roles"] = infer_preferred_roles(new_item)
1085
+ new_item["wireless_mode_tags"] = infer_wireless_mode_tags(new_item)
1086
+ new_item.setdefault("trust_level", "trusted")
1087
+ new_item.setdefault("origin", "cisco-local")
1088
+ new_item.setdefault("role", "primary")
1089
+ new_item.setdefault("prototype_eligible", True)
1090
+ new_item.setdefault("license_or_permission", "local-cisco")
1091
+ new_item.setdefault("promotion_status", "validated_primary")
1092
+ new_item.setdefault("validation_status", "validated")
1093
+ new_item.setdefault("packet_tracer_version", new_item.get("version", ""))
1094
+ new_item.setdefault("donor_eligible", bool(new_item.get("prototype_eligible", True)))
1095
+ new_item.setdefault("device_families", infer_device_families(new_item))
1096
+ new_item.setdefault("model_families", infer_model_families(new_item))
1097
+ new_item.setdefault("port_media_types", infer_port_media_types(new_item))
1098
+ new_item.setdefault("service_support", infer_service_support(new_item))
1099
+ new_item.setdefault("iot_roles", infer_iot_roles(new_item))
1100
+ new_item["runtime_features"] = sorted(set(new_item.get("runtime_features", [])) | set(infer_runtime_features(new_item)))
1101
+ new_item.setdefault("donor_graph_fingerprint", infer_donor_graph_fingerprint(new_item))
1102
+ new_item.setdefault("apply_safety_level", infer_apply_safety_level(new_item))
1103
+ new_item.setdefault("archetype_tags", infer_archetype_tags(new_item))
1104
+ new_item.setdefault("validated_edit_capabilities", infer_validated_edit_capabilities(new_item))
1105
+ new_item.setdefault("acceptance_fixtures", [])
1106
+ new_item.setdefault("provenance", "")
1107
+ new_item.setdefault("promotion_evidence", infer_promotion_evidence(new_item))
1108
+ new_item.setdefault("acceptance_notes", infer_acceptance_notes(new_item))
1109
+ new_item.setdefault("workspace_validation_status", "")
1110
+ new_item.setdefault("evidence_source", "inferred")
1111
+ enriched.append(new_item)
1112
+ return enriched
1113
+
1114
+
1115
+ @lru_cache(maxsize=8)
1116
+ def _load_catalog_cached(path_str: str) -> tuple[SampleDescriptor, ...]:
1117
+ raw_items = json.loads(Path(path_str).read_text(encoding="utf-8"))
1118
+ items = enrich_catalog_items(raw_items)
1119
+ saves_root = get_packet_tracer_saves_root()
1120
+ return tuple(
1121
+ _descriptor_from_item(
1122
+ {
1123
+ **item,
1124
+ "path": str((saves_root / item["relative_path"]) if saves_root is not None else item.get("path", item["relative_path"])),
1125
+ }
1126
+ )
1127
+ for item in items
1128
+ if "error" not in item
1129
+ )
1130
+
1131
+
1132
+ def load_catalog(path: Path | None = None) -> list[SampleDescriptor]:
1133
+ return list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
1134
+
1135
+
1136
+ def _summarize_pkt(path: Path, relative_path: str, origin: str, prototype_eligible: bool) -> dict[str, Any]:
1137
+ # Catalogued labs are parsed immediately below, so a bad file shows up as
1138
+ # unparseable XML without paying for the authentication pass.
1139
+ xml, _container = decode_pkt_auto(path.read_bytes(), verify=False)
1140
+ root = parse_pkt_xml(xml)
1141
+ config_text = "\n".join(
1142
+ line.text or "" for line in root.findall(".//ENGINE/RUNNINGCONFIG/LINE")
1143
+ )
1144
+ from pkt_editor import inventory_root
1145
+
1146
+ inventory = inventory_root(root)
1147
+ workspace_result = inspect_workspace_integrity(root)
1148
+ devices = []
1149
+ indexed_devices = root.findall(".//DEVICES/DEVICE")
1150
+ index_to_name = {str(index): device.findtext("./ENGINE/NAME", default="") for index, device in enumerate(indexed_devices)}
1151
+ save_ref_to_name = {
1152
+ device.findtext("./ENGINE/SAVE_REF_ID", default=""): device.findtext("./ENGINE/NAME", default="")
1153
+ for device in indexed_devices
1154
+ if device.findtext("./ENGINE/SAVE_REF_ID", default="")
1155
+ }
1156
+ # Imported here rather than at module scope: `pkt_transformer` imports this
1157
+ # module, so a top-level import would close the circle.
1158
+ from pkt_transformer import port_capacity
1159
+
1160
+ for device in root.findall(".//DEVICES/DEVICE"):
1161
+ type_node = device.find("./ENGINE/TYPE")
1162
+ devices.append(
1163
+ {
1164
+ "name": device.findtext("./ENGINE/NAME", default=""),
1165
+ "type": device.findtext("./ENGINE/TYPE", default=""),
1166
+ "model": type_node.get("model", "") if type_node is not None else "",
1167
+ # Whether a device can carry a serial WAN is not visible from
1168
+ # its type or model: the ports live on an installed module. A
1169
+ # prompt asking for a leased line was ranked against samples
1170
+ # that had no serial hardware at all, because nothing in the
1171
+ # catalogue recorded it.
1172
+ "serial_ports": port_capacity(device).get("Serial", 0),
1173
+ }
1174
+ )
1175
+ links = []
1176
+ for link in root.findall(".//LINKS/LINK"):
1177
+ cable = link.find("./CABLE")
1178
+ ports = cable.findall("PORT") if cable is not None else []
1179
+ from_ref = cable.findtext("FROM", default="") if cable is not None else ""
1180
+ to_ref = cable.findtext("TO", default="") if cable is not None else ""
1181
+ links.append(
1182
+ {
1183
+ "from": save_ref_to_name.get(from_ref, index_to_name.get(from_ref, "")),
1184
+ "to": save_ref_to_name.get(to_ref, index_to_name.get(to_ref, "")),
1185
+ "type": link.findtext("./TYPE", default=""),
1186
+ "cable_type": cable.findtext("./TYPE", default="") if cable is not None else "",
1187
+ "media": cable.findtext("./TYPE", default="") if cable is not None else "",
1188
+ "ports": [port.text or "" for port in ports[:2]],
1189
+ }
1190
+ )
1191
+ service_aliases = {
1192
+ "dhcp_server": "dhcp",
1193
+ "dns_server": "dns",
1194
+ "http_server": "http",
1195
+ "https_server": "https",
1196
+ "ftp_server": "ftp",
1197
+ "tftp_server": "tftp",
1198
+ "ntp_server": "ntp",
1199
+ "email_server": "email",
1200
+ "syslog_server": "syslog",
1201
+ "acs_server": "aaa",
1202
+ }
1203
+ service_support = sorted(
1204
+ {
1205
+ service_aliases[service]
1206
+ for services in inventory.get("services", {}).values()
1207
+ for service in services
1208
+ if service in service_aliases
1209
+ }
1210
+ )
1211
+ iot_roles = sorted({str(entry.get("role", "")).strip() for entry in inventory.get("iot", {}).values() if str(entry.get("role", "")).strip()})
1212
+ return {
1213
+ "path": str(path),
1214
+ "relative_path": relative_path,
1215
+ "config_capability_tags": sorted(config_capability_tags(config_text)),
1216
+ "version": root.findtext("./VERSION", default=""),
1217
+ "device_count": len(devices),
1218
+ "link_count": len(links),
1219
+ "devices": devices,
1220
+ "links": links,
1221
+ "origin": origin,
1222
+ "trust_level": "reference-only" if origin != "cisco-local" else "trusted",
1223
+ "role": "reference" if origin != "cisco-local" else "primary",
1224
+ "prototype_eligible": prototype_eligible,
1225
+ "service_support": service_support,
1226
+ "iot_roles": iot_roles,
1227
+ "workspace_validation": {
1228
+ "workspace_mode": workspace_result.workspace_mode,
1229
+ "logical_status": workspace_result.logical_status,
1230
+ "physical_status": workspace_result.physical_status,
1231
+ "blocking_issue_count": len(workspace_result.blocking_issues),
1232
+ "blocking_issues": workspace_result.blocking_issues,
1233
+ },
1234
+ }
1235
+
1236
+
1237
+ def summarize_pkt_descriptor(
1238
+ path: Path,
1239
+ relative_path: str | None = None,
1240
+ *,
1241
+ origin: str = "external-reference",
1242
+ prototype_eligible: bool = False,
1243
+ trust_level: str | None = None,
1244
+ role: str | None = None,
1245
+ license_or_permission: str | None = None,
1246
+ promotion_status: str | None = None,
1247
+ validation_status: str | None = None,
1248
+ donor_eligible: bool | None = None,
1249
+ ) -> SampleDescriptor:
1250
+ item = _summarize_pkt(path, relative_path or path.name, origin, prototype_eligible)
1251
+ if trust_level is not None:
1252
+ item["trust_level"] = trust_level
1253
+ if role is not None:
1254
+ item["role"] = role
1255
+ if license_or_permission is not None:
1256
+ item["license_or_permission"] = license_or_permission
1257
+ if promotion_status is not None:
1258
+ item["promotion_status"] = promotion_status
1259
+ if validation_status is not None:
1260
+ item["validation_status"] = validation_status
1261
+ if donor_eligible is not None:
1262
+ item["donor_eligible"] = donor_eligible
1263
+ enriched = enrich_catalog_items([item])[0]
1264
+ return _descriptor_from_item(enriched)
1265
+
1266
+
1267
+ @lru_cache(maxsize=4)
1268
+ def _load_reference_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
1269
+ items: list[dict[str, Any]] = []
1270
+ for root_str in roots_key:
1271
+ root = Path(root_str)
1272
+ if not root.exists():
1273
+ continue
1274
+ for pkt_path in sorted(root.rglob("*.pkt")):
1275
+ try:
1276
+ rel = str(pkt_path.relative_to(root))
1277
+ item = _summarize_pkt(pkt_path, rel, "external-reference", False)
1278
+ validation = validate_external_sample_summary(item, curated=False)
1279
+ item["license_or_permission"] = _detect_license_or_permission(root)
1280
+ item["validation_status"] = validation.validation_status
1281
+ item["promotion_status"] = validation.promotion_status
1282
+ item["donor_eligible"] = validation.donor_eligible
1283
+ items.append(item)
1284
+ except Exception:
1285
+ continue
1286
+ enriched = enrich_catalog_items(items)
1287
+ return tuple(_descriptor_from_item(item) for item in enriched)
1288
+
1289
+
1290
+ def load_reference_catalog(reference_roots: list[Path] | None = None) -> list[SampleDescriptor]:
1291
+ if not reference_roots:
1292
+ return []
1293
+ roots_key = tuple(sorted(str(path) for path in reference_roots))
1294
+ return list(_load_reference_catalog_cached(roots_key))
1295
+
1296
+
1297
+ @lru_cache(maxsize=4)
1298
+ def _load_curated_donor_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
1299
+ items: list[dict[str, Any]] = []
1300
+ registry = load_curated_donor_registry()
1301
+ for root_str in roots_key:
1302
+ root = Path(root_str)
1303
+ if not root.exists():
1304
+ continue
1305
+ license_or_permission = _detect_license_or_permission(root)
1306
+ for pkt_path in sorted(root.rglob("*.pkt")):
1307
+ try:
1308
+ rel = str(pkt_path.relative_to(root))
1309
+ item = _summarize_pkt(pkt_path, rel, "external-curated", True)
1310
+ validation = validate_external_sample_summary(item, curated=True)
1311
+ item["trust_level"] = "curated" if validation.donor_eligible else "reference-only"
1312
+ item["role"] = "secondary" if validation.donor_eligible else "reference"
1313
+ item["prototype_eligible"] = validation.donor_eligible
1314
+ item["license_or_permission"] = license_or_permission
1315
+ item["validation_status"] = validation.validation_status
1316
+ item["promotion_status"] = validation.promotion_status
1317
+ item["packet_tracer_version"] = validation.packet_tracer_version
1318
+ item["donor_eligible"] = validation.donor_eligible
1319
+ registry_entry = registry.get(_normalize_registry_key(rel)) or registry.get(_normalize_registry_key(pkt_path.name))
1320
+ item = _merge_curated_registry_entry(item, registry_entry, validation)
1321
+ items.append(item)
1322
+ except Exception:
1323
+ continue
1324
+ enriched = enrich_catalog_items(items)
1325
+ return tuple(_descriptor_from_item(item) for item in enriched)
1326
+
1327
+
1328
+ def load_curated_donor_catalog(donor_roots: list[Path] | None = None) -> list[SampleDescriptor]:
1329
+ if not donor_roots:
1330
+ return []
1331
+ roots_key = tuple(sorted(str(path) for path in donor_roots))
1332
+ return list(_load_curated_donor_catalog_cached(roots_key))
1333
+
1334
+
1335
+ def extract_reference_patterns(samples: list[SampleDescriptor]) -> list[ReferencePattern]:
1336
+ patterns: list[ReferencePattern] = []
1337
+ for sample in samples:
1338
+ patterns.append(
1339
+ ReferencePattern(
1340
+ relative_path=sample.relative_path,
1341
+ origin=sample.origin,
1342
+ capability_tags=sample.capability_tags,
1343
+ topology_tags=sample.topology_tags,
1344
+ device_summary=sample.normalized_device_counts(),
1345
+ wireless_mode_tags=sample.wireless_mode_tags,
1346
+ )
1347
+ )
1348
+ return patterns
1349
+
1350
+
1351
+ def write_catalog_outputs(items: list[dict[str, Any]], json_path: Path | None = None, md_path: Path | None = None) -> None:
1352
+ enriched = enrich_catalog_items(items)
1353
+ compact_items: list[dict[str, Any]] = []
1354
+ for item in enriched:
1355
+ saved = dict(item)
1356
+ saved.pop("path", None)
1357
+ compact_items.append(saved)
1358
+ (json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(compact_items, indent=2, ensure_ascii=False), encoding="utf-8")
1359
+ root_label = "<PACKET_TRACER_SAVES_ROOT>"
1360
+ lines = ["# Packet Tracer Installed Sample Catalog", "", f"Source root: `{root_label}`", ""]
1361
+ for item in enriched:
1362
+ if "error" in item:
1363
+ lines.append(f"- `{item['relative_path']}`")
1364
+ lines.append(f" decode error: `{item['error']}`")
1365
+ continue
1366
+ labels = ", ".join(f"{device['name']} ({device['type']}/{device['model']})" for device in item["devices"])
1367
+ lines.append(f"- `{item['relative_path']}`")
1368
+ lines.append(f" version: `{item['version']}`, devices: `{item['device_count']}`, links: `{item['link_count']}`")
1369
+ lines.append(f" tags: {', '.join(item.get('capability_tags', []))}")
1370
+ lines.append(f" topology: {', '.join(item.get('topology_tags', []))}")
1371
+ lines.append(f" devices: {labels}")
1372
+ (md_path or DEFAULT_CATALOG_MD).write_text("\n".join(lines) + "\n", encoding="utf-8")