unifi-network-maps 1.4.12__tar.gz → 1.4.13__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 (132) hide show
  1. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/CHANGELOG.md +11 -1
  2. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/PKG-INFO +2 -2
  3. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/pyproject.toml +3 -3
  4. unifi_network_maps-1.4.13/src/unifi_network_maps/__init__.py +1 -0
  5. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/adapters/config.py +6 -2
  6. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/adapters/unifi.py +17 -7
  7. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/main.py +46 -3
  8. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/render.py +4 -2
  9. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/export.py +11 -2
  10. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/mkdocs_assets.py +4 -2
  11. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/mock_data.py +4 -2
  12. unifi_network_maps-1.4.13/src/unifi_network_maps/io/paths.py +197 -0
  13. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/topology.py +3 -3
  14. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/device_ports_md.py +27 -18
  15. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/lldp_md.py +4 -1
  16. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/theme.py +2 -1
  17. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/PKG-INFO +2 -2
  18. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/SOURCES.txt +2 -0
  19. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/requires.txt +1 -1
  20. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_device_ports_md.py +36 -0
  21. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_lldp_md.py +112 -1
  22. unifi_network_maps-1.4.13/tests/test_mkdocs.py +113 -0
  23. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_topology.py +130 -0
  24. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_unifi.py +102 -0
  25. unifi_network_maps-1.4.12/src/unifi_network_maps/__init__.py +0 -1
  26. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/CONTRIBUTING.md +0 -0
  27. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/LICENSE +0 -0
  28. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/LICENSES.md +0 -0
  29. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/MANIFEST.in +0 -0
  30. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/README.md +0 -0
  31. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/RELEASING.md +0 -0
  32. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/SECURITY.md +0 -0
  33. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/setup.cfg +0 -0
  34. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/__main__.py +0 -0
  35. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/adapters/__init__.py +0 -0
  36. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/__init__.py +0 -0
  37. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/__init__.py +0 -0
  38. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/access-point.svg +0 -0
  39. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  40. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/block.svg +0 -0
  41. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cache.svg +0 -0
  42. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cardterminal.svg +0 -0
  43. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cloud.svg +0 -0
  44. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cronjob.svg +0 -0
  45. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/cube.svg +0 -0
  46. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/desktop.svg +0 -0
  47. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/diamond.svg +0 -0
  48. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/dns.svg +0 -0
  49. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/document.svg +0 -0
  50. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/firewall.svg +0 -0
  51. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/function-module.svg +0 -0
  52. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/image.svg +0 -0
  53. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/laptop.svg +0 -0
  54. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/loadbalancer.svg +0 -0
  55. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/lock.svg +0 -0
  56. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/mail.svg +0 -0
  57. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/mailmultiple.svg +0 -0
  58. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/mobiledevice.svg +0 -0
  59. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/office.svg +0 -0
  60. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/package-module.svg +0 -0
  61. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/paymentcard.svg +0 -0
  62. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/plane.svg +0 -0
  63. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/printer.svg +0 -0
  64. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/pyramid.svg +0 -0
  65. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/queue.svg +0 -0
  66. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/router.svg +0 -0
  67. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/server.svg +0 -0
  68. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/speech.svg +0 -0
  69. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/sphere.svg +0 -0
  70. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/storage.svg +0 -0
  71. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/switch-module.svg +0 -0
  72. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/tower.svg +0 -0
  73. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/truck-2.svg +0 -0
  74. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/truck.svg +0 -0
  75. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/user.svg +0 -0
  76. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/isometric/vm.svg +0 -0
  77. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/laptop.svg +0 -0
  78. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/router-network.svg +0 -0
  79. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/server-network.svg +0 -0
  80. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/icons/server.svg +0 -0
  81. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/themes/dark.yaml +0 -0
  82. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/assets/themes/default.yaml +0 -0
  83. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/__init__.py +0 -0
  84. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/__main__.py +0 -0
  85. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/args.py +0 -0
  86. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/cli/runtime.py +0 -0
  87. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/__init__.py +0 -0
  88. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/debug.py +0 -0
  89. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/io/mock_generate.py +0 -0
  90. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/__init__.py +0 -0
  91. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/labels.py +0 -0
  92. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/lldp.py +0 -0
  93. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/mock.py +0 -0
  94. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/model/ports.py +0 -0
  95. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/__init__.py +0 -0
  96. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/legend.py +0 -0
  97. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/markdown_tables.py +0 -0
  98. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/mermaid.py +0 -0
  99. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/mermaid_theme.py +0 -0
  100. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/mkdocs.py +0 -0
  101. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/svg.py +0 -0
  102. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/svg_theme.py +0 -0
  103. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/device_port_block.md.j2 +0 -0
  104. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/legend_compact.html.j2 +0 -0
  105. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/lldp_device_section.md.j2 +0 -0
  106. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/markdown_section.md.j2 +0 -0
  107. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +0 -0
  108. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_document.md.j2 +0 -0
  109. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +0 -0
  110. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +0 -0
  111. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_legend.css.j2 +0 -0
  112. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_legend.js.j2 +0 -0
  113. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +0 -0
  114. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps/render/templating.py +0 -0
  115. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/dependency_links.txt +0 -0
  116. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/entry_points.txt +0 -0
  117. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/src/unifi_network_maps.egg-info/top_level.txt +0 -0
  118. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_cli.py +0 -0
  119. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_clients.py +0 -0
  120. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_config.py +0 -0
  121. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_contract_unifi.py +0 -0
  122. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_contract_unifi_live.py +0 -0
  123. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_debug.py +0 -0
  124. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_export.py +0 -0
  125. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_groups.py +0 -0
  126. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_labels.py +0 -0
  127. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_lldp.py +0 -0
  128. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_mermaid.py +0 -0
  129. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_mock_generate.py +0 -0
  130. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_svg.py +0 -0
  131. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_svg_iso.py +0 -0
  132. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.13}/tests/test_theme.py +0 -0
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.13] - 2026-01-25
9
+ ### Fixed
10
+ - Path Traversal Vulnerability in File Operations
11
+ - Cache Directory Symlink Attack vector
12
+
13
+ ### Changed
14
+ - Improved escaping in Markdown Output
15
+ - Made logging less chatty, moved messages to debug level
16
+
8
17
  ## [1.4.12] - 2026-01-21
9
18
  ### Added
10
19
  - Filter UniFi clients with --only-unifi, and not only neighbors
@@ -183,7 +192,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
183
192
  - Introduced SVG renderer and tree layout fixes.
184
193
  - Increased test coverage and added coverage tooling.
185
194
 
186
- [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.12...HEAD
195
+ [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.13...HEAD
196
+ [1.4.13]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...v1.4.13
187
197
  [1.4.12]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...v1.4.12
188
198
  [1.4.11]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.10...v1.4.11
189
199
  [1.4.10]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.9...v1.4.10
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-maps
3
- Version: 1.4.12
3
+ Version: 1.4.13
4
4
  Summary: Dynamic UniFi -> network maps in mermaid or svg
5
5
  Author: Merlijn
6
6
  License-Expression: MIT
@@ -32,7 +32,7 @@ Requires-Dist: pre-commit==4.5.1; extra == "dev"
32
32
  Requires-Dist: pytest==9.0.2; extra == "dev"
33
33
  Requires-Dist: pytest-cov==7.0.0; extra == "dev"
34
34
  Requires-Dist: pyright==1.1.408; extra == "dev"
35
- Requires-Dist: ruff==0.14.13; extra == "dev"
35
+ Requires-Dist: ruff==0.14.14; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
38
  # unifi-network-maps
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools==80.9.0", "wheel==0.45.1"]
2
+ requires = ["setuptools==80.10.1", "wheel==0.46.3"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unifi-network-maps"
7
- version = "1.4.12"
7
+ version = "1.4.13"
8
8
  description = "Dynamic UniFi -> network maps in mermaid or svg"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -44,7 +44,7 @@ dev = [
44
44
  "pytest==9.0.2",
45
45
  "pytest-cov==7.0.0",
46
46
  "pyright==1.1.408",
47
- "ruff==0.14.13"
47
+ "ruff==0.14.14"
48
48
  ]
49
49
 
50
50
  [project.scripts]
@@ -0,0 +1 @@
1
+ __version__ = "1.4.13"
@@ -4,6 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..io.paths import resolve_env_file
7
10
 
8
11
 
9
12
  def _parse_bool(value: str | None, default: bool = True) -> bool:
@@ -26,13 +29,14 @@ class Config:
26
29
  verify_ssl: bool
27
30
 
28
31
  @classmethod
29
- def from_env(cls, *, env_file: str | None = None) -> Config:
32
+ def from_env(cls, *, env_file: str | Path | None = None) -> Config:
30
33
  if env_file:
31
34
  try:
32
35
  from dotenv import load_dotenv
33
36
  except ImportError:
34
37
  raise ValueError("python-dotenv required for --env-file") from None
35
- load_dotenv(dotenv_path=env_file)
38
+ env_path = resolve_env_file(env_file)
39
+ load_dotenv(dotenv_path=env_path)
36
40
  url = os.environ.get("UNIFI_URL", "").strip()
37
41
  site = os.environ.get("UNIFI_SITE", "default").strip()
38
42
  user = os.environ.get("UNIFI_USER", "").strip()
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import stat
10
+ import tempfile
10
11
  import time
11
12
  from collections.abc import Callable, Iterator, Sequence
12
13
  from concurrent.futures import ThreadPoolExecutor
@@ -15,6 +16,7 @@ from contextlib import contextmanager
15
16
  from pathlib import Path
16
17
  from typing import IO, TYPE_CHECKING
17
18
 
19
+ from ..io.paths import resolve_cache_dir
18
20
  from .config import Config
19
21
 
20
22
  if TYPE_CHECKING:
@@ -24,7 +26,15 @@ logger = logging.getLogger(__name__)
24
26
 
25
27
 
26
28
  def _cache_dir() -> Path:
27
- return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
29
+ default_dir = ".cache/unifi_network_maps"
30
+ if os.environ.get("PYTEST_CURRENT_TEST"):
31
+ default_dir = str(Path(tempfile.gettempdir()) / f"unifi_network_maps_pytest_{os.getpid()}")
32
+ value = os.environ.get("UNIFI_CACHE_DIR", default_dir)
33
+ try:
34
+ return resolve_cache_dir(value)
35
+ except ValueError as exc:
36
+ logger.warning("Invalid UNIFI_CACHE_DIR (%s); using default: %s", value, exc)
37
+ return resolve_cache_dir(".cache/unifi_network_maps")
28
38
 
29
39
 
30
40
  def _device_attr(device: object, name: str) -> object | None:
@@ -377,13 +387,13 @@ def fetch_devices(
377
387
  cached = None
378
388
  stale_cached, cache_age = None, None
379
389
  if cached is not None:
380
- logger.info("Using cached devices (%d)", len(cached))
390
+ logger.debug("Using cached devices (%d)", len(cached))
381
391
  return cached
382
392
 
383
393
  try:
384
394
  controller = _init_controller(config, is_udm_pro=True)
385
395
  except UnifiAuthenticationError:
386
- logger.info("UDM Pro authentication failed, retrying legacy auth")
396
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
387
397
  controller = _init_controller(config, is_udm_pro=False)
388
398
 
389
399
  def _fetch() -> Sequence[object]:
@@ -402,7 +412,7 @@ def fetch_devices(
402
412
  raise
403
413
  if use_cache:
404
414
  _save_cache(cache_path, _serialize_devices_for_cache(devices))
405
- logger.info("Fetched %d devices", len(devices))
415
+ logger.debug("Fetched %d devices", len(devices))
406
416
  return devices
407
417
 
408
418
 
@@ -428,13 +438,13 @@ def fetch_clients(
428
438
  cached = None
429
439
  stale_cached, cache_age = None, None
430
440
  if cached is not None:
431
- logger.info("Using cached clients (%d)", len(cached))
441
+ logger.debug("Using cached clients (%d)", len(cached))
432
442
  return cached
433
443
 
434
444
  try:
435
445
  controller = _init_controller(config, is_udm_pro=True)
436
446
  except UnifiAuthenticationError:
437
- logger.info("UDM Pro authentication failed, retrying legacy auth")
447
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
438
448
  controller = _init_controller(config, is_udm_pro=False)
439
449
 
440
450
  def _fetch() -> Sequence[object]:
@@ -453,5 +463,5 @@ def fetch_clients(
453
463
  raise
454
464
  if use_cache:
455
465
  _save_cache(cache_path, clients)
456
- logger.info("Fetched %d clients", len(clients))
466
+ logger.debug("Fetched %d clients", len(clients))
457
467
  return clients
@@ -4,10 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import logging
7
+ from pathlib import Path
7
8
 
8
9
  from ..adapters.config import Config
9
10
  from ..io.export import write_output
10
11
  from ..io.mock_data import load_mock_data
12
+ from ..io.paths import (
13
+ resolve_env_file,
14
+ resolve_mock_data_path,
15
+ resolve_output_path,
16
+ resolve_theme_path,
17
+ )
11
18
  from ..render.legend import render_legend_only, resolve_legend_style
12
19
  from ..render.theme import resolve_themes
13
20
  from .args import build_parser
@@ -16,7 +23,7 @@ from .render import render_lldp_format, render_standard_format
16
23
  logger = logging.getLogger(__name__)
17
24
 
18
25
 
19
- def _load_dotenv(env_file: str | None = None) -> None:
26
+ def _load_dotenv(env_file: str | Path | None = None) -> None:
20
27
  try:
21
28
  from dotenv import load_dotenv
22
29
  except ImportError:
@@ -30,6 +37,36 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
30
37
  return parser.parse_args(argv)
31
38
 
32
39
 
40
+ class _DowngradeInfoToDebugFilter(logging.Filter):
41
+ def filter(self, record: logging.LogRecord) -> bool:
42
+ if record.name.startswith("unifi_controller_api") and record.levelno == logging.INFO:
43
+ record.levelno = logging.DEBUG
44
+ record.levelname = logging.getLevelName(logging.DEBUG)
45
+ return True
46
+
47
+
48
+ def _downgrade_unifi_controller_logs() -> logging.Filter:
49
+ return _DowngradeInfoToDebugFilter()
50
+
51
+
52
+ def _validate_paths(args: argparse.Namespace) -> bool:
53
+ try:
54
+ if args.env_file:
55
+ resolve_env_file(args.env_file)
56
+ if args.mock_data:
57
+ resolve_mock_data_path(args.mock_data, require_exists=False)
58
+ if args.theme_file:
59
+ resolve_theme_path(args.theme_file, require_exists=False)
60
+ if args.generate_mock:
61
+ resolve_output_path(args.generate_mock, format_name="mock")
62
+ if args.output:
63
+ resolve_output_path(args.output, format_name=args.format)
64
+ except ValueError as exc:
65
+ logging.error(str(exc))
66
+ return False
67
+ return True
68
+
69
+
33
70
  def _load_config(args: argparse.Namespace) -> Config | None:
34
71
  try:
35
72
  _load_dotenv(args.env_file)
@@ -59,7 +96,8 @@ def _handle_generate_mock(args: argparse.Namespace) -> int | None:
59
96
  wireless_client_count=max(0, args.mock_wireless_clients),
60
97
  )
61
98
  content = mock_payload_json(options)
62
- write_output(content, output_path=args.generate_mock, stdout=args.stdout)
99
+ output_kwargs = {"format_name": "mock"} if args.generate_mock else {}
100
+ write_output(content, output_path=args.generate_mock, stdout=args.stdout, **output_kwargs)
63
101
  return 0
64
102
 
65
103
 
@@ -81,7 +119,11 @@ def _load_runtime_context(
81
119
 
82
120
  def main(argv: list[str] | None = None) -> int:
83
121
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
122
+ for handler in logging.getLogger().handlers:
123
+ handler.addFilter(_downgrade_unifi_controller_logs())
84
124
  args = _parse_args(argv)
125
+ if not _validate_paths(args):
126
+ return 2
85
127
  mock_result = _handle_generate_mock(args)
86
128
  if mock_result is not None:
87
129
  return mock_result
@@ -107,7 +149,8 @@ def main(argv: list[str] | None = None) -> int:
107
149
  markdown=args.markdown,
108
150
  theme=mermaid_theme,
109
151
  )
110
- write_output(content, output_path=args.output, stdout=args.stdout)
152
+ output_kwargs = {"format_name": args.format} if args.output else {}
153
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
111
154
  return 0
112
155
 
113
156
  if args.format == "lldp-md":
@@ -207,7 +207,8 @@ def render_lldp_format(
207
207
  client_mode=args.client_scope,
208
208
  only_unifi=args.only_unifi,
209
209
  )
210
- write_output(content, output_path=args.output, stdout=args.stdout)
210
+ output_kwargs = {"format_name": args.format} if args.output else {}
211
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
211
212
  return 0
212
213
 
213
214
 
@@ -267,5 +268,6 @@ def render_standard_format(
267
268
  logging.error("Unsupported format: %s", args.format)
268
269
  return 2
269
270
 
270
- write_output(content, output_path=args.output, stdout=args.stdout)
271
+ output_kwargs = {"format_name": args.format} if args.output else {}
272
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
271
273
  return 0
@@ -7,10 +7,19 @@ import sys
7
7
  import tempfile
8
8
  from pathlib import Path
9
9
 
10
+ from .paths import resolve_output_path
10
11
 
11
- def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
12
+
13
+ def write_output(
14
+ content: str,
15
+ *,
16
+ output_path: str | Path | None,
17
+ stdout: bool,
18
+ format_name: str | None = None,
19
+ ) -> None:
12
20
  if output_path:
13
- _write_atomic(Path(output_path), content)
21
+ resolved = resolve_output_path(output_path, format_name=format_name)
22
+ _write_atomic(resolved, content)
14
23
  if stdout or not output_path:
15
24
  sys.stdout.write(content)
16
25
 
@@ -5,10 +5,12 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from ..render.templating import render_template
8
+ from .paths import resolve_output_file
8
9
 
9
10
 
10
- def write_mkdocs_sidebar_assets(output_path: str) -> None:
11
- output_dir = Path(output_path).resolve().parent
11
+ def write_mkdocs_sidebar_assets(output_path: str | Path) -> None:
12
+ resolved = resolve_output_file(output_path, extensions=None, label="MkDocs output file")
13
+ output_dir = resolved.parent
12
14
  assets_dir = output_dir / "assets"
13
15
  assets_dir.mkdir(parents=True, exist_ok=True)
14
16
  (assets_dir / "legend.js").write_text(
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from pathlib import Path
6
+
7
+ from .paths import resolve_mock_data_path
7
8
 
8
9
 
9
10
  def _as_list(value: object, name: str) -> list[object]:
@@ -15,7 +16,8 @@ def _as_list(value: object, name: str) -> list[object]:
15
16
 
16
17
 
17
18
  def load_mock_data(path: str) -> tuple[list[object], list[object]]:
18
- payload = json.loads(Path(path).read_text(encoding="utf-8"))
19
+ resolved = resolve_mock_data_path(path)
20
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
19
21
  if not isinstance(payload, dict):
20
22
  raise ValueError("Mock data must be a JSON object")
21
23
  devices = _as_list(payload.get("devices"), "devices")
@@ -0,0 +1,197 @@
1
+ """Path validation helpers for user-supplied file system inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+
10
+
11
+ def _safe_home_dir() -> Path | None:
12
+ try:
13
+ return Path.home().resolve()
14
+ except Exception:
15
+ return None
16
+
17
+
18
+ def _base_roots() -> list[Path]:
19
+ roots = [Path.cwd().resolve()]
20
+ home = _safe_home_dir()
21
+ if home:
22
+ roots.append(home)
23
+ try:
24
+ roots.append(Path(tempfile.gettempdir()).resolve())
25
+ except Exception:
26
+ pass
27
+ return roots
28
+
29
+
30
+ def _extra_roots_from_env() -> list[Path]:
31
+ extra = os.environ.get("UNIFI_ALLOWED_PATHS", "")
32
+ roots: list[Path] = []
33
+ if extra:
34
+ for raw in extra.split(os.pathsep):
35
+ raw = raw.strip()
36
+ if raw:
37
+ roots.append(Path(raw).expanduser().resolve())
38
+ return roots
39
+
40
+
41
+ def _allowed_roots() -> tuple[Path, ...]:
42
+ roots = _base_roots() + _extra_roots_from_env()
43
+ seen: set[str] = set()
44
+ unique: list[Path] = []
45
+ for root in roots:
46
+ key = str(root)
47
+ if key not in seen:
48
+ seen.add(key)
49
+ unique.append(root)
50
+ return tuple(unique)
51
+
52
+
53
+ def _resolve_user_path(path: str | Path) -> Path:
54
+ return Path(path).expanduser().resolve(strict=False)
55
+
56
+
57
+ def _ensure_within_allowed(path: Path, roots: Iterable[Path], *, label: str) -> None:
58
+ for root in roots:
59
+ try:
60
+ path.relative_to(root)
61
+ except ValueError:
62
+ continue
63
+ else:
64
+ return
65
+ root_list = ", ".join(str(root) for root in roots)
66
+ raise ValueError(f"{label} must be within: {root_list}")
67
+
68
+
69
+ def _ensure_no_symlink(path: Path, *, label: str) -> None:
70
+ if path.exists() and path.is_symlink():
71
+ raise ValueError(f"{label} must not be a symlink: {path}")
72
+
73
+
74
+ def _ensure_no_symlink_in_parents(path: Path, *, label: str) -> None:
75
+ for parent in path.parents:
76
+ if parent.exists() and parent.is_symlink():
77
+ raise ValueError(f"{label} parent must not be a symlink: {parent}")
78
+
79
+
80
+ def _normalize_extensions(extensions: Iterable[str]) -> set[str]:
81
+ normalized = set()
82
+ for ext in extensions:
83
+ ext = ext.strip().lower()
84
+ if not ext:
85
+ continue
86
+ if not ext.startswith("."):
87
+ ext = f".{ext}"
88
+ normalized.add(ext)
89
+ return normalized
90
+
91
+
92
+ def _ensure_extension(
93
+ path: Path,
94
+ extensions: Iterable[str] | None,
95
+ *,
96
+ label: str,
97
+ allow_missing: bool = False,
98
+ ) -> None:
99
+ if not extensions:
100
+ return
101
+ allowed = _normalize_extensions(extensions)
102
+ suffix = path.suffix.lower()
103
+ if not suffix:
104
+ if allow_missing:
105
+ return
106
+ raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
107
+ if suffix not in allowed:
108
+ raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
109
+
110
+
111
+ def resolve_input_file(
112
+ path: str | Path,
113
+ *,
114
+ extensions: Iterable[str] | None,
115
+ label: str,
116
+ require_exists: bool = True,
117
+ ) -> Path:
118
+ resolved = _resolve_user_path(path)
119
+ _ensure_within_allowed(resolved, _allowed_roots(), label=label)
120
+ _ensure_extension(resolved, extensions, label=label)
121
+ if require_exists:
122
+ if not resolved.exists():
123
+ raise ValueError(f"{label} does not exist: {resolved}")
124
+ if not resolved.is_file():
125
+ raise ValueError(f"{label} must be a file: {resolved}")
126
+ return resolved
127
+
128
+
129
+ def resolve_output_file(
130
+ path: str | Path,
131
+ *,
132
+ extensions: Iterable[str] | None,
133
+ label: str,
134
+ allow_missing_extension: bool = False,
135
+ ) -> Path:
136
+ resolved = _resolve_user_path(path)
137
+ _ensure_within_allowed(resolved, _allowed_roots(), label=label)
138
+ _ensure_extension(
139
+ resolved,
140
+ extensions,
141
+ label=label,
142
+ allow_missing=allow_missing_extension,
143
+ )
144
+ return resolved
145
+
146
+
147
+ def resolve_env_file(path: str | Path) -> Path:
148
+ resolved = _resolve_user_path(path)
149
+ _ensure_within_allowed(resolved, _allowed_roots(), label="Env file")
150
+ if not (resolved.name.startswith(".env") or resolved.name.endswith(".env")):
151
+ raise ValueError("Env file must end with .env")
152
+ if resolved.exists() and not resolved.is_file():
153
+ raise ValueError(f"Env file must be a file: {resolved}")
154
+ return resolved
155
+
156
+
157
+ def resolve_mock_data_path(path: str | Path, *, require_exists: bool = True) -> Path:
158
+ return resolve_input_file(
159
+ path,
160
+ extensions={".json"},
161
+ label="Mock data file",
162
+ require_exists=require_exists,
163
+ )
164
+
165
+
166
+ def resolve_theme_path(path: str | Path, *, require_exists: bool = True) -> Path:
167
+ return resolve_input_file(
168
+ path,
169
+ extensions={".yml", ".yaml"},
170
+ label="Theme file",
171
+ require_exists=require_exists,
172
+ )
173
+
174
+
175
+ def resolve_output_path(path: str | Path, *, format_name: str | None) -> Path:
176
+ extensions: set[str] | None
177
+ if format_name == "svg" or format_name == "svg-iso":
178
+ extensions = {".svg"}
179
+ elif format_name == "mock":
180
+ extensions = {".json"}
181
+ elif format_name == "mermaid":
182
+ extensions = {".md", ".mermaid", ".mmd"}
183
+ elif format_name == "lldp-md":
184
+ extensions = {".md"}
185
+ elif format_name == "mkdocs":
186
+ extensions = {".md"}
187
+ else:
188
+ extensions = None
189
+ return resolve_output_file(path, extensions=extensions, label="Output file")
190
+
191
+
192
+ def resolve_cache_dir(path: str | Path) -> Path:
193
+ resolved = _resolve_user_path(path)
194
+ _ensure_within_allowed(resolved, _allowed_roots(), label="Cache directory")
195
+ _ensure_no_symlink(resolved, label="Cache directory")
196
+ _ensure_no_symlink_in_parents(resolved, label="Cache directory")
197
+ return resolved
@@ -723,7 +723,7 @@ def build_edges(
723
723
  )
724
724
 
725
725
  poe_edges = sum(1 for edge in edges if edge.poe)
726
- logger.info("Built %d unique edges (%d PoE)", len(edges), poe_edges)
726
+ logger.debug("Built %d unique edges (%d PoE)", len(edges), poe_edges)
727
727
  return edges
728
728
 
729
729
 
@@ -978,14 +978,14 @@ def build_topology(
978
978
  ) -> TopologyResult:
979
979
  normalized_devices = list(devices)
980
980
  lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
981
- logger.info(
981
+ logger.debug(
982
982
  "Normalized %d devices (%d LLDP entries)",
983
983
  len(normalized_devices),
984
984
  lldp_entries,
985
985
  )
986
986
  raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
987
987
  tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
988
- logger.info(
988
+ logger.debug(
989
989
  "Built %d hierarchy edges (gateways=%d)",
990
990
  len(tree_edges),
991
991
  len(gateways),
@@ -83,11 +83,11 @@ def _render_device_ports(
83
83
  rows = _build_port_rows(device, port_map, client_ports)
84
84
  table_rows = [
85
85
  [
86
- _escape_cell(port_label),
87
- _escape_cell(connected or "-"),
88
- _escape_cell(speed),
89
- _escape_cell(poe_state),
90
- _escape_cell(power),
86
+ _escape_markdown_text(port_label),
87
+ _escape_connected_cell(connected or "-"),
88
+ _escape_markdown_text(speed),
89
+ _escape_markdown_text(poe_state),
90
+ _escape_markdown_text(power),
91
91
  ]
92
92
  for port_label, connected, speed, poe_state, power in rows
93
93
  ]
@@ -224,9 +224,11 @@ def _format_connections(
224
224
  for peer in sorted(peers, key=str.lower):
225
225
  peer_label = port_map.get((peer, device_name))
226
226
  if peer_label:
227
- peer_entries.append(f"{peer} ({peer_label})")
227
+ peer_entries.append(
228
+ f"{_escape_markdown_text(peer)} ({_escape_markdown_text(peer_label)})"
229
+ )
228
230
  else:
229
- peer_entries.append(peer)
231
+ peer_entries.append(_escape_markdown_text(peer))
230
232
  peer_text = ", ".join(peer_entries)
231
233
  client_text = _format_client_connections(clients)
232
234
  if peer_text and client_text:
@@ -292,8 +294,15 @@ def _port_sort_key(port: object) -> tuple[int, str]:
292
294
  return (1, name.lower())
293
295
 
294
296
 
295
- def _escape_cell(value: str) -> str:
296
- return value.replace("|", "\\|")
297
+ def _escape_markdown_text(value: str) -> str:
298
+ escaped = value.replace("\\", "\\\\")
299
+ for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
300
+ escaped = escaped.replace(char, f"\\{char}")
301
+ return escaped
302
+
303
+
304
+ def _escape_connected_cell(value: str) -> str:
305
+ return value
297
306
 
298
307
 
299
308
  def _render_device_details(device: Device) -> list[str]:
@@ -302,14 +311,14 @@ def _render_device_details(device: Device) -> list[str]:
302
311
  "",
303
312
  "| Field | Value |",
304
313
  "| --- | --- |",
305
- f"| Model | {_escape_cell(_device_model_label(device))} |",
306
- f"| Type | {_escape_cell(device.type or '-')} |",
307
- f"| IP | {_escape_cell(device.ip or '-')} |",
308
- f"| MAC | {_escape_cell(device.mac or '-')} |",
309
- f"| Firmware | {_escape_cell(device.version or '-')} |",
310
- f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
311
- f"| Ports | {_escape_cell(_port_summary(device))} |",
312
- f"| PoE | {_escape_cell(_poe_summary(device))} |",
314
+ f"| Model | {_escape_markdown_text(_device_model_label(device))} |",
315
+ f"| Type | {_escape_markdown_text(device.type or '-')} |",
316
+ f"| IP | {_escape_markdown_text(device.ip or '-')} |",
317
+ f"| MAC | {_escape_markdown_text(device.mac or '-')} |",
318
+ f"| Firmware | {_escape_markdown_text(device.version or '-')} |",
319
+ f"| Uplink | {_escape_markdown_text(_uplink_summary(device))} |",
320
+ f"| Ports | {_escape_markdown_text(_port_summary(device))} |",
321
+ f"| PoE | {_escape_markdown_text(_poe_summary(device))} |",
313
322
  "",
314
323
  ]
315
324
  return lines
@@ -367,7 +376,7 @@ def _format_client_connections(clients: list[str]) -> str:
367
376
  if not clients:
368
377
  return ""
369
378
  if len(clients) == 1:
370
- return f"{clients[0]} (client)"
379
+ return f"{_escape_markdown_text(clients[0])} (client)"
371
380
  items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
372
381
  return f'<ul class="unifi-port-clients">{items}</ul>'
373
382
 
@@ -271,7 +271,10 @@ def _lldp_rows(
271
271
 
272
272
 
273
273
  def _escape_cell(value: str) -> str:
274
- return value.replace("|", "\\|")
274
+ escaped = value.replace("\\", "\\\\")
275
+ for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
276
+ escaped = escaped.replace(char, f"\\{char}")
277
+ return escaped
275
278
 
276
279
 
277
280
  def _client_rows(