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,727 +1,1072 @@
1
- from __future__ import annotations
2
-
3
- import copy
4
- from functools import lru_cache
5
- import re
6
- import xml.etree.ElementTree as ET
7
- from pathlib import Path
8
- from typing import Any
9
-
10
- from packet_tracer_env import get_packet_tracer_compatibility_donor, get_packet_tracer_target_version, require_packet_tracer_compatibility_donor, resolve_sample_path
11
- from pkt_codec import decode_pkt_modern
12
- from sample_catalog import SampleDescriptor, load_catalog, normalize_device_type
13
- from workspace_repair import sanitize_generated_physical_workspace, validate_workspace_integrity
14
-
15
- SCRIPT_DIR = Path(__file__).resolve().parent
16
- SKILL_ROOT = SCRIPT_DIR.parent
17
- TEMPLATE_DIR = SKILL_ROOT / "templates" / "pt900" / "device_library"
18
- BASE_TEMPLATE_ROOT = SKILL_ROOT / "templates" / "pt900" / "base_empty.xml"
19
- FALLBACK_PROTOTYPE_SAMPLE = r"01 Networking\FTP\FTP.pkt"
20
- DEVICE_TEMPLATE_FILES = {
21
- "PC": "pc.xml",
22
- "Printer": "printer.xml",
23
- "Router": "router.xml",
24
- "Switch": "switch.xml",
25
- }
26
- GENERIC_COPPER_HOST_TYPES = {"PC", "Server", "Printer", "Laptop"}
27
-
28
-
29
- def load_sample_root(sample_path: str | Path) -> ET.Element:
30
- return copy.deepcopy(_load_sample_root_cached(str(sample_path)))
31
-
32
-
33
- @lru_cache(maxsize=512)
34
- def _load_sample_root_cached(sample_path: str) -> ET.Element:
35
- path = Path(sample_path)
36
- if path.suffix.lower() == ".xml":
37
- xml_bytes = path.read_bytes()
38
- else:
39
- xml_bytes = decode_pkt_modern(path.read_bytes())
40
- return ET.fromstring(xml_bytes)
41
-
42
-
43
- @lru_cache(maxsize=1)
44
- def generation_root_sample() -> str:
45
- donor = get_packet_tracer_compatibility_donor()
46
- if donor is not None:
47
- return str(donor)
48
- try:
49
- fallback = resolve_sample_path(FALLBACK_PROTOTYPE_SAMPLE)
50
- except FileNotFoundError:
51
- fallback = None
52
- if fallback is not None and fallback.exists():
53
- return str(fallback)
54
- return str(BASE_TEMPLATE_ROOT)
55
-
56
-
57
- def generation_root_version() -> str:
58
- donor = get_packet_tracer_compatibility_donor()
59
- if donor is not None:
60
- root = _load_sample_root_cached(str(donor))
61
- return root.findtext("./VERSION", default=get_packet_tracer_target_version())
62
- return get_packet_tracer_target_version()
63
-
64
-
65
- def strict_compatibility_mode() -> bool:
66
- root_sample = Path(generation_root_sample())
67
- return root_sample.suffix.lower() == ".pkt" and generation_root_version().startswith("9.")
68
-
69
-
70
- def _device_type(device: ET.Element) -> str:
71
- return normalize_device_type(device.findtext("./ENGINE/TYPE", default=""))
72
-
73
-
74
- def _device_model(device: ET.Element) -> str:
75
- node = device.find("./ENGINE/TYPE")
76
- return node.get("model", "") if node is not None else ""
77
-
78
-
79
- def _logical_position(device: ET.Element) -> tuple[int, int]:
80
- x_node = device.find("./WORKSPACE/LOGICAL/X")
81
- y_node = device.find("./WORKSPACE/LOGICAL/Y")
82
- x_val = float(x_node.text) if x_node is not None and x_node.text else 200.0
83
- y_val = float(y_node.text) if y_node is not None and y_node.text else 200.0
84
- return int(round(x_val)), int(round(y_val))
85
-
86
-
87
- def _set_device_name(device: ET.Element, new_name: str) -> None:
88
- node = device.find("./ENGINE/NAME")
89
- if node is not None:
90
- node.text = new_name
91
-
92
-
93
- def _set_device_model(device: ET.Element, model: str) -> None:
94
- node = device.find("./ENGINE/TYPE")
95
- if node is not None:
96
- node.set("model", model)
97
-
98
-
99
- def _set_position(device: ET.Element, x_pos: int, y_pos: int) -> None:
100
- x_node = device.find("./WORKSPACE/LOGICAL/X")
101
- y_node = device.find("./WORKSPACE/LOGICAL/Y")
102
- if x_node is not None:
103
- x_node.text = str(x_pos)
104
- if y_node is not None:
105
- y_node.text = str(y_pos)
106
-
107
-
108
- def _set_workspace_ids(device: ET.Element, index: int) -> None:
109
- mem_node = device.find("./WORKSPACE/LOGICAL/MEM_ADDR")
110
- dev_node = device.find("./WORKSPACE/LOGICAL/DEV_ADDR")
111
- if mem_node is not None:
112
- mem_node.text = str(3_000_000_000_000 + index * 32)
113
- if dev_node is not None:
114
- dev_node.text = str(3_100_000_000_000 + index * 32)
115
-
116
-
117
- def _set_runtime_ids(device: ET.Element, index: int) -> None:
118
- save_ref = device.find("./ENGINE/SAVE_REF_ID")
119
- serial = device.find("./ENGINE/SERIALNUMBER")
120
- start_time = device.find("./ENGINE/STARTTIME")
121
- coord_x = device.find("./ENGINE/COORD_SETTINGS/X_COORD")
122
- coord_y = device.find("./ENGINE/COORD_SETTINGS/Y_COORD")
123
- logical_x = device.findtext("./WORKSPACE/LOGICAL/X", default="")
124
- logical_y = device.findtext("./WORKSPACE/LOGICAL/Y", default="")
125
- if save_ref is not None:
126
- save_ref.text = f"save-ref-id:{9_000_000_000_000_000_000 + index}"
127
- if serial is not None:
128
- prefix = (serial.text or "AUTO").split("-")[0][:8] or "AUTO"
129
- serial.text = f"{prefix}{index:04d}-"
130
- if start_time is not None:
131
- start_time.text = str(800_000_000_000 + index)
132
- if coord_x is not None and logical_x:
133
- coord_x.text = logical_x
134
- if coord_y is not None and logical_y:
135
- coord_y.text = logical_y
136
-
137
-
138
- def _build_workspace_reference_library(root: ET.Element) -> dict[str, list[ET.Element]]:
139
- buckets: dict[str, list[ET.Element]] = {}
140
- for device in root.findall(".//DEVICES/DEVICE"):
141
- workspace = device.find("./WORKSPACE")
142
- if workspace is None:
143
- continue
144
- buckets.setdefault(_device_type(device), []).append(workspace)
145
- return buckets
146
-
147
-
148
- def _normalize_workspace_physical(device: ET.Element, requested_type: str, reference_workspaces: dict[str, list[ET.Element]], usage_index: int) -> None:
149
- candidates = reference_workspaces.get(requested_type, [])
150
- if not candidates:
151
- return
152
- reference = copy.deepcopy(candidates[(usage_index - 1) % len(candidates)])
153
- target_workspace = device.find("./WORKSPACE")
154
- if target_workspace is None:
155
- return
156
-
157
- logical = target_workspace.find("./LOGICAL")
158
- logical_copy = copy.deepcopy(logical) if logical is not None else None
159
- target_workspace.clear()
160
- for child in list(reference):
161
- target_workspace.append(copy.deepcopy(child))
162
- if logical_copy is not None:
163
- logical_target = target_workspace.find("./LOGICAL")
164
- if logical_target is not None:
165
- target_workspace.remove(logical_target)
166
- target_workspace.insert(0, logical_copy)
167
-
168
-
169
- def _find_switch_config(device: ET.Element) -> ET.Element | None:
170
- for node in device.findall(".//FILE_CONTENT/CONFIG"):
171
- rendered = "\n".join(line.text or "" for line in node.findall("./LINE"))
172
- if "interface FastEthernet0/1" in rendered:
173
- return node
174
- return None
175
-
176
-
177
- def apply_host_ip(device: ET.Element, config: dict[str, Any]) -> None:
178
- port = device.find("./ENGINE/MODULE/SLOT/MODULE/PORT")
179
- if port is None:
180
- return
181
- for source_key, xml_key in [("ip", "IP"), ("mask", "SUBNET")]:
182
- if source_key in config:
183
- node = port.find(xml_key)
184
- if node is not None:
185
- node.text = str(config[source_key])
186
- if "gw" in config:
187
- node = device.find("./ENGINE/GATEWAY")
188
- if node is not None:
189
- node.text = str(config["gw"])
190
- if "dns" in config:
191
- node = device.find("./ENGINE/DNS_CLIENT/SERVER_IP")
192
- if node is not None:
193
- node.text = str(config["dns"])
194
- port_dns = port.find("PORT_DNS")
195
- if port_dns is not None:
196
- port_dns.text = str(config["dns"])
197
-
198
-
199
- def apply_router_config(device: ET.Element, lines: list[str]) -> None:
200
- for target in [device.find("./ENGINE/RUNNINGCONFIG"), device.find("./ENGINE/STARTUPCONFIG")]:
201
- if target is None:
202
- continue
203
- target.clear()
204
- final_lines = [str(line) for line in lines]
205
- if final_lines and final_lines[-1].strip().lower() != "end":
206
- final_lines.append("end")
207
- for line in final_lines:
208
- node = ET.SubElement(target, "LINE")
209
- node.text = line
210
-
211
-
212
- def apply_switch_config(device: ET.Element, lines: list[str]) -> None:
213
- target = _find_switch_config(device)
214
- if target is None:
215
- return
216
- target.clear()
217
- final_lines = [str(line) for line in lines]
218
- if final_lines and final_lines[-1].strip().lower() != "end":
219
- final_lines.append("end")
220
- for line in final_lines:
221
- node = ET.SubElement(target, "LINE")
222
- node.text = line
223
-
224
-
225
- def build_device_library(source_root: ET.Element) -> dict[str, list[ET.Element]]:
226
- buckets: dict[str, list[ET.Element]] = {}
227
- for device in source_root.findall(".//DEVICES/DEVICE"):
228
- buckets.setdefault(_device_type(device), []).append(device)
229
- return buckets
230
-
231
-
232
- def _load_device_template(device_type: str, model: str | None = None) -> ET.Element | None:
233
- normalized = normalize_device_type(device_type)
234
- template_name = DEVICE_TEMPLATE_FILES.get(normalized, f"{normalized.lower()}.xml")
235
- if not template_name:
236
- return None
237
- template_path = TEMPLATE_DIR / template_name
238
- if not template_path.exists():
239
- return None
240
- root = ET.fromstring(template_path.read_text(encoding="utf-8"))
241
- if root.find("./WORKSPACE") is None and root.find("./POSITION") is not None:
242
- root = _synthesize_template_device(root)
243
- actual_type = _device_type(root)
244
- actual_model = _device_model(root)
245
- if actual_type != normalized and not (not actual_type and actual_model):
246
- return None
247
- if model and actual_model.lower() != str(model).lower():
248
- return None
249
- return root
250
-
251
-
252
- def _template_text(node: ET.Element | None, default: str) -> str:
253
- if node is None or node.text is None:
254
- return default
255
- value = node.text.strip()
256
- if not value or (value.startswith("__") and value.endswith("__")):
257
- return default
258
- return value
259
-
260
-
261
- def _synthesize_template_device(template_root: ET.Element) -> ET.Element:
262
- engine_type = template_root.find("./ENGINE/TYPE")
263
- device_type = normalize_device_type(template_root.findtext("./ENGINE/TYPE", default=""))
264
- model = engine_type.get("model", "") if engine_type is not None else ""
265
- device_name = _template_text(template_root.find("./ENGINE/NAME"), f"{device_type}0")
266
- x_pos = _template_text(template_root.find("./POSITION/X"), "200")
267
- y_pos = _template_text(template_root.find("./POSITION/Y"), "200")
268
-
269
- device = ET.Element("DEVICE")
270
- engine = ET.SubElement(device, "ENGINE")
271
- type_node = ET.SubElement(engine, "TYPE")
272
- type_node.text = device_type
273
- type_node.set("model", model)
274
- type_node.set("customModel", "")
275
- name_node = ET.SubElement(engine, "NAME")
276
- name_node.set("translate", "true")
277
- name_node.text = device_name
278
- save_ref = ET.SubElement(engine, "SAVE_REF_ID")
279
- save_ref.text = "save-ref-id:template-0"
280
- serial = ET.SubElement(engine, "SERIALNUMBER")
281
- serial.text = "TEMPLATE0000-"
282
- start_time = ET.SubElement(engine, "STARTTIME")
283
- start_time.text = "800000000000"
284
- coord = ET.SubElement(engine, "COORD_SETTINGS")
285
- coord_x = ET.SubElement(coord, "X_COORD")
286
- coord_x.text = x_pos
287
- coord_y = ET.SubElement(coord, "Y_COORD")
288
- coord_y.text = y_pos
289
- coord_z = ET.SubElement(coord, "Z_COORD")
290
- coord_z.text = "0"
291
-
292
- port_specs = [
293
- (
294
- port.get("name", f"Port{index}"),
295
- port.get("kind", "eCopperFastEthernet"),
296
- str(4_000_000_000_000 + index * 64),
297
- )
298
- for index, port in enumerate(template_root.findall("./PORTS/PORT"), start=1)
299
- ]
300
- if not port_specs:
301
- port_specs = [("FastEthernet0", "eCopperFastEthernet", "4000000000064")]
302
-
303
- if device_type in GENERIC_COPPER_HOST_TYPES:
304
- module = ET.SubElement(engine, "MODULE")
305
- module_type = ET.SubElement(module, "TYPE")
306
- module_type.text = "eNonRemovableModule"
307
- slot = ET.SubElement(module, "SLOT")
308
- slot_type = ET.SubElement(slot, "TYPE")
309
- slot_type.text = "ePtHostModule"
310
- slot_module = ET.SubElement(slot, "MODULE")
311
- slot_module_type = ET.SubElement(slot_module, "TYPE")
312
- slot_module_type.text = "ePtHostModule"
313
- slot_module_model = ET.SubElement(slot_module, "MODEL")
314
- slot_module_model.text = "PT-HOST-NM-1CFE"
315
- port_name, port_kind, port_mem = port_specs[0]
316
- port = ET.SubElement(slot_module, "PORT")
317
- port_type = ET.SubElement(port, "TYPE")
318
- port_type.text = port_kind
319
- port_mem_node = ET.SubElement(port, "MEM_ADDR")
320
- port_mem_node.text = port_mem
321
- ip_node = ET.SubElement(port, "IP")
322
- ip_node.text = _template_text(template_root.find("./CONFIG/IP"), "")
323
- mask_node = ET.SubElement(port, "SUBNET")
324
- mask_node.text = _template_text(template_root.find("./CONFIG/MASK"), "")
325
- gateway_node = ET.SubElement(port, "PORT_GATEWAY")
326
- gateway_node.text = _template_text(template_root.find("./CONFIG/GATEWAY"), "")
327
- dns_node = ET.SubElement(port, "PORT_DNS")
328
- dns_node.text = _template_text(template_root.find("./CONFIG/DNS"), "")
329
- port_name_node = ET.SubElement(port, "NAME")
330
- port_name_node.text = port_name
331
- gateway = ET.SubElement(engine, "GATEWAY")
332
- gateway.text = _template_text(template_root.find("./CONFIG/GATEWAY"), "")
333
- dns_client = ET.SubElement(engine, "DNS_CLIENT")
334
- dns_server_ip = ET.SubElement(dns_client, "SERVER_IP")
335
- dns_server_ip.text = _template_text(template_root.find("./CONFIG/DNS"), "")
336
- else:
337
- ports_parent = ET.SubElement(engine, "PORTS")
338
- for port_name, port_kind, port_mem in port_specs:
339
- port = ET.SubElement(ports_parent, "PORT")
340
- port_type = ET.SubElement(port, "TYPE")
341
- port_type.text = port_kind
342
- port_mem_node = ET.SubElement(port, "MEM_ADDR")
343
- port_mem_node.text = port_mem
344
- port_name_node = ET.SubElement(port, "NAME")
345
- port_name_node.text = port_name
346
-
347
- if device_type == "Router":
348
- for tag in ("RUNNINGCONFIG", "STARTUPCONFIG"):
349
- config_root = ET.SubElement(engine, tag)
350
- for line in (f"hostname {device_name}", "end"):
351
- line_node = ET.SubElement(config_root, "LINE")
352
- line_node.text = line
353
- if device_type == "Switch":
354
- file_content = ET.SubElement(engine, "FILE_CONTENT")
355
- config = ET.SubElement(file_content, "CONFIG")
356
- for line in (f"hostname {device_name}", "interface FastEthernet0/1", "end"):
357
- line_node = ET.SubElement(config, "LINE")
358
- line_node.text = line
359
-
360
- workspace = ET.SubElement(device, "WORKSPACE")
361
- logical = ET.SubElement(workspace, "LOGICAL")
362
- logical_x = ET.SubElement(logical, "X")
363
- logical_x.text = x_pos
364
- logical_y = ET.SubElement(logical, "Y")
365
- logical_y.text = y_pos
366
- devcluster = ET.SubElement(logical, "DEVCLUSTERID")
367
- devcluster.text = "1-1"
368
- custom_logical = ET.SubElement(logical, "CUSTOM_IMAGE_LOGICAL")
369
- custom_logical.text = ""
370
- custom_physical = ET.SubElement(logical, "CUSTOM_IMAGE_PHYSICAL")
371
- custom_physical.text = ""
372
- mem_addr = ET.SubElement(logical, "MEM_ADDR")
373
- mem_addr.text = "3000000000032"
374
- dev_addr = ET.SubElement(logical, "DEV_ADDR")
375
- dev_addr.text = "3100000000032"
376
- physical = ET.SubElement(workspace, "PHYSICAL")
377
- physical.set("translate", "true")
378
- physical.text = f"Intercity,Home City,Corporate Office,Main Wiring Closet,Table,{device_name}"
379
- return device
380
-
381
-
382
- def _existing_sample_path(sample_path: str | Path) -> Path | None:
383
- path = Path(sample_path)
384
- return path if path.exists() else None
385
-
386
-
387
- def _generic_link_prototype() -> ET.Element:
388
- return ET.fromstring(
389
- """
390
- <LINK>
391
- <TYPE>Cable</TYPE>
392
- <CABLE>
393
- <FROM>0</FROM>
394
- <TO>1</TO>
395
- <PORT>FastEthernet0</PORT>
396
- <PORT>FastEthernet0/1</PORT>
397
- <TYPE>eStraightThrough</TYPE>
398
- <FROM_DEVICE_MEM_ADDR>0</FROM_DEVICE_MEM_ADDR>
399
- <TO_DEVICE_MEM_ADDR>1</TO_DEVICE_MEM_ADDR>
400
- <FROM_PORT_MEM_ADDR>0</FROM_PORT_MEM_ADDR>
401
- <TO_PORT_MEM_ADDR>1</TO_PORT_MEM_ADDR>
402
- <FUNCTIONAL>true</FUNCTIONAL>
403
- <GEO_VIEW_COLOR>#6ba72e</GEO_VIEW_COLOR>
404
- <IS_MANAGED_IN_RACK_VIEW>false</IS_MANAGED_IN_RACK_VIEW>
405
- </CABLE>
406
- </LINK>
407
- """.strip()
408
- )
409
-
410
-
411
- def find_device_prototype(device_type: str, model: str | None, preferred_sample: SampleDescriptor) -> ET.Element:
412
- return ET.fromstring(_find_device_prototype_xml(device_type, model, preferred_sample.path))
413
-
414
-
415
- @lru_cache(maxsize=512)
416
- def _find_device_prototype_xml(device_type: str, model: str | None, preferred_sample_path: str) -> str:
417
- compatibility_root = generation_root_sample()
418
- visited_paths: set[str] = set()
419
- normalized_target = normalize_device_type(device_type)
420
- wanted_model = (model or "").lower()
421
- if strict_compatibility_mode():
422
- candidate_paths = [compatibility_root]
423
- else:
424
- candidate_paths = [compatibility_root, preferred_sample_path]
425
- candidate_paths.extend(sample.path for sample in load_catalog())
426
- for candidate_path in candidate_paths:
427
- if candidate_path in visited_paths:
428
- continue
429
- visited_paths.add(candidate_path)
430
- if not _existing_sample_path(candidate_path):
431
- continue
432
- root = _load_sample_root_cached(candidate_path)
433
- for device in root.findall(".//DEVICES/DEVICE"):
434
- if _device_type(device) != normalized_target:
435
- continue
436
- actual_model = _device_model(device).lower()
437
- if wanted_model and actual_model != wanted_model:
438
- continue
439
- return ET.tostring(device, encoding="unicode")
440
- if strict_compatibility_mode():
441
- donor = require_packet_tracer_compatibility_donor()
442
- target = f"{device_type} with model {model}" if model else device_type
443
- raise ValueError(f"Strict 9.0 donor {donor} does not contain a prototype for {target}")
444
- template = _load_device_template(normalized_target, model)
445
- if template is not None:
446
- return ET.tostring(template, encoding="unicode")
447
- if model:
448
- raise ValueError(f"No prototype found for device type {device_type} with model {model}")
449
- raise ValueError(f"No prototype found for device type {device_type}")
450
-
451
-
452
- def infer_default_links(devices: list[dict[str, Any]]) -> list[dict[str, Any]]:
453
- routers = [device for device in devices if device["type"] == "Router"]
454
- switches = [device for device in devices if device["type"] == "Switch"]
455
- hosts = [device for device in devices if device["type"] in {"PC", "Server"}]
456
- links: list[dict[str, Any]] = []
457
- if switches:
458
- uplink_switch = switches[0]
459
- for index, host in enumerate(hosts, start=1):
460
- links.append({"a": {"dev": host["name"], "port": "FastEthernet0"}, "b": {"dev": uplink_switch["name"], "port": f"FastEthernet0/{min(index, 24)}"}, "media": "copper"})
461
- if routers:
462
- links.append({"a": {"dev": uplink_switch["name"], "port": "FastEthernet0/24"}, "b": {"dev": routers[0]["name"], "port": "FastEthernet0/0"}, "media": "copper"})
463
- for prev, nxt in zip(switches, switches[1:]):
464
- links.append({"a": {"dev": prev["name"], "port": "FastEthernet0/23"}, "b": {"dev": nxt["name"], "port": "FastEthernet0/24"}, "media": "copper"})
465
- elif len(routers) >= 2:
466
- for prev, nxt in zip(routers, routers[1:]):
467
- links.append({"a": {"dev": prev["name"], "port": "FastEthernet0/0"}, "b": {"dev": nxt["name"], "port": "FastEthernet0/1"}, "media": "copper"})
468
- return links
469
-
470
-
471
- def _effective_link_type(device_type: str) -> str:
472
- normalized = normalize_device_type(device_type)
473
- if normalized in GENERIC_COPPER_HOST_TYPES:
474
- return "PC"
475
- return normalized
476
-
477
-
478
- def _prototype_link_by_pair(source_sample_path: str | Path, left_type: str, right_type: str) -> ET.Element:
479
- return ET.fromstring(_prototype_link_xml(str(source_sample_path), left_type, right_type))
480
-
481
-
482
- @lru_cache(maxsize=512)
483
- def _prototype_link_xml(source_sample_path: str, left_type: str, right_type: str) -> str:
484
- compatibility_root = generation_root_sample()
485
- if strict_compatibility_mode():
486
- candidate_paths = [str(compatibility_root)]
487
- else:
488
- candidate_paths = [str(compatibility_root), str(source_sample_path)]
489
- try:
490
- candidate_paths.append(str(resolve_sample_path(FALLBACK_PROTOTYPE_SAMPLE)))
491
- except FileNotFoundError:
492
- pass
493
- candidate_paths.extend(sample.path for sample in load_catalog())
494
- visited_paths: set[str] = set()
495
- wanted_left = _effective_link_type(left_type)
496
- wanted_right = _effective_link_type(right_type)
497
- for candidate in candidate_paths:
498
- root_key = str(candidate)
499
- if root_key in visited_paths:
500
- continue
501
- visited_paths.add(root_key)
502
- if not _existing_sample_path(root_key):
503
- continue
504
- root = _load_sample_root_cached(root_key)
505
- devices = root.findall(".//DEVICES/DEVICE")
506
- index_to_type = {str(index): _effective_link_type(_device_type(device)) for index, device in enumerate(devices)}
507
- save_ref_to_type = {
508
- device.findtext("./ENGINE/SAVE_REF_ID", default=""): _effective_link_type(_device_type(device))
509
- for device in devices
510
- if device.findtext("./ENGINE/SAVE_REF_ID", default="")
511
- }
512
- for link in root.findall(".//LINKS/LINK"):
513
- cable = link.find("./CABLE")
514
- if cable is None:
515
- continue
516
- from_ref = cable.findtext("FROM", default="")
517
- to_ref = cable.findtext("TO", default="")
518
- from_type = save_ref_to_type.get(from_ref) or index_to_type.get(from_ref)
519
- to_type = save_ref_to_type.get(to_ref) or index_to_type.get(to_ref)
520
- if from_type == wanted_left and to_type == wanted_right:
521
- return ET.tostring(link, encoding="unicode")
522
- if from_type == wanted_right and to_type == wanted_left:
523
- return ET.tostring(link, encoding="unicode")
524
- if strict_compatibility_mode():
525
- donor = require_packet_tracer_compatibility_donor()
526
- raise ValueError(f"Strict 9.0 donor {donor} does not contain a prototype link for {left_type} <-> {right_type}")
527
- return ET.tostring(_generic_link_prototype(), encoding="unicode")
528
-
529
-
530
- def apply_cable_type(cable: ET.Element, media: str) -> None:
531
- media_key = media.lower()
532
- node = cable.find("TYPE")
533
- if node is None:
534
- return
535
- mapping = {
536
- "copper": "eStraightThrough",
537
- "straight-through": "eStraightThrough",
538
- "straight": "eStraightThrough",
539
- "crossover": "eCrossOver",
540
- "cross-over": "eCrossOver",
541
- "serial": "eSerialDCE",
542
- "fiber": "eFiber",
543
- }
544
- node.text = mapping.get(media_key, node.text or "eStraightThrough")
545
-
546
-
547
- def _port_nodes(device: ET.Element) -> list[ET.Element]:
548
- return [port for port in device.findall(".//PORT") if port.findtext("TYPE", "").startswith("eCopper")]
549
-
550
-
551
- def _parse_port_index(port_name: str) -> int | None:
552
- match = re.search(r"(\d+)$", port_name)
553
- return int(match.group(1)) if match else None
554
-
555
-
556
- def _canonical_port_name(port_name: str) -> str:
557
- lowered = port_name.strip()
558
- if lowered.lower().startswith("fa"):
559
- return "FastEthernet" + lowered[2:]
560
- if lowered.lower().startswith("gi"):
561
- return "GigabitEthernet" + lowered[2:]
562
- return lowered
563
-
564
-
565
- def _port_address_for_name(device: ET.Element, port_name: str) -> str | None:
566
- canonical = _canonical_port_name(port_name)
567
- fast_nodes = [port for port in _port_nodes(device) if "FastEthernet" in port.findtext("TYPE", "")]
568
- gig_nodes = [port for port in _port_nodes(device) if "GigabitEthernet" in port.findtext("TYPE", "")]
569
- target: ET.Element | None = None
570
- if canonical == "FastEthernet0":
571
- target = fast_nodes[0] if fast_nodes else None
572
- elif canonical.startswith("FastEthernet"):
573
- index = _parse_port_index(canonical)
574
- if index is not None and fast_nodes:
575
- if _device_type(device) == "Router":
576
- target = fast_nodes[index] if index < len(fast_nodes) else None
577
- else:
578
- target = fast_nodes[index - 1] if 0 < index <= len(fast_nodes) else None
579
- elif canonical.startswith("GigabitEthernet"):
580
- index = _parse_port_index(canonical)
581
- if index is not None and gig_nodes:
582
- if _device_type(device) == "Router" and "/" in canonical and canonical.count("/") == 1:
583
- target = gig_nodes[index] if index < len(gig_nodes) else None
584
- else:
585
- target = gig_nodes[index - 1] if 0 < index <= len(gig_nodes) else None
586
- if target is None:
587
- return None
588
- return target.findtext("MEM_ADDR")
589
-
590
-
591
- def _sanitize_generated_runtime_sections(root: ET.Element) -> None:
592
- scenario_set = root.find("./SCENARIOSET")
593
- if scenario_set is not None:
594
- scenario_set.clear()
595
- scenario = ET.SubElement(scenario_set, "SCENARIO")
596
- name = ET.SubElement(scenario, "NAME")
597
- name.set("translate", "true")
598
- name.text = "Scenario 0"
599
- description = ET.SubElement(scenario, "DESCRIPTION")
600
- description.set("translate", "true")
601
- command_logs = root.find("./COMMAND_LOGS")
602
- if command_logs is not None:
603
- command_logs.clear()
604
- ceps = root.find("./CEPS")
605
- if ceps is not None:
606
- ceps.clear()
607
-
608
-
609
- def transform_from_blueprint(blueprint: dict[str, Any], sample: SampleDescriptor) -> bytes:
610
- root_sample_path = generation_root_sample()
611
- target_root = copy.deepcopy(load_sample_root(root_sample_path))
612
- version_node = target_root.find("./VERSION")
613
- if version_node is not None:
614
- version_node.text = generation_root_version()
615
- devices_parent = target_root.find(".//DEVICES")
616
- links_parent = target_root.find(".//LINKS")
617
- if devices_parent is None or links_parent is None:
618
- raise ValueError("Sample XML is missing DEVICES or LINKS")
619
- devices_parent.clear()
620
- links_parent.clear()
621
- for tag in ["LINES", "RECTANGLES", "ELLIPSES", "POLYGONS", "GEOVIEW_GRAPHICSITEMS", "NOTES"]:
622
- node = target_root.find(tag)
623
- if node is not None:
624
- node.clear()
625
- compatibility_mode = strict_compatibility_mode()
626
- preferred_source = _existing_sample_path(sample.path)
627
- library_source_root = load_sample_root(root_sample_path)
628
- if not compatibility_mode and preferred_source is not None:
629
- library_source_root = load_sample_root(preferred_source)
630
- reference_workspaces = _build_workspace_reference_library(load_sample_root(root_sample_path))
631
-
632
- device_library = build_device_library(library_source_root)
633
- requested_devices = blueprint.get("devices", [])
634
- if not requested_devices:
635
- raise ValueError("Blueprint must include devices")
636
- requested_links = blueprint.get("links") or infer_default_links(requested_devices)
637
- configs = blueprint.get("configs", {})
638
-
639
- built_devices: list[ET.Element] = []
640
- name_to_device: dict[str, ET.Element] = {}
641
- name_to_index: dict[str, int] = {}
642
- type_usage: dict[str, int] = {}
643
- workspace_usage: dict[str, int] = {}
644
-
645
- for requested in requested_devices:
646
- requested_type = normalize_device_type(str(requested["type"]))
647
- requested_model = requested.get("model")
648
- prototypes = device_library.get(requested_type, [])
649
- use_index = type_usage.get(requested_type, 0)
650
- workspace_index = workspace_usage.get(requested_type, 0) + 1
651
- workspace_usage[requested_type] = workspace_index
652
- matching_prototypes = prototypes
653
- if requested_model:
654
- matching_prototypes = [prototype for prototype in prototypes if _device_model(prototype).lower() == str(requested_model).lower()]
655
- if use_index < len(matching_prototypes):
656
- built = copy.deepcopy(matching_prototypes[use_index])
657
- type_usage[requested_type] = use_index + 1
658
- else:
659
- if compatibility_mode:
660
- donor = require_packet_tracer_compatibility_donor()
661
- target = f"{requested_type} with model {requested_model}" if requested_model else requested_type
662
- raise ValueError(
663
- f"Strict 9.0 donor {donor} has only {len(matching_prototypes)} prototype(s) for {target}; requested more devices than donor supports."
664
- )
665
- if requested_model:
666
- built = copy.deepcopy(find_device_prototype(requested_type, str(requested_model), sample))
667
- else:
668
- built = copy.deepcopy(find_device_prototype(requested_type, None, sample))
669
- name = str(requested["name"])
670
- _set_device_name(built, name)
671
- if requested_model:
672
- _set_device_model(built, str(requested_model))
673
- default_x, default_y = _logical_position(built)
674
- _set_position(built, int(requested.get("x", default_x)), int(requested.get("y", default_y)))
675
- if not compatibility_mode:
676
- _normalize_workspace_physical(built, requested_type, reference_workspaces, workspace_index)
677
- _set_workspace_ids(built, len(built_devices) + 1)
678
- _set_runtime_ids(built, len(built_devices) + 1)
679
- device_config = configs.get(name, {})
680
- if requested_type in {"PC", "Server"} and isinstance(device_config, dict):
681
- apply_host_ip(built, device_config)
682
- elif requested_type == "Router" and isinstance(device_config, list):
683
- apply_router_config(built, device_config)
684
- elif requested_type == "Switch" and isinstance(device_config, list):
685
- apply_switch_config(built, device_config)
686
- name_to_index[name] = len(built_devices)
687
- name_to_device[name] = built
688
- built_devices.append(built)
689
- devices_parent.append(built)
690
-
691
- requested_lookup = {str(device["name"]): normalize_device_type(str(device["type"])) for device in requested_devices}
692
- for requested_link in requested_links:
693
- left_name = str(requested_link["a"]["dev"])
694
- right_name = str(requested_link["b"]["dev"])
695
- prototype = copy.deepcopy(_prototype_link_by_pair(sample.path, requested_lookup[left_name], requested_lookup[right_name]))
696
- cable = prototype.find("./CABLE")
697
- if cable is None:
698
- continue
699
- from_device = name_to_device[left_name]
700
- to_device = name_to_device[right_name]
701
- if compatibility_mode:
702
- cable.find("FROM").text = from_device.findtext("./ENGINE/SAVE_REF_ID", default=str(name_to_index[left_name]))
703
- cable.find("TO").text = to_device.findtext("./ENGINE/SAVE_REF_ID", default=str(name_to_index[right_name]))
704
- else:
705
- cable.find("FROM").text = str(name_to_index[left_name])
706
- cable.find("TO").text = str(name_to_index[right_name])
707
- ports = cable.findall("PORT")
708
- if len(ports) >= 2:
709
- ports[0].text = str(requested_link["a"]["port"])
710
- ports[1].text = str(requested_link["b"]["port"])
711
- mappings = [
712
- ("FROM_DEVICE_MEM_ADDR", from_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR")),
713
- ("TO_DEVICE_MEM_ADDR", to_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR")),
714
- ("FROM_PORT_MEM_ADDR", _port_address_for_name(from_device, str(requested_link["a"]["port"]))),
715
- ("TO_PORT_MEM_ADDR", _port_address_for_name(to_device, str(requested_link["b"]["port"]))),
716
- ]
717
- for node_name, value in mappings:
718
- node = cable.find(node_name)
719
- if node is not None and value:
720
- node.text = value
721
- apply_cable_type(cable, str(requested_link.get("media", "copper")))
722
- links_parent.append(prototype)
723
-
724
- _sanitize_generated_runtime_sections(target_root)
725
- sanitize_generated_physical_workspace(target_root)
726
- validate_workspace_integrity(target_root)
727
- return ET.tostring(target_root, encoding="utf-8", xml_declaration=False)
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from collections import Counter
5
+ from functools import lru_cache
6
+ import re
7
+ import xml.etree.ElementTree as ET
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from packet_tracer_env import get_packet_tracer_compatibility_donor, get_packet_tracer_target_version, require_packet_tracer_compatibility_donor, resolve_sample_path
12
+ from pkt_codec import decode_pkt_modern
13
+ from sample_catalog import SampleDescriptor, load_catalog, normalize_device_type
14
+ from workspace_repair import sanitize_generated_physical_workspace, validate_workspace_integrity
15
+
16
+ SCRIPT_DIR = Path(__file__).resolve().parent
17
+ SKILL_ROOT = SCRIPT_DIR.parent
18
+ TEMPLATE_DIR = SKILL_ROOT / "templates" / "pt900" / "device_library"
19
+ BASE_TEMPLATE_ROOT = SKILL_ROOT / "templates" / "pt900" / "base_empty.xml"
20
+ FALLBACK_PROTOTYPE_SAMPLE = r"01 Networking\FTP\FTP.pkt"
21
+ DEVICE_TEMPLATE_FILES = {
22
+ "PC": "pc.xml",
23
+ "Printer": "printer.xml",
24
+ "Router": "router.xml",
25
+ "Switch": "switch.xml",
26
+ }
27
+ GENERIC_COPPER_HOST_TYPES = {"PC", "Server", "Printer", "Laptop"}
28
+
29
+
30
+ def load_sample_root(sample_path: str | Path) -> ET.Element:
31
+ return copy.deepcopy(_load_sample_root_cached(str(sample_path)))
32
+
33
+
34
+ @lru_cache(maxsize=512)
35
+ def _load_sample_root_cached(sample_path: str) -> ET.Element:
36
+ path = Path(sample_path)
37
+ if path.suffix.lower() == ".xml":
38
+ xml_bytes = path.read_bytes()
39
+ else:
40
+ xml_bytes = decode_pkt_modern(path.read_bytes())
41
+ return ET.fromstring(xml_bytes)
42
+
43
+
44
+ @lru_cache(maxsize=1)
45
+ def generation_root_sample() -> str:
46
+ donor = get_packet_tracer_compatibility_donor()
47
+ if donor is not None:
48
+ return str(donor)
49
+ try:
50
+ fallback = resolve_sample_path(FALLBACK_PROTOTYPE_SAMPLE)
51
+ except FileNotFoundError:
52
+ fallback = None
53
+ if fallback is not None and fallback.exists():
54
+ return str(fallback)
55
+ return str(BASE_TEMPLATE_ROOT)
56
+
57
+
58
+ def generation_root_version() -> str:
59
+ donor = get_packet_tracer_compatibility_donor()
60
+ if donor is not None:
61
+ root = _load_sample_root_cached(str(donor))
62
+ return root.findtext("./VERSION", default=get_packet_tracer_target_version())
63
+ return get_packet_tracer_target_version()
64
+
65
+
66
+ def strict_compatibility_mode() -> bool:
67
+ root_sample = Path(generation_root_sample())
68
+ return root_sample.suffix.lower() == ".pkt" and generation_root_version().startswith("9.")
69
+
70
+
71
+ def _device_type(device: ET.Element) -> str:
72
+ return normalize_device_type(device.findtext("./ENGINE/TYPE", default=""))
73
+
74
+
75
+ def _device_model(device: ET.Element) -> str:
76
+ node = device.find("./ENGINE/TYPE")
77
+ return node.get("model", "") if node is not None else ""
78
+
79
+
80
+ def _logical_position(device: ET.Element) -> tuple[int, int]:
81
+ x_node = device.find("./WORKSPACE/LOGICAL/X")
82
+ y_node = device.find("./WORKSPACE/LOGICAL/Y")
83
+ x_val = float(x_node.text) if x_node is not None and x_node.text else 200.0
84
+ y_val = float(y_node.text) if y_node is not None and y_node.text else 200.0
85
+ return int(round(x_val)), int(round(y_val))
86
+
87
+
88
+ def _set_device_name(device: ET.Element, new_name: str) -> None:
89
+ node = device.find("./ENGINE/NAME")
90
+ if node is not None:
91
+ node.text = new_name
92
+
93
+
94
+ def _set_device_model(device: ET.Element, model: str) -> None:
95
+ node = device.find("./ENGINE/TYPE")
96
+ if node is not None:
97
+ node.set("model", model)
98
+
99
+
100
+ def _set_position(device: ET.Element, x_pos: int, y_pos: int) -> None:
101
+ x_node = device.find("./WORKSPACE/LOGICAL/X")
102
+ y_node = device.find("./WORKSPACE/LOGICAL/Y")
103
+ if x_node is not None:
104
+ x_node.text = str(x_pos)
105
+ if y_node is not None:
106
+ y_node.text = str(y_pos)
107
+
108
+
109
+ def _set_workspace_ids(device: ET.Element, index: int) -> None:
110
+ mem_node = device.find("./WORKSPACE/LOGICAL/MEM_ADDR")
111
+ dev_node = device.find("./WORKSPACE/LOGICAL/DEV_ADDR")
112
+ if mem_node is not None:
113
+ mem_node.text = str(3_000_000_000_000 + index * 32)
114
+ if dev_node is not None:
115
+ dev_node.text = str(3_100_000_000_000 + index * 32)
116
+
117
+
118
+ def _set_runtime_ids(device: ET.Element, index: int) -> None:
119
+ save_ref = device.find("./ENGINE/SAVE_REF_ID")
120
+ serial = device.find("./ENGINE/SERIALNUMBER")
121
+ start_time = device.find("./ENGINE/STARTTIME")
122
+ coord_x = device.find("./ENGINE/COORD_SETTINGS/X_COORD")
123
+ coord_y = device.find("./ENGINE/COORD_SETTINGS/Y_COORD")
124
+ logical_x = device.findtext("./WORKSPACE/LOGICAL/X", default="")
125
+ logical_y = device.findtext("./WORKSPACE/LOGICAL/Y", default="")
126
+ if save_ref is not None:
127
+ save_ref.text = f"save-ref-id:{9_000_000_000_000_000_000 + index}"
128
+ if serial is not None:
129
+ prefix = (serial.text or "AUTO").split("-")[0][:8] or "AUTO"
130
+ serial.text = f"{prefix}{index:04d}-"
131
+ if start_time is not None:
132
+ start_time.text = str(800_000_000_000 + index)
133
+ if coord_x is not None and logical_x:
134
+ coord_x.text = logical_x
135
+ if coord_y is not None and logical_y:
136
+ coord_y.text = logical_y
137
+
138
+
139
+ def _build_workspace_reference_library(root: ET.Element) -> dict[str, list[ET.Element]]:
140
+ buckets: dict[str, list[ET.Element]] = {}
141
+ for device in root.findall(".//DEVICES/DEVICE"):
142
+ workspace = device.find("./WORKSPACE")
143
+ if workspace is None:
144
+ continue
145
+ buckets.setdefault(_device_type(device), []).append(workspace)
146
+ return buckets
147
+
148
+
149
+ def _normalize_workspace_physical(device: ET.Element, requested_type: str, reference_workspaces: dict[str, list[ET.Element]], usage_index: int) -> None:
150
+ candidates = reference_workspaces.get(requested_type, [])
151
+ if not candidates:
152
+ return
153
+ reference = copy.deepcopy(candidates[(usage_index - 1) % len(candidates)])
154
+ target_workspace = device.find("./WORKSPACE")
155
+ if target_workspace is None:
156
+ return
157
+
158
+ logical = target_workspace.find("./LOGICAL")
159
+ logical_copy = copy.deepcopy(logical) if logical is not None else None
160
+ target_workspace.clear()
161
+ for child in list(reference):
162
+ target_workspace.append(copy.deepcopy(child))
163
+ if logical_copy is not None:
164
+ logical_target = target_workspace.find("./LOGICAL")
165
+ if logical_target is not None:
166
+ target_workspace.remove(logical_target)
167
+ target_workspace.insert(0, logical_copy)
168
+
169
+
170
+ def _find_switch_config(device: ET.Element) -> ET.Element | None:
171
+ for node in device.findall(".//FILE_CONTENT/CONFIG"):
172
+ rendered = "\n".join(line.text or "" for line in node.findall("./LINE"))
173
+ if "interface FastEthernet0/1" in rendered:
174
+ return node
175
+ return None
176
+
177
+
178
+ def _ensure_text(parent: ET.Element, tag: str, value: str) -> None:
179
+ """Set a child element's text, creating the element if it is absent."""
180
+ node = parent.find(tag)
181
+ if node is None:
182
+ node = ET.SubElement(parent, tag)
183
+ node.text = value
184
+
185
+
186
+ def apply_host_ip(device: ET.Element, config: dict[str, Any]) -> None:
187
+ """Set a host's addressing.
188
+
189
+ A static address on a port still marked `PORT_DHCP_ENABLE=true` is ignored:
190
+ Packet Tracer keeps asking for a lease and the interface reports 0.0.0.0.
191
+ Measured against a live Packet Tracer -- the file carried 192.168.1.20 and
192
+ the device reported nothing, which opening the file could never reveal.
193
+ """
194
+ port = device.find("./ENGINE/MODULE/SLOT/MODULE/PORT")
195
+ if port is None:
196
+ return
197
+ if config.get("ip"):
198
+ for node in device.findall(".//PORT"):
199
+ if node.find("PORT_DHCP_ENABLE") is not None:
200
+ _ensure_text(node, "PORT_DHCP_ENABLE", "false")
201
+ for source_key, xml_key in [("ip", "IP"), ("mask", "SUBNET")]:
202
+ if source_key in config:
203
+ node = port.find(xml_key)
204
+ if node is not None:
205
+ node.text = str(config[source_key])
206
+ if "gw" in config:
207
+ # A host stores its gateway twice: once on the device and once on the
208
+ # port. Writing only the device copy left the port holding the donor's
209
+ # value -- a lab addressed 192.168.1.21 with the port still pointing at
210
+ # 2.1.1.1, which no file-level check notices.
211
+ engine = device.find("./ENGINE")
212
+ _ensure_text(device if engine is None else engine, "GATEWAY", str(config["gw"]))
213
+ _ensure_text(port, "PORT_GATEWAY", str(config["gw"]))
214
+ if "dns" in config:
215
+ node = device.find("./ENGINE/DNS_CLIENT/SERVER_IP")
216
+ if node is not None:
217
+ node.text = str(config["dns"])
218
+ port_dns = port.find("PORT_DNS")
219
+ if port_dns is not None:
220
+ port_dns.text = str(config["dns"])
221
+
222
+
223
+ def apply_router_config(device: ET.Element, lines: list[str]) -> None:
224
+ for target in [device.find("./ENGINE/RUNNINGCONFIG"), device.find("./ENGINE/STARTUPCONFIG")]:
225
+ if target is None:
226
+ continue
227
+ target.clear()
228
+ final_lines = [str(line) for line in lines]
229
+ if final_lines and final_lines[-1].strip().lower() != "end":
230
+ final_lines.append("end")
231
+ for line in final_lines:
232
+ node = ET.SubElement(target, "LINE")
233
+ node.text = line
234
+
235
+
236
+ def apply_switch_config(device: ET.Element, lines: list[str]) -> None:
237
+ target = _find_switch_config(device)
238
+ if target is None:
239
+ return
240
+ target.clear()
241
+ final_lines = [str(line) for line in lines]
242
+ if final_lines and final_lines[-1].strip().lower() != "end":
243
+ final_lines.append("end")
244
+ for line in final_lines:
245
+ node = ET.SubElement(target, "LINE")
246
+ node.text = line
247
+
248
+
249
+ def build_device_library(source_root: ET.Element) -> dict[str, list[ET.Element]]:
250
+ buckets: dict[str, list[ET.Element]] = {}
251
+ for device in source_root.findall(".//DEVICES/DEVICE"):
252
+ buckets.setdefault(_device_type(device), []).append(device)
253
+ return buckets
254
+
255
+
256
+ def _load_device_template(device_type: str, model: str | None = None) -> ET.Element | None:
257
+ normalized = normalize_device_type(device_type)
258
+ template_name = DEVICE_TEMPLATE_FILES.get(normalized, f"{normalized.lower()}.xml")
259
+ if not template_name:
260
+ return None
261
+ template_path = TEMPLATE_DIR / template_name
262
+ if not template_path.exists():
263
+ return None
264
+ root = ET.fromstring(template_path.read_text(encoding="utf-8"))
265
+ if root.find("./WORKSPACE") is None and root.find("./POSITION") is not None:
266
+ root = _synthesize_template_device(root)
267
+ actual_type = _device_type(root)
268
+ actual_model = _device_model(root)
269
+ if actual_type != normalized and not (not actual_type and actual_model):
270
+ return None
271
+ if model and actual_model.lower() != str(model).lower():
272
+ return None
273
+ return root
274
+
275
+
276
+ def _template_text(node: ET.Element | None, default: str) -> str:
277
+ if node is None or node.text is None:
278
+ return default
279
+ value = node.text.strip()
280
+ if not value or (value.startswith("__") and value.endswith("__")):
281
+ return default
282
+ return value
283
+
284
+
285
+ def _synthesize_template_device(template_root: ET.Element) -> ET.Element:
286
+ engine_type = template_root.find("./ENGINE/TYPE")
287
+ device_type = normalize_device_type(template_root.findtext("./ENGINE/TYPE", default=""))
288
+ model = engine_type.get("model", "") if engine_type is not None else ""
289
+ device_name = _template_text(template_root.find("./ENGINE/NAME"), f"{device_type}0")
290
+ x_pos = _template_text(template_root.find("./POSITION/X"), "200")
291
+ y_pos = _template_text(template_root.find("./POSITION/Y"), "200")
292
+
293
+ device = ET.Element("DEVICE")
294
+ engine = ET.SubElement(device, "ENGINE")
295
+ type_node = ET.SubElement(engine, "TYPE")
296
+ type_node.text = device_type
297
+ type_node.set("model", model)
298
+ type_node.set("customModel", "")
299
+ name_node = ET.SubElement(engine, "NAME")
300
+ name_node.set("translate", "true")
301
+ name_node.text = device_name
302
+ save_ref = ET.SubElement(engine, "SAVE_REF_ID")
303
+ save_ref.text = "save-ref-id:template-0"
304
+ serial = ET.SubElement(engine, "SERIALNUMBER")
305
+ serial.text = "TEMPLATE0000-"
306
+ start_time = ET.SubElement(engine, "STARTTIME")
307
+ start_time.text = "800000000000"
308
+ coord = ET.SubElement(engine, "COORD_SETTINGS")
309
+ coord_x = ET.SubElement(coord, "X_COORD")
310
+ coord_x.text = x_pos
311
+ coord_y = ET.SubElement(coord, "Y_COORD")
312
+ coord_y.text = y_pos
313
+ coord_z = ET.SubElement(coord, "Z_COORD")
314
+ coord_z.text = "0"
315
+
316
+ port_specs = [
317
+ (
318
+ port.get("name", f"Port{index}"),
319
+ port.get("kind", "eCopperFastEthernet"),
320
+ str(4_000_000_000_000 + index * 64),
321
+ )
322
+ for index, port in enumerate(template_root.findall("./PORTS/PORT"), start=1)
323
+ ]
324
+ if not port_specs:
325
+ port_specs = [("FastEthernet0", "eCopperFastEthernet", "4000000000064")]
326
+
327
+ if device_type in GENERIC_COPPER_HOST_TYPES:
328
+ module = ET.SubElement(engine, "MODULE")
329
+ module_type = ET.SubElement(module, "TYPE")
330
+ module_type.text = "eNonRemovableModule"
331
+ slot = ET.SubElement(module, "SLOT")
332
+ slot_type = ET.SubElement(slot, "TYPE")
333
+ slot_type.text = "ePtHostModule"
334
+ slot_module = ET.SubElement(slot, "MODULE")
335
+ slot_module_type = ET.SubElement(slot_module, "TYPE")
336
+ slot_module_type.text = "ePtHostModule"
337
+ slot_module_model = ET.SubElement(slot_module, "MODEL")
338
+ slot_module_model.text = "PT-HOST-NM-1CFE"
339
+ port_name, port_kind, port_mem = port_specs[0]
340
+ port = ET.SubElement(slot_module, "PORT")
341
+ port_type = ET.SubElement(port, "TYPE")
342
+ port_type.text = port_kind
343
+ port_mem_node = ET.SubElement(port, "MEM_ADDR")
344
+ port_mem_node.text = port_mem
345
+ ip_node = ET.SubElement(port, "IP")
346
+ ip_node.text = _template_text(template_root.find("./CONFIG/IP"), "")
347
+ mask_node = ET.SubElement(port, "SUBNET")
348
+ mask_node.text = _template_text(template_root.find("./CONFIG/MASK"), "")
349
+ gateway_node = ET.SubElement(port, "PORT_GATEWAY")
350
+ gateway_node.text = _template_text(template_root.find("./CONFIG/GATEWAY"), "")
351
+ dns_node = ET.SubElement(port, "PORT_DNS")
352
+ dns_node.text = _template_text(template_root.find("./CONFIG/DNS"), "")
353
+ port_name_node = ET.SubElement(port, "NAME")
354
+ port_name_node.text = port_name
355
+ gateway = ET.SubElement(engine, "GATEWAY")
356
+ gateway.text = _template_text(template_root.find("./CONFIG/GATEWAY"), "")
357
+ dns_client = ET.SubElement(engine, "DNS_CLIENT")
358
+ dns_server_ip = ET.SubElement(dns_client, "SERVER_IP")
359
+ dns_server_ip.text = _template_text(template_root.find("./CONFIG/DNS"), "")
360
+ else:
361
+ ports_parent = ET.SubElement(engine, "PORTS")
362
+ for port_name, port_kind, port_mem in port_specs:
363
+ port = ET.SubElement(ports_parent, "PORT")
364
+ port_type = ET.SubElement(port, "TYPE")
365
+ port_type.text = port_kind
366
+ port_mem_node = ET.SubElement(port, "MEM_ADDR")
367
+ port_mem_node.text = port_mem
368
+ port_name_node = ET.SubElement(port, "NAME")
369
+ port_name_node.text = port_name
370
+
371
+ if device_type == "Router":
372
+ for tag in ("RUNNINGCONFIG", "STARTUPCONFIG"):
373
+ config_root = ET.SubElement(engine, tag)
374
+ for line in (f"hostname {device_name}", "end"):
375
+ line_node = ET.SubElement(config_root, "LINE")
376
+ line_node.text = line
377
+ if device_type == "Switch":
378
+ file_content = ET.SubElement(engine, "FILE_CONTENT")
379
+ config = ET.SubElement(file_content, "CONFIG")
380
+ for line in (f"hostname {device_name}", "interface FastEthernet0/1", "end"):
381
+ line_node = ET.SubElement(config, "LINE")
382
+ line_node.text = line
383
+
384
+ workspace = ET.SubElement(device, "WORKSPACE")
385
+ logical = ET.SubElement(workspace, "LOGICAL")
386
+ logical_x = ET.SubElement(logical, "X")
387
+ logical_x.text = x_pos
388
+ logical_y = ET.SubElement(logical, "Y")
389
+ logical_y.text = y_pos
390
+ devcluster = ET.SubElement(logical, "DEVCLUSTERID")
391
+ devcluster.text = "1-1"
392
+ custom_logical = ET.SubElement(logical, "CUSTOM_IMAGE_LOGICAL")
393
+ custom_logical.text = ""
394
+ custom_physical = ET.SubElement(logical, "CUSTOM_IMAGE_PHYSICAL")
395
+ custom_physical.text = ""
396
+ mem_addr = ET.SubElement(logical, "MEM_ADDR")
397
+ mem_addr.text = "3000000000032"
398
+ dev_addr = ET.SubElement(logical, "DEV_ADDR")
399
+ dev_addr.text = "3100000000032"
400
+ physical = ET.SubElement(workspace, "PHYSICAL")
401
+ physical.set("translate", "true")
402
+ physical.text = f"Intercity,Home City,Corporate Office,Main Wiring Closet,Table,{device_name}"
403
+ return device
404
+
405
+
406
+ def _existing_sample_path(sample_path: str | Path) -> Path | None:
407
+ path = Path(sample_path)
408
+ return path if path.exists() else None
409
+
410
+
411
+ def _generic_link_prototype() -> ET.Element:
412
+ return ET.fromstring(
413
+ """
414
+ <LINK>
415
+ <TYPE>Cable</TYPE>
416
+ <CABLE>
417
+ <FROM>0</FROM>
418
+ <TO>1</TO>
419
+ <PORT>FastEthernet0</PORT>
420
+ <PORT>FastEthernet0/1</PORT>
421
+ <TYPE>eStraightThrough</TYPE>
422
+ <FROM_DEVICE_MEM_ADDR>0</FROM_DEVICE_MEM_ADDR>
423
+ <TO_DEVICE_MEM_ADDR>1</TO_DEVICE_MEM_ADDR>
424
+ <FROM_PORT_MEM_ADDR>0</FROM_PORT_MEM_ADDR>
425
+ <TO_PORT_MEM_ADDR>1</TO_PORT_MEM_ADDR>
426
+ <FUNCTIONAL>true</FUNCTIONAL>
427
+ <GEO_VIEW_COLOR>#6ba72e</GEO_VIEW_COLOR>
428
+ <IS_MANAGED_IN_RACK_VIEW>false</IS_MANAGED_IN_RACK_VIEW>
429
+ </CABLE>
430
+ </LINK>
431
+ """.strip()
432
+ )
433
+
434
+
435
+ def find_device_prototype(device_type: str, model: str | None, preferred_sample: SampleDescriptor) -> ET.Element:
436
+ return ET.fromstring(_find_device_prototype_xml(device_type, model, preferred_sample.path))
437
+
438
+
439
+ @lru_cache(maxsize=512)
440
+ def _find_device_prototype_xml(device_type: str, model: str | None, preferred_sample_path: str) -> str:
441
+ compatibility_root = generation_root_sample()
442
+ visited_paths: set[str] = set()
443
+ normalized_target = normalize_device_type(device_type)
444
+ wanted_model = (model or "").lower()
445
+ if strict_compatibility_mode():
446
+ candidate_paths = [compatibility_root]
447
+ else:
448
+ candidate_paths = [compatibility_root, preferred_sample_path]
449
+ candidate_paths.extend(sample.path for sample in load_catalog())
450
+ for candidate_path in candidate_paths:
451
+ if candidate_path in visited_paths:
452
+ continue
453
+ visited_paths.add(candidate_path)
454
+ if not _existing_sample_path(candidate_path):
455
+ continue
456
+ root = _load_sample_root_cached(candidate_path)
457
+ for device in root.findall(".//DEVICES/DEVICE"):
458
+ if _device_type(device) != normalized_target:
459
+ continue
460
+ actual_model = _device_model(device).lower()
461
+ if wanted_model and actual_model != wanted_model:
462
+ continue
463
+ return ET.tostring(device, encoding="unicode")
464
+ if strict_compatibility_mode():
465
+ donor = require_packet_tracer_compatibility_donor()
466
+ target = f"{device_type} with model {model}" if model else device_type
467
+ raise ValueError(f"Strict 9.0 donor {donor} does not contain a prototype for {target}")
468
+ template = _load_device_template(normalized_target, model)
469
+ if template is not None:
470
+ return ET.tostring(template, encoding="unicode")
471
+ if model:
472
+ raise ValueError(f"No prototype found for device type {device_type} with model {model}")
473
+ raise ValueError(f"No prototype found for device type {device_type}")
474
+
475
+
476
+ def infer_default_links(devices: list[dict[str, Any]]) -> list[dict[str, Any]]:
477
+ routers = [device for device in devices if device["type"] == "Router"]
478
+ switches = [device for device in devices if device["type"] == "Switch"]
479
+ hosts = [device for device in devices if device["type"] in {"PC", "Server"}]
480
+ links: list[dict[str, Any]] = []
481
+ if switches:
482
+ uplink_switch = switches[0]
483
+ for index, host in enumerate(hosts, start=1):
484
+ links.append({"a": {"dev": host["name"], "port": "FastEthernet0"}, "b": {"dev": uplink_switch["name"], "port": f"FastEthernet0/{min(index, 24)}"}, "media": "copper"})
485
+ if routers:
486
+ links.append({"a": {"dev": uplink_switch["name"], "port": "FastEthernet0/24"}, "b": {"dev": routers[0]["name"], "port": "FastEthernet0/0"}, "media": "copper"})
487
+ for prev, nxt in zip(switches, switches[1:]):
488
+ links.append({"a": {"dev": prev["name"], "port": "FastEthernet0/23"}, "b": {"dev": nxt["name"], "port": "FastEthernet0/24"}, "media": "copper"})
489
+ elif len(routers) >= 2:
490
+ for prev, nxt in zip(routers, routers[1:]):
491
+ links.append({"a": {"dev": prev["name"], "port": "FastEthernet0/0"}, "b": {"dev": nxt["name"], "port": "FastEthernet0/1"}, "media": "copper"})
492
+ return links
493
+
494
+
495
+ def _effective_link_type(device_type: str) -> str:
496
+ normalized = normalize_device_type(device_type)
497
+ if normalized in GENERIC_COPPER_HOST_TYPES:
498
+ return "PC"
499
+ return normalized
500
+
501
+
502
+ def _prototype_link_by_pair(source_sample_path: str | Path, left_type: str, right_type: str) -> ET.Element:
503
+ return ET.fromstring(_prototype_link_xml(str(source_sample_path), left_type, right_type))
504
+
505
+
506
+ @lru_cache(maxsize=512)
507
+ def _prototype_link_xml(source_sample_path: str, left_type: str, right_type: str) -> str:
508
+ compatibility_root = generation_root_sample()
509
+ if strict_compatibility_mode():
510
+ candidate_paths = [str(compatibility_root)]
511
+ else:
512
+ candidate_paths = [str(compatibility_root), str(source_sample_path)]
513
+ try:
514
+ candidate_paths.append(str(resolve_sample_path(FALLBACK_PROTOTYPE_SAMPLE)))
515
+ except FileNotFoundError:
516
+ pass
517
+ candidate_paths.extend(sample.path for sample in load_catalog())
518
+ visited_paths: set[str] = set()
519
+ wanted_left = _effective_link_type(left_type)
520
+ wanted_right = _effective_link_type(right_type)
521
+ for candidate in candidate_paths:
522
+ root_key = str(candidate)
523
+ if root_key in visited_paths:
524
+ continue
525
+ visited_paths.add(root_key)
526
+ if not _existing_sample_path(root_key):
527
+ continue
528
+ root = _load_sample_root_cached(root_key)
529
+ devices = root.findall(".//DEVICES/DEVICE")
530
+ index_to_type = {str(index): _effective_link_type(_device_type(device)) for index, device in enumerate(devices)}
531
+ save_ref_to_type = {
532
+ device.findtext("./ENGINE/SAVE_REF_ID", default=""): _effective_link_type(_device_type(device))
533
+ for device in devices
534
+ if device.findtext("./ENGINE/SAVE_REF_ID", default="")
535
+ }
536
+ for link in root.findall(".//LINKS/LINK"):
537
+ cable = link.find("./CABLE")
538
+ if cable is None:
539
+ continue
540
+ from_ref = cable.findtext("FROM", default="")
541
+ to_ref = cable.findtext("TO", default="")
542
+ from_type = save_ref_to_type.get(from_ref) or index_to_type.get(from_ref)
543
+ to_type = save_ref_to_type.get(to_ref) or index_to_type.get(to_ref)
544
+ if from_type == wanted_left and to_type == wanted_right:
545
+ return ET.tostring(link, encoding="unicode")
546
+ if from_type == wanted_right and to_type == wanted_left:
547
+ return ET.tostring(link, encoding="unicode")
548
+ if strict_compatibility_mode():
549
+ donor = require_packet_tracer_compatibility_donor()
550
+ raise ValueError(f"Strict 9.0 donor {donor} does not contain a prototype link for {left_type} <-> {right_type}")
551
+ return ET.tostring(_generic_link_prototype(), encoding="unicode")
552
+
553
+
554
+ #
555
+ # A cable has two halves in the file, and they are not interchangeable.
556
+ # Measured across 140 bundled labs:
557
+ #
558
+ # LINK/TYPE eCopper 393, eSerial 55, eCoaxial 12, eOctal 11, ePhoneLine 3
559
+ # CABLE/TYPE eStraightThrough 274, eCrossOver 119, absent 81
560
+ #
561
+ # `LINK/TYPE` is the family; `CABLE/TYPE` is only the copper sub-type and is
562
+ # absent for everything else. Writing `eSerialDCE` or `eFiber` into `CABLE/TYPE`
563
+ # -- which is what this did -- put the value in the wrong element and never set
564
+ # the family, so a serial or fibre link came out as plain copper.
565
+ CABLE_FAMILIES = {
566
+ "copper": ("eCopper", "eStraightThrough"),
567
+ "straight-through": ("eCopper", "eStraightThrough"),
568
+ "straight": ("eCopper", "eStraightThrough"),
569
+ "crossover": ("eCopper", "eCrossOver"),
570
+ "cross-over": ("eCopper", "eCrossOver"),
571
+ "fiber": ("eFiber", ""),
572
+ "fibre": ("eFiber", ""),
573
+ "serial": ("eSerial", ""),
574
+ "serial-dce": ("eSerial", ""),
575
+ "serial-dte": ("eSerial", ""),
576
+ "coaxial": ("eCoaxial", ""),
577
+ "coax": ("eCoaxial", ""),
578
+ "console": ("eOctal", ""),
579
+ "octal": ("eOctal", ""),
580
+ "phone": ("ePhoneLine", ""),
581
+ "phoneline": ("ePhoneLine", ""),
582
+ }
583
+
584
+
585
+ def apply_cable_type(cable: ET.Element, media: str, link: ET.Element | None = None) -> None:
586
+ """Set the cable family on the link and the copper sub-type on the cable.
587
+
588
+ `link` is optional so existing callers keep working, but without it only the
589
+ copper sub-type can be set -- a serial cable needs the family, which lives
590
+ one level up.
591
+ """
592
+ family, subtype = CABLE_FAMILIES.get(media.strip().lower(), ("", ""))
593
+
594
+ if link is not None and family:
595
+ node = link.find("TYPE")
596
+ if node is None:
597
+ node = ET.SubElement(link, "TYPE")
598
+ node.text = family
599
+
600
+ node = cable.find("TYPE")
601
+ if node is None:
602
+ return
603
+ if subtype:
604
+ node.text = subtype
605
+ elif family and family != "eCopper":
606
+ # Non-copper links carry no cable sub-type at all in a real save.
607
+ cable.remove(node)
608
+
609
+
610
+ #
611
+ # Port `TYPE` values as they actually appear in saved labs, counted across the
612
+ # local collection: eCopperFastEthernet 1148, eBluetooth 291,
613
+ # eCopperGigabitEthernet 116, eHostWirelessN 51, eAccessPointWirelessAC 20,
614
+ # eAccessPointWirelessG 18, eSerial 12, eFiberFastEthernet 10.
615
+ #
616
+ # Only the two eCopper families were recognised, so serial, fibre and wireless
617
+ # interfaces were invisible: 33 of 782 real link endpoints -- `Serial2/0` on a
618
+ # router, `Port 0` on an access point -- were reported as not existing.
619
+ PORT_TYPE_FAMILIES = {
620
+ "eCopperFastEthernet": "FastEthernet",
621
+ "eFiberFastEthernet": "FastEthernet",
622
+ "eCopperGigabitEthernet": "GigabitEthernet",
623
+ "eFiberGigabitEthernet": "GigabitEthernet",
624
+ "eSerial": "Serial",
625
+ # A smart-serial port is a serial port. Measured: a router carrying an
626
+ # HWIC-2T reports `eSmartSerial` on its two interfaces and cables to another
627
+ # over `Serial0/0/0` with an `eSerial` link -- Packet Tracer's own doing.
628
+ # Missing here, `port_capacity` answered `Serial: 0` for exactly the routers
629
+ # that have serial, so `port_exists` rejected `Serial0/0/0`, the port repair
630
+ # moved the WAN cable onto Ethernet, and media reconciliation demoted it to
631
+ # copper. Every serial prompt came out over copper for this one line.
632
+ #
633
+ # It hides 196 ports across the local donor pool, against 110 spelled
634
+ # `eSerial`, so most of the serial hardware available was invisible.
635
+ "eSmartSerial": "Serial",
636
+ "eHostWirelessN": "Wireless",
637
+ "eHostWirelessG": "Wireless",
638
+ "eHostWirelessAC": "Wireless",
639
+ "eBluetooth": "Bluetooth",
640
+ }
641
+
642
+
643
+ def _port_nodes(device: ET.Element) -> list[ET.Element]:
644
+ """Wired interfaces that can carry a cable.
645
+
646
+ Restricted to the families whose numbering this module models; wireless
647
+ radios and Bluetooth are counted separately by `port_capacity` and are never
648
+ candidates for a cable.
649
+ """
650
+ return [port for port in device.findall(".//PORT") if port.findtext("TYPE", "").startswith("eCopper")]
651
+
652
+
653
+ def _parse_port_index(port_name: str) -> int | None:
654
+ match = re.search(r"(\d+)$", port_name)
655
+ return int(match.group(1)) if match else None
656
+
657
+
658
+ def _canonical_port_name(port_name: str) -> str:
659
+ """Expand an abbreviated interface name, and leave a full one alone.
660
+
661
+ This assumed its input was always the short form and sliced two characters
662
+ off unconditionally, so `FastEthernet0` came back as
663
+ `FastEthernetstEthernet0`. Every caller that passed a full name -- which is
664
+ the form stored in a saved lab -- got a nonsense string, and `port_exists`
665
+ then reported real interfaces as missing.
666
+ """
667
+ trimmed = port_name.strip()
668
+ lowered = trimmed.lower()
669
+ for prefix, expanded in (("fastethernet", "FastEthernet"), ("gigabitethernet", "GigabitEthernet")):
670
+ if lowered.startswith(prefix):
671
+ return expanded + trimmed[len(prefix):]
672
+ if lowered.startswith("fa"):
673
+ return "FastEthernet" + trimmed[2:]
674
+ if lowered.startswith("gi"):
675
+ return "GigabitEthernet" + trimmed[2:]
676
+ return trimmed
677
+
678
+
679
+ def port_capacity(device: ET.Element) -> dict[str, int]:
680
+ """How many FastEthernet and GigabitEthernet interfaces a device really has.
681
+
682
+ `_port_address_for_name` is a MEM_ADDR lookup, not an existence test: it
683
+ returns None whenever the donor's port nodes carry no address, which is
684
+ common. Using it to decide whether a port exists made every port look
685
+ missing, and inventing `GigabitEthernet0/3` on a 2960-24TT produced a file
686
+ Packet Tracer refused to open.
687
+ """
688
+ nodes = device.findall(".//PORT")
689
+ counts = {"FastEthernet": 0, "GigabitEthernet": 0, "Serial": 0}
690
+ for node in nodes:
691
+ family = PORT_TYPE_FAMILIES.get((node.findtext("TYPE") or "").strip())
692
+ if family in counts:
693
+ counts[family] += 1
694
+ return counts
695
+
696
+
697
+ # Hosts carry a single unslotted interface. Switches and routers slot theirs.
698
+ HOST_DEVICE_TYPES = {"PC", "Server", "Printer", "Laptop", "Tablet", "Smartphone", "WirelessEndDevice"}
699
+ # Devices whose interfaces are numbered straight through, with no slot: a hub's
700
+ # `FastEthernet0`, an access point's `GigabitEthernet0`, a controller's
701
+ # `GigabitEthernet1`.
702
+ #
703
+ # The four after the first three were measured by scanning every cable in 130
704
+ # donor labs, and by asking what happened to a lab that wired one. A device
705
+ # missing from this set falls to the slotted rule, `0 < index <= count`, which
706
+ # rejects index 0 and accepts index 1 -- so for an access point carrying a
707
+ # single `GigabitEthernet0`, the real port was reported absent and the
708
+ # nonexistent `GigabitEthernet0/1` was reported present. The port repair then
709
+ # dutifully replaced the good name with the bad one, and Packet Tracer refused
710
+ # the file.
711
+ UNSLOTTED_MULTIPORT_TYPES = {
712
+ "Hub",
713
+ "Repeater",
714
+ "CoaxialSplitter",
715
+ "LightWeightAccessPoint",
716
+ "WirelessLanController",
717
+ "NetworkController",
718
+ "MerakiServer",
719
+ }
720
+
721
+
722
+ def donor_interface_names(device: ET.Element) -> list[str]:
723
+ """The interfaces a donor device really has, in the order it lists them.
724
+
725
+ A `PORT` element carries no name -- names are positional and depend on the
726
+ model -- so the device's own running config is the authoritative source.
727
+ Subinterfaces and `Vlan` interfaces are excluded: they are configuration,
728
+ not hardware, and cannot take a cable.
729
+
730
+ This exists because guessing from the model name cannot work. A PT8200
731
+ router matched no entry in the prefix table and was handed
732
+ `FastEthernet0/1`, while its real interfaces are `GigabitEthernet0/0/0`
733
+ through `0/0/2`. Packet Tracer refuses to open a lab naming an interface a
734
+ device does not have, so that one wrong name cost the whole file.
735
+ """
736
+ for tag in ("RUNNINGCONFIG", "STARTUPCONFIG"):
737
+ config = device.find(f"./ENGINE/{tag}")
738
+ if config is None:
739
+ continue
740
+ names: list[str] = []
741
+ for line in config.findall("LINE"):
742
+ text = (line.text or "").strip()
743
+ if not text.startswith("interface "):
744
+ continue
745
+ name = text.split(" ", 1)[1].strip()
746
+ if "." in name or name.lower().startswith("vlan"):
747
+ continue
748
+ if name not in names:
749
+ names.append(name)
750
+ if names:
751
+ return names
752
+ return []
753
+
754
+
755
+ def _name_contradicts_device_shape(device: ET.Element, canonical: str, kind: str) -> bool:
756
+ """Whether this interface name disagrees with how the device names its own.
757
+
758
+ Counting ports says how many exist, not what they are called. A stacked 3650
759
+ numbers its uplinks `GigabitEthernet1/0/1`, and asking for
760
+ `GigabitEthernet0/1` on it passed the count test -- twenty-eight gigabit
761
+ ports, index one -- while naming an interface the switch does not have.
762
+ Packet Tracer refused the lab.
763
+
764
+ The device's own configuration is the only place its naming shape is written
765
+ down, since a PORT node carries no name at all. Membership in the
766
+ configuration is not enough either: a stacked switch whose sockets are all
767
+ `GigabitEthernet1/0/N` still carried a stale `interface GigabitEthernet0/1`
768
+ line, so accepting any name the config mentions let a cable land on it --
769
+ the one link, out of twelve, that made `four_switch` the last corpus lab
770
+ Packet Tracer refused.
771
+
772
+ So the shape is compared, not the set. Two axes matter:
773
+
774
+ * Depth. `FastEthernet0/1` and `GigabitEthernet1/0/1` are different shapes,
775
+ and the shape the device really uses is the one most of its interfaces of
776
+ that kind share.
777
+ * Which component varies. A 2960 numbers within one slot -- `FastEthernet0/1`
778
+ .. `0/24` -- while a modular switch numbers by slot: `FastEthernet0/1`,
779
+ `1/1`, ... `9/1`. Both have one slash, so depth cannot tell them apart,
780
+ and the generator's `FastEthernet0/{index}` asked such a switch for
781
+ `FastEthernet0/2`. Measured: that single link is why a lab built from
782
+ `Senan_Haciyev_tapsiriq.pkt` was refused; the same file with the uplink on
783
+ `FastEthernet2/1` opens.
784
+
785
+ Two names are required before concluding anything about the second axis, so
786
+ a device with one configured interface keeps the benefit of the doubt rather
787
+ than having its whole numbering inferred from a single sample.
788
+ """
789
+ named = [name for name in donor_interface_names(device) if name.startswith(kind)]
790
+ if not named:
791
+ return False
792
+
793
+ depths = Counter(name.count("/") for name in named)
794
+ if canonical.count("/") != depths.most_common(1)[0][0]:
795
+ return True
796
+
797
+ slotted = [name for name in named if "/" in name]
798
+ if len(slotted) > 1 and "/" in canonical:
799
+ tails = {name.rsplit("/", 1)[-1] for name in slotted}
800
+ heads = {name.rsplit("/", 1)[0] for name in slotted}
801
+ if len(tails) == 1 and len(heads) > 1:
802
+ return canonical.rsplit("/", 1)[-1] not in tails
803
+ return False
804
+
805
+
806
+ def port_exists(device: ET.Element, port_name: str) -> bool:
807
+ """Whether `port_name` names an interface this device actually has.
808
+
809
+ Numbering differs by device kind, and getting it backwards is how invalid
810
+ interfaces reach a saved lab -- Packet Tracer then refuses to open it.
811
+
812
+ A host's interface is bare: `FastEthernet0`. The slotted form the switch
813
+ rule accepts, `FastEthernet0/1`, does not exist on a PC. This used to answer
814
+ exactly the wrong way round for hosts, calling the real name missing and the
815
+ imaginary one present, while every lab on disk links its PCs on
816
+ `FastEthernet0`.
817
+ """
818
+ canonical = _canonical_port_name(port_name)
819
+ device_type = _device_type(device)
820
+
821
+ # Logical interfaces are configuration, not sockets: nothing plugs into a
822
+ # Port-channel, a PRP-channel, a VLAN interface or a subinterface. They fell
823
+ # through the permissive branch below and were treated as real, so wiring a
824
+ # multilayer switch from an industrial donor put a cable on `PRP-channel 1`
825
+ # and Packet Tracer refused to open the lab at all.
826
+ lowered = canonical.lower()
827
+ if "channel" in lowered or lowered.startswith("vlan") or "." in canonical:
828
+ return False
829
+
830
+ # Serial is modelled now, so it gets a real answer: a router with no serial
831
+ # card cannot carry `Serial0/0/0`, and letting that through produced a WAN
832
+ # lab whose PPP configuration sat on an interface that was never cabled.
833
+ #
834
+ # Owning serial hardware is not the same as owning *that* interface. A pair
835
+ # of routers whose only serial ports are `Serial2/0` and `Serial3/0` passed
836
+ # this test for `Serial0/0/0` -- two serial ports, so yes -- and the lab
837
+ # built from them was refused. The naming shape is checked here for the same
838
+ # reason it is checked for Ethernet below.
839
+ if canonical.startswith("Serial"):
840
+ if port_capacity(device).get("Serial", 0) <= 0:
841
+ return False
842
+ return not _name_contradicts_device_shape(device, canonical, "Serial")
843
+
844
+ # Everything else is genuinely outside this module -- `Port 0` on an access
845
+ # point, `RS 232` on a laptop, the `Switch` pass-through on an IP phone.
846
+ # Reporting those as missing made real links look invalid, which is the
847
+ # damaging direction: a legitimate link gets dropped.
848
+ if not canonical.startswith(("FastEthernet", "GigabitEthernet")):
849
+ return True
850
+
851
+ for kind, count in port_capacity(device).items():
852
+ if not canonical.startswith(kind):
853
+ continue
854
+ # `port_capacity` reports every kind it models, including the ones the
855
+ # device has none of, so a match here means the *name* is of this kind,
856
+ # not that the hardware exists. Without this guard the multi-slot router
857
+ # branch below answered `0 <= 0 <= 0` and declared
858
+ # `GigabitEthernet0/0/0` present on a 2811 that has two FastEthernet
859
+ # ports and no gigabit at all. The link kept that interface, the repair
860
+ # pass found nothing to fix, and Packet Tracer refused the file --
861
+ # measured as "Incompatible File" on every generated lab.
862
+ if count <= 0:
863
+ return False
864
+ # Counting ports says how many exist, not what they are called. A
865
+ # stacked 3650 numbers its uplinks `GigabitEthernet1/0/1`, and asking
866
+ # for `GigabitEthernet0/1` on it passed the count test -- twenty-eight
867
+ # gigabit ports, index one -- while naming an interface the switch does
868
+ # not have. Packet Tracer refused the lab.
869
+ #
870
+ # The device's own configuration is the only place its naming shape is
871
+ # written down, since a PORT node carries no name at all. Compare the
872
+ # slot depth rather than requiring an exact match: a config lists the
873
+ # interfaces someone touched, not every socket on the device.
874
+ # Membership in the configuration is not enough. A stacked switch whose
875
+ # sockets are all `GigabitEthernet1/0/N` still carried a stale
876
+ # `interface GigabitEthernet0/1` line, so accepting any name the config
877
+ # mentions let a cable land on `GigabitEthernet0/1` -- the one link, out
878
+ # of twelve, that made `four_switch` the last corpus lab Packet Tracer
879
+ # refused. Removing only that link opens the file.
880
+ #
881
+ # The shape the device really uses is the one most of its interfaces of
882
+ # that kind share, so compare against that rather than against the set.
883
+ if _name_contradicts_device_shape(device, canonical, kind):
884
+ return False
885
+ if device_type in HOST_DEVICE_TYPES:
886
+ # `FastEthernet0` and, for tolerance, a bare `FastEthernet`.
887
+ return count > 0 and canonical in {kind, f"{kind}0"}
888
+ if device_type in UNSLOTTED_MULTIPORT_TYPES:
889
+ # A hub numbers its ports `FastEthernet0`, `FastEthernet1`, ... --
890
+ # unslotted like a host but many of them.
891
+ index = _parse_port_index(canonical)
892
+ return index is not None and 0 <= index < max(count, 1)
893
+ index = _parse_port_index(canonical)
894
+ if index is None:
895
+ return canonical == kind or canonical == f"{kind}0"
896
+ if device_type == "Router":
897
+ # Routers number from zero, in every form. The multi-slot spelling
898
+ # used to be bounded inclusively -- `0 <= index <= count` -- on the
899
+ # theory that the trailing number is a position within a slot. On an
900
+ # ISR4331 carrying three gigabit ports, `GigabitEthernet0/0/0`
901
+ # through `0/0/2`, that admitted `GigabitEthernet0/0/3`, and the
902
+ # generated lab named an interface the router does not have. Packet
903
+ # Tracer refused it. Measured on the donor: three PORT nodes, three
904
+ # interfaces in the configuration, highest index two.
905
+ return 0 <= index < count
906
+ return 0 < index <= count
907
+ return False
908
+
909
+
910
+ def _port_address_for_name(device: ET.Element, port_name: str) -> str | None:
911
+ canonical = _canonical_port_name(port_name)
912
+ fast_nodes = [port for port in _port_nodes(device) if "FastEthernet" in port.findtext("TYPE", "")]
913
+ gig_nodes = [port for port in _port_nodes(device) if "GigabitEthernet" in port.findtext("TYPE", "")]
914
+ target: ET.Element | None = None
915
+ if canonical == "FastEthernet0":
916
+ target = fast_nodes[0] if fast_nodes else None
917
+ elif canonical.startswith("FastEthernet"):
918
+ index = _parse_port_index(canonical)
919
+ if index is not None and fast_nodes:
920
+ if _device_type(device) == "Router":
921
+ target = fast_nodes[index] if index < len(fast_nodes) else None
922
+ else:
923
+ target = fast_nodes[index - 1] if 0 < index <= len(fast_nodes) else None
924
+ elif canonical.startswith("GigabitEthernet"):
925
+ index = _parse_port_index(canonical)
926
+ if index is not None and gig_nodes:
927
+ if _device_type(device) == "Router" and "/" in canonical and canonical.count("/") == 1:
928
+ target = gig_nodes[index] if index < len(gig_nodes) else None
929
+ else:
930
+ target = gig_nodes[index - 1] if 0 < index <= len(gig_nodes) else None
931
+ if target is None:
932
+ return None
933
+ return target.findtext("MEM_ADDR")
934
+
935
+
936
+ def _sanitize_generated_runtime_sections(root: ET.Element) -> None:
937
+ scenario_set = root.find("./SCENARIOSET")
938
+ if scenario_set is not None:
939
+ scenario_set.clear()
940
+ scenario = ET.SubElement(scenario_set, "SCENARIO")
941
+ name = ET.SubElement(scenario, "NAME")
942
+ name.set("translate", "true")
943
+ name.text = "Scenario 0"
944
+ description = ET.SubElement(scenario, "DESCRIPTION")
945
+ description.set("translate", "true")
946
+ command_logs = root.find("./COMMAND_LOGS")
947
+ if command_logs is not None:
948
+ command_logs.clear()
949
+ ceps = root.find("./CEPS")
950
+ if ceps is not None:
951
+ ceps.clear()
952
+
953
+
954
+ def transform_from_blueprint(blueprint: dict[str, Any], sample: SampleDescriptor) -> bytes:
955
+ root_sample_path = generation_root_sample()
956
+ target_root = copy.deepcopy(load_sample_root(root_sample_path))
957
+ version_node = target_root.find("./VERSION")
958
+ if version_node is not None:
959
+ version_node.text = generation_root_version()
960
+ devices_parent = target_root.find(".//DEVICES")
961
+ links_parent = target_root.find(".//LINKS")
962
+ if devices_parent is None or links_parent is None:
963
+ raise ValueError("Sample XML is missing DEVICES or LINKS")
964
+ devices_parent.clear()
965
+ links_parent.clear()
966
+ for tag in ["LINES", "RECTANGLES", "ELLIPSES", "POLYGONS", "GEOVIEW_GRAPHICSITEMS", "NOTES"]:
967
+ node = target_root.find(tag)
968
+ if node is not None:
969
+ node.clear()
970
+ compatibility_mode = strict_compatibility_mode()
971
+ preferred_source = _existing_sample_path(sample.path)
972
+ library_source_root = load_sample_root(root_sample_path)
973
+ if not compatibility_mode and preferred_source is not None:
974
+ library_source_root = load_sample_root(preferred_source)
975
+ reference_workspaces = _build_workspace_reference_library(load_sample_root(root_sample_path))
976
+
977
+ device_library = build_device_library(library_source_root)
978
+ requested_devices = blueprint.get("devices", [])
979
+ if not requested_devices:
980
+ raise ValueError("Blueprint must include devices")
981
+ requested_links = blueprint.get("links") or infer_default_links(requested_devices)
982
+ configs = blueprint.get("configs", {})
983
+
984
+ built_devices: list[ET.Element] = []
985
+ name_to_device: dict[str, ET.Element] = {}
986
+ name_to_index: dict[str, int] = {}
987
+ type_usage: dict[str, int] = {}
988
+ workspace_usage: dict[str, int] = {}
989
+
990
+ for requested in requested_devices:
991
+ requested_type = normalize_device_type(str(requested["type"]))
992
+ requested_model = requested.get("model")
993
+ prototypes = device_library.get(requested_type, [])
994
+ use_index = type_usage.get(requested_type, 0)
995
+ workspace_index = workspace_usage.get(requested_type, 0) + 1
996
+ workspace_usage[requested_type] = workspace_index
997
+ matching_prototypes = prototypes
998
+ if requested_model:
999
+ matching_prototypes = [prototype for prototype in prototypes if _device_model(prototype).lower() == str(requested_model).lower()]
1000
+ if use_index < len(matching_prototypes):
1001
+ built = copy.deepcopy(matching_prototypes[use_index])
1002
+ type_usage[requested_type] = use_index + 1
1003
+ else:
1004
+ if compatibility_mode:
1005
+ donor = require_packet_tracer_compatibility_donor()
1006
+ target = f"{requested_type} with model {requested_model}" if requested_model else requested_type
1007
+ raise ValueError(
1008
+ f"Strict 9.0 donor {donor} has only {len(matching_prototypes)} prototype(s) for {target}; requested more devices than donor supports."
1009
+ )
1010
+ if requested_model:
1011
+ built = copy.deepcopy(find_device_prototype(requested_type, str(requested_model), sample))
1012
+ else:
1013
+ built = copy.deepcopy(find_device_prototype(requested_type, None, sample))
1014
+ name = str(requested["name"])
1015
+ _set_device_name(built, name)
1016
+ if requested_model:
1017
+ _set_device_model(built, str(requested_model))
1018
+ default_x, default_y = _logical_position(built)
1019
+ _set_position(built, int(requested.get("x", default_x)), int(requested.get("y", default_y)))
1020
+ if not compatibility_mode:
1021
+ _normalize_workspace_physical(built, requested_type, reference_workspaces, workspace_index)
1022
+ _set_workspace_ids(built, len(built_devices) + 1)
1023
+ _set_runtime_ids(built, len(built_devices) + 1)
1024
+ device_config = configs.get(name, {})
1025
+ if requested_type in {"PC", "Server"} and isinstance(device_config, dict):
1026
+ apply_host_ip(built, device_config)
1027
+ elif requested_type == "Router" and isinstance(device_config, list):
1028
+ apply_router_config(built, device_config)
1029
+ elif requested_type == "Switch" and isinstance(device_config, list):
1030
+ apply_switch_config(built, device_config)
1031
+ name_to_index[name] = len(built_devices)
1032
+ name_to_device[name] = built
1033
+ built_devices.append(built)
1034
+ devices_parent.append(built)
1035
+
1036
+ requested_lookup = {str(device["name"]): normalize_device_type(str(device["type"])) for device in requested_devices}
1037
+ for requested_link in requested_links:
1038
+ left_name = str(requested_link["a"]["dev"])
1039
+ right_name = str(requested_link["b"]["dev"])
1040
+ prototype = copy.deepcopy(_prototype_link_by_pair(sample.path, requested_lookup[left_name], requested_lookup[right_name]))
1041
+ cable = prototype.find("./CABLE")
1042
+ if cable is None:
1043
+ continue
1044
+ from_device = name_to_device[left_name]
1045
+ to_device = name_to_device[right_name]
1046
+ if compatibility_mode:
1047
+ cable.find("FROM").text = from_device.findtext("./ENGINE/SAVE_REF_ID", default=str(name_to_index[left_name]))
1048
+ cable.find("TO").text = to_device.findtext("./ENGINE/SAVE_REF_ID", default=str(name_to_index[right_name]))
1049
+ else:
1050
+ cable.find("FROM").text = str(name_to_index[left_name])
1051
+ cable.find("TO").text = str(name_to_index[right_name])
1052
+ ports = cable.findall("PORT")
1053
+ if len(ports) >= 2:
1054
+ ports[0].text = str(requested_link["a"]["port"])
1055
+ ports[1].text = str(requested_link["b"]["port"])
1056
+ mappings = [
1057
+ ("FROM_DEVICE_MEM_ADDR", from_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR")),
1058
+ ("TO_DEVICE_MEM_ADDR", to_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR")),
1059
+ ("FROM_PORT_MEM_ADDR", _port_address_for_name(from_device, str(requested_link["a"]["port"]))),
1060
+ ("TO_PORT_MEM_ADDR", _port_address_for_name(to_device, str(requested_link["b"]["port"]))),
1061
+ ]
1062
+ for node_name, value in mappings:
1063
+ node = cable.find(node_name)
1064
+ if node is not None and value:
1065
+ node.text = value
1066
+ apply_cable_type(cable, str(requested_link.get("media", "copper")))
1067
+ links_parent.append(prototype)
1068
+
1069
+ _sanitize_generated_runtime_sections(target_root)
1070
+ sanitize_generated_physical_workspace(target_root)
1071
+ validate_workspace_integrity(target_root)
1072
+ return ET.tostring(target_root, encoding="utf-8", xml_declaration=False)