lanscape 2.2.1__tar.gz → 2.3.0a1__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 (92) hide show
  1. {lanscape-2.2.1/lanscape.egg-info → lanscape-2.3.0a1}/PKG-INFO +3 -1
  2. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/net_tools.py +14 -3
  3. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/service_scan.py +38 -3
  4. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/port.py +14 -0
  5. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/css/style.css +2 -2
  6. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/scan-config.js +17 -6
  7. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/config.html +5 -3
  8. {lanscape-2.2.1 → lanscape-2.3.0a1/lanscape.egg-info}/PKG-INFO +3 -1
  9. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/SOURCES.txt +1 -0
  10. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/requires.txt +2 -0
  11. {lanscape-2.2.1 → lanscape-2.3.0a1}/pyproject.toml +4 -2
  12. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_api.py +29 -0
  13. lanscape-2.3.0a1/tests/test_port_scan_linux_fd_exhaustion.py +297 -0
  14. {lanscape-2.2.1 → lanscape-2.3.0a1}/LICENSE +0 -0
  15. {lanscape-2.2.1 → lanscape-2.3.0a1}/MANIFEST.in +0 -0
  16. {lanscape-2.2.1 → lanscape-2.3.0a1}/README.md +0 -0
  17. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/__init__.py +0 -0
  18. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/__main__.py +0 -0
  19. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/__init__.py +0 -0
  20. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/app_scope.py +0 -0
  21. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/decorators.py +0 -0
  22. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/device_alive.py +0 -0
  23. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/errors.py +0 -0
  24. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/ip_parser.py +0 -0
  25. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/logger.py +0 -0
  26. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/mac_lookup.py +0 -0
  27. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/port_manager.py +0 -0
  28. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/runtime_args.py +0 -0
  29. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/scan_config.py +0 -0
  30. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/subnet_scan.py +0 -0
  31. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/core/version_manager.py +0 -0
  32. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/mac_addresses/convert_csv.py +0 -0
  33. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/mac_addresses/mac_db.json +0 -0
  34. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/convert_csv.py +0 -0
  35. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/full.json +0 -0
  36. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/large.json +0 -0
  37. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/medium.json +0 -0
  38. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/small.json +0 -0
  39. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/ports/test_port_list_scan.json +0 -0
  40. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/resources/services/definitions.jsonc +0 -0
  41. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/__init__.py +0 -0
  42. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/app.py +0 -0
  43. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/__init__.py +0 -0
  44. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/__init__.py +0 -0
  45. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/scan.py +0 -0
  46. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/api/tools.py +0 -0
  47. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/web/__init__.py +0 -0
  48. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/blueprints/web/routes.py +0 -0
  49. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/main.py +0 -0
  50. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/shutdown_handler.py +0 -0
  51. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/android-chrome-192x192.png +0 -0
  52. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/android-chrome-512x512.png +0 -0
  53. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/apple-touch-icon.png +0 -0
  54. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon-16x16.png +0 -0
  55. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon-32x32.png +0 -0
  56. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/favicon.ico +0 -0
  57. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/img/ico/site.webmanifest +0 -0
  58. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/core.js +0 -0
  59. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/layout-sizing.js +0 -0
  60. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/main.js +0 -0
  61. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/on-tab-close.js +0 -0
  62. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/quietReload.js +0 -0
  63. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/shutdown-server.js +0 -0
  64. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/subnet-info.js +0 -0
  65. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/js/subnet-selector.js +0 -0
  66. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/static/lanscape.webmanifest +0 -0
  67. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/base.html +0 -0
  68. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/core/head.html +0 -0
  69. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/core/scripts.html +0 -0
  70. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/error.html +0 -0
  71. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/info.html +0 -0
  72. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/main.html +0 -0
  73. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/device-detail.html +0 -0
  74. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/export.html +0 -0
  75. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/ip-table-row.html +0 -0
  76. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/ip-table.html +0 -0
  77. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/overview.html +0 -0
  78. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan/scan-error.html +0 -0
  79. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/scan.html +0 -0
  80. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape/ui/templates/shutdown.html +0 -0
  81. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/dependency_links.txt +0 -0
  82. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/entry_points.txt +0 -0
  83. {lanscape-2.2.1 → lanscape-2.3.0a1}/lanscape.egg-info/top_level.txt +0 -0
  84. {lanscape-2.2.1 → lanscape-2.3.0a1}/setup.cfg +0 -0
  85. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_decorators.py +0 -0
  86. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_env.py +0 -0
  87. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_globals.py +0 -0
  88. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_library.py +0 -0
  89. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_logging.py +0 -0
  90. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_port_scan.py +0 -0
  91. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_service_scan.py +0 -0
  92. {lanscape-2.2.1 → lanscape-2.3.0a1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lanscape
3
- Version: 2.2.1
3
+ Version: 2.3.0a1
4
4
  Summary: A python based local network scanner
5
5
  Author-email: Michael Dennis <michael@dipduo.com>
6
6
  License-Expression: MIT
@@ -31,6 +31,8 @@ Requires-Dist: pytest>=8.0; extra == "dev"
31
31
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
32
32
  Requires-Dist: pytest-xdist>=3.0; extra == "dev"
33
33
  Requires-Dist: openai>=1.0.0; extra == "dev"
34
+ Requires-Dist: pylint>=3.0; extra == "dev"
35
+ Requires-Dist: autopep8>=2.0; extra == "dev"
34
36
  Dynamic: license-file
35
37
 
36
38
  # LANscape
@@ -138,18 +138,29 @@ class Device(BaseModel):
138
138
  @timeout_enforcer(enforcer_timeout, False)
139
139
  def do_test():
140
140
  for attempt in range(port_config.retries + 1):
141
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
142
- sock.settimeout(port_config.timeout)
141
+ sock = None
143
142
  try:
143
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
144
+ sock.settimeout(port_config.timeout)
144
145
  result = sock.connect_ex((self.ip, port))
145
146
  if result == 0:
146
147
  if port not in self.ports:
147
148
  self.ports.append(port)
148
149
  return True
150
+ except OSError as e:
151
+ # Handle socket creation failures (e.g., "Too many open files")
152
+ # Log and continue to retry if attempts remain
153
+ log = logging.getLogger('Device.test_port')
154
+ log.debug(f"OSError on {self.ip}:{port} attempt {attempt + 1}: {e}")
149
155
  except Exception:
150
156
  pass # Connection failed, try again if retries remain
151
157
  finally:
152
- sock.close()
158
+ # Always close socket if it was created
159
+ if sock is not None:
160
+ try:
161
+ sock.close()
162
+ except Exception:
163
+ pass # Ignore errors during cleanup
153
164
 
154
165
  # Wait before retry (except on last attempt)
155
166
  if attempt < port_config.retries:
@@ -154,7 +154,9 @@ def get_port_probes(port: int, strategy: ServiceScanStrategy):
154
154
 
155
155
  def scan_service(ip: str, port: int, cfg: ServiceScanConfig) -> str:
156
156
  """
157
- Synchronous function that attempts to identify the service running on a given port.
157
+ Synchronous function that attempts to identify the service
158
+ running on a given port.
159
+ TODO: This is AI slop and needs to be reworked properly.
158
160
  """
159
161
 
160
162
  async def _async_scan_service(
@@ -183,5 +185,38 @@ def scan_service(ip: str, port: int, cfg: ServiceScanConfig) -> str:
183
185
  log.debug(traceback.format_exc())
184
186
  return "Unknown"
185
187
 
186
- # Use asyncio.run to execute the asynchronous logic synchronously
187
- return asyncio.run(_async_scan_service(ip, port, cfg=cfg))
188
+ # Create and properly manage event loop to avoid file descriptor leaks
189
+ # Using new_event_loop + explicit close is safer in threaded environments
190
+ # than asyncio.run() which can leave resources open under heavy load
191
+ loop = None
192
+ try:
193
+ try:
194
+ # Try to get existing loop first (if running in async context)
195
+ loop = asyncio.get_running_loop()
196
+ # If we're already in an async context, just await directly
197
+ return asyncio.run_coroutine_threadsafe(
198
+ _async_scan_service(ip, port, cfg=cfg), loop
199
+ ).result(timeout=cfg.timeout + 5)
200
+ except RuntimeError:
201
+ # No running loop, create a new one
202
+ loop = asyncio.new_event_loop()
203
+ asyncio.set_event_loop(loop)
204
+ try:
205
+ return loop.run_until_complete(_async_scan_service(ip, port, cfg=cfg))
206
+ finally:
207
+ # Clean up the loop properly
208
+ try:
209
+ # Cancel all remaining tasks
210
+ pending = asyncio.all_tasks(loop)
211
+ for task in pending:
212
+ task.cancel()
213
+ # Run loop once more to process cancellations
214
+ if pending:
215
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
216
+ except Exception:
217
+ pass
218
+ finally:
219
+ loop.close()
220
+ except Exception as e:
221
+ log.error(f"Event loop error scanning {ip}:{port}: {e}")
222
+ return "Unknown"
@@ -21,6 +21,20 @@ def get_port_lists():
21
21
  return jsonify(PortManager().get_port_lists())
22
22
 
23
23
 
24
+ @api_bp.route('/api/port/list/summary', methods=['GET'])
25
+ def get_port_lists_summary():
26
+ """Get port list names with their port counts."""
27
+ manager = PortManager()
28
+ summaries = []
29
+ for name in manager.get_port_lists():
30
+ ports = manager.get_port_list(name) or {}
31
+ summaries.append({
32
+ 'name': name,
33
+ 'count': len(ports)
34
+ })
35
+ return jsonify(summaries)
36
+
37
+
24
38
  @api_bp.route('/api/port/list/<port_list>', methods=['GET'])
25
39
  def get_port_list(port_list):
26
40
  """
@@ -564,8 +564,8 @@ input[type="range"] {
564
564
  /* Service Strategy Select Styles */
565
565
  .service-strategy-wrapper {
566
566
  position: relative;
567
- display: inline-block;
568
- width: 60%; /* Narrower than port list */
567
+ display: block;
568
+ width: 100%;
569
569
  }
570
570
 
571
571
  .service-strategy {
@@ -139,15 +139,26 @@ function getScanConfig() {
139
139
  }
140
140
 
141
141
  function getPortLists(callback=null) {
142
- $.get('/api/port/list', function(data) {
143
- const customSelectDropdown = $('#port_list');
142
+ const customSelectDropdown = $('#port_list');
143
+
144
+ const renderOptions = (items) => {
144
145
  customSelectDropdown.empty();
145
-
146
- // Populate the dropdown with the options
147
- data.forEach(function(portList) {
148
- customSelectDropdown.append('<option>' + portList + '</option>');
146
+ items.forEach((item) => {
147
+ const name = item.name || item;
148
+ const count = item.count;
149
+ const label = count !== undefined ? `${name} (${count} ports)` : name;
150
+ customSelectDropdown.append(`<option value="${name}">${label}</option>`);
149
151
  });
152
+ };
153
+
154
+ $.get('/api/port/list/summary', function(data) {
155
+ renderOptions(data || []);
150
156
  if (callback) callback();
157
+ }).fail(function() {
158
+ $.get('/api/port/list', function(data) {
159
+ renderOptions(data || []);
160
+ if (callback) callback();
161
+ });
151
162
  });
152
163
  }
153
164
 
@@ -201,7 +201,7 @@
201
201
  <div id="section-service-scan" class="form-group mt-2 config-section div-hide">
202
202
  <h6>Service Scanning</h6>
203
203
  <div class="row">
204
- <div class="col">
204
+ <div class="col-12">
205
205
  <label for="service_lookup_type" class="form-label">Strategy</label>
206
206
  <div class="service-strategy-wrapper">
207
207
  <select id="service_lookup_type" class="service-strategy">
@@ -211,11 +211,13 @@
211
211
  </select>
212
212
  </div>
213
213
  </div>
214
- <div class="col">
214
+ </div>
215
+ <div class="row mt-2">
216
+ <div class="col-12 col-md-6">
215
217
  <label for="service_timeout" class="form-label">Timeout (sec)</label>
216
218
  <input type="number" step="0.1" id="service_timeout" class="form-control">
217
219
  </div>
218
- <div class="col">
220
+ <div class="col-12 col-md-6 mt-2 mt-md-0">
219
221
  <label for="service_max_concurrent_probes" class="form-label">Max Concurrent Probes</label>
220
222
  <input type="number" id="service_max_concurrent_probes" class="form-control">
221
223
  </div>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lanscape
3
- Version: 2.2.1
3
+ Version: 2.3.0a1
4
4
  Summary: A python based local network scanner
5
5
  Author-email: Michael Dennis <michael@dipduo.com>
6
6
  License-Expression: MIT
@@ -31,6 +31,8 @@ Requires-Dist: pytest>=8.0; extra == "dev"
31
31
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
32
32
  Requires-Dist: pytest-xdist>=3.0; extra == "dev"
33
33
  Requires-Dist: openai>=1.0.0; extra == "dev"
34
+ Requires-Dist: pylint>=3.0; extra == "dev"
35
+ Requires-Dist: autopep8>=2.0; extra == "dev"
34
36
  Dynamic: license-file
35
37
 
36
38
  # LANscape
@@ -85,5 +85,6 @@ tests/test_globals.py
85
85
  tests/test_library.py
86
86
  tests/test_logging.py
87
87
  tests/test_port_scan.py
88
+ tests/test_port_scan_linux_fd_exhaustion.py
88
89
  tests/test_service_scan.py
89
90
  tests/test_utils.py
@@ -13,3 +13,5 @@ pytest>=8.0
13
13
  pytest-cov>=5.0
14
14
  pytest-xdist>=3.0
15
15
  openai>=1.0.0
16
+ pylint>=3.0
17
+ autopep8>=2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lanscape"
3
- version = "2.2.1"
3
+ version = "2.3.0a1"
4
4
  authors = [
5
5
  { name="Michael Dennis", email="michael@dipduo.com" },
6
6
  ]
@@ -36,7 +36,9 @@ dev = [
36
36
  "pytest>=8.0",
37
37
  "pytest-cov>=5.0",
38
38
  "pytest-xdist>=3.0",
39
- "openai>=1.0.0"
39
+ "openai>=1.0.0",
40
+ "pylint>=3.0",
41
+ "autopep8>=2.0"
40
42
  ]
41
43
  [project.urls]
42
44
  Homepage = "https://github.com/mdennis281/py-lanscape"
@@ -15,6 +15,7 @@ from tests.test_globals import (
15
15
  MIN_EXPECTED_ALIVE_DEVICES
16
16
  )
17
17
  from lanscape.ui.app import app
18
+ import lanscape.ui.blueprints.api.port as port_api
18
19
 
19
20
 
20
21
  @pytest.fixture
@@ -97,6 +98,34 @@ def test_port_lifecycle(api_client, sample_port_list, updated_port_list):
97
98
  response = api_client.delete(f'/api/port/list/{test_list_name}')
98
99
  assert response.status_code == 200
99
100
 
101
+
102
+ def test_port_list_summary(api_client, monkeypatch):
103
+ """Verify port list summary returns names with counts."""
104
+
105
+ class FakePortManager: # pylint: disable=too-few-public-methods
106
+ """Lightweight fake port manager for summary testing."""
107
+ def __init__(self):
108
+ self._lists = {
109
+ 'small': {'80': 'http', '443': 'https'},
110
+ 'custom': {'22': 'ssh'}
111
+ }
112
+
113
+ def get_port_lists(self):
114
+ """Return available list names."""
115
+ return list(self._lists.keys())
116
+
117
+ def get_port_list(self, name):
118
+ """Return a specific port list by name."""
119
+ return self._lists.get(name, {})
120
+
121
+ monkeypatch.setattr(port_api, 'PortManager', FakePortManager)
122
+
123
+ response = api_client.get('/api/port/list/summary')
124
+ assert response.status_code == 200
125
+ data = response.get_json()
126
+ assert isinstance(data, list)
127
+ assert {item['name']: item['count'] for item in data} == {'small': 2, 'custom': 1}
128
+
100
129
  # API Scan Tests
101
130
  ################
102
131
 
@@ -0,0 +1,297 @@
1
+ """
2
+ Tests for file descriptor exhaustion during large port scans.
3
+
4
+ This module tests the scenario where scanning a large number of ports
5
+ can exhaust file descriptors (sockets) on Unix-like systems such as
6
+ Linux and macOS, causing "Too many open files" OSError.
7
+ """
8
+
9
+ import sys
10
+ from unittest.mock import patch, MagicMock
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+ import time
13
+ import pytest
14
+
15
+ from lanscape.core.net_tools import Device
16
+ from lanscape.core.scan_config import PortScanConfig
17
+
18
+ # Skip all tests in this module on non-Linux platforms
19
+ pytestmark = pytest.mark.skipif(
20
+ sys.platform != "linux",
21
+ reason="File descriptor exhaustion tests are Linux-specific"
22
+ )
23
+
24
+ try:
25
+ import resource
26
+ except ImportError:
27
+ resource = None # Will be skipped anyway on non-Linux
28
+
29
+
30
+ def test_socket_properly_closed_on_success():
31
+ """Test that sockets are properly closed when connection succeeds."""
32
+ device = Device(ip="127.0.0.1")
33
+ config = PortScanConfig(timeout=0.5, retries=0)
34
+
35
+ with patch('socket.socket') as mock_socket_class:
36
+ mock_socket = MagicMock()
37
+ mock_socket_class.return_value = mock_socket
38
+ mock_socket.connect_ex.return_value = 0 # Success
39
+
40
+ device.test_port(80, config)
41
+
42
+ # Verify socket was closed
43
+ mock_socket.close.assert_called_once()
44
+
45
+
46
+ def test_socket_properly_closed_on_failure():
47
+ """Test that sockets are properly closed when connection fails."""
48
+ device = Device(ip="127.0.0.1")
49
+ config = PortScanConfig(timeout=0.5, retries=0)
50
+
51
+ with patch('socket.socket') as mock_socket_class:
52
+ mock_socket = MagicMock()
53
+ mock_socket_class.return_value = mock_socket
54
+ mock_socket.connect_ex.return_value = 1 # Failure
55
+
56
+ device.test_port(54321, config)
57
+
58
+ # Verify socket was closed
59
+ mock_socket.close.assert_called_once()
60
+
61
+
62
+ def test_socket_properly_closed_on_exception():
63
+ """Test that sockets are properly closed when an exception occurs."""
64
+ device = Device(ip="127.0.0.1")
65
+ config = PortScanConfig(timeout=0.5, retries=0)
66
+
67
+ with patch('socket.socket') as mock_socket_class:
68
+ mock_socket = MagicMock()
69
+ mock_socket_class.return_value = mock_socket
70
+ mock_socket.connect_ex.side_effect = Exception("Connection error")
71
+
72
+ device.test_port(54322, config)
73
+
74
+ # Verify socket was closed even with exception
75
+ mock_socket.close.assert_called_once()
76
+
77
+
78
+ def test_socket_properly_closed_with_retries():
79
+ """Test that sockets are properly closed on each retry attempt."""
80
+ device = Device(ip="127.0.0.1")
81
+ config = PortScanConfig(timeout=0.5, retries=2, retry_delay=0.05)
82
+
83
+ with patch('socket.socket') as mock_socket_class:
84
+ # Create multiple mock socket instances
85
+ mock_sockets = [MagicMock() for _ in range(3)]
86
+ mock_socket_class.side_effect = mock_sockets
87
+
88
+ # All attempts fail
89
+ for mock_sock in mock_sockets:
90
+ mock_sock.connect_ex.return_value = 1
91
+
92
+ device.test_port(54323, config)
93
+
94
+ # Verify each socket was closed (initial + 2 retries = 3 total)
95
+ for mock_sock in mock_sockets:
96
+ mock_sock.close.assert_called_once()
97
+
98
+
99
+ def test_socket_closed_on_socket_creation_failure():
100
+ """Test handling when socket creation itself fails (OSError: Too many open files)."""
101
+ device = Device(ip="127.0.0.1")
102
+ config = PortScanConfig(timeout=0.5, retries=1, retry_delay=0.05)
103
+
104
+ with patch('socket.socket') as mock_socket_class:
105
+ # First attempt: socket creation fails with "Too many open files"
106
+ # Second attempt: succeeds
107
+ mock_socket = MagicMock()
108
+ mock_socket.connect_ex.return_value = 1
109
+
110
+ mock_socket_class.side_effect = [
111
+ OSError(24, "Too many open files"),
112
+ mock_socket
113
+ ]
114
+
115
+ # Should not crash, should handle the error gracefully
116
+ result = device.test_port(54324, config)
117
+
118
+ # Should return False since we couldn't connect
119
+ assert result is False
120
+
121
+ # Second socket should have been closed
122
+ mock_socket.close.assert_called_once()
123
+
124
+
125
+ def test_multiple_concurrent_port_scans_socket_cleanup():
126
+ """Test that concurrent port scans properly clean up sockets."""
127
+ device = Device(ip="192.168.1.100")
128
+ config = PortScanConfig(timeout=0.1, retries=0)
129
+ ports = list(range(8000, 8100)) # 100 ports
130
+
131
+ with patch('socket.socket') as mock_socket_class:
132
+ mock_sockets = []
133
+
134
+ def create_mock_socket(*_args, **_kwargs):
135
+ mock_sock = MagicMock()
136
+ mock_sock.connect_ex.return_value = 1 # All closed
137
+ mock_sockets.append(mock_sock)
138
+ return mock_sock
139
+
140
+ mock_socket_class.side_effect = create_mock_socket
141
+
142
+ # Scan all ports concurrently (simulating real scenario)
143
+ with ThreadPoolExecutor(max_workers=20) as executor:
144
+ futures = [executor.submit(device.test_port, port, config) for port in ports]
145
+ for future in as_completed(futures):
146
+ future.result()
147
+
148
+ # Verify all sockets were closed
149
+ assert len(mock_sockets) == 100
150
+ for mock_sock in mock_sockets:
151
+ mock_sock.close.assert_called_once()
152
+
153
+
154
+ def test_socket_cleanup_with_high_fd_limit():
155
+ """Test socket cleanup when approaching file descriptor limits."""
156
+ device = Device(ip="192.168.1.100")
157
+ config = PortScanConfig(timeout=0.1, retries=1, retry_delay=0.05)
158
+
159
+ # Simulate a scenario where we're near the FD limit
160
+ with patch('socket.socket') as mock_socket_class:
161
+ call_count = 0
162
+ max_calls_before_error = 50
163
+
164
+ def socket_with_limit(*_args, **_kwargs):
165
+ nonlocal call_count
166
+ call_count += 1
167
+
168
+ if call_count >= max_calls_before_error:
169
+ # Simulate "too many open files" error
170
+ raise OSError(24, "Too many open files")
171
+
172
+ mock_sock = MagicMock()
173
+ mock_sock.connect_ex.return_value = 1
174
+ return mock_sock
175
+
176
+ mock_socket_class.side_effect = socket_with_limit
177
+
178
+ # Scan ports until we hit the limit
179
+ ports_scanned = 0
180
+ for port in range(8000, 8200):
181
+ try:
182
+ device.test_port(port, config)
183
+ ports_scanned += 1
184
+ except OSError:
185
+ # Should handle gracefully
186
+ break
187
+
188
+ # Should have scanned some ports before hitting limit
189
+ assert ports_scanned > 0
190
+ # Should not have crashed the entire scan
191
+
192
+
193
+ def test_socket_error_handling_retries():
194
+ """Test that socket errors during retries are handled properly."""
195
+ device = Device(ip="192.168.1.100")
196
+ config = PortScanConfig(timeout=0.5, retries=2, retry_delay=0.05)
197
+
198
+ with patch('socket.socket') as mock_socket_class:
199
+ # First attempt: Too many open files
200
+ # Second attempt: Connection timeout
201
+ # Third attempt: Success
202
+ mock_sock2 = MagicMock()
203
+ mock_sock3 = MagicMock()
204
+
205
+ mock_socket_class.side_effect = [
206
+ OSError(24, "Too many open files"),
207
+ mock_sock2,
208
+ mock_sock3
209
+ ]
210
+
211
+ mock_sock2.connect_ex.return_value = 1 # Fail
212
+ mock_sock3.connect_ex.return_value = 0 # Success
213
+
214
+ result = device.test_port(80, config)
215
+
216
+ # Should succeed on third attempt
217
+ assert result is True
218
+
219
+ # Both successful socket creations should have been closed
220
+ mock_sock2.close.assert_called_once()
221
+ mock_sock3.close.assert_called_once()
222
+
223
+
224
+ def test_context_manager_style_socket_would_be_better():
225
+ """
226
+ This test documents that using 'with' statement for sockets would be better.
227
+ This is more of a design verification test.
228
+ """
229
+ device = Device(ip="127.0.0.1")
230
+ config = PortScanConfig(timeout=0.5, retries=0)
231
+
232
+ with patch('socket.socket') as mock_socket_class:
233
+ mock_socket = MagicMock()
234
+ mock_socket_class.return_value = mock_socket
235
+
236
+ # Simulate that the socket supports context manager
237
+ mock_socket.__enter__ = MagicMock(return_value=mock_socket)
238
+ mock_socket.__exit__ = MagicMock(return_value=None)
239
+ mock_socket.connect_ex.return_value = 0
240
+
241
+ device.test_port(80, config)
242
+
243
+ # Verify close was called
244
+ # (In the future, if we switch to 'with socket.socket()...',
245
+ # __exit__ would be called automatically)
246
+ mock_socket.close.assert_called()
247
+
248
+
249
+ @pytest.mark.skipif(
250
+ not resource or resource.getrlimit(resource.RLIMIT_NOFILE)[0] > 10000,
251
+ reason="Only run on Linux systems with reasonable FD limits"
252
+ )
253
+ def test_real_socket_exhaustion_scenario():
254
+ """
255
+ Test with real sockets to verify the fix handles actual FD exhaustion.
256
+ This test creates many sockets to approach system limits.
257
+ """
258
+ device = Device(ip="192.168.254.254") # Non-existent IP to ensure timeout
259
+ config = PortScanConfig(timeout=0.01, retries=0) # Very short timeout
260
+
261
+ # Get current FD limit
262
+ soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
263
+
264
+ # Try to scan many ports and collect results
265
+ results = []
266
+ for port in range(1, min(200, soft_limit // 10)):
267
+ result = device.test_port(port, config)
268
+ results.append(result)
269
+
270
+ # All results should be boolean flags (success/failure), and no OSError
271
+ # should have propagated out of test_port during the scan.
272
+ assert all(isinstance(r, bool) for r in results)
273
+ # For a non-existent IP, we expect all failures since the IP doesn't exist
274
+ assert all(not r for r in results)
275
+
276
+
277
+ def test_socket_cleanup_in_timeout_enforcer():
278
+ """Test that sockets are cleaned up even when timeout_enforcer kills the function."""
279
+ device = Device(ip="192.168.1.100")
280
+ # Very short enforcer timeout to trigger timeout: 0.1 * 1 * 1.5 = 0.15s enforcer
281
+ config = PortScanConfig(timeout=0.1, retries=0)
282
+
283
+ with patch('socket.socket') as mock_socket_class:
284
+ mock_socket = MagicMock()
285
+ mock_socket_class.return_value = mock_socket
286
+
287
+ # Make connect_ex hang longer than timeout (simulate very slow network)
288
+ def slow_connect(*_args):
289
+ time.sleep(0.5) # Sleep longer than 0.15s enforcer timeout
290
+ return 1
291
+
292
+ mock_socket.connect_ex.side_effect = slow_connect
293
+
294
+ device.test_port(54325, config)
295
+
296
+ # Socket should still be closed
297
+ mock_socket.close.assert_called()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes