python-snap7 3.1.1__py3-none-any.whl → 3.1.2__py3-none-any.whl

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.
@@ -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
@@ -1,6 +1,6 @@
1
- python_snap7-3.1.1.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
1
+ python_snap7-3.1.2.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
2
2
  snap7/__init__.py,sha256=jWGpca2kG7gskQbcM5qoflWEyMMEJ7655kM0KxVVLaA,980
3
- snap7/async_client.py,sha256=7GwUslkmK27ADGrZ-BtF2UogbJbnzf6WOCLc-D2V3-8,44157
3
+ snap7/async_client.py,sha256=kc4OT8ZcBpTg3DBd9qEfi3yFlPBOX_EiEVkJV9ZAlRU,44560
4
4
  snap7/cli.py,sha256=Td7z1O0gV7t-w9umUaX91MHGw-HuXYEDHKgtUO-9d3Q,13755
5
5
  snap7/client.py,sha256=wOy7-BzAIl9atE-uYWxEDvUo_HHrhCsRj8vhdMbhQVg,97264
6
6
  snap7/client_base.py,sha256=0e1mAU6qsskzMBSYPjccSClznf4jpTNzYGlvGpCC0Cs,7716
@@ -18,15 +18,15 @@ snap7/s7protocol.py,sha256=a-obZR5fLzHS1wHpZ88N37XxzYjKiS2JgK8SFi5qTjQ,57360
18
18
  snap7/szl.py,sha256=VPKNz8MCU7ok_Run-JVuwHKJSeGY-Wcj93SJu56tgtw,3898
19
19
  snap7/tags.py,sha256=Qh8dm6rDWcVnYhbSB_htrmqIAXsmWQWcDS8-wXqN_zE,27033
20
20
  snap7/type.py,sha256=-ad6v8vQP21DvWFtHCaSlk-BllFGPKOtbhprqKdOlJ0,9391
21
- snap7/server/__init__.py,sha256=t-BM4Iphw8j5BzCIPf5xOFQaYHQmuLh7X8F-lvDOkaU,101708
21
+ snap7/server/__init__.py,sha256=4l5qeEO1LMSLU77c9ggo6FSK0NH_qs--Kdt7bcrGDv8,106197
22
22
  snap7/server/__main__.py,sha256=OdOgyMmFThjSe2jSgvlbkquGYnZcBW6wubs2ldhNSOE,1206
23
23
  snap7/util/__init__.py,sha256=d6n2oy7tNYke1ia0TUyWz_ni3l0XN1qvilM5sW0T-Es,1874
24
24
  snap7/util/db.py,sha256=geesYS2oyOuTADOJa2me1p1rqJVzPuLTCfye6JIeav0,27120
25
25
  snap7/util/getters.py,sha256=g-z6jve3JEQchsXzeS0ajn56cpVepq1nhbRpShAXD9I,24896
26
26
  snap7/util/setters.py,sha256=aTd-rrTS87G_tI0hAqNhyWv1T7U4eLxbKs5TkzEfpd0,27111
27
27
  snap7/util/symbols.py,sha256=L1dY7I1-HZ5Odtv6ZqGBeLppb3E5hjL64MyATT7XyEw,16454
28
- python_snap7-3.1.1.dist-info/METADATA,sha256=bZ_pI6rndBbgswfDxQqH5YGw8c0L2Rcw6i0Xs7fR4zc,4663
29
- python_snap7-3.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
- python_snap7-3.1.1.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
31
- python_snap7-3.1.1.dist-info/top_level.txt,sha256=VpFXm1w5_73qgJEIN_5OzQWINHyYJBtNoOW4DMoizgw,6
32
- python_snap7-3.1.1.dist-info/RECORD,,
28
+ python_snap7-3.1.2.dist-info/METADATA,sha256=di-S3uFMbV8bIQXkuriHZcQcrDnAUHvzVpxSBblcSJw,4663
29
+ python_snap7-3.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
+ python_snap7-3.1.2.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
31
+ python_snap7-3.1.2.dist-info/top_level.txt,sha256=VpFXm1w5_73qgJEIN_5OzQWINHyYJBtNoOW4DMoizgw,6
32
+ python_snap7-3.1.2.dist-info/RECORD,,
snap7/async_client.py CHANGED
@@ -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
 
snap7/server/__init__.py CHANGED
@@ -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)