panorama-super-cli 0.2.3__tar.gz → 0.2.4__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 (95) hide show
  1. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/CHANGELOG.md +13 -0
  2. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/PKG-INFO +1 -1
  3. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/duplicates-and-merging.md +21 -0
  4. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/reference/cli.md +1 -1
  5. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/_version.py +1 -1
  6. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/dedup_cmds.py +16 -3
  7. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/dedup.py +17 -5
  8. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/normalize.py +32 -4
  9. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/pyproject.toml +1 -1
  10. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/skills/panorama-super-cli/SKILL.md +2 -1
  11. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_cli.py +30 -0
  12. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_dedup.py +45 -1
  13. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_normalize.py +27 -0
  14. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/uv.lock +1 -1
  15. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/CODEOWNERS +0 -0
  16. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/dependabot.yml +0 -0
  20. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/workflows/docs.yml +0 -0
  21. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/workflows/lint.yml +0 -0
  22. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/workflows/release.yml +0 -0
  23. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.github/workflows/test.yml +0 -0
  24. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.gitignore +0 -0
  25. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.pre-commit-config.yaml +0 -0
  26. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/.python-version +0 -0
  27. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/AGENTS.md +0 -0
  28. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/CLAUDE.md +0 -0
  29. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/LICENSE +0 -0
  30. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/README.md +0 -0
  31. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/contributing/branching.md +0 -0
  32. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/contributing/development.md +0 -0
  33. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/contributing/release-process.md +0 -0
  34. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/getting-started/concepts.md +0 -0
  35. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/getting-started/first-run.md +0 -0
  36. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/getting-started/install.md +0 -0
  37. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/finding-objects.md +0 -0
  38. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/live-vs-offline.md +0 -0
  39. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/naming.md +0 -0
  40. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/output-formats.md +0 -0
  41. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/references-and-audit.md +0 -0
  42. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/safety.md +0 -0
  43. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/guides/using-with-ai-agents.md +0 -0
  44. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/index.md +0 -0
  45. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/reference/config.md +0 -0
  46. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/docs/reference/exit-codes.md +0 -0
  47. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/justfile +0 -0
  48. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/mkdocs.yml +0 -0
  49. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/__init__.py +0 -0
  50. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/__main__.py +0 -0
  51. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/__init__.py +0 -0
  52. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/_plan.py +0 -0
  53. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/app.py +0 -0
  54. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/auth_cmds.py +0 -0
  55. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/find_cmds.py +0 -0
  56. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/name_cmds.py +0 -0
  57. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/profile_cmds.py +0 -0
  58. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/refs_cmds.py +0 -0
  59. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/cli/runtime.py +0 -0
  60. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/config/__init__.py +0 -0
  61. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/config/loader.py +0 -0
  62. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/config/models.py +0 -0
  63. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/__init__.py +0 -0
  64. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/apply_live.py +0 -0
  65. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/apply_xml.py +0 -0
  66. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/changeset.py +0 -0
  67. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/models.py +0 -0
  68. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/naming.py +0 -0
  69. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/parse.py +0 -0
  70. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/refs.py +0 -0
  71. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/resolve.py +0 -0
  72. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/setcmd.py +0 -0
  73. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/core/source.py +0 -0
  74. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/output/__init__.py +0 -0
  75. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/output/errors.py +0 -0
  76. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/psc/output/format.py +0 -0
  77. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/scripts/sync_agents_md.py +0 -0
  78. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/__init__.py +0 -0
  79. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/conftest.py +0 -0
  80. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/fixtures/nested-device-groups.xml +0 -0
  81. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/fixtures/panorama-config.xml +0 -0
  82. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_apply_xml.py +0 -0
  83. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_auth.py +0 -0
  84. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_changeset.py +0 -0
  85. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_cli_auth.py +0 -0
  86. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_hardening.py +0 -0
  87. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_live_apply.py +0 -0
  88. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_naming.py +0 -0
  89. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_nested_dg.py +0 -0
  90. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_output.py +0 -0
  91. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_parse.py +0 -0
  92. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_refs.py +0 -0
  93. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_resolve.py +0 -0
  94. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_setcmd.py +0 -0
  95. {panorama_super_cli-0.2.3 → panorama_super_cli-0.2.4}/tests/test_source.py +0 -0
@@ -7,6 +7,19 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.2.4 — 2026-06-04
11
+
12
+ ### Changed
13
+
14
+ - **`psc dedup addresses` is now strict by default** (#38). Addresses are
15
+ grouped only when their values are byte-identical, so a host accidentally
16
+ written with a subnet mask (`10.1.1.50/24`) is no longer reported as a
17
+ duplicate of the network `10.1.1.0/24` — merging those would have silently
18
+ changed rule matching. Genuinely identical forms (`10.0.0.10` and
19
+ `10.0.0.10/32`) still group. Pass `--not-strict` for the previous
20
+ host-bit-masking behaviour. The `dedup merge` safety gate now compares exact
21
+ values too, so such a merge is blocked unless `--allow-value-change` is given.
22
+
10
23
  ## v0.2.3 — 2026-06-04
11
24
 
12
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely.
5
5
  Project-URL: Homepage, https://github.com/thomaschristory/panorama-super-cli
6
6
  Project-URL: Documentation, https://thomaschristory.github.io/panorama-super-cli/
@@ -12,6 +12,27 @@ Objects are grouped by **value**, not name. `h-web1`, `web-primary`, and
12
12
  `svc-https` (both TCP/443) in another. Each row tells you the value and every
13
13
  object name + location that defines it.
14
14
 
15
+ ### Strict by default
16
+
17
+ Address matching is **strict**: only byte-identical values are duplicates. A
18
+ host accidentally written with a subnet mask — `web-server = 10.1.1.50/24` —
19
+ is **not** reported as a duplicate of the network `internal = 10.1.1.0/24`,
20
+ even though both mask down to the same `/24`. (`10.0.0.10` and `10.0.0.10/32`
21
+ *are* still the same host, so they group.)
22
+
23
+ Pass `--not-strict` for the looser, fringe behaviour that masks host bits and
24
+ collapses a host-with-mask onto its network:
25
+
26
+ ```console
27
+ psc -c panorama.xml dedup addresses --not-strict
28
+ ```
29
+
30
+ `--not-strict` only widens what's *listed* — it grants no merge power. A pair it
31
+ surfaces (e.g. a host and its network) has different exact values, so
32
+ [`dedup merge`](#the-safety-gate) still refuses it unless you pass
33
+ `--allow-value-change`. Treat `--not-strict` as a discovery aid, then decide
34
+ case by case whether the masked-equal objects really should be merged.
35
+
15
36
  ## Merge two objects
16
37
 
17
38
  `dedup merge` collapses one object (`--remove`) into another (`--keep`),
@@ -40,7 +40,7 @@ object by exact name. See [Finding objects](../guides/finding-objects.md).
40
40
  ### dedup
41
41
 
42
42
  ```
43
- psc dedup addresses
43
+ psc dedup addresses [--not-strict]
44
44
  psc dedup services
45
45
  psc dedup merge --keep NAME --remove NAME [--location LOC]
46
46
  [--keep-location LOC] [--remove-location LOC]
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.2.3"
7
+ __version__ = "0.2.4"
@@ -30,10 +30,23 @@ def _dup_rows(groups: list[DuplicateGroup]) -> list[dict[str, object]]:
30
30
 
31
31
 
32
32
  @app.command("addresses")
33
- def addresses(ctx: typer.Context) -> None:
34
- """List address objects that share an identical value under different names."""
33
+ def addresses(
34
+ ctx: typer.Context,
35
+ not_strict: bool = typer.Option(
36
+ False,
37
+ "--not-strict",
38
+ help="Also group host objects onto their network (mask host bits). "
39
+ "Default: only byte-identical values are duplicates.",
40
+ ),
41
+ ) -> None:
42
+ """List address objects that share an identical value under different names.
43
+
44
+ By default this is *strict*: a host written with a subnet mask
45
+ (`10.1.1.50/24`) is not treated as a duplicate of the network `10.1.1.0/24`.
46
+ Pass `--not-strict` for the looser, host-bit-masking behaviour.
47
+ """
35
48
  rt: Runtime = ctx.obj
36
- groups = find_duplicate_addresses(rt.snapshot())
49
+ groups = find_duplicate_addresses(rt.snapshot(), strict=not not_strict)
37
50
  if rt.strict and not groups:
38
51
  raise PscError("no duplicate addresses", ErrorType.NOT_FOUND)
39
52
  render(
@@ -45,21 +45,30 @@ class DuplicateGroup(BaseModel):
45
45
  return len(self.members)
46
46
 
47
47
 
48
- def find_duplicate_addresses(snapshot: Snapshot) -> list[DuplicateGroup]:
49
- """Address objects sharing an identical normalized value, 2+ per bucket."""
48
+ def find_duplicate_addresses(snapshot: Snapshot, *, strict: bool = True) -> list[DuplicateGroup]:
49
+ """Address objects sharing the same value, 2+ per bucket.
50
+
51
+ `strict` (default) groups only byte-identical values, so a host written with
52
+ a subnet mask (`10.1.1.50/24`) is *not* a duplicate of its network
53
+ (`10.1.1.0/24`). `strict=False` groups by masked network — the looser,
54
+ fringe behaviour that collapses host-with-mask onto the network.
55
+ """
50
56
  buckets: dict[str, list[ObjectRef]] = {}
51
57
  display: dict[str, str] = {}
52
58
  for a in snapshot.addresses:
53
59
  nv = normalize_address(a)
54
60
  if nv is None:
55
61
  continue
56
- key = nv.overlaps_key()
62
+ key = nv.exact_key() if strict else nv.overlaps_key()
57
63
  buckets.setdefault(key, []).append(ObjectRef(name=a.name, location=a.location.name))
58
- display.setdefault(key, f"{a.type.value} {nv.key}")
64
+ label = (nv.exact or nv.key) if strict else nv.key
65
+ display.setdefault(key, f"{a.type.value} {label}")
59
66
  return _to_groups("address", buckets, display)
60
67
 
61
68
 
62
69
  def find_duplicate_services(snapshot: Snapshot) -> list[DuplicateGroup]:
70
+ # Service dedup is already exact (protocol + normalized port lists); there
71
+ # is no host-bit-masking analogue, so no strict/loose distinction applies.
63
72
  buckets: dict[str, list[ObjectRef]] = {}
64
73
  display: dict[str, str] = {}
65
74
  for s in snapshot.services:
@@ -93,10 +102,13 @@ def _rewrite_members(before: list[str], drop: str, keep: str) -> list[str]:
93
102
 
94
103
 
95
104
  def _addr_value_key(snapshot: Snapshot, ref: ObjectRef) -> str | None:
105
+ # Exact (host-bit-preserving) key: the merge gate must treat a /24-masked
106
+ # host and the /24 network as *different* values, so it blocks the merge
107
+ # unless --allow-value-change is passed.
96
108
  for a in snapshot.addresses:
97
109
  if a.name == ref.name and a.location == ref.loc:
98
110
  nv = normalize_address(a)
99
- return nv.overlaps_key() if nv else None
111
+ return nv.exact_key() if nv else None
100
112
  return None
101
113
 
102
114
 
@@ -41,11 +41,20 @@ class AddrValue:
41
41
  range: tuple[int, int] | None = None
42
42
  family: int | None = None
43
43
  fqdn: str | None = None
44
+ exact: str | None = None
45
+ """Host-bit-preserving canonical string for ip-netmask (e.g. `10.1.1.50/24`
46
+ stays distinct from `10.1.1.0/24`); `None` means `key` is already exact."""
44
47
 
45
48
  def overlaps_key(self) -> str:
46
- """Kind-qualified key for grouping exact duplicates."""
49
+ """Kind-qualified key grouping by *masked network* — loose: a host
50
+ written with a subnet mask collapses onto its network."""
47
51
  return f"{self.kind.value}:{self.key}"
48
52
 
53
+ def exact_key(self) -> str:
54
+ """Kind-qualified key grouping only *byte-identical* values — strict:
55
+ host bits are preserved, so `10.1.1.50/24` != `10.1.1.0/24`."""
56
+ return f"{self.kind.value}:{self.exact or self.key}"
57
+
49
58
 
50
59
  def _as_network(value: str) -> IPNetwork | None:
51
60
  try:
@@ -54,6 +63,16 @@ def _as_network(value: str) -> IPNetwork | None:
54
63
  return None
55
64
 
56
65
 
66
+ def _as_interface(value: str) -> ipaddress.IPv4Interface | ipaddress.IPv6Interface | None:
67
+ """Parse host-preserving: `10.1.1.50/24` keeps its host bits, `10.0.0.10`
68
+ becomes `10.0.0.10/32`. `.network` is the masked form (equals
69
+ `_as_network`), `str(...)` is the exact form. `None` if unparseable."""
70
+ try:
71
+ return ipaddress.ip_interface(value.strip())
72
+ except ValueError:
73
+ return None
74
+
75
+
57
76
  def _range_bounds(value: str) -> tuple[int, int, int] | None:
58
77
  """`a-b` -> (start_int, end_int, family); None if unparseable."""
59
78
  if "-" not in value:
@@ -75,10 +94,19 @@ def normalize_address(addr: Address) -> AddrValue | None:
75
94
  """
76
95
  v = addr.value.strip()
77
96
  if addr.type is AddressType.IP_NETMASK:
78
- net = _as_network(v)
79
- if net is None:
97
+ # One parse yields both keys: `.network` is the masked grouping key
98
+ # (loose), `str(iface)` preserves host bits for the exact key (strict).
99
+ iface = _as_interface(v)
100
+ if iface is None:
80
101
  return None
81
- return AddrValue(kind=addr.type, key=str(net), network=net, family=net.version)
102
+ net = iface.network
103
+ return AddrValue(
104
+ kind=addr.type,
105
+ key=str(net),
106
+ network=net,
107
+ family=net.version,
108
+ exact=str(iface),
109
+ )
82
110
  if addr.type is AddressType.IP_RANGE:
83
111
  bounds = _range_bounds(v)
84
112
  if bounds is None:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "panorama-super-cli"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -78,7 +78,8 @@ like `10.0.0.0/8` would otherwise drown out the host you asked for.
78
78
  ### dedup — duplicates and merging
79
79
 
80
80
  ```bash
81
- psc -c cfg.xml -o json dedup addresses # same value, different names
81
+ psc -c cfg.xml -o json dedup addresses # strict: byte-identical values only
82
+ psc -c cfg.xml -o json dedup addresses --not-strict # also mask host bits (10.1.1.50/24 ~ 10.1.1.0/24)
82
83
  psc -c cfg.xml -o json dedup services
83
84
  psc -c cfg.xml -o json dedup merge --keep h-web1 --remove web-primary # dry-run plan
84
85
  psc -c cfg.xml dedup merge --keep h-web1 --remove web-primary --apply --out fixed.xml
@@ -64,6 +64,36 @@ def test_strict_not_found_exit_5() -> None:
64
64
  assert json.loads(cp.stdout)["type"] == "not_found"
65
65
 
66
66
 
67
+ _HOST_AND_NET_CONFIG = """<?xml version="1.0"?>
68
+ <config version="11.0.0">
69
+ <shared>
70
+ <address>
71
+ <entry name="host-with-mask"><ip-netmask>10.1.1.50/24</ip-netmask></entry>
72
+ <entry name="real-network"><ip-netmask>10.1.1.0/24</ip-netmask></entry>
73
+ </address>
74
+ </shared>
75
+ </config>
76
+ """
77
+
78
+
79
+ def test_dedup_addresses_strict_default_finds_no_duplicates(tmp_path: Path) -> None:
80
+ cfg = tmp_path / "cfg.xml"
81
+ cfg.write_text(_HOST_AND_NET_CONFIG)
82
+ cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses")
83
+ assert cp.returncode == 0
84
+ assert json.loads(cp.stdout) == []
85
+
86
+
87
+ def test_dedup_addresses_not_strict_groups_host_with_network(tmp_path: Path) -> None:
88
+ cfg = tmp_path / "cfg.xml"
89
+ cfg.write_text(_HOST_AND_NET_CONFIG)
90
+ cp = run("-c", str(cfg), "-o", "json", "dedup", "addresses", "--not-strict")
91
+ assert cp.returncode == 0
92
+ groups = json.loads(cp.stdout)
93
+ assert len(groups) == 1
94
+ assert {m["name"] for m in groups[0]["members"]} == {"host-with-mask", "real-network"}
95
+
96
+
67
97
  def test_merge_dry_run_exit_0_writes_nothing(tmp_path: Path) -> None:
68
98
  out = tmp_path / "x.xml"
69
99
  cp = run(
@@ -6,16 +6,45 @@ from psc.core.dedup import (
6
6
  find_duplicate_services,
7
7
  plan_merge,
8
8
  )
9
- from psc.core.models import Snapshot
9
+ from psc.core.models import Address, AddressType, Snapshot
10
10
  from psc.core.refs import ReferenceGraph
11
11
 
12
12
 
13
+ def _host_and_network() -> Snapshot:
14
+ """A host written with a subnet mask and a real network object: identical
15
+ only after host-bit masking, so a strict dedup must keep them apart."""
16
+ return Snapshot(
17
+ addresses=[
18
+ Address(name="host", type=AddressType.IP_NETMASK, value="10.1.1.50/24"),
19
+ Address(name="net", type=AddressType.IP_NETMASK, value="10.1.1.0/24"),
20
+ ]
21
+ )
22
+
23
+
13
24
  def test_duplicate_addresses_grouped_by_value(snapshot: Snapshot) -> None:
14
25
  groups = {g.value: {m.name for m in g.members} for g in find_duplicate_addresses(snapshot)}
15
26
  assert groups["ip-netmask 10.0.0.10/32"] == {"h-web1", "web-primary", "h-web1-slash"}
16
27
  assert groups["ip-netmask 192.168.1.1/32"] == {"edge-dup", "local-only"}
17
28
 
18
29
 
30
+ def test_dedup_strict_default_excludes_host_with_mask() -> None:
31
+ # Strict (default): a /24-masked host is NOT a duplicate of the /24 network.
32
+ assert find_duplicate_addresses(_host_and_network()) == []
33
+
34
+
35
+ def test_dedup_not_strict_groups_host_with_mask() -> None:
36
+ # --not-strict restores the host-bit-masking behaviour for the fringe case.
37
+ groups = find_duplicate_addresses(_host_and_network(), strict=False)
38
+ assert len(groups) == 1
39
+ assert {m.name for m in groups[0].members} == {"host", "net"}
40
+
41
+
42
+ def test_dedup_strict_still_groups_genuine_duplicates(snapshot: Snapshot) -> None:
43
+ # Strict must keep collapsing 10.0.0.10 and 10.0.0.10/32 — genuinely identical.
44
+ groups = {g.value: {m.name for m in g.members} for g in find_duplicate_addresses(snapshot)}
45
+ assert groups["ip-netmask 10.0.0.10/32"] == {"h-web1", "web-primary", "h-web1-slash"}
46
+
47
+
19
48
  def test_duplicate_services(snapshot: Snapshot) -> None:
20
49
  groups = find_duplicate_services(snapshot)
21
50
  members = {m.name for g in groups for m in g.members}
@@ -51,6 +80,21 @@ def test_merge_blocks_value_mismatch(snapshot: Snapshot) -> None:
51
80
  assert any("value mismatch" in b for b in cs.blockers)
52
81
 
53
82
 
83
+ def test_merge_blocks_host_vs_network_value_mismatch() -> None:
84
+ # The merge gate compares exact values: a /24-masked host and the /24
85
+ # network mean different things, so the merge is blocked without --force.
86
+ snap = _host_and_network()
87
+ graph = ReferenceGraph.build(snap)
88
+ cs = plan_merge(
89
+ snap,
90
+ graph,
91
+ keep=ObjectRef(name="net", location="shared"),
92
+ drop=ObjectRef(name="host", location="shared"),
93
+ )
94
+ assert cs.is_blocked
95
+ assert any("value mismatch" in b for b in cs.blockers)
96
+
97
+
54
98
  def test_merge_allows_value_change_when_forced(snapshot: Snapshot) -> None:
55
99
  graph = ReferenceGraph.build(snapshot)
56
100
  cs = plan_merge(
@@ -15,6 +15,33 @@ def test_host_and_slash32_normalize_equal() -> None:
15
15
  assert a.overlaps_key() == b.overlaps_key()
16
16
 
17
17
 
18
+ def test_exact_key_preserves_host_bits() -> None:
19
+ # Same masked network, different host bits: the loose key collapses them,
20
+ # the strict (exact) key keeps them apart.
21
+ host = normalize_address(_addr("10.1.1.50/24"))
22
+ net = normalize_address(_addr("10.1.1.0/24"))
23
+ assert host is not None and net is not None
24
+ assert host.overlaps_key() == net.overlaps_key()
25
+ assert host.exact_key() != net.exact_key()
26
+
27
+
28
+ def test_exact_key_unifies_host_and_slash32() -> None:
29
+ # A bare host and its /32 are genuinely identical under the strict key too.
30
+ a = normalize_address(_addr("10.0.0.10"))
31
+ b = normalize_address(_addr("10.0.0.10/32"))
32
+ assert a is not None and b is not None
33
+ assert a.exact_key() == b.exact_key()
34
+
35
+
36
+ def test_exact_key_canonicalizes_ipv6_forms() -> None:
37
+ # Strict must still group genuinely identical objects written differently:
38
+ # `0:0:0:0:0:0:0:1` and `::1` are the same host.
39
+ a = normalize_address(_addr("0:0:0:0:0:0:0:1"))
40
+ b = normalize_address(_addr("::1"))
41
+ assert a is not None and b is not None
42
+ assert a.exact_key() == b.exact_key()
43
+
44
+
18
45
  def test_exact_contains_within() -> None:
19
46
  host = normalize_address(_addr("10.0.0.10/32"))
20
47
  net = normalize_address(_addr("10.0.0.0/24"))
@@ -694,7 +694,7 @@ wheels = [
694
694
 
695
695
  [[package]]
696
696
  name = "panorama-super-cli"
697
- version = "0.2.3"
697
+ version = "0.2.4"
698
698
  source = { editable = "." }
699
699
  dependencies = [
700
700
  { name = "click" },