confgraph 0.3.8__tar.gz → 0.3.9__tar.gz

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 (125) hide show
  1. {confgraph-0.3.8 → confgraph-0.3.9}/PKG-INFO +14 -1
  2. {confgraph-0.3.8 → confgraph-0.3.9}/README.md +13 -0
  3. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/topology.py +12 -1
  4. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/__init__.py +62 -1
  5. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/iosxr_parser.py +169 -17
  6. confgraph-0.3.9/confgraph/parsers/junos_deletions.py +308 -0
  7. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/junos_hierarchy.py +133 -11
  8. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/junos_parser.py +89 -12
  9. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/nxos_parser.py +44 -14
  10. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/utils/ospf.py +30 -0
  11. confgraph-0.3.9/dist-0.3.8/confgraph-0.3.8-py3-none-any.whl +0 -0
  12. confgraph-0.3.9/dist-0.3.8/confgraph-0.3.8.tar.gz +0 -0
  13. confgraph-0.3.9/dist-0.3.9/.gitignore +1 -0
  14. {confgraph-0.3.8 → confgraph-0.3.9}/pyproject.toml +1 -1
  15. {confgraph-0.3.8 → confgraph-0.3.9}/uv.lock +1 -1
  16. {confgraph-0.3.8 → confgraph-0.3.9}/.github/workflows/publish.yml +0 -0
  17. {confgraph-0.3.8 → confgraph-0.3.9}/.github/workflows/redos-scan.yml +0 -0
  18. {confgraph-0.3.8 → confgraph-0.3.9}/.gitignore +0 -0
  19. {confgraph-0.3.8 → confgraph-0.3.9}/.python-version +0 -0
  20. {confgraph-0.3.8 → confgraph-0.3.9}/CHANGELOG.md +0 -0
  21. {confgraph-0.3.8 → confgraph-0.3.9}/CLAUDE.md +0 -0
  22. {confgraph-0.3.8 → confgraph-0.3.9}/INTERFACE.md +0 -0
  23. {confgraph-0.3.8 → confgraph-0.3.9}/LICENSE +0 -0
  24. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/analysis/__init__.py +0 -0
  25. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/analysis/dependency_resolver.py +0 -0
  26. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/change_ir.py +0 -0
  27. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/cli.py +0 -0
  28. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/__init__.py +0 -0
  29. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/assets/cytoscape-dagre.min.js +0 -0
  30. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/assets/cytoscape.min.js +0 -0
  31. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/assets/dagre.min.js +0 -0
  32. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/builder.py +0 -0
  33. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/exporters/__init__.py +0 -0
  34. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/exporters/base.py +0 -0
  35. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/exporters/html.py +0 -0
  36. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/graph/exporters/json.py +0 -0
  37. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/loader.py +0 -0
  38. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/__init__.py +0 -0
  39. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/aaa.py +0 -0
  40. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/acl.py +0 -0
  41. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/banner.py +0 -0
  42. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/base.py +0 -0
  43. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/bfd.py +0 -0
  44. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/bgp.py +0 -0
  45. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/cdp.py +0 -0
  46. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/community_list.py +0 -0
  47. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/crypto.py +0 -0
  48. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/dhcp.py +0 -0
  49. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/dns.py +0 -0
  50. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/eem.py +0 -0
  51. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/eigrp.py +0 -0
  52. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/evpn.py +0 -0
  53. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/interface.py +0 -0
  54. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/ipsla.py +0 -0
  55. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/isis.py +0 -0
  56. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/line.py +0 -0
  57. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/lldp.py +0 -0
  58. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/logging_config.py +0 -0
  59. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/mpls.py +0 -0
  60. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/multicast.py +0 -0
  61. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/nat.py +0 -0
  62. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/netflow.py +0 -0
  63. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/ntp.py +0 -0
  64. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/object_group.py +0 -0
  65. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/object_tracking.py +0 -0
  66. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/ospf.py +0 -0
  67. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/panos_zone.py +0 -0
  68. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/parsed_config.py +0 -0
  69. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/prefix_list.py +0 -0
  70. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/qos.py +0 -0
  71. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/rip.py +0 -0
  72. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/route_map.py +0 -0
  73. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/snmp.py +0 -0
  74. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/static_route.py +0 -0
  75. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/stp.py +0 -0
  76. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/vlan.py +0 -0
  77. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/vpc.py +0 -0
  78. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/vrf.py +0 -0
  79. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/models/vxlan.py +0 -0
  80. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/base.py +0 -0
  81. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/eos_parser.py +0 -0
  82. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/ios_parser.py +0 -0
  83. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/junos_groups.py +0 -0
  84. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/panos_parser.py +0 -0
  85. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/parsers/panos_xml.py +0 -0
  86. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/tombstones.py +0 -0
  87. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/topology/__init__.py +0 -0
  88. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/topology/exporters.py +0 -0
  89. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/topology/graph.py +0 -0
  90. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/topology/ingest.py +0 -0
  91. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/utils/__init__.py +0 -0
  92. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/utils/escaping.py +0 -0
  93. {confgraph-0.3.8 → confgraph-0.3.9}/confgraph/utils/interface.py +0 -0
  94. {confgraph-0.3.8 → confgraph-0.3.9}/dist-0.3.7/.gitignore +0 -0
  95. {confgraph-0.3.8 → confgraph-0.3.9}/dist-0.3.7/confgraph-0.3.7-py3-none-any.whl +0 -0
  96. {confgraph-0.3.8 → confgraph-0.3.9}/dist-0.3.7/confgraph-0.3.7.tar.gz +0 -0
  97. {confgraph-0.3.8 → confgraph-0.3.9}/dist-0.3.8/.gitignore +0 -0
  98. {confgraph-0.3.8 → confgraph-0.3.9}/docs/ADDING_NEW_OS_SUPPORT.md +0 -0
  99. {confgraph-0.3.8 → confgraph-0.3.9}/docs/ARCHITECTURE.md +0 -0
  100. {confgraph-0.3.8 → confgraph-0.3.9}/docs/CLI_USAGE.md +0 -0
  101. {confgraph-0.3.8 → confgraph-0.3.9}/docs/EOS_PARSER_SUPPORT.md +0 -0
  102. {confgraph-0.3.8 → confgraph-0.3.9}/docs/IOSXR_PARSER_SUPPORT.md +0 -0
  103. {confgraph-0.3.8 → confgraph-0.3.9}/docs/IOS_PARSER_SUPPORT.md +0 -0
  104. {confgraph-0.3.8 → confgraph-0.3.9}/docs/JUNOS_PARSER_SUPPORT.md +0 -0
  105. {confgraph-0.3.8 → confgraph-0.3.9}/docs/NXOS_PARSER_SUPPORT.md +0 -0
  106. {confgraph-0.3.8 → confgraph-0.3.9}/docs/PANOS_PARSER_SUPPORT.md +0 -0
  107. {confgraph-0.3.8 → confgraph-0.3.9}/docs/PARSER_SUPPORT_MATRIX.md +0 -0
  108. {confgraph-0.3.8 → confgraph-0.3.9}/docs/bgp_cluster_view.png +0 -0
  109. {confgraph-0.3.8 → confgraph-0.3.9}/docs/default_view.png +0 -0
  110. {confgraph-0.3.8 → confgraph-0.3.9}/docs/specific_node_highlight.png +0 -0
  111. {confgraph-0.3.8 → confgraph-0.3.9}/docs/supported_os_types.md +0 -0
  112. {confgraph-0.3.8 → confgraph-0.3.9}/main.py +0 -0
  113. {confgraph-0.3.8 → confgraph-0.3.9}/samples/complex_router.ios +0 -0
  114. {confgraph-0.3.8 → confgraph-0.3.9}/samples/eos.txt +0 -0
  115. {confgraph-0.3.8 → confgraph-0.3.9}/samples/ios.txt +0 -0
  116. {confgraph-0.3.8 → confgraph-0.3.9}/samples/ios01.txt +0 -0
  117. {confgraph-0.3.8 → confgraph-0.3.9}/samples/ios_xe.txt +0 -0
  118. {confgraph-0.3.8 → confgraph-0.3.9}/samples/ios_xr.txt +0 -0
  119. {confgraph-0.3.8 → confgraph-0.3.9}/samples/iosxr_test.cfg +0 -0
  120. {confgraph-0.3.8 → confgraph-0.3.9}/samples/junos_test.cfg +0 -0
  121. {confgraph-0.3.8 → confgraph-0.3.9}/samples/nxos.txt +0 -0
  122. {confgraph-0.3.8 → confgraph-0.3.9}/samples/panos_sample.xml +0 -0
  123. {confgraph-0.3.8 → confgraph-0.3.9}/tools/redos_baseline.json +0 -0
  124. {confgraph-0.3.8 → confgraph-0.3.9}/tools/redos_scan.json +0 -0
  125. {confgraph-0.3.8 → confgraph-0.3.9}/tools/redos_scan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: confgraph
