python-snap7 3.1.1__tar.gz → 3.1.2__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 (64) hide show
  1. {python_snap7-3.1.1/python_snap7.egg-info → python_snap7-3.1.2}/PKG-INFO +1 -1
  2. {python_snap7-3.1.1 → python_snap7-3.1.2}/pyproject.toml +1 -1
  3. {python_snap7-3.1.1 → python_snap7-3.1.2/python_snap7.egg-info}/PKG-INFO +1 -1
  4. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/async_client.py +10 -2
  5. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/server/__init__.py +121 -26
  6. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_async_client.py +51 -2
  7. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_client.py +12 -1
  8. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_server.py +110 -3
  9. {python_snap7-3.1.1 → python_snap7-3.1.2}/LICENSE +0 -0
  10. {python_snap7-3.1.1 → python_snap7-3.1.2}/MANIFEST.in +0 -0
  11. {python_snap7-3.1.1 → python_snap7-3.1.2}/README.rst +0 -0
  12. {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/SOURCES.txt +0 -0
  13. {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/dependency_links.txt +0 -0
  14. {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/entry_points.txt +0 -0
  15. {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/requires.txt +0 -0
  16. {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/top_level.txt +0 -0
  17. {python_snap7-3.1.1 → python_snap7-3.1.2}/setup.cfg +0 -0
  18. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/__init__.py +0 -0
  19. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/cli.py +0 -0
  20. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/client.py +0 -0
  21. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/client_base.py +0 -0
  22. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/connection.py +0 -0
  23. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/datatypes.py +0 -0
  24. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/demo.py +0 -0
  25. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/discovery.py +0 -0
  26. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/error.py +0 -0
  27. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/log.py +0 -0
  28. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/logo.py +0 -0
  29. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/optimizer.py +0 -0
  30. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/partner.py +0 -0
  31. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/py.typed +0 -0
  32. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/s7protocol.py +0 -0
  33. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/server/__main__.py +0 -0
  34. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/szl.py +0 -0
  35. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/tags.py +0 -0
  36. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/type.py +0 -0
  37. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/__init__.py +0 -0
  38. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/db.py +0 -0
  39. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/getters.py +0 -0
  40. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/setters.py +0 -0
  41. {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/symbols.py +0 -0
  42. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_api_surface.py +0 -0
  43. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_behavioral_compatibility.py +0 -0
  44. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_cli.py +0 -0
  45. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_client_e2e.py +0 -0
  46. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_datatypes.py +0 -0
  47. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_demo.py +0 -0
  48. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_discovery.py +0 -0
  49. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_finalize.py +0 -0
  50. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_hypothesis.py +0 -0
  51. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_logging.py +0 -0
  52. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_logo_client.py +0 -0
  53. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_mainloop.py +0 -0
  54. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_multipacket.py +0 -0
  55. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_optimizer.py +0 -0
  56. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_partner.py +0 -0
  57. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_property.py +0 -0
  58. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_reconnect.py +0 -0
  59. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_routing.py +0 -0
  60. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_s7protocol.py +0 -0
  61. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_stress.py +0 -0
  62. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_tags.py +0 -0
  63. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_typed_access.py +0 -0
  64. {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-snap7
3
- Version: 3.1.1
3
+ Version: 3.1.2
4
4
  Summary: Pure Python S7 communication library for Siemens PLCs
5
5
  Author-email: Gijs Molenaar <gijsmolenaar@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-snap7"
7
- version = "3.1.1"
7
+ version = "3.1.2"
8
8
  description = "Pure Python S7 communication library for Siemens PLCs"
9
9
  readme = "README.rst"
10
10
  authors = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-snap7
3
- Version: 3.1.1
3
+ Version: 3.1.2
4
4
  Summary: Pure Python S7 communication library for Siemens PLCs
5
5
  Author-email: Gijs Molenaar <gijsmolenaar@gmail.com>
6
6
  License-Expression: MIT
@@ -239,9 +239,17 @@ class AsyncISOTCPConnection:
239
239
  param_data = data[offset + 2 : offset + 2 + param_len]
240
240
  if param_code == self.COTP_PARAM_PDU_SIZE:
241
241
  if param_len == 1:
242
- self.pdu_size = 1 << param_data[0]
242
+ exponent = param_data[0]
243
+ if 7 <= exponent <= 13:
244
+ self.pdu_size = 1 << exponent
245
+ else:
246
+ logger.warning(f"Invalid COTP PDU size exponent {exponent}, using default")
243
247
  elif param_len == 2:
244
- self.pdu_size = struct.unpack(">H", param_data)[0]
248
+ raw = struct.unpack(">H", param_data)[0]
249
+ if 128 <= raw <= 8192:
250
+ self.pdu_size = raw
251
+ else:
252
+ logger.warning(f"Invalid COTP PDU size {raw}, using default")
245
253
  logger.debug(f"Negotiated PDU size: {self.pdu_size}")
246
254
  offset += 2 + param_len
247
255
 
@@ -53,14 +53,18 @@ class Server:
53
53
  >>> server.stop()
54
54
  """
55
55
 
56
- def __init__(self, log: bool = True, **kwargs: object) -> None:
56
+ def __init__(self, log: bool = True, max_clients: int = 64, **kwargs: object) -> None:
57
57
  """
58
58
  Initialize S7 server.
59
59
 
60
60
  Args:
61
61
  log: Enable event logging
62
+ max_clients: Maximum number of simultaneous client connections
62
63
  **kwargs: Ignored. Kept for backwards compatibility.
63
64
  """
65
+ if max_clients < 1:
66
+ raise ValueError("max_clients must be at least 1")
67
+
64
68
  self.server_socket: Optional[socket.socket] = None
65
69
  self.server_thread: Optional[threading.Thread] = None
66
70
  self.running = False
@@ -86,6 +90,7 @@ class Server:
86
90
  # Client connections
87
91
  self.clients: List[threading.Thread] = []
88
92
  self.client_lock = threading.Lock()
93
+ self.max_clients = max_clients
89
94
 
90
95
  # Event queue for pick_event
91
96
  self._event_queue: List[SrvEvent] = []
@@ -177,9 +182,13 @@ class Server:
177
182
 
178
183
  # Close all client connections
179
184
  with self.client_lock:
180
- for client_thread in self.clients[:]:
181
- if client_thread.is_alive():
182
- client_thread.join(timeout=1.0)
185
+ client_threads = self.clients[:]
186
+
187
+ for client_thread in client_threads:
188
+ if client_thread.is_alive():
189
+ client_thread.join(timeout=1.0)
190
+
191
+ with self.client_lock:
183
192
  self.clients.clear()
184
193
  self.client_count = 0
185
194
 
@@ -448,6 +457,10 @@ class Server:
448
457
  """
449
458
  if param == Parameter.LocalPort:
450
459
  self.port = value
460
+ elif param == Parameter.MaxClients:
461
+ if value < 1:
462
+ raise ValueError("MaxClients must be at least 1")
463
+ self.max_clients = value
451
464
  logger.debug(f"Set parameter {param} = {value}")
452
465
  return 0
453
466
 
@@ -481,7 +494,7 @@ class Server:
481
494
  param_values = {
482
495
  Parameter.LocalPort: self.port,
483
496
  Parameter.WorkInterval: 100,
484
- Parameter.MaxClients: 1024,
497
+ Parameter.MaxClients: self.max_clients,
485
498
  }
486
499
  return param_values.get(param, 0)
487
500
 
@@ -579,6 +592,10 @@ class Server:
579
592
  client_thread = threading.Thread(target=self._handle_client, args=(client_socket, address), daemon=True)
580
593
 
581
594
  with self.client_lock:
595
+ if self.client_count >= self.max_clients:
596
+ logger.warning(f"Rejecting client {address}: maximum of {self.max_clients} clients reached")
597
+ client_socket.close()
598
+ continue
582
599
  self.clients.append(client_thread)
583
600
  self.client_count += 1
584
601
 
@@ -645,6 +662,11 @@ class Server:
645
662
  self.clients.remove(current_thread)
646
663
  self.client_count = max(0, self.client_count - 1)
647
664
 
665
+ if hasattr(self, "_download_contexts"):
666
+ self._download_contexts.pop(address, None)
667
+ if hasattr(self, "_upload_contexts"):
668
+ self._upload_contexts.pop(address, None)
669
+
648
670
  logger.info(f"Client {address} handler finished")
649
671
 
650
672
  def _process_request(self, request_data: bytes, client_address: Tuple[str, int]) -> Optional[bytes]:
@@ -2301,22 +2323,55 @@ class Server:
2301
2323
  try:
2302
2324
  raw_params = request.get("raw_parameters", b"")
2303
2325
 
2304
- # Parse block address from parameters
2305
- block_type = 0x41 # Default to DB
2306
- block_num = 1
2326
+ # Parse and validate the target before allocating transfer state.
2327
+ if len(raw_params) < 6:
2328
+ return self._build_error_response(request, 0x8104)
2307
2329
 
2308
- if len(raw_params) >= 6:
2309
- addr_len = raw_params[5]
2310
- if len(raw_params) >= 6 + addr_len:
2311
- block_addr = raw_params[6 : 6 + addr_len]
2312
- try:
2313
- block_type = int(block_addr[0:2], 16)
2314
- block_num = int(block_addr[2:7])
2315
- except (ValueError, IndexError):
2316
- pass
2330
+ addr_len = raw_params[4]
2331
+ address_end = 5 + addr_len
2332
+ if addr_len < 7 or len(raw_params) < address_end:
2333
+ return self._build_error_response(request, 0x8104)
2334
+
2335
+ block_addr = raw_params[5:address_end]
2336
+ try:
2337
+ block_type = int(block_addr[0:2], 16)
2338
+ block_num = int(block_addr[2:7])
2339
+ except (ValueError, IndexError):
2340
+ return self._build_error_response(request, 0x8104)
2317
2341
 
2318
2342
  logger.info(f"Request download from {client_address}: type={block_type:#02x}, num={block_num}")
2319
2343
 
2344
+ if block_type != 0x41: # Only DB downloads are implemented.
2345
+ return self._build_error_response(request, 0x8104)
2346
+
2347
+ area_key = (S7Area.DB, block_num)
2348
+ if area_key not in self.memory_areas:
2349
+ logger.warning(f"Download rejected: area DB{block_num} not registered")
2350
+ return self._build_error_response(request, 0x8104)
2351
+
2352
+ area_capacity = len(self.memory_areas[area_key])
2353
+
2354
+ # The request includes an ASCII block length. Reject an oversized
2355
+ # transfer early, while still enforcing the limit on every chunk.
2356
+ if len(raw_params) <= address_end:
2357
+ return self._build_error_response(request, 0x8104)
2358
+
2359
+ length_len = raw_params[address_end]
2360
+ length_start = address_end + 1
2361
+ length_end = length_start + length_len
2362
+ if not length_len or len(raw_params) < length_end:
2363
+ return self._build_error_response(request, 0x8104)
2364
+
2365
+ try:
2366
+ declared_size = int(raw_params[length_start:length_end])
2367
+ except ValueError:
2368
+ return self._build_error_response(request, 0x8104)
2369
+ if declared_size < 0 or declared_size > area_capacity:
2370
+ logger.warning(
2371
+ f"Download rejected: declared size {declared_size} outside DB{block_num} capacity 0..{area_capacity}"
2372
+ )
2373
+ return self._build_error_response(request, 0x8104)
2374
+
2320
2375
  # Store download context
2321
2376
  if not hasattr(self, "_download_contexts"):
2322
2377
  self._download_contexts: Dict[Tuple[str, int], Dict[str, Any]] = {}
@@ -2324,6 +2379,7 @@ class Server:
2324
2379
  "block_type": block_type,
2325
2380
  "block_num": block_num,
2326
2381
  "data": bytearray(),
2382
+ "max_size": declared_size,
2327
2383
  }
2328
2384
 
2329
2385
  # Build response acknowledging download
@@ -2370,7 +2426,14 @@ class Server:
2370
2426
  data_info = request.get("data", {})
2371
2427
  block_data = data_info.get("data", b"")
2372
2428
 
2373
- # Append data to context
2429
+ # Bound accumulated data by the pre-registered target area. The
2430
+ # final storage copy is not the only allocation: this bytearray is
2431
+ # grown for every DOWNLOAD_BLOCK request.
2432
+ if len(ctx["data"]) + len(block_data) > ctx["max_size"]:
2433
+ logger.warning(f"Download rejected: data exceeds target capacity {ctx['max_size']}")
2434
+ del self._download_contexts[client_address]
2435
+ return self._build_error_response(request, 0x8104)
2436
+
2374
2437
  ctx["data"].extend(block_data)
2375
2438
 
2376
2439
  logger.info(f"Download block from {client_address}: received {len(block_data)} bytes")
@@ -2488,6 +2551,9 @@ class Server:
2488
2551
  class ServerISOConnection:
2489
2552
  """ISO connection wrapper for server-side communication."""
2490
2553
 
2554
+ RECEIVE_DEADLINE = 5.0
2555
+ MAX_REASSEMBLED_SIZE = 1024 * 1024
2556
+
2491
2557
  # COTP PDU types
2492
2558
  COTP_CR = 0xE0 # Connection Request
2493
2559
  COTP_CC = 0xD0 # Connection Confirm
@@ -2510,15 +2576,20 @@ class ServerISOConnection:
2510
2576
  def accept_connection(self) -> bool:
2511
2577
  """Accept ISO connection from client."""
2512
2578
  try:
2579
+ deadline = time.monotonic() + self.RECEIVE_DEADLINE
2580
+
2513
2581
  # Receive COTP Connection Request
2514
- tpkt_header = self._recv_exact(4)
2582
+ tpkt_header = self._recv_exact(4, deadline)
2515
2583
  version, reserved, length = struct.unpack(">BBH", tpkt_header)
2516
2584
 
2517
2585
  if version != 3:
2518
2586
  logger.error(f"Invalid TPKT version: {version}")
2519
2587
  return False
2588
+ if length < 4:
2589
+ logger.error(f"Invalid TPKT length: {length}")
2590
+ return False
2520
2591
 
2521
- payload = self._recv_exact(length - 4)
2592
+ payload = self._recv_exact(length - 4, deadline)
2522
2593
 
2523
2594
  # Parse COTP Connection Request
2524
2595
  if not self._parse_cotp_cr(payload):
@@ -2545,8 +2616,10 @@ class ServerISOConnection:
2545
2616
  the concatenated payload.
2546
2617
  """
2547
2618
  fragments: list[bytes] = []
2619
+ total_size = 0
2620
+ deadline = time.monotonic() + self.RECEIVE_DEADLINE
2548
2621
  while True:
2549
- tpkt_header = self._recv_exact(4)
2622
+ tpkt_header = self._recv_exact(4, deadline)
2550
2623
  version, reserved, length = struct.unpack(">BBH", tpkt_header)
2551
2624
 
2552
2625
  if version != 3:
@@ -2556,7 +2629,7 @@ class ServerISOConnection:
2556
2629
  if remaining <= 0:
2557
2630
  raise S7ConnectionError("Invalid TPKT length")
2558
2631
 
2559
- payload = self._recv_exact(remaining)
2632
+ payload = self._recv_exact(remaining, deadline)
2560
2633
 
2561
2634
  if len(payload) < 3:
2562
2635
  raise S7ConnectionError("Invalid COTP DT: too short")
@@ -2566,7 +2639,11 @@ class ServerISOConnection:
2566
2639
  if pdu_type != self.COTP_DT:
2567
2640
  raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
2568
2641
 
2569
- fragments.append(payload[3:])
2642
+ fragment = payload[3:]
2643
+ total_size += len(fragment)
2644
+ if total_size > self.MAX_REASSEMBLED_SIZE:
2645
+ raise S7ConnectionError(f"Reassembled COTP request exceeds {self.MAX_REASSEMBLED_SIZE} bytes")
2646
+ fragments.append(fragment)
2570
2647
 
2571
2648
  if eot_num & 0x80:
2572
2649
  break
@@ -2637,12 +2714,30 @@ class ServerISOConnection:
2637
2714
 
2638
2715
  return base_pdu + pdu_size_param
2639
2716
 
2640
- def _recv_exact(self, size: int) -> bytes:
2641
- """Receive exactly the specified number of bytes."""
2717
+ def _recv_exact(self, size: int, deadline: float | None = None) -> bytes:
2718
+ """Receive exactly the specified bytes within one absolute deadline."""
2719
+ if size < 0:
2720
+ raise S7ConnectionError(f"Invalid receive size: {size}")
2721
+
2722
+ if deadline is None:
2723
+ deadline = time.monotonic() + self.RECEIVE_DEADLINE
2724
+
2642
2725
  data = bytearray()
2643
2726
 
2644
2727
  while len(data) < size:
2645
- chunk = self.socket.recv(size - len(data))
2728
+ remaining_time = deadline - time.monotonic()
2729
+ if remaining_time <= 0:
2730
+ if data:
2731
+ raise S7ConnectionError("Receive deadline exceeded after partial frame")
2732
+ raise TimeoutError("Receive deadline exceeded")
2733
+
2734
+ self.socket.settimeout(remaining_time)
2735
+ try:
2736
+ chunk = self.socket.recv(size - len(data))
2737
+ except TimeoutError as e:
2738
+ if data:
2739
+ raise S7ConnectionError("Receive deadline exceeded after partial frame") from e
2740
+ raise
2646
2741
  if not chunk:
2647
2742
  raise ConnectionResetError("Connection closed by peer")
2648
2743
  data.extend(chunk)
@@ -5,14 +5,16 @@ Uses the same Server fixture as test_client.py for integration tests.
5
5
 
6
6
  import asyncio
7
7
  import logging
8
+ import struct
8
9
  from collections.abc import AsyncGenerator, Generator
10
+ from unittest.mock import AsyncMock, patch
9
11
 
10
12
  import pytest
11
13
  import pytest_asyncio
12
14
 
13
- from snap7.async_client import AsyncClient
15
+ from snap7.async_client import AsyncClient, AsyncISOTCPConnection
14
16
  from snap7.server import Server
15
- from snap7.type import SrvArea, Area, Parameter
17
+ from snap7.type import Area, Parameter, SrvArea
16
18
 
17
19
  logging.basicConfig(level=logging.WARNING)
18
20
 
@@ -301,6 +303,53 @@ def test_get_param_non_client_raises() -> None:
301
303
  c.get_param(Parameter.LocalPort)
302
304
 
303
305
 
306
+ @pytest.mark.asyncio
307
+ async def test_invalid_negotiated_pdu_length_uses_safe_default() -> None:
308
+ client = AsyncClient()
309
+ response = {"parameters": {"pdu_length": 0}}
310
+
311
+ with patch.object(client, "_send_receive", new=AsyncMock(return_value=response)):
312
+ await client._setup_communication()
313
+
314
+ assert client.pdu_length == 240
315
+ assert client._max_read_size() == 222
316
+ assert client._max_write_size() == 205
317
+
318
+
319
+ @pytest.mark.parametrize(
320
+ ("parameter", "expected"),
321
+ [
322
+ (struct.pack(">BBB", 0xC0, 1, 0x0A), 1024),
323
+ (struct.pack(">BBH", 0xC0, 2, 2048), 2048),
324
+ ],
325
+ )
326
+ def test_async_cotp_accepts_valid_pdu_sizes(parameter: bytes, expected: int) -> None:
327
+ connection = AsyncISOTCPConnection("127.0.0.1")
328
+ base = struct.pack(">BBHHB", 6 + len(parameter), 0xD0, 0x0001, 0x0001, 0x00)
329
+
330
+ connection._parse_cotp_cc(base + parameter)
331
+
332
+ assert connection.pdu_size == expected
333
+
334
+
335
+ @pytest.mark.parametrize(
336
+ "parameter",
337
+ [
338
+ struct.pack(">BBB", 0xC0, 1, 0x00),
339
+ struct.pack(">BBB", 0xC0, 1, 0xFF),
340
+ struct.pack(">BBH", 0xC0, 2, 127),
341
+ struct.pack(">BBH", 0xC0, 2, 8193),
342
+ ],
343
+ )
344
+ def test_async_cotp_ignores_invalid_pdu_sizes(parameter: bytes) -> None:
345
+ connection = AsyncISOTCPConnection("127.0.0.1")
346
+ base = struct.pack(">BBHHB", 6 + len(parameter), 0xD0, 0x0001, 0x0001, 0x00)
347
+
348
+ connection._parse_cotp_cc(base + parameter)
349
+
350
+ assert connection.pdu_size == 240
351
+
352
+
304
353
  # -------------------------------------------------------------------
305
354
  # Block info / CPU info (against server)
306
355
  # -------------------------------------------------------------------
@@ -2,7 +2,7 @@ import logging
2
2
  import struct
3
3
  import time
4
4
  from typing import Any, Tuple
5
- from unittest.mock import MagicMock
5
+ from unittest.mock import MagicMock, patch
6
6
 
7
7
  import pytest
8
8
  import unittest
@@ -1050,6 +1050,17 @@ class TestPDUSplitting:
1050
1050
  client.pdu_length = 480
1051
1051
  assert client._max_write_size() == 480 - 35
1052
1052
 
1053
+ def test_invalid_negotiated_pdu_length_uses_safe_default(self) -> None:
1054
+ client = Client()
1055
+ response = {"parameters": {"pdu_length": 0}}
1056
+
1057
+ with patch.object(client, "_send_receive", return_value=response):
1058
+ client._setup_communication()
1059
+
1060
+ assert client.pdu_length == 240
1061
+ assert client._max_read_size() == 222
1062
+ assert client._max_write_size() == 205
1063
+
1053
1064
  def test_read_area_splits_large_request(self) -> None:
1054
1065
  """read_area should make multiple requests when size > max_read_size."""
1055
1066
  client = Client()
@@ -1,15 +1,17 @@
1
1
  from ctypes import c_char
2
2
  import logging
3
+ import socket
3
4
  import time
4
5
  from datetime import datetime
5
6
 
6
7
  import pytest
7
8
  import unittest
8
9
  from threading import Thread
10
+ from unittest.mock import MagicMock
9
11
 
10
12
  from snap7.client import Client
11
- from snap7.error import server_errors, error_text
12
- from snap7.server import Server
13
+ from snap7.error import server_errors, error_text, S7ConnectionError
14
+ from snap7.server import Server, ServerISOConnection
13
15
  from snap7.type import SrvEvent, mkEvent, mkLog, SrvArea, Parameter, Block
14
16
 
15
17
  logging.basicConfig(level=logging.WARNING)
@@ -125,7 +127,7 @@ class TestServer(unittest.TestCase):
125
127
  # check the defaults
126
128
  self.assertEqual(self.server.get_param(Parameter.LocalPort), 12102)
127
129
  self.assertEqual(self.server.get_param(Parameter.WorkInterval), 100)
128
- self.assertEqual(self.server.get_param(Parameter.MaxClients), 1024)
130
+ self.assertEqual(self.server.get_param(Parameter.MaxClients), 64)
129
131
 
130
132
  # invalid param for server
131
133
  self.assertRaises(Exception, self.server.get_param, Parameter.RemotePort)
@@ -143,11 +145,80 @@ class TestServerBeforeStart(unittest.TestCase):
143
145
  def test_set_param(self) -> None:
144
146
  self.server.set_param(Parameter.LocalPort, 1102)
145
147
 
148
+ def test_max_clients_parameter(self) -> None:
149
+ self.server.set_param(Parameter.MaxClients, 3)
150
+ self.assertEqual(self.server.get_param(Parameter.MaxClients), 3)
151
+ self.assertRaises(ValueError, self.server.set_param, Parameter.MaxClients, 0)
152
+
153
+ def test_max_clients_constructor_validation(self) -> None:
154
+ self.assertRaises(ValueError, Server, max_clients=0)
155
+
146
156
 
147
157
  @pytest.mark.server
148
158
  class TestServerRobustness(unittest.TestCase):
149
159
  """Test server robustness and edge cases."""
150
160
 
161
+ def test_max_clients_is_enforced(self) -> None:
162
+ server = Server(max_clients=1)
163
+ first = None
164
+ second = None
165
+ try:
166
+ server.start(0)
167
+ assert server.server_socket is not None
168
+ port = server.server_socket.getsockname()[1]
169
+
170
+ first = socket.create_connection(("127.0.0.1", port), timeout=1)
171
+ deadline = time.monotonic() + 2
172
+ while server.client_count != 1 and time.monotonic() < deadline:
173
+ time.sleep(0.01)
174
+ self.assertEqual(server.client_count, 1)
175
+
176
+ second = socket.create_connection(("127.0.0.1", port), timeout=1)
177
+ second.settimeout(2)
178
+ try:
179
+ self.assertEqual(second.recv(1), b"")
180
+ except ConnectionResetError:
181
+ pass
182
+
183
+ self.assertEqual(server.client_count, 1)
184
+ finally:
185
+ if first is not None:
186
+ first.close()
187
+ if second is not None:
188
+ second.close()
189
+ server.stop()
190
+
191
+ def test_download_target_and_size_are_bounded(self) -> None:
192
+ server = Server()
193
+ server.register_area(SrvArea.DB, 2, bytearray(4))
194
+ address = ("127.0.0.1", 12345)
195
+
196
+ start_pdu = server.protocol.build_download_request(0x41, 2, b"1234")
197
+ start_request = server._parse_request(start_pdu)
198
+ response = server._handle_request_download(start_request, address)
199
+
200
+ self.assertEqual(response[10:12], b"\x00\x00")
201
+ self.assertEqual(server._download_contexts[address]["block_num"], 2)
202
+ self.assertEqual(server._download_contexts[address]["max_size"], 4)
203
+
204
+ server._handle_download_block({"sequence": 1, "data": {"data": b"123"}}, address)
205
+ response = server._handle_download_block({"sequence": 2, "data": {"data": b"45"}}, address)
206
+
207
+ self.assertEqual(response[10:12], b"\x81\x04")
208
+ self.assertNotIn(address, server._download_contexts)
209
+
210
+ def test_oversized_download_is_rejected_before_context_allocation(self) -> None:
211
+ server = Server()
212
+ server.register_area(SrvArea.DB, 2, bytearray(4))
213
+ address = ("127.0.0.1", 12345)
214
+
215
+ pdu = server.protocol.build_download_request(0x41, 2, b"12345")
216
+ request = server._parse_request(pdu)
217
+ response = server._handle_request_download(request, address)
218
+
219
+ self.assertEqual(response[10:12], b"\x81\x04")
220
+ self.assertFalse(hasattr(server, "_download_contexts"))
221
+
151
222
  def test_multiple_server_instances(self) -> None:
152
223
  """Test multiple server instances on different ports."""
153
224
  from snap7.client import Client
@@ -243,6 +314,33 @@ ip = "127.0.0.1"
243
314
  SERVER_PORT = 12200
244
315
 
245
316
 
317
+ @pytest.mark.server
318
+ class TestServerISOConnectionLimits:
319
+ def test_partial_frame_timeout_closes_connection(self) -> None:
320
+ client_socket = MagicMock()
321
+ client_socket.recv.side_effect = [b"\x03", TimeoutError()]
322
+ connection = ServerISOConnection(client_socket)
323
+
324
+ with pytest.raises(S7ConnectionError, match="partial frame"):
325
+ connection._recv_exact(4, time.monotonic() + 1)
326
+
327
+ def test_reassembled_request_size_is_bounded(self) -> None:
328
+ client_socket = MagicMock()
329
+ connection = ServerISOConnection(client_socket)
330
+ connection.MAX_REASSEMBLED_SIZE = 4
331
+ connection._recv_exact = MagicMock(
332
+ side_effect=[
333
+ b"\x03\x00\x00\x0a",
334
+ b"\x02\xf0\x00abc",
335
+ b"\x03\x00\x00\x09",
336
+ b"\x02\xf0\x80de",
337
+ ]
338
+ )
339
+
340
+ with pytest.raises(S7ConnectionError, match="exceeds 4 bytes"):
341
+ connection.receive_data()
342
+
343
+
246
344
  @pytest.mark.server
247
345
  class TestServerBlockOperations(unittest.TestCase):
248
346
  """Test block operations through client-server communication."""
@@ -354,6 +452,15 @@ class TestServerBlockOperations(unittest.TestCase):
354
452
  read_back = self.client.db_read(1, 0, 4)
355
453
  self.assertEqual(read_back, download_data)
356
454
 
455
+ def test_download_uses_requested_block_number(self) -> None:
456
+ """Download data to DB2 rather than silently falling back to DB1."""
457
+ download_data = bytearray([0x12, 0x34, 0x56, 0x78])
458
+
459
+ result = self.client.download(download_data, block_num=2)
460
+
461
+ self.assertEqual(result, 0)
462
+ self.assertEqual(self.client.db_read(2, 0, 4), download_data)
463
+
357
464
 
358
465
  @pytest.mark.server
359
466
  class TestServerUserdataOperations(unittest.TestCase):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes