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.
- {python_snap7-3.1.1/python_snap7.egg-info → python_snap7-3.1.2}/PKG-INFO +1 -1
- {python_snap7-3.1.1 → python_snap7-3.1.2}/pyproject.toml +1 -1
- {python_snap7-3.1.1 → python_snap7-3.1.2/python_snap7.egg-info}/PKG-INFO +1 -1
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/async_client.py +10 -2
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/server/__init__.py +121 -26
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_async_client.py +51 -2
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_client.py +12 -1
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_server.py +110 -3
- {python_snap7-3.1.1 → python_snap7-3.1.2}/LICENSE +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/MANIFEST.in +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/README.rst +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/SOURCES.txt +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/dependency_links.txt +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/entry_points.txt +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/requires.txt +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/python_snap7.egg-info/top_level.txt +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/setup.cfg +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/__init__.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/cli.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/client.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/client_base.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/connection.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/datatypes.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/demo.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/discovery.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/error.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/log.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/logo.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/optimizer.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/partner.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/py.typed +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/s7protocol.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/server/__main__.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/szl.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/tags.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/type.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/__init__.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/db.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/getters.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/setters.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/snap7/util/symbols.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_api_surface.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_behavioral_compatibility.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_cli.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_client_e2e.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_datatypes.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_demo.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_discovery.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_finalize.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_hypothesis.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_logging.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_logo_client.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_mainloop.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_multipacket.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_optimizer.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_partner.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_property.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_reconnect.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_routing.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_s7protocol.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_stress.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_tags.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_typed_access.py +0 -0
- {python_snap7-3.1.1 → python_snap7-3.1.2}/tests/test_util.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
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
|
|
2305
|
-
|
|
2306
|
-
|
|
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
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|