3
- Version: 0.3.8
3
+ Version: 0.3.9
4
4
  Summary: Local-first network config dependency mapper — parse, visualize, and lint vendor configs
5
5
  Project-URL: Homepage, https://github.com/verigraphs/confgraph
6
6
  Project-URL: Repository, https://github.com/verigraphs/confgraph
@@ -131,6 +131,19 @@ See [`docs/CLI_USAGE.md`](docs/CLI_USAGE.md) for all commands, options, and exam
131
131
 
132
132
  Contributions welcome — new parsers, bug fixes, additional protocol coverage. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) to get started.
133
133
 
134
+ ### Running the tests
135
+
136
+ ```bash
137
+ uv run --extra dev pytest
138
+ ```
139
+
140
+ **The `--extra dev` is not optional.** `pytest` is an optional dependency (see
141
+ `[project.optional-dependencies]`), so a bare `uv run pytest` falls back to
142
+ whatever `pytest` is on your `PATH` — which runs the suite against a different
143
+ environment and fails well over a thousand tests for reasons that have nothing
144
+ to do with your change. There is no error message saying so; it just looks like
145
+ you broke everything.
146
+
134
147
  ## License
135
148
 
136
149
  Apache 2.0
@@ -101,6 +101,19 @@ See [`docs/CLI_USAGE.md`](docs/CLI_USAGE.md) for all commands, options, and exam
101
101
 
