packet-tracer-skill 0.1.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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/LICENSE.Twofish-BSD-3-Clause.txt +29 -0
  3. package/README.md +687 -0
  4. package/SKILL.md +221 -0
  5. package/bin/packet-tracer-skill.js +635 -0
  6. package/examples/blueprint_minimal.json +46 -0
  7. package/package.json +42 -0
  8. package/references/packettracer-sample-catalog.json +21410 -0
  9. package/references/packettracer-sample-catalog.md +1124 -0
  10. package/references/pkt-format.md +57 -0
  11. package/references/xml-skeleton-notes.md +44 -0
  12. package/requirements-dev.txt +1 -0
  13. package/requirements.txt +6 -0
  14. package/scripts/build_sample_catalog.py +65 -0
  15. package/scripts/donor_diagnostics.py +35 -0
  16. package/scripts/generate_pkt.py +1264 -0
  17. package/scripts/install_skill.py +71 -0
  18. package/scripts/intent_parser.py +712 -0
  19. package/scripts/packet_tracer_env.py +278 -0
  20. package/scripts/pkt_builder.py +15 -0
  21. package/scripts/pkt_codec.py +181 -0
  22. package/scripts/pkt_editor.py +752 -0
  23. package/scripts/pkt_transformer.py +541 -0
  24. package/scripts/sample_catalog.py +385 -0
  25. package/scripts/sample_selector.py +156 -0
  26. package/scripts/setup.ps1 +26 -0
  27. package/scripts/twofish_diagnostics.py +91 -0
  28. package/scripts/vendor/README.md +46 -0
  29. package/scripts/vendor/twofish.py +81 -0
  30. package/scripts/workspace_repair.py +441 -0
  31. package/templates/pt900/base_empty.xml +21 -0
  32. package/templates/pt900/device_library/pc.xml +20 -0
  33. package/templates/pt900/device_library/printer.xml +432 -0
  34. package/templates/pt900/device_library/router.xml +16 -0
  35. package/templates/pt900/device_library/switch.xml +38 -0