102
102
  Contributions welcome — new parsers, bug fixes, additional protocol coverage. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) to get started.
103
103
 
104
+ ### Running the tests
105
+
106
+ ```bash
107
+ uv run --extra dev pytest
108
+ ```
109
+
110
+ **The `--extra dev` is not optional.** `pytest` is an optional dependency (see
111
+ `[project.optional-dependencies]`), so a bare `uv run pytest` falls back to
112
+ whatever `pytest` is on your `PATH` — which runs the suite against a different
113
+ environment and fails well over a thousand tests for reasons that have nothing
114
+ to do with your change. There is no error message saying so; it just looks like
115
+ you broke everything.
116
+
104
117
  ## License
105
118
 
106
119
  Apache 2.0
@@ -28,13 +28,24 @@ class PhysicalLink:
28
28
  "cdp" — from CDP neighbor data
29
29
  "lldp" — from LLDP neighbor data
30
30
  "mac-arp" — validated via MAC-ARP correlation (supplementary only)
31
+ "upload" — supplied by the operator (an export from their source
32
+ of truth / NMS / IPAM, or a topology file)
33
+ "synth" — INFERRED, not observed: synthesized from two devices
34
+ having interfaces on a shared L3 subnet
35
+
36
+ The "synth" value is load-bearing rather than merely informative, so a
37
+ producer must label an inferred link honestly. Consumers distinguish
38
+ supplied cabling from inference on this field alone — the simulator's
39
+ link-vs-config consistency checks deliberately say nothing about a "synth"
40
+ link, because a warning citing one would be the engine disputing its own
41
+ inference rather than disclosing an input it cannot trust.
31
42
  """
32
43
 
33
44
  device_a: str
34
45
  port_a: str # canonical long-form logical interface on device_a
35
46
  device_b: str
36
47
  port_b: str # canonical long-form logical interface on device_b
37
- source: str # "cdp" | "lldp" | "mac-arp"
48
+ source: str # "cdp" | "lldp" | "mac-arp" | "upload" | "synth"
38
49
  member_count: int = 1
39
50
 
40
51
 
@@ -145,7 +145,68 @@ from confgraph.parsers.panos_parser import PANOSParser
145
145
  # this module's header is per-commit and not optional for a qualifying
146
146
  # change, and deciding otherwise would mean re-deriving "was the predecessor
147
147
  # released?" at every review.
148
- PARSER_BEHAVIOR_VERSION: int = 13
148
+ # 14 (CCR-0217): the JunOS brace branch of ``unsupported_lines`` is fail-closed
149
+ # on the parse OUTCOME — a document routed to the brace tokenizer that yields
150
+ # an EMPTY canonical tree while carrying at least one non-blank
151
+ # comment-stripped line discloses those lines as UnrecognizedBlocks instead of
152
+ # returning silence. The hole was the document with no JunOS idiom in it at all
153
+ # (Cisco ``no`` commands, an IOS ``interface`` block): zero set-family lines
154
+ # routes it brace-ward, every statement is discarded at EOF for want of a ``;``
155
+ # or ``{``, and the result read "no recognized configuration change" for text
156
+ # that plainly requested one — the last silent arm of CCR-0210's fail-closed
157
+ # story, modulo the CCR-0236 mixed-document residual. Observable parse output
158
+ # moves for every such document, with NO ParsedConfig JSON-schema change — the
159
+ # sharp case this constant exists for. Comment-only and whitespace-only
160
+ # documents carry no content and stay silent; ``inactive:``-only documents
161
+ # parse non-empty and keep their existing tag disclosure.
162
+ # 15 (CCR-0214): JunOS ``delete`` / ``deactivate`` statements on MODELLED
163
+ # paths compile to native change ops instead of being disclosed. JunOS emitted
164
+ # no deletion artifact of any kind before this — no tombstones (the grammar is
165
+ # Cisco syntax throughout) and no ops — so a withdrawal, the most outage-prone
166
+ # change class on the OS, could only ever be reported as content the engine
167
+ # could not read. The modelled set is one declarative table
168
+ # (``parsers/junos_deletions.py``): a BGP neighbour, a whole BGP group, a
169
+ # static route, an interface unit. Every path outside it stays on the CCR-0210
170
+ # disclosure channel, so nothing became silent and nothing is half-applied;
171
+ # ``deactivate`` compiles to the same op as ``delete`` because an inactive
172
+ # subtree makes no contribution to the running configuration, which is the
173
+ # only configuration the model holds. Observable parse output moves for any
174
+ # JunOS document carrying one of those statements —
175
+ # ``native_change_ops`` gains entries and ``unrecognized_blocks`` loses the
176
+ # matching lines — with NO ParsedConfig JSON-schema change. No BASELINE moves:
177
+ # a saved configuration states no deletion verb (the brace idiom has none),
178
+ # and the ``inactive:`` statement tag is untouched and still disclosed.
179
+ # 16 (CCR-0214, validation round 1 finding R1): a JunOS removal that the SAME
180
+ # document later re-states no longer compiles — it falls back to the CCR-0210
181
+ # disclosure channel. JunOS applies candidate statements in order, so
182
+ # ``delete protocols bgp group RR`` followed by ``set protocols bgp group RR …``
183
+ # rebuilds the group rather than removing it, and the same idiom moves a static
184
+ # route to a new next-hop. Those are the two commonest JunOS rewrite idioms, and
185
+ # compiling the deletion alone reported a confident teardown of something the
186
+ # device would still have — measured in validation as a CRITICAL full-teardown
187
+ # verdict on a group rebuild. The guard is order-aware (``set`` strictly AFTER
188
+ # the deletion line, at an equal or deeper path): the reverse order really is a
189
+ # removal on a device, so it still compiles. Observable parse output moves for
190
+ # any JunOS document mixing a removal with a later restatement of the same
191
+ # path — ``native_change_ops`` loses that op and ``unrecognized_blocks`` gains
192
+ # the line back — with NO ParsedConfig JSON-schema change, and no baseline
193
+ # motion (a saved configuration states no deletion verb).
194
+ # 17 (CCR-0239 + CCR-0240, bump landed late — caught at the v0.3.9 release
195
+ # audit, 2026-11-06): both landings changed parse output without moving this
196
+ # constant, so PBV-16 parse-cache blobs minted before them could be served as
197
+ # hits carrying the pre-fix models. CCR-0239: NX-OS named OSPF instance tags
198
+ # (``ip router ospf CORE area …``) enroll their interfaces — previously the
199
+ # named-instance spelling bound nothing, so those interfaces were
200
+ # OSPF-invisible. CCR-0240: IOS-XR OSPF passive resolves at all four scopes
201
+ # (process default, area, interface ``passive enable`` AND ``passive
202
+ # disable``) — previously the process-scope default was hardwired False and
203
+ # ``passive disable`` was not in the vocabulary, so suppressed interfaces
204
+ # formed phantom adjacencies while exempted uplinks read as suppressed.
205
+ # Observable parse output moves for any NX-OS config using named instance
206
+ # tags and any IOS-XR config using process/area-scope passive — BASELINES
207
+ # included (both spellings exist in saved configurations). No ParsedConfig
208
+ # JSON-schema change.
209
+ PARSER_BEHAVIOR_VERSION: int = 17
149
210
 
150
211
  __all__ = [
151
212
  "BaseParser",
@@ -78,6 +78,26 @@ _AF_HEADER_RE = re.compile(r"^\s*address-family\s+\S+")
78
78
  _AF_SPLICE_RE = re.compile(r"^\s*address-family\s+ipv4\s+unicast\b")
79
79
 
80
80
 
81
+ # CCR-0240. The ONE spelling of the IOS-XR OSPF `passive` line, shared by the
82
+ # scope reader (_read_iosxr_passive) and the `area > interface` body vocabulary
83
+ # table (_OSPF_AREA_IFACE_PATTERNS) so the two cannot drift into disagreeing
84
+ # about which lines count.
85
+ #
86
+ # `disable` is a FIRST-CLASS VALUE, not the absence of `enable`: its whole job is
87
+ # to override a passive inherited from an enclosing scope, which is how an XR
88
+ # estate hardened with a process-wide `passive enable` exempts its uplinks. A
89
+ # model that encoded `disable` as absence could not express that exemption.
90
+ #
91
+ # The optional keyword is deliberate tolerance. Cisco's command reference marks
92
+ # both `disable` and `enable` "(Optional)" and its own example TYPES a bare
93
+ # `passive`, while the running-config emits the keyword
94
+ # (syntax-corpus/iosxr/ospf.yaml -> `passive`, status doc-only: `passive disable`
95
+ # is witnessed in a device transcript, the literal `passive enable` is not). A
96
+ # bare `passive` means passive ON, matching the typed form's documented meaning.
97
+ _IOSXR_PASSIVE_PATTERN = r"^\s+passive(?:\s+(?P<val>enable|disable))?\s*$"
98
+ _IOSXR_PASSIVE_RE = re.compile(_IOSXR_PASSIVE_PATTERN)
99
+
100
+
81
101
  class _AFTransparentBlock:
82
102
  """A read-through view of an IOS-XR block that reads *through* its
83
103
  ``address-family ipv4 unicast`` sub-block — and only that one.
@@ -785,6 +805,41 @@ class IOSXRParser(IOSParser):
785
805
  # OSPF — interfaces nested under area blocks
786
806
  # -----------------------------------------------------------------------
787
807
 
808
+ @staticmethod
809
+ def _read_iosxr_passive(obj) -> bool | None:
810
+ """Scope-local OSPF ``passive`` as a TRI-STATE (CCR-0240).
811
+
812
+ ``True`` = this scope says passive, ``False`` = this scope says
813
+ explicitly NOT passive (``passive disable``), ``None`` = this scope said
814
+ nothing, so the question belongs to the enclosing scope.
815
+
816
+ The tri-state is the whole mechanism. IOS-XR makes ``passive``
817
+ inheritable at four scopes — router (process), VRF, area and interface —
818
+ and the vendor states the chain outright: *"If this command is not
819
+ specified in interface configuration mode, then the interface adopts the
820
+ passive parameter specified by the area. If this command is not
821
+ specified in area configuration mode, then the interface adopts the
822
+ passive parameter specified for the process. If this command is not
823
+ specified at any level, then the passive parameter is disabled"*
824
+ (ASR9000 routing command reference, "passive (OSPF)"; Command Modes:
825
+ Interface / Area / Router / VRF). A two-valued read cannot tell "not
826
+ specified here" from "specified off", and collapsing the two is exactly
827
+ the bug: the exempted uplink becomes indistinguishable from a
828
+ suppressed one.
829
+
830
+ Direct children only. ``obj.children`` rather than
831
+ ``find_child_objects`` because an ``area N`` block's descendants include
832
+ its ``interface X`` sub-blocks, whose ``passive`` belongs to the
833
+ interface and must not be read as the area's. Last line wins, which is
834
+ what a device does with a repeated statement.
835
+ """
836
+ state: bool | None = None
837
+ for child in obj.children:
838
+ m = _IOSXR_PASSIVE_RE.match(child.text)
839
+ if m:
840
+ state = m.group("val") != "disable"
841
+ return state
842
+
788
843
  def parse_ospf(self) -> list[OSPFConfig]:
789
844
  """Parse OSPF configurations for IOS-XR.
790
845
 
@@ -828,13 +883,27 @@ class IOSXRParser(IOSParser):
828
883
  if v:
829
884
  auto_cost_ref_bw = int(v)
830
885
 
831
- # Passive interface default (IOS-XR: per-interface "passive enable")
832
- passive_interface_default = False
833
- passive_interfaces: list[str] = []
834
- non_passive_interfaces: list[str] = []
886
+ # Passive, at PROCESS scope (CCR-0240). `passive enable` / `passive
887
+ # disable` as a direct child of `router ospf N` is the default every
888
+ # area and interface inherits; it used to be hardwired False, so the
889
+ # standard XR hardening idiom ("suppress everywhere, re-enable per
890
+ # uplink") read as nothing suppressed anywhere.
891
+ #
892
+ # `passive_interface_default` carries the PROCESS-scope answer only —
893
+ # a tri-state None means nothing was specified at this level, which
894
+ # the vendor defines as not passive. Area- and interface-scope
895
+ # overrides are resolved per interface into the two lists below, so
896
+ # the engine's resolver gets the right answer under either branch of
897
+ # `passive_interface_default`.
898
+ process_passive = self._read_iosxr_passive(ospf_obj)
899
+ passive_interface_default = process_passive is True
835
900
 
836
901
  # Parse areas — IOS-XR has "area N" stanzas with nested interfaces
837
- areas, passive_interfaces = self._parse_ospf_areas_iosxr(ospf_obj, process_id)
902
+ areas, passive_interfaces, non_passive_interfaces = (
903
+ self._parse_ospf_areas_iosxr(
904
+ ospf_obj, process_id, passive_default=process_passive
905
+ )
906
+ )
838
907
 
839
908
  # Redistribution
840
909
  redistribute = self._parse_ospf_redistribute_iosxr(ospf_obj)
@@ -1008,7 +1077,27 @@ class IOSXRParser(IOSParser):
1008
1077
  except ValueError:
1009
1078
  pass
1010
1079
 
1011
- areas, passive_interfaces = self._parse_ospf_areas_iosxr(vrf_obj, process_id)
1080
+ # CCR-0240. `passive` is legal in VRF configuration mode as well as
1081
+ # router/area/interface mode, and a VRF sub-block sits INSIDE the process
1082
+ # block — so the inherited default here is the VRF's own setting if it has
1083
+ # one, else the enclosing `router ospf N`'s. Threading the process scope
1084
+ # through is not optional: without it a process-wide `passive enable`
1085
+ # would apply to the global instance's interfaces and silently vanish for
1086
+ # the VRF's, which is the same defect in a second place.
1087
+ parent = getattr(vrf_obj, "parent", None)
1088
+ process_passive = (
1089
+ self._read_iosxr_passive(parent)
1090
+ if parent is not None and parent is not vrf_obj
1091
+ else None
1092
+ )
1093
+ vrf_passive = self._read_iosxr_passive(vrf_obj)
1094
+ passive_default = vrf_passive if vrf_passive is not None else process_passive
1095
+
1096
+ areas, passive_interfaces, non_passive_interfaces = (
1097
+ self._parse_ospf_areas_iosxr(
1098
+ vrf_obj, process_id, passive_default=passive_default
1099
+ )
1100
+ )
1012
1101
  redistribute = self._parse_ospf_redistribute_iosxr(vrf_obj)
1013
1102
 
1014
1103
  return OSPFConfig(
@@ -1020,20 +1109,36 @@ class IOSXRParser(IOSParser):
1020
1109
  vrf=vrf_name,
1021
1110
  router_id=router_id,
1022
1111
  areas=areas,
1112
+ passive_interface_default=passive_default is True,
1023
1113
  passive_interfaces=passive_interfaces,
1114
+ non_passive_interfaces=non_passive_interfaces,
1024
1115
  redistribute=redistribute,
1025
1116
  )
1026
1117
 
1027
1118
  def _parse_ospf_areas_iosxr(
1028
- self, ospf_obj, process_id: int | str | None = None
1029
- ) -> tuple[list[OSPFArea], list[str]]:
1119
+ self,
1120
+ ospf_obj,
1121
+ process_id: int | str | None = None,
1122
+ *,
1123
+ passive_default: bool | None = None,
1124
+ ) -> tuple[list[OSPFArea], list[str], list[str]]:
1030
1125
  """Parse OSPF areas with nested interface blocks (IOS-XR style).
1031
1126
 
1032
- Returns a tuple of (areas, passive_interfaces).
1127
+ ``passive_default`` is the ``passive`` state inherited from the enclosing
1128
+ scope as a tri-state (``None`` = nothing specified above), already
1129
+ resolved by the caller down the process → VRF chain.
1130
+
1131
+ Returns a tuple of (areas, passive_interfaces, non_passive_interfaces).
1132
+ Both lists are populated from the RESOLVED per-interface state, so the
1133
+ engine's resolver is correct under either branch of
1134
+ ``passive_interface_default`` — it asks ``name not in
1135
+ non_passive_interfaces`` when the default is passive and ``name in
1136
+ passive_interfaces`` when it is not (CCR-0240).
1033
1137
  """
1034
1138
  areas: list[OSPFArea] = []
1035
1139
  area_dict: dict[str, dict] = {}
1036
1140
  passive_interfaces: list[str] = []
1141
+ non_passive_interfaces: list[str] = []
1037
1142
 
1038
1143
  area_children = ospf_obj.find_child_objects(r"^\s+area\s+(\S+)")
1039
1144
  for area_child in area_children:
@@ -1136,6 +1241,12 @@ class IOSXRParser(IOSParser):
1136
1241
  except ValueError:
1137
1242
  pass
1138
1243
 
1244
+ # Passive, at AREA scope (CCR-0240) — overrides the process/VRF
1245
+ # default for this area's interfaces, and is itself overridden by an
1246
+ # interface-scope statement.
1247
+ area_passive = self._read_iosxr_passive(area_child)
1248
+ area_default = area_passive if area_passive is not None else passive_default
1249
+
1139
1250
  # Interfaces nested under area
1140
1251
  for intf_child in area_child.find_child_objects(r"^\s+interface\s+(\S+)"):
1141
1252
  intf_name = self._extract_match(intf_child.text, r"^\s+interface\s+(\S+)")
@@ -1143,24 +1254,39 @@ class IOSXRParser(IOSParser):
1143
1254
  continue
1144
1255
  if intf_name not in area_dict[area_id]["interfaces"]:
1145
1256
  area_dict[area_id]["interfaces"].append(intf_name)
1146
- # IOS-XR marks passive with "passive enable" inside the interface block
1147
- if intf_child.find_child_objects(r"^\s+passive\s+enable"):
1257
+
1258
+ # Resolve the inheritance chain: interface ?? area ?? VRF ??
1259
+ # process. `passive disable` here is the uplink exemption and
1260
+ # resolves to an explicit False, which is a different fact from
1261
+ # "nobody said" (None) — the first lands in
1262
+ # non_passive_interfaces so the engine can un-gate the uplink
1263
+ # against a passive default, the second lands in neither list
1264
+ # because no scope made a claim.
1265
+ intf_passive = self._read_iosxr_passive(intf_child)
1266
+ resolved = intf_passive if intf_passive is not None else area_default
1267
+ if resolved is True:
1148
1268
  if intf_name not in passive_interfaces:
1149
1269
  passive_interfaces.append(intf_name)
1270
+ elif resolved is False:
1271
+ if intf_name not in non_passive_interfaces:
1272
+ non_passive_interfaces.append(intf_name)
1273
+
1150
1274
  area_dict[area_id]["interface_settings"][intf_name] = (
1151
1275
  self._parse_ospf_area_interface(
1152
- intf_child, intf_name, area_id, process_id
1276
+ intf_child, intf_name, area_id, process_id,
1277
+ passive=resolved,
1153
1278
  )
1154
1279
  )
1155
1280
 
1156
1281
  for area_data in area_dict.values():
1157
1282
  areas.append(OSPFArea(**area_data))
1158
1283
 
1159
- return areas, passive_interfaces
1284
+ return areas, passive_interfaces, non_passive_interfaces
1160
1285
 
1161
1286
  # IOS-XR OSPF `area > interface` body → OSPFInterfaceConfig field. One row per
1162
- # setting; the regex's `val` group is the value, or the row is a bare flag
1163
- # (no group) whose presence means True.
1287
+ # setting; the regex's `val` group is the value — cast by _OSPF_INT_FIELDS or
1288
+ # read as an enable/disable keyword by _OSPF_ENABLE_DISABLE_FIELDS — or the row
1289
+ # is a bare flag (no group) whose presence means True.
1164
1290
  #
1165
1291
  # This is the extraction half of CCR-0038 Theme 2. The ATTRIBUTION half — the
1166
1292
  # part that used to be missing entirely on IOS-XR, and duplicated per-OS on
@@ -1177,10 +1303,12 @@ class IOSXRParser(IOSParser):
1177
1303
  ("authentication", r"^\s+authentication\s+(?P<val>message-digest|null)\s*$"),
1178
1304
  ("bfd_interval", r"^\s+bfd\s+minimum-interval\s+(?P<val>\d+)\s*$"),
1179
1305
  ("bfd_multiplier", r"^\s+bfd\s+multiplier\s+(?P<val>\d+)\s*$"),
1306
+ # `<setting> enable | disable` — the keyword IS the value, so `disable`
1307
+ # is parsed as False rather than dropped on the floor (CCR-0240).
1308
+ ("passive", _IOSXR_PASSIVE_PATTERN),
1180
1309
  # Bare flags — presence is the value.
1181
1310
  ("bfd", r"^\s+bfd\s+fast-detect\s*$"),
1182
1311
  ("mtu_ignore", r"^\s+mtu-ignore\s+enable\s*$"),
1183
- ("passive", r"^\s+passive\s+enable\s*$"),
1184
1312
  )
1185
1313
 
1186
1314
  _OSPF_INT_FIELDS = frozenset({
@@ -1188,9 +1316,18 @@ class IOSXRParser(IOSParser):
1188
1316
  "bfd_interval", "bfd_multiplier",
1189
1317
  })
1190
1318
 
1319
+ # Rows whose `val` group is an IOS-XR `enable`/`disable` keyword rather than a
1320
+ # scalar: `enable` (or the keyword's absence, which is legal when typed) means
1321
+ # True and `disable` means False. A sibling of _OSPF_INT_FIELDS, so the
1322
+ # (N+1)th enable/disable-valued setting is one row in the table plus one entry
1323
+ # here — not a branch in the loop. [[CCR-0240]]
1324
+ _OSPF_ENABLE_DISABLE_FIELDS = frozenset({"passive"})
1325
+
1191
1326
  def _parse_ospf_area_interface(
1192
1327
  self, intf_child, intf_name: str, area_id: str,
1193
1328
  process_id: int | str | None = None,
1329
+ *,
1330
+ passive: bool | None = None,
1194
1331
  ) -> OSPFInterfaceConfig:
1195
1332
  """One ``area N > interface NAME`` block → OSPFInterfaceConfig.
1196
1333
 
@@ -1202,6 +1339,15 @@ class IOSXRParser(IOSParser):
1202
1339
  settings = OSPFInterfaceConfig(
1203
1340
  name=intf_name, area_id=area_id, process_id=process_id
1204
1341
  )
1342
+ # CCR-0240: `passive` is seeded with the RESOLVED state the caller worked
1343
+ # out down the process → VRF → area → interface chain, before the body is
1344
+ # read. An interface-scope `passive` line then overwrites it with the
1345
+ # same answer (the caller derived `passive` from that very line), so the
1346
+ # seed is what makes an INHERITED passive visible on an interface whose
1347
+ # own block is silent — the model field is a plain bool and has no way to
1348
+ # say "ask my area".
1349
+ if passive is not None:
1350
+ settings.passive = passive
1205
1351
  for child in intf_child.children:
1206
1352
  text = child.text
1207
1353
  for field, pattern in self._OSPF_AREA_IFACE_PATTERNS:
@@ -1210,7 +1356,13 @@ class IOSXRParser(IOSParser):
1210
1356
  continue
1211
1357
  if "val" in m.groupdict():
1212
1358
  raw = m.group("val")
1213
- value: object = int(raw) if field in self._OSPF_INT_FIELDS else raw
1359
+ if field in self._OSPF_ENABLE_DISABLE_FIELDS:
1360
+ # `enable`, or the keyword omitted entirely, means True.
1361
+ value: object = raw != "disable"
1362
+ elif field in self._OSPF_INT_FIELDS:
1363
+ value = int(raw)
1364
+ else:
1365
+ value = raw
1214
1366
  else:
1215
1367
  value = True # bare flag
1216
1368
  setattr(settings, field, value)