@@ -0,0 +1,46 @@
1
+ # Twofish Bridge Setup
2
+
3
+ This repository does not ship a prebuilt Twofish bridge binary by default.
4
+
5
+ The Packet Tracer `.pkt` codec needs a local Twofish bridge at runtime for
6
+ modern Packet Tracer 9.x encode/decode operations.
7
+
8
+ ## Supported loading paths
9
+
10
+ The wrapper in `twofish.py` loads the bridge from one of these locations:
11
+
12
+ 1. `PKT_TWOFISH_LIBRARY`
13
+ 2. a sibling file in this folder named like:
14
+ - `_twofish*.pyd`
15
+ - `_twofish*.so`
16
+ - `_twofish*.dll`
17
+
18
+ ## Recommended public-repo workflow
19
+
20
+ - keep this repository free of prebuilt machine-specific binaries
21
+ - keep the bridge local to your machine
22
+ - preferred: place the bridge next to `scripts/vendor/twofish.py` inside the installed skill folder
23
+ - optional: store the bridge elsewhere and point `PKT_TWOFISH_LIBRARY` at that local file
24
+
25
+ Example on Windows:
26
+
27
+ ```powershell
28
+ $env:PKT_TWOFISH_LIBRARY="$env:USERPROFILE\.codex\skills\pkt\scripts\vendor\_twofish.cp314-win_amd64.pyd"
29
+ ```
30
+
31
+ ## Supported runtime
32
+
33
+ - supported Python runtime: `3.14.x`
34
+ - current bridge filename: `_twofish.cp314-win_amd64.pyd`
35
+ - other Python ABIs are not considered supported by this public setup
36
+
37
+ ## Security and privacy
38
+
39
+ - do not commit machine-specific binaries unless you have reviewed them
40
+ - do not commit binaries that embed private paths, usernames, or internal build metadata
41
+ - prefer rebuilding or sourcing the bridge in a reproducible way for your own machine
42
+
43
+ ## Failure mode
44
+
45
+ If the bridge is missing, the wrapper raises an `ImportError` with setup guidance
46
+ instead of silently loading a repo-shipped binary.
@@ -0,0 +1,81 @@
1
+ """
2
+ Vendored Twofish ctypes wrapper for the pkt skill.
3
+
4
+ This wrapper loads a local Twofish bridge from either:
5
+
6
+ - `PKT_TWOFISH_LIBRARY`
7
+ - a sibling binary named like `_twofish*.pyd` / `.so` / `.dll`
8
+
9
+ See `scripts/vendor/README.md` for setup guidance.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from ctypes import POINTER, CDLL, Structure, c_char_p, c_int, c_uint32, create_string_buffer, pointer
16
+ from pathlib import Path
17
+
18
+
19
+ def _load_library() -> CDLL:
20
+ here = Path(__file__).resolve().parent
21
+ env_path = os.getenv("PKT_TWOFISH_LIBRARY")
22
+ candidates: list[Path] = []
23
+ if env_path:
24
+ candidate = Path(env_path).expanduser()
25
+ if candidate.exists():
26
+ candidates.append(candidate)
27
+ candidates.extend(sorted(here.glob("_twofish*.pyd")))
28
+ candidates.extend(sorted(here.glob("_twofish*.so")))
29
+ candidates.extend(sorted(here.glob("_twofish*.dll")))
30
+ if not candidates:
31
+ raise ImportError(
32
+ "Twofish bridge not found. Set PKT_TWOFISH_LIBRARY or place a local _twofish binary "
33
+ "next to scripts/vendor/twofish.py. See scripts/vendor/README.md."
34
+ )
35
+ return CDLL(str(candidates[0]))
36
+
37
+
38
+ _twofish = _load_library()
39
+
40
+
41
+ class _TwofishKey(Structure):
42
+ _fields_ = [("s", (c_uint32 * 4) * 256), ("K", c_uint32 * 40)]
43
+
44
+
45
+ _twofish.exp_Twofish_initialise.argtypes = []
46
+ _twofish.exp_Twofish_initialise.restype = None
47
+ _twofish.exp_Twofish_prepare_key.argtypes = [c_char_p, c_int, POINTER(_TwofishKey)]
48
+ _twofish.exp_Twofish_prepare_key.restype = None
49
+ _twofish.exp_Twofish_encrypt.argtypes = [POINTER(_TwofishKey), c_char_p, c_char_p]
50
+ _twofish.exp_Twofish_encrypt.restype = None
51
+ _twofish.exp_Twofish_decrypt.argtypes = [POINTER(_TwofishKey), c_char_p, c_char_p]
52
+ _twofish.exp_Twofish_decrypt.restype = None
53
+ _twofish.exp_Twofish_initialise()
54
+
55
+
56
+ class Twofish:
57
+ def __init__(self, key: bytes) -> None:
58
+ if not isinstance(key, bytes):
59
+ raise TypeError("Twofish key must be bytes")
60
+ if not 0 < len(key) <= 32:
61
+ raise ValueError("invalid Twofish key length")
62
+ self._key = _TwofishKey()
63
+ _twofish.exp_Twofish_prepare_key(key, len(key), pointer(self._key))
64
+
65
+ def encrypt(self, block: bytes) -> bytes:
66
+ if not isinstance(block, bytes):
67
+ raise TypeError("block must be bytes")
68
+ if len(block) != 16:
69
+ raise ValueError("Twofish encrypt expects a 16-byte block")
70
+ outbuf = create_string_buffer(16)
71
+ _twofish.exp_Twofish_encrypt(pointer(self._key), block, outbuf)
72
+ return outbuf.raw
73
+
74
+ def decrypt(self, block: bytes) -> bytes:
75
+ if not isinstance(block, bytes):
76
+ raise TypeError("block must be bytes")
77
+ if len(block) != 16:
78
+ raise ValueError("Twofish decrypt expects a 16-byte block")
79
+ outbuf = create_string_buffer(16)
80
+ _twofish.exp_Twofish_decrypt(pointer(self._key), block, outbuf)
81
+ return outbuf.raw
@@ -0,0 +1,441 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from dataclasses import dataclass
5
+ import xml.etree.ElementTree as ET
6
+
7
+ PHYSICAL_HOME_PATH = ["Intercity", "Home City", "Corporate Office", "Main Wiring Closet"]
8
+
9
+
10
+ @dataclass
11
+ class WorkspaceValidationResult:
12
+ workspace_mode: str
13
+ logical_status: str
14
+ physical_status: str
15
+ blocking_issues: list[str]
16
+
17
+
18
+ @dataclass
19
+ class DonorCoherenceResult:
20
+ device_metadata_status: str
21
+ scenario_status: str
22
+ physical_runtime_status: str
23
+ blocking_issues: list[str]
24
+
25
+
26
+ def _make_workspace_node(name: str, node_type: int, x_pos: int, y_pos: int, width: int, height: int, path: str = "") -> ET.Element:
27
+ node = ET.Element("NODE")
28
+ for tag, value in [
29
+ ("X", str(x_pos)),
30
+ ("Y", str(y_pos)),
31
+ ("TYPE", str(node_type)),
32
+ ]:
33
+ child = ET.SubElement(node, tag)
34
+ child.text = value
35
+ name_node = ET.SubElement(node, "NAME")
36
+ name_node.set("translate", "true")
37
+ name_node.text = name
38
+ for tag, value in [
39
+ ("SX", "1"),
40
+ ("SY", "1"),
41
+ ("W", str(width)),
42
+ ("H", str(height)),
43
+ ("PATH", path),
44
+ ]:
45
+ child = ET.SubElement(node, tag)
46
+ child.text = value
47
+ ET.SubElement(node, "CHILDREN")
48
+ for tag, value in [
49
+ ("MANUAL_SCALING", "false"),
50
+ ("SCALED_PIXMAP_WIDTH", "-842150451"),
51
+ ("SCALED_PIXMAP_HEIGHT", "-842150451"),
52
+ ("INIT_WIDTH", str(width)),
53
+ ("INIT_HEIGHT", str(height)),
54
+ ("INIT_SX", "1"),
55
+ ("INIT_SY", "1"),
56
+ ]:
57
+ child = ET.SubElement(node, tag)
58
+ child.text = value
59
+ return node
60
+
61
+
62
+ def _build_minimal_physical_workspace() -> tuple[ET.Element, ET.Element, ET.Element]:
63
+ physical = ET.Element("PHYSICALWORKSPACE")
64
+ homerack = ET.SubElement(physical, "HOMERACK")
65
+ homerack.set("translate", "true")
66
+ homerack.text = ",".join(PHYSICAL_HOME_PATH)
67
+
68
+ intercity = _make_workspace_node("Intercity", 0, 0, 0, 20000, 12416, "../art/Background/gGeoViewInterCity.png")
69
+ city = _make_workspace_node("Home City", 1, 200, 200, 2000, 1238, "../art/Background/gGeoViewCity.png")
70
+ building = _make_workspace_node("Corporate Office", 2, 100, 100, 200, 125, "../art/Background/gGeoViewBuilding.png")
71
+ closet = _make_workspace_node("Main Wiring Closet", 3, 50, 50, 20, 20, "")
72
+ table = _make_workspace_node("Table", 5, 0, 0, 0, 0, "")
73
+ rack = _make_workspace_node("Rack", 4, 0, 0, 0, 0, "")
74
+
75
+ intercity.find("./CHILDREN").append(city)
76
+ city.find("./CHILDREN").append(building)
77
+ building.find("./CHILDREN").append(closet)
78
+ closet.find("./CHILDREN").extend([table, rack])
79
+ physical.append(intercity)
80
+ return physical, table, rack
81
+
82
+
83
+ def _closet_node_from_physical_root(physical_root: ET.Element) -> ET.Element | None:
84
+ path = "./NODE/CHILDREN/NODE/CHILDREN/NODE/CHILDREN/NODE"
85
+ closet = physical_root.find(path)
86
+ if closet is not None and closet.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
87
+ return closet
88
+ for node in physical_root.findall(".//NODE"):
89
+ if node.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
90
+ return node
91
+ return None
92
+
93
+
94
+ def _container_node(closet: ET.Element, container_name: str, node_type: int) -> ET.Element:
95
+ children = closet.find("./CHILDREN")
96
+ if children is None:
97
+ children = ET.SubElement(closet, "CHILDREN")
98
+ for node in children.findall("./NODE"):
99
+ if node.findtext("./NAME", default="") == container_name:
100
+ target = node.find("./TYPE")
101
+ if target is not None:
102
+ target.text = str(node_type)
103
+ return node
104
+ node = _make_workspace_node(container_name, node_type, 0, 0, 0, 0, "")
105
+ children.append(node)
106
+ return node
107
+
108
+
109
+ def _leaf_template(container: ET.Element, default_name: str) -> ET.Element:
110
+ for node in container.findall("./CHILDREN/NODE"):
111
+ if node.findtext("./TYPE", default="") == "6":
112
+ return copy.deepcopy(node)
113
+ return _make_workspace_node(default_name, 6, 0, 0, 0, 0, "")
114
+
115
+
116
+ def _clone_leaf(template: ET.Element, name: str, index: int) -> ET.Element:
117
+ leaf = copy.deepcopy(template)
118
+ for child in list(leaf):
119
+ if child.tag == "CHILDREN":
120
+ child.clear()
121
+ name_node = leaf.find("./NAME")
122
+ if name_node is None:
123
+ name_node = ET.SubElement(leaf, "NAME")
124
+ name_node.set("translate", "true")
125
+ name_node.text = name
126
+ x_node = leaf.find("./X")
127
+ y_node = leaf.find("./Y")
128
+ if x_node is None:
129
+ x_node = ET.SubElement(leaf, "X")
130
+ if y_node is None:
131
+ y_node = ET.SubElement(leaf, "Y")
132
+ x_node.text = str((index % 8) * 8)
133
+ y_node.text = str((index // 8) * 8)
134
+ return leaf
135
+
136
+
137
+ def _physical_leaf_path(device_name: str, container_name: str) -> str:
138
+ return ",".join([*PHYSICAL_HOME_PATH, container_name, device_name])
139
+
140
+
141
+ def _device_physical_container(device: ET.Element) -> str:
142
+ device_type = device.findtext("./ENGINE/TYPE", default="")
143
+ if device_type in {"Router", "Switch", "MultiLayerSwitch", "Server", "WirelessRouter", "WirelessLanController", "LightWeightAccessPoint"}:
144
+ return "Rack"
145
+ return "Table"
146
+
147
+
148
+ def sanitize_generated_physical_workspace(root: ET.Element) -> None:
149
+ devices_parent = root.find(".//DEVICES")
150
+ if devices_parent is None:
151
+ return
152
+ version = root.findtext("./VERSION", default="")
153
+ if version.startswith("9.") and any(device.find("./WORKSPACE/PHYSICAL_CPUR") is not None for device in devices_parent.findall("./DEVICE")):
154
+ return
155
+
156
+ physical_root = root.find("./PHYSICALWORKSPACE")
157
+ if physical_root is None:
158
+ physical_root, _, _ = _build_minimal_physical_workspace()
159
+ insert_at = 1 if root.find("./VERSION") is not None else 0
160
+ root.insert(insert_at, physical_root)
161
+
162
+ closet = _closet_node_from_physical_root(physical_root)
163
+ if closet is None:
164
+ replacement_root, _, _ = _build_minimal_physical_workspace()
165
+ existing = root.find("./PHYSICALWORKSPACE")
166
+ if existing is not None:
167
+ root.remove(existing)
168
+ insert_at = 1 if root.find("./VERSION") is not None else 0
169
+ root.insert(insert_at, replacement_root)
170
+ physical_root = replacement_root
171
+ closet = _closet_node_from_physical_root(physical_root)
172
+ if closet is None:
173
+ return
174
+
175
+ table = _container_node(closet, "Table", 5)
176
+ rack = _container_node(closet, "Rack", 4)
177
+ table_template = _leaf_template(table, "PC0")
178
+ rack_template = _leaf_template(rack, "Server0")
179
+ table_children = table.find("./CHILDREN")
180
+ rack_children = rack.find("./CHILDREN")
181
+ assert table_children is not None and rack_children is not None
182
+ table_children.clear()
183
+ rack_children.clear()
184
+
185
+ for index, device in enumerate(devices_parent.findall("./DEVICE"), start=1):
186
+ name = device.findtext("./ENGINE/NAME", default=f"Device{index}")
187
+ container_name = _device_physical_container(device)
188
+ template = rack_template if container_name == "Rack" else table_template
189
+ leaf = _clone_leaf(template, name, index - 1)
190
+ if container_name == "Rack":
191
+ rack_children.append(leaf)
192
+ else:
193
+ table_children.append(leaf)
194
+
195
+ workspace = device.find("./WORKSPACE")
196
+ if workspace is None:
197
+ workspace = ET.SubElement(device, "WORKSPACE")
198
+ logical = workspace.find("./LOGICAL")
199
+ logical_copy = ET.fromstring(ET.tostring(logical, encoding="unicode")) if logical is not None else ET.Element("LOGICAL")
200
+ for child in list(workspace):
201
+ workspace.remove(child)
202
+ workspace.append(logical_copy)
203
+ physical = ET.SubElement(workspace, "PHYSICAL")
204
+ physical.set("translate", "true")
205
+ physical.text = _physical_leaf_path(name, container_name)
206
+
207
+
208
+ def _collect_physical_paths(root: ET.Element) -> set[str]:
209
+ physical_root = root.find("./PHYSICALWORKSPACE")
210
+ if physical_root is None:
211
+ return set()
212
+ paths: set[str] = set()
213
+ for top in physical_root.findall("./NODE"):
214
+ _walk_physical_node(top, [], paths)
215
+ return paths
216
+
217
+
218
+ def _walk_physical_node(node: ET.Element, ancestors: list[str], sink: set[str]) -> None:
219
+ name = node.findtext("./NAME", default="")
220
+ if not name:
221
+ return
222
+ current = [*ancestors, name]
223
+ sink.add(",".join(current))
224
+ for child in node.findall("./CHILDREN/NODE"):
225
+ _walk_physical_node(child, current, sink)
226
+
227
+
228
+ def inspect_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
229
+ issues: list[str] = []
230
+ if root.tag != "PACKETTRACER5":
231
+ issues.append("Root element must be PACKETTRACER5")
232
+ if root.find("VERSION") is None:
233
+ issues.append("Packet Tracer XML is missing VERSION")
234
+
235
+ devices_parent = root.find(".//DEVICES")
236
+ links_parent = root.find(".//LINKS")
237
+ if devices_parent is None or links_parent is None:
238
+ issues.append("Packet Tracer XML is missing DEVICES or LINKS container")
239
+ return WorkspaceValidationResult("unknown", "invalid", "invalid", issues)
240
+
241
+ devices = devices_parent.findall("./DEVICE")
242
+ links = links_parent.findall("./LINK")
243
+ if not devices:
244
+ issues.append("No devices remain in generated Packet Tracer XML")
245
+
246
+ logical_mem_addrs: list[str] = []
247
+ save_ref_ids: list[str] = []
248
+ for device in devices:
249
+ if device.find("./WORKSPACE/LOGICAL/X") is None or device.find("./WORKSPACE/LOGICAL/Y") is None:
250
+ issues.append("Device is missing logical workspace coordinates")
251
+ mem_addr = device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")
252
+ if not mem_addr:
253
+ issues.append("Device is missing logical workspace MEM_ADDR")
254
+ else:
255
+ logical_mem_addrs.append(mem_addr)
256
+ save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
257
+ if save_ref:
258
+ save_ref_ids.append(save_ref)
259
+
260
+ device_count = len(devices)
261
+ for link in links:
262
+ cable = link.find("./CABLE")
263
+ if cable is None:
264
+ issues.append("Link is missing CABLE node")
265
+ continue
266
+ from_idx = cable.findtext("FROM", default="")
267
+ to_idx = cable.findtext("TO", default="")
268
+ if not from_idx or not to_idx:
269
+ issues.append("Link is missing FROM or TO index")
270
+ continue
271
+ if from_idx.isdigit() and to_idx.isdigit():
272
+ if int(from_idx) >= device_count or int(to_idx) >= device_count:
273
+ issues.append("Link references a device index outside the DEVICES list")
274
+ elif save_ref_ids:
275
+ if from_idx not in save_ref_ids or to_idx not in save_ref_ids:
276
+ issues.append("Link references device SAVE_REF_ID values not present in DEVICES")
277
+ else:
278
+ issues.append("Link FROM or TO index is not numeric")
279
+ continue
280
+
281
+ ports = [port.text or "" for port in cable.findall("PORT")]
282
+ if len(ports) < 2 or not ports[0] or not ports[1]:
283
+ issues.append("Link is missing endpoint port names")
284
+
285
+ from_device_mem = cable.findtext("FROM_DEVICE_MEM_ADDR", default="")
286
+ to_device_mem = cable.findtext("TO_DEVICE_MEM_ADDR", default="")
287
+ if not from_device_mem or not to_device_mem:
288
+ issues.append("Link is missing device MEM_ADDR references")
289
+ elif from_device_mem not in logical_mem_addrs or to_device_mem not in logical_mem_addrs:
290
+ issues.append("Link device MEM_ADDR references do not match device workspace records")
291
+
292
+ physical_paths = _collect_physical_paths(root)
293
+ if not physical_paths:
294
+ issues.append("Packet Tracer XML is missing PHYSICALWORKSPACE tree")
295
+ all_physical_values = [device.findtext("./WORKSPACE/PHYSICAL", default="") for device in devices]
296
+ legacy_uuid_physical = any("{" in value and "}" in value for value in all_physical_values if value)
297
+ for device in devices:
298
+ physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
299
+ if not physical:
300
+ issues.append("Device is missing physical workspace path")
301
+ continue
302
+ if not legacy_uuid_physical and physical not in physical_paths:
303
+ issues.append(f"Device physical path does not exist in PHYSICALWORKSPACE: {physical}")
304
+
305
+ if all(device.find("./WORKSPACE/PHYSICAL_CPUR") is None for device in devices):
306
+ workspace_mode = "logical_only_safe"
307
+ elif legacy_uuid_physical:
308
+ workspace_mode = "legacy_uuid_physical"
309
+ else:
310
+ workspace_mode = "mixed_physical"
311
+ logical_status = "ok" if not any("logical" in issue.lower() or "mem_addr" in issue.lower() for issue in issues) else "invalid"
312
+ physical_status = "ok" if not any("physical" in issue.lower() for issue in issues) else "invalid"
313
+ return WorkspaceValidationResult(workspace_mode, logical_status, physical_status, issues)
314
+
315
+
316
+ def validate_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
317
+ result = inspect_workspace_integrity(root)
318
+ if result.blocking_issues:
319
+ raise ValueError("; ".join(result.blocking_issues))
320
+ return result
321
+
322
+
323
+ def _section_xml(root: ET.Element, path: str) -> str:
324
+ node = root.find(path)
325
+ if node is None:
326
+ return ""
327
+ return ET.tostring(node, encoding="unicode")
328
+
329
+
330
+ def _device_by_save_ref(root: ET.Element) -> dict[str, ET.Element]:
331
+ devices: dict[str, ET.Element] = {}
332
+ for device in root.findall(".//DEVICES/DEVICE"):
333
+ save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="").strip()
334
+ if save_ref:
335
+ devices[save_ref] = device
336
+ return devices
337
+
338
+
339
+ def _device_leaf_uuid(device: ET.Element) -> str:
340
+ physical = device.findtext("./WORKSPACE/PHYSICAL", default="").strip()
341
+ if not physical:
342
+ return ""
343
+ token = physical.split(",")[-1].strip()
344
+ if token.startswith("{") and token.endswith("}"):
345
+ return token
346
+ return ""
347
+
348
+
349
+ def _physical_leaf_index(root: ET.Element) -> dict[str, ET.Element]:
350
+ nodes: dict[str, ET.Element] = {}
351
+ for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
352
+ uuid = node.findtext("./UUID_STR", default="").strip()
353
+ if uuid:
354
+ nodes[uuid] = node
355
+ return nodes
356
+
357
+
358
+ def _config_hostname_hits(device: ET.Element, old_name: str) -> bool:
359
+ target = f"hostname {old_name}"
360
+ for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
361
+ if (line.text or "").strip() == target:
362
+ return True
363
+ for line in device.findall("./ENGINE/STARTUPCONFIG/LINE"):
364
+ if (line.text or "").strip() == target:
365
+ return True
366
+ for line in device.findall(".//FILE_CONTENT/CONFIG/LINE"):
367
+ if (line.text or "").strip() == target:
368
+ return True
369
+ return False
370
+
371
+
372
+ def inspect_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
373
+ issues: list[str] = []
374
+ donor_devices = _device_by_save_ref(donor_root)
375
+ generated_devices = _device_by_save_ref(generated_root)
376
+
377
+ scenario_sections = {
378
+ "SCENARIOSET": _section_xml(generated_root, "./SCENARIOSET"),
379
+ "COMMAND_LOGS": _section_xml(generated_root, "./COMMAND_LOGS"),
380
+ "CEPS": _section_xml(generated_root, "./CEPS"),
381
+ "FILTERS": _section_xml(generated_root, "./FILTERS"),
382
+ }
383
+ physical_sections = {
384
+ "PHYSICALWORKSPACE": _section_xml(generated_root, "./PHYSICALWORKSPACE"),
385
+ "GEOVIEW_GRAPHICSITEMS": _section_xml(generated_root, "./GEOVIEW_GRAPHICSITEMS"),
386
+ "CLUSTERS": _section_xml(generated_root, "./CLUSTERS"),
387
+ }
388
+
389
+ pruned_ids = sorted(set(donor_devices) - set(generated_devices))
390
+ for save_ref in pruned_ids:
391
+ donor_device = donor_devices[save_ref]
392
+ donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
393
+ original_uuid = donor_device.findtext(".//ORIGINAL_DEVICE_UUID", default="").strip()
394
+ physical_uuid = _device_leaf_uuid(donor_device)
395
+ for section_name, text in scenario_sections.items():
396
+ for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid)]:
397
+ if value and value in text:
398
+ issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
399
+ for section_name, text in physical_sections.items():
400
+ for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid), ("physical-leaf", physical_uuid)]:
401
+ if value and value in text:
402
+ issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
403
+
404
+ physical_index = _physical_leaf_index(generated_root)
405
+ for save_ref in sorted(set(donor_devices) & set(generated_devices)):
406
+ donor_device = donor_devices[save_ref]
407
+ generated_device = generated_devices[save_ref]
408
+ donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
409
+ generated_name = generated_device.findtext("./ENGINE/NAME", default="").strip()
410
+ sys_name = generated_device.findtext("./ENGINE/SYS_NAME", default="").strip()
411
+ if donor_name != generated_name and sys_name == donor_name:
412
+ issues.append(f"Renamed device {generated_name} still keeps donor SYS_NAME {donor_name}")
413
+ if donor_name != generated_name and _config_hostname_hits(generated_device, donor_name):
414
+ issues.append(f"Renamed device {generated_name} still keeps donor hostname {donor_name} in config")
415
+ leaf_uuid = _device_leaf_uuid(generated_device)
416
+ if leaf_uuid:
417
+ leaf = physical_index.get(leaf_uuid)
418
+ if leaf is None:
419
+ issues.append(f"Generated device {generated_name} references missing physical leaf {leaf_uuid}")
420
+ else:
421
+ leaf_name = leaf.findtext("./NAME", default="").strip()
422
+ if leaf_name != generated_name:
423
+ issues.append(f"Generated device {generated_name} physical leaf name is {leaf_name}")
424
+
425
+ device_issues = [issue for issue in issues if "SYS_NAME" in issue or "hostname" in issue]
426
+ scenario_issues = [issue for issue in issues if any(section in issue for section in ["SCENARIOSET", "COMMAND_LOGS", "CEPS", "FILTERS"])]
427
+ physical_issues = [issue for issue in issues if any(section in issue for section in ["PHYSICALWORKSPACE", "GEOVIEW_GRAPHICSITEMS", "CLUSTERS", "physical leaf"])]
428
+
429
+ return DonorCoherenceResult(
430
+ device_metadata_status="ok" if not device_issues else "invalid",
431
+ scenario_status="ok" if not scenario_issues else "invalid",
432
+ physical_runtime_status="ok" if not physical_issues else "invalid",
433
+ blocking_issues=issues,
434
+ )
435
+
436
+
437
+ def validate_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
438
+ result = inspect_donor_coherence(donor_root, generated_root)
439
+ if result.blocking_issues:
440
+ raise ValueError("; ".join(result.blocking_issues))
441
+ return result
@@ -0,0 +1,21 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <PACKETTRACER5>
3
+ <VERSION>9.0.0.0810</VERSION>
4
+ <PIXMAPBANK />
5
+ <MOVIEBANK />
6
+ <NETWORK>
7
+ <DEVICES />
8
+ <LINKS />
9
+ <SHAPETESTS />
10
+ </NETWORK>
11
+ <OPTIONS>
12
+ <LOGICALVIEW>true</LOGICALVIEW>
13
+ </OPTIONS>
14
+ <PHYSICALWORKSPACE>
15
+ <ROOT>
16
+ <NAME>Physical Workspace</NAME>
17
+ <X>0</X>
18
+ <Y>0</Y>
19
+ </ROOT>
20
+ </PHYSICALWORKSPACE>
21
+ </PACKETTRACER5>
@@ -0,0 +1,20 @@
1
+ <DEVICE class="end-device">
2
+ <ID>__DEVICE_ID__</ID>
3
+ <ENGINE>
4
+ <TYPE model="PC-PT" customModel="">PC</TYPE>
5
+ <NAME translate="true">__NAME__</NAME>
6
+ </ENGINE>
7
+ <POSITION>
8
+ <X>__X__</X>
9
+ <Y>__Y__</Y>
10
+ </POSITION>
11
+ <PORTS>
12
+ <PORT name="FastEthernet0" kind="eCopperFastEthernet" />
13
+ </PORTS>
14
+ <CONFIG>
15
+ <IP>__IP__</IP>
16
+ <MASK>__MASK__</MASK>
17
+ <GATEWAY>__GATEWAY__</GATEWAY>
18
+ <DNS>__DNS__</DNS>
19
+ </CONFIG>
20
+ </DEVICE>