Merge pull request '[s7] S7 — PLC password / protection-level handling' (#406) from auto/s7/PR-S7-E2 into auto/driver-gaps
This commit was merged in pull request #406.
This commit is contained in:
@@ -25,6 +25,8 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
|
||||
| `--tsap-mode` | `Auto` | ISO-on-TCP connection class: `Auto` / `Pg` / `Op` / `S7Basic` / `Other`. Hardened S7-1500 / ET 200SP CPUs may require `Op` or `S7Basic`. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type). |
|
||||
| `--local-tsap` | (unset) | Optional 16-bit local TSAP override (e.g. `0x0200`). Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
||||
| `--remote-tsap` | (unset) | Optional 16-bit remote TSAP override. Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
||||
| `--password` | (unset) | Connection-level password sent right after `OpenAsync`. Used by hardened S7-300/400 (protection levels 1-3) and S7-1200/1500 (TIA Portal *Connection Mechanism* gate). Never logged. NB: S7netplus 0.20 doesn't expose `SendPassword`; the CLI prints a one-line warning and continues. See [s7.md "PLC password / protection levels"](v2/s7.md#plc-password--protection-levels). |
|
||||
| `--protection-level` | `Auto` | Declarative hint: `Auto` / `None` / `Level1` / `Level2` / `Level3` (S7-300/400) / `ConnectionMechanism` (S7-1200/1500). Diagnostic only — the wire-side unlock is driven by `--password`. |
|
||||
| `--verbose` | off | Serilog debug output |
|
||||
|
||||
## PUT/GET must be enabled
|
||||
@@ -139,6 +141,43 @@ wrong `--slot` produces also shows up when the CPU rejects PG class — try
|
||||
endpoint config. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type)
|
||||
for the byte table and motivation.
|
||||
|
||||
### Hardened CPU — supplying a connection-level password
|
||||
|
||||
```powershell
|
||||
# S7-300 protection-level 2 — read+write protected without unlock.
|
||||
otopcua-s7-cli read -h 192.168.1.31 -c S7300 --slot 2 `
|
||||
--password "tia-portal-set-password" `
|
||||
--protection-level Level2 `
|
||||
-a DB1.DBW0 -t Int16
|
||||
|
||||
# S7-1500 ConnectionMechanism — TIA Portal Protection & Security pane gate.
|
||||
otopcua-s7-cli probe -h 10.50.12.30 `
|
||||
--tsap-mode Op `
|
||||
--password "tia-portal-set-password" `
|
||||
--protection-level ConnectionMechanism
|
||||
```
|
||||
|
||||
The password is emitted to the PLC immediately after `OpenAsync` succeeds and
|
||||
before the pre-flight PUT/GET probe runs (the same probe that would otherwise
|
||||
be the first operation a hardened CPU refuses). Never logged in any form;
|
||||
identifier-only success line is `S7 password sent for {Host}`.
|
||||
|
||||
**S7netplus 0.20 does not yet expose a public `SendPassword`** — the driver
|
||||
discovers the method reflectively, so a future minor release will be picked
|
||||
up automatically. Until then, configuring `--password` on a hardened CPU
|
||||
emits this warning at Init:
|
||||
|
||||
```
|
||||
[Warning] S7 password is set on driver '<id>' against host '<host>', but
|
||||
the linked S7netplus library does not expose SendPassword; password is
|
||||
being ignored at the wire.
|
||||
```
|
||||
|
||||
Init still completes (the COTP handshake itself doesn't require the
|
||||
password) but the first read against a hardened CPU will surface
|
||||
`BadDeviceFailure`. See [s7.md "PLC password / protection levels"](v2/s7.md#plc-password--protection-levels)
|
||||
for the full motivation, the no-log invariant, and the workaround matrix.
|
||||
|
||||
### `subscribe`
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -109,6 +109,27 @@ or we ship a raw S7comm PDU helper. See
|
||||
[`docs/v2/s7.md` "CPU diagnostics (SZL)"](../v2/s7.md#cpu-diagnostics-szl)
|
||||
for the wire-status detail.
|
||||
|
||||
### 7. Password / protection levels — not modelled by snap7
|
||||
|
||||
PR-S7-E2 / [#303](https://github.com/dohertj2/lmxopcua/issues/303) adds
|
||||
`Password` + `ProtectionLevel` options that emit a connection-level password
|
||||
right after `OpenAsync`. **snap7 does not model S7 protection levels** — the
|
||||
simulator accepts every connection regardless of the password set on the
|
||||
client, so the integration profile cannot distinguish "password sent
|
||||
correctly" from "password ignored". Coverage stays at the unit-test seam:
|
||||
`S7PasswordOptionsTests` injects a fake `IS7PlcAuthGate` to assert the
|
||||
dispatch contract (Password=null skips the call; Password+SupportsSendPassword
|
||||
calls the gate; auth-failed wraps to a clean `InvalidOperationException`),
|
||||
plus the no-log invariant on `S7DriverOptions.ToString()`.
|
||||
|
||||
The wire path is also fundamentally limited until S7netplus 0.20 exposes a
|
||||
public `SendPassword` — the driver currently logs a warning and continues
|
||||
when the API is missing. See
|
||||
[`docs/v2/s7.md` "PLC password / protection levels"](../v2/s7.md#plc-password--protection-levels)
|
||||
for the library-limitation note. Live-firmware coverage of the unlock path
|
||||
requires a hardened S7-1500 lab rig with TIA Portal "Protection & Security"
|
||||
configured, which is parked as a follow-up.
|
||||
|
||||
## When to trust the S7 tests, when to reach for a rig
|
||||
|
||||
| Question | Unit tests | Real PLC |
|
||||
|
||||
117
docs/v2/s7.md
117
docs/v2/s7.md
@@ -1156,6 +1156,123 @@ diagnostics tests where you want every read to hit the wire.
|
||||
}
|
||||
```
|
||||
|
||||
## PLC password / protection levels
|
||||
|
||||
PR-S7-E2 (issue #303) adds a connection-level password option for hardened
|
||||
deployments. The driver emits the password to the PLC immediately after
|
||||
`OpenAsync` succeeds and before the pre-flight PUT/GET probe runs (the same
|
||||
pre-flight read that would otherwise be the first operation a hardened CPU
|
||||
refuses).
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Default | Purpose |
|
||||
| ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Password` | `null` | Connection-level password. Secret — never logged. `null` or empty = no password is sent. |
|
||||
| `ProtectionLevel` | `Auto` | Declarative hint about the PLC's protection scheme. One of `Auto`, `None`, `Level1`, `Level2`, `Level3` (S7-300/400 SFC 109/110 levels), or `ConnectionMechanism` (S7-1200/1500 TIA Portal "Protection & Security" pane). |
|
||||
|
||||
### S7-300 / S7-400 protection levels (1, 2, 3)
|
||||
|
||||
S7-300/400 firmware exposes three CPU-side protection levels:
|
||||
|
||||
* **Level 1** — write protection. Reads work without a password; writes
|
||||
(parameter, DB, M/Q changes) require an unlock.
|
||||
* **Level 2** — read and write protection. Both kinds of operation require
|
||||
the password.
|
||||
* **Level 3** — full protection. Even online presence detection / status
|
||||
list reads require the password.
|
||||
|
||||
Set `ProtectionLevel = Level1` / `Level2` / `Level3` and supply
|
||||
`Password` to match the level configured in the CPU's HW Config dialog.
|
||||
The level value is descriptive — the driver doesn't switch behaviour
|
||||
between Level1/2/3, since the wire-side `SendPassword` is the same call
|
||||
in all three cases. The hint surfaces in the driver-diagnostics RPC so a
|
||||
"PLC said Level 3 but config says Level 1" mismatch is spottable from the
|
||||
Admin UI.
|
||||
|
||||
### S7-1200 / S7-1500 connection mechanism
|
||||
|
||||
S7-1200/1500 firmware uses a different gate: TIA Portal's "Protection &
|
||||
Security" pane has a single **Connection Mechanism** dropdown that, when
|
||||
set to anything stricter than "No access", requires every PG/HMI/SCADA
|
||||
connection to authenticate after the COTP handshake. The wire-level
|
||||
exchange is the same `SendPassword` call but the diagnostic flag is
|
||||
distinct, so set `ProtectionLevel = ConnectionMechanism` for these
|
||||
families.
|
||||
|
||||
### No-log invariant
|
||||
|
||||
`Password` is a secret. The driver MUST NOT include the password value in
|
||||
log lines, exception messages, or diagnostic surfaces. Specifically:
|
||||
|
||||
* `S7DriverOptions.ToString()` redacts the field as `***`.
|
||||
* `S7Driver`'s success log line is `S7 password sent for {Host}` —
|
||||
identifier-only, no value.
|
||||
* The "S7netplus does not expose SendPassword" warning logs the host name
|
||||
and driver instance ID only, never the password.
|
||||
* Authentication-failure exceptions wrap the inner `S7.Net.PlcException`
|
||||
but their own message says only "S7 password authentication failed for
|
||||
host '{Host}'" — no password value.
|
||||
|
||||
Any new logging surface that flows an `S7DriverOptions` value MUST
|
||||
continue to redact. See the FOCAS-F4-d
|
||||
`docs/v2/focas-deployment.md` § "FOCAS password handling" entry for the
|
||||
sister no-log discipline on the FOCAS driver.
|
||||
|
||||
### Library limitation — S7netplus 0.20
|
||||
|
||||
**S7netplus 0.20.0 (the pinned dependency) does not expose a public
|
||||
`SendPassword` method.** The driver discovers the method reflectively
|
||||
(checking for `SendPasswordAsync(string, CancellationToken)` first, then
|
||||
`SendPassword(string)`) so a future minor release that ships the API
|
||||
will be picked up automatically without a code change here.
|
||||
|
||||
Until the upstream lands, configuring `Password` on a hardened CPU
|
||||
produces this Init-time warning:
|
||||
|
||||
```
|
||||
[Warning] S7 password is set on driver '<DriverInstanceId>' against
|
||||
host '<Host>', but the linked S7netplus library does not expose
|
||||
SendPassword; password is being ignored at the wire. Hardened-CPU
|
||||
connect may fail at first read.
|
||||
```
|
||||
|
||||
Init still completes — the COTP/S7comm handshake itself doesn't require
|
||||
the password — but the first read against a hardened CPU will surface
|
||||
`BadDeviceFailure` because PUT/GET-disabled and "level-3 protection"
|
||||
return identical "function not allowed" PDUs at the wire layer.
|
||||
|
||||
If your S7-1200/1500 deployment requires `ConnectionMechanism`, the
|
||||
near-term workarounds are:
|
||||
|
||||
1. **Lower the protection setting** in TIA Portal's Protection & Security
|
||||
pane to "Full access (no protection)" for the duration of the
|
||||
evaluation.
|
||||
2. **Configure a separate non-hardened connection** on a CP module that
|
||||
the driver can target while keeping the production endpoint hardened.
|
||||
3. **Track upstream S7netplus** for a `SendPassword` PR (the package owner
|
||||
has discussed adding it; see issue
|
||||
<https://github.com/S7NetPlus/s7netplus/issues>).
|
||||
|
||||
For S7-300/400 CPUs, levels 1 and 2 leave at least *read* access open
|
||||
without a password, so most monitoring use cases work without
|
||||
`SendPassword` until the library catches up — only Level 3 and the
|
||||
S7-1200/1500 ConnectionMechanism require the wire-level unlock.
|
||||
|
||||
### JSON config example
|
||||
|
||||
```json
|
||||
{
|
||||
"DriverConfig": {
|
||||
"Host": "192.168.10.50",
|
||||
"Port": 102,
|
||||
"CpuType": "S71500",
|
||||
"Password": "tia-portal-set-password",
|
||||
"ProtectionLevel": "ConnectionMechanism"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||||
|
||||
@@ -50,6 +50,20 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
"the class default under Pg/Op/S7Basic.")]
|
||||
public ushort? RemoteTsap { get; init; }
|
||||
|
||||
[CommandOption("password", Description =
|
||||
"Connection-level password emitted to the PLC right after OpenAsync. Used by hardened " +
|
||||
"S7-300/400 deployments running protection levels 1-3 and S7-1200/1500 deployments with " +
|
||||
"a connection-mechanism password set. Default unset. Never logged. NB: S7netplus 0.20 " +
|
||||
"does not yet expose SendPassword — when the linked library lacks the API the CLI prints " +
|
||||
"a warning and continues. See docs/v2/s7.md \"PLC password / protection levels\".")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("protection-level", Description =
|
||||
"Declarative hint about the PLC's protection scheme: Auto (default), None, Level1, Level2, " +
|
||||
"Level3 (S7-300/400), or ConnectionMechanism (S7-1200/1500). Diagnostic hint only; the " +
|
||||
"wire path is driven by --password.")]
|
||||
public ProtectionLevel ProtectionLevel { get; init; } = ProtectionLevel.Auto;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
@@ -75,6 +89,8 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
TsapMode = TsapMode,
|
||||
LocalTsap = LocalTsap,
|
||||
RemoteTsap = RemoteTsap,
|
||||
Password = string.IsNullOrEmpty(Password) ? null : Password,
|
||||
ProtectionLevel = ProtectionLevel,
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
@@ -123,6 +125,42 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
/// <summary>Test-only access to the SZL cache for assertions about TTL behaviour.</summary>
|
||||
internal S7SzlCache? SzlCache => _szlCache;
|
||||
|
||||
// ---- PR-S7-E2 / #303 — connection-level password (SendPassword) seam ----
|
||||
//
|
||||
// AuthGate wraps the reflective probe over S7.Net.Plc.SendPassword; setting it
|
||||
// before InitializeAsync lets unit tests inject a fake that reports
|
||||
// SupportsSendPassword + observes the call without standing up a real PLC.
|
||||
// Logger is an ILogger seam so the warning ("S7netplus does not expose
|
||||
// SendPassword") and the success line ("S7 password sent") flow into Serilog
|
||||
// through the host's default factory; tests inject a capturing logger.
|
||||
private IS7PlcAuthGate? _authGate;
|
||||
private ILogger<S7Driver> _logger = NullLogger<S7Driver>.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 — test seam for the password-send path. Setting before
|
||||
/// <see cref="InitializeAsync"/> overrides the default reflective gate so unit
|
||||
/// tests can verify the call site without needing a live PLC. <c>null</c> =
|
||||
/// production behaviour: <see cref="ReflectionS7PlcAuthGate"/> is constructed
|
||||
/// once <see cref="Plc"/> is open.
|
||||
/// </summary>
|
||||
internal IS7PlcAuthGate? AuthGate
|
||||
{
|
||||
get => _authGate;
|
||||
set => _authGate = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 — ILogger seam. Production callers go through the host's DI
|
||||
/// container to wire a Serilog-backed <see cref="ILoggerFactory"/>; tests
|
||||
/// inject a capturing logger to assert the warning-vs-info contract on the
|
||||
/// password path.
|
||||
/// </summary>
|
||||
internal ILogger<S7Driver> Logger
|
||||
{
|
||||
get => _logger;
|
||||
set => _logger = value ?? NullLogger<S7Driver>.Instance;
|
||||
}
|
||||
|
||||
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
||||
//
|
||||
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
||||
@@ -257,6 +295,13 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
_szlReader ??= new S7NetSzlReader(plc);
|
||||
_szlCache = new S7SzlCache(_options.SzlCacheTtl);
|
||||
|
||||
// PR-S7-E2 / #303 — connection-level password. After a clean OpenAsync, if the
|
||||
// operator supplied Password, hand it to the auth gate. The gate is reflective
|
||||
// over S7.Net.Plc.SendPassword by default; tests inject a fake. When S7netplus
|
||||
// doesn't yet expose SendPassword (true for 0.20), we log a one-line warning
|
||||
// and continue — failure shifts to first per-tag read on a hardened CPU.
|
||||
await TrySendPlcPasswordAsync(plc, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// PR-S7-C5 — pre-flight PUT/GET enablement probe. After a clean OpenAsync,
|
||||
// issue a tiny 2-byte read against Probe.ProbeAddress (default MW0). Hardened
|
||||
// S7-1200 / S7-1500 CPUs that have PUT/GET communication disabled in TIA
|
||||
@@ -1118,6 +1163,73 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private global::S7.Net.Plc RequirePlc() =>
|
||||
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — emit the connection-level password to the freshly-opened PLC.
|
||||
/// Caller is <see cref="InitializeAsync"/>, immediately after <c>OpenAsync</c> and
|
||||
/// before <see cref="RunPreflightAsync"/> — that ordering matters because the
|
||||
/// pre-flight read is exactly the operation a hardened CPU will refuse without an
|
||||
/// unlock. No-op when <see cref="S7DriverOptions.Password"/> is null/empty (the
|
||||
/// standard development case). When the underlying S7netplus build doesn't expose
|
||||
/// <c>SendPassword</c>, surfaces a single warning log and continues — see the
|
||||
/// "Library limitation" remark on <see cref="S7DriverOptions.Password"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>No-log invariant:</b> never include the password value in any log, exception
|
||||
/// message, or diagnostic surface. The host name is logged as the only identifier.
|
||||
/// </remarks>
|
||||
private async Task TrySendPlcPasswordAsync(global::S7.Net.Plc plc, CancellationToken ct)
|
||||
{
|
||||
var password = _options.Password;
|
||||
if (string.IsNullOrEmpty(password)) return;
|
||||
|
||||
// Lazily build the gate so tests can pre-inject a fake; production gets the
|
||||
// reflective gate over the live S7.Net.Plc instance.
|
||||
_authGate ??= new ReflectionS7PlcAuthGate(plc);
|
||||
|
||||
if (!_authGate.SupportsSendPassword)
|
||||
{
|
||||
// Library doesn't oblige (S7netplus 0.20). Don't fail Init — emit one
|
||||
// warning so the operator sees the limitation in Serilog, then continue.
|
||||
// Hardened CPUs will surface a per-read failure later, which is the same
|
||||
// shape as a missing PUT/GET enable.
|
||||
_logger.LogWarning(
|
||||
"S7 password is set on driver '{DriverInstanceId}' against host '{Host}', " +
|
||||
"but the linked S7netplus library does not expose SendPassword; " +
|
||||
"password is being ignored at the wire. Hardened-CPU connect may fail at " +
|
||||
"first read. See docs/v2/s7.md \"PLC password / protection levels\" for the " +
|
||||
"library-limitation note.",
|
||||
DriverInstanceId, _options.Host);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sent = await _authGate.TrySendPasswordAsync(password, ct).ConfigureAwait(false);
|
||||
if (sent)
|
||||
{
|
||||
// Identifier-only log line — no password leakage.
|
||||
_logger.LogInformation(
|
||||
"S7 password sent for {Host} (driver '{DriverInstanceId}', protection {ProtectionLevel}).",
|
||||
_options.Host, DriverInstanceId, _options.ProtectionLevel);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Wire reported auth-failed. Wrap in a clean InvalidOperationException so the
|
||||
// operator sees a typed message rather than a raw S7.Net.PlcException stack;
|
||||
// inner exception preserved for diagnostics. No password value in the message.
|
||||
throw new InvalidOperationException(
|
||||
$"S7 password authentication failed for host '{_options.Host}'. " +
|
||||
"Check the protection password configured in TIA Portal's Protection & Security pane " +
|
||||
"and the ProtectionLevel option matches the CPU's actual scheme.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-C5 — issue the post-<c>OpenAsync</c> pre-flight probe read against
|
||||
/// <see cref="S7ProbeOptions.ProbeAddress"/> and translate a "PUT/GET disabled"
|
||||
|
||||
@@ -85,6 +85,14 @@ public static class S7DriverFactoryExtensions
|
||||
fallback: TsapMode.Auto),
|
||||
LocalTsap = dto.LocalTsap,
|
||||
RemoteTsap = dto.RemoteTsap,
|
||||
// PR-S7-E2 / #303 — connection-level password + declarative protection-level
|
||||
// hint. Password defaults to null (no auth) per the no-log invariant; an
|
||||
// explicit empty-string in JSON also collapses to null so a "Password": ""
|
||||
// typo doesn't try to send a 0-byte password to the PLC. ProtectionLevel
|
||||
// defaults to Auto when the field is absent.
|
||||
Password = string.IsNullOrEmpty(dto.Password) ? null : dto.Password,
|
||||
ProtectionLevel = ParseEnum<ProtectionLevel>(dto.ProtectionLevel, driverInstanceId,
|
||||
"ProtectionLevel", fallback: ProtectionLevel.Auto),
|
||||
ScanGroupIntervals = scanGroupMap,
|
||||
// PR-S7-D2 — UDT layout declarations referenced by tags whose UdtName is set.
|
||||
// Empty list when the config doesn't declare any UDTs (the typical scalar-only case).
|
||||
@@ -264,6 +272,8 @@ public static class S7DriverFactoryExtensions
|
||||
RemoteTsap = options.RemoteTsap,
|
||||
ScanGroupIntervals = options.ScanGroupIntervals,
|
||||
Udts = options.Udts,
|
||||
Password = options.Password,
|
||||
ProtectionLevel = options.ProtectionLevel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -332,6 +342,26 @@ public static class S7DriverFactoryExtensions
|
||||
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section.
|
||||
/// </summary>
|
||||
public List<S7UdtDto>? Udts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — connection-level password emitted to the PLC right
|
||||
/// after <c>OpenAsync</c> succeeds and before the pre-flight PUT/GET probe
|
||||
/// runs. Default <c>null</c> = no password is sent (the standard case).
|
||||
/// <b>Secret:</b> never logged. See <c>docs/v2/s7.md</c> §"PLC password /
|
||||
/// protection levels" for the no-log invariant and the S7netplus 0.20
|
||||
/// library-limitation note.
|
||||
/// </summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — declarative hint about the protection scheme on the
|
||||
/// target PLC. One of <c>Auto</c> (default), <c>None</c>, <c>Level1</c>,
|
||||
/// <c>Level2</c>, <c>Level3</c> (S7-300/400), or <c>ConnectionMechanism</c>
|
||||
/// (S7-1200/1500). Surfaced via the driver-diagnostics RPC so a
|
||||
/// misconfigured "level 3 PLC seen as level 1" deployment is spottable
|
||||
/// from the Admin UI.
|
||||
/// </summary>
|
||||
public string? ProtectionLevel { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class S7TagDto
|
||||
|
||||
@@ -193,6 +193,90 @@ public sealed class S7DriverOptions
|
||||
/// (every read goes to the wire) — only useful for diagnostics tests.
|
||||
/// </summary>
|
||||
public TimeSpan SzlCacheTtl { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — connection-level password emitted to the PLC right after
|
||||
/// <c>OpenAsync</c> succeeds and before the pre-flight PUT/GET probe runs. Used
|
||||
/// for hardened S7-300/400 deployments running protection level 1, 2 or 3, and
|
||||
/// for S7-1200/1500 deployments that have a connection-mechanism password set
|
||||
/// in TIA Portal's "Protection & Security" pane. Default <c>null</c> = no
|
||||
/// password is sent (the standard case for development PLCs and freshly-flashed
|
||||
/// CPUs without a protection password).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>No-log invariant.</b> <see cref="Password"/> is a secret. The driver
|
||||
/// MUST NOT log it; the override on <see cref="ToString"/> below redacts
|
||||
/// the field as <c>***</c>, and any new logging surface that touches an
|
||||
/// <see cref="S7DriverOptions"/> instance must continue to do the same.
|
||||
/// See <c>docs/v2/s7.md</c> §"PLC password / protection levels".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Library limitation.</b> S7netplus 0.20 does not expose a public
|
||||
/// <c>SendPassword</c> method. When <see cref="Password"/> is set on a
|
||||
/// driver linked against a library version that lacks the API, the driver
|
||||
/// logs a one-line warning at Init time and continues — the connection
|
||||
/// succeeds at the COTP layer but a hardened CPU may then refuse the very
|
||||
/// first read with a "function not allowed" PDU. The driver discovers the
|
||||
/// method reflectively (<see cref="ReflectionS7PlcAuthGate"/>), so a future
|
||||
/// S7netplus minor release that adds <c>SendPasswordAsync(string,
|
||||
/// CancellationToken)</c> or <c>SendPassword(string)</c> gets used
|
||||
/// automatically without requiring a code change in this driver.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — declarative hint about the protection scheme the operator
|
||||
/// expects on the target PLC. The driver currently uses this for diagnostics
|
||||
/// and forward-compat (the value is exposed via the driver-diagnostics RPC
|
||||
/// surface so a misconfigured "level 3 PLC seen as level 1" deployment can be
|
||||
/// spotted from the Admin UI), and as a place to hang per-protection-level
|
||||
/// behaviour as S7netplus matures. Default <see cref="ProtectionLevel.Auto"/> =
|
||||
/// no hint, which matches existing behaviour.
|
||||
/// </summary>
|
||||
public ProtectionLevel ProtectionLevel { get; init; } = ProtectionLevel.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// Override the auto-generated reference-typed <c>ToString</c> with one that
|
||||
/// redacts <see cref="Password"/>. Mirrors the FOCAS-F4-d
|
||||
/// <c>FocasDeviceOptions.PrintMembers</c> pattern (which uses positional-record
|
||||
/// plumbing); this class is a reference type, so an explicit override is the
|
||||
/// cheap equivalent. Field set kept compact — only the fields an operator is
|
||||
/// likely to want in a log line are emitted.
|
||||
/// </summary>
|
||||
public override string ToString() =>
|
||||
$"S7DriverOptions {{ Host = {Host}, Port = {Port}, CpuType = {CpuType}, " +
|
||||
$"Rack = {Rack}, Slot = {Slot}, TsapMode = {TsapMode}, " +
|
||||
$"ProtectionLevel = {ProtectionLevel}, Password = {(Password is null ? "<null>" : "***")} }}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — declarative hint about the protection scheme on the target
|
||||
/// PLC. S7-300/400 firmware exposes three CPU-side levels via the
|
||||
/// <c>SFC 109 / 110</c> family; S7-1200/1500 firmware uses TIA Portal's "Connection
|
||||
/// Mechanism" instead (a single PUT/GET-vs-password switch with a different wire
|
||||
/// handshake). The enum carries both vocabularies and Auto for the no-hint case.
|
||||
/// </summary>
|
||||
public enum ProtectionLevel
|
||||
{
|
||||
/// <summary>No declared protection scheme — driver doesn't surface a hint. Default.</summary>
|
||||
Auto,
|
||||
|
||||
/// <summary>Operator asserts the PLC has no protection set. Equivalent to Auto for the wire path; surfaces as "None" in diagnostics.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>S7-300/400 protection level 1 — write-protected unless password is supplied.</summary>
|
||||
Level1,
|
||||
|
||||
/// <summary>S7-300/400 protection level 2 — read- and write-protected unless password is supplied.</summary>
|
||||
Level2,
|
||||
|
||||
/// <summary>S7-300/400 protection level 3 — full protection, all reads/writes require password.</summary>
|
||||
Level3,
|
||||
|
||||
/// <summary>S7-1200/1500 "Connection Mechanism" password gate (TIA Portal Protection & Security pane).</summary>
|
||||
ConnectionMechanism,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
124
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PlcAuthGate.cs
Normal file
124
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7PlcAuthGate.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — narrow seam covering the "send a password to a hardened CPU"
|
||||
/// wire path. <see cref="S7Driver.InitializeAsync"/> calls
|
||||
/// <see cref="TrySendPasswordAsync"/> after <c>OpenAsync</c> succeeds and before the
|
||||
/// pre-flight PUT/GET probe runs, so a hardened S7-1500 / ET 200SP CPU that gates
|
||||
/// reads behind a connection-level password unlocks before the probe drops it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The runtime implementation (<see cref="ReflectionS7PlcAuthGate"/>) discovers
|
||||
/// the underlying <c>S7.Net.Plc.SendPassword</c> / <c>SendPasswordAsync</c>
|
||||
/// methods reflectively because S7netplus 0.20 doesn't yet expose them in a
|
||||
/// strongly-typed surface — the seam keeps this driver compiling against the
|
||||
/// current pinned package version while still calling whatever the next minor
|
||||
/// release ships. When neither method exists,
|
||||
/// <see cref="SupportsSendPassword"/> stays <c>false</c> and
|
||||
/// <see cref="TrySendPasswordAsync"/> is a no-op so a misconfigured "Password
|
||||
/// set, library doesn't oblige" deployment surfaces as a one-line warning at
|
||||
/// Init rather than a hard failure (failure shifts to first per-tag read on the
|
||||
/// hardened CPU, which is the same shape as if the operator had forgotten to
|
||||
/// enable PUT/GET).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tests inject a fake to exercise both branches without touching the live
|
||||
/// S7netplus stack.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal interface IS7PlcAuthGate
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when the underlying S7netplus <c>Plc</c> exposes a public
|
||||
/// <c>SendPassword(string)</c> or <c>SendPasswordAsync(string, CancellationToken)</c>
|
||||
/// method. <c>false</c> on S7netplus 0.20 (which has no such surface).
|
||||
/// </summary>
|
||||
bool SupportsSendPassword { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Send <paramref name="password"/> to the connected PLC. No-op (and returns
|
||||
/// <c>false</c>) when <see cref="SupportsSendPassword"/> is <c>false</c>;
|
||||
/// returns <c>true</c> after a successful send. Throws cleanly when the wire
|
||||
/// reports auth-failed — <see cref="S7Driver.InitializeAsync"/> wraps the
|
||||
/// throw into a typed <see cref="InvalidOperationException"/> so the operator
|
||||
/// sees a "password authentication failed" message rather than a generic
|
||||
/// <c>S7.Net.PlcException</c>.
|
||||
/// </summary>
|
||||
Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IS7PlcAuthGate"/> backed by reflection over the S7netplus
|
||||
/// <c>S7.Net.Plc</c> instance. S7netplus 0.20 does NOT expose a
|
||||
/// <c>SendPassword</c>; the reflection probe survives that gracefully and a future
|
||||
/// 0.21+ that adds the API gets called automatically without a code change here.
|
||||
/// </summary>
|
||||
internal sealed class ReflectionS7PlcAuthGate : IS7PlcAuthGate
|
||||
{
|
||||
private readonly object _plc;
|
||||
private readonly MethodInfo? _syncMethod;
|
||||
private readonly MethodInfo? _asyncMethod;
|
||||
|
||||
public ReflectionS7PlcAuthGate(object plc)
|
||||
{
|
||||
_plc = plc ?? throw new ArgumentNullException(nameof(plc));
|
||||
var type = plc.GetType();
|
||||
|
||||
// Probe both shapes: synchronous void SendPassword(string) and async
|
||||
// Task SendPasswordAsync(string, CancellationToken). Either is acceptable;
|
||||
// the async overload wins when both exist (no thread-block on init).
|
||||
_asyncMethod = type.GetMethod(
|
||||
"SendPasswordAsync",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
binder: null,
|
||||
types: [typeof(string), typeof(CancellationToken)],
|
||||
modifiers: null);
|
||||
_syncMethod = type.GetMethod(
|
||||
"SendPassword",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
binder: null,
|
||||
types: [typeof(string)],
|
||||
modifiers: null);
|
||||
}
|
||||
|
||||
public bool SupportsSendPassword => _asyncMethod is not null || _syncMethod is not null;
|
||||
|
||||
public async Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(password);
|
||||
if (_asyncMethod is not null)
|
||||
{
|
||||
// Unwrap TargetInvocationException so the caller sees the real S7.Net.PlcException
|
||||
// (or whatever the library threw) rather than the reflection wrapper.
|
||||
try
|
||||
{
|
||||
var result = _asyncMethod.Invoke(_plc, [password, cancellationToken]);
|
||||
if (result is Task task)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (TargetInvocationException tie) when (tie.InnerException is not null)
|
||||
{
|
||||
throw tie.InnerException;
|
||||
}
|
||||
}
|
||||
if (_syncMethod is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_syncMethod.Invoke(_plc, [password]);
|
||||
return true;
|
||||
}
|
||||
catch (TargetInvocationException tie) when (tie.InnerException is not null)
|
||||
{
|
||||
throw tie.InnerException;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — connection-level password + protection-level options-binding
|
||||
/// tests. Verifies the no-log invariant on <see cref="S7DriverOptions.ToString"/>,
|
||||
/// DTO round-trip on the JSON wire form, and the
|
||||
/// <see cref="IS7PlcAuthGate"/> dispatch contract that <see cref="S7Driver"/>
|
||||
/// uses to send the password right after <c>OpenAsync</c>. The live wire path
|
||||
/// (S7-1500 with a real protection password) is hardware-gated and exercised in
|
||||
/// a separate fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7PasswordOptionsTests
|
||||
{
|
||||
// ---- Defaults ----
|
||||
|
||||
[Fact]
|
||||
public void Default_Password_is_null_and_ProtectionLevel_is_Auto()
|
||||
{
|
||||
var opts = new S7DriverOptions();
|
||||
opts.Password.ShouldBeNull();
|
||||
opts.ProtectionLevel.ShouldBe(ProtectionLevel.Auto);
|
||||
}
|
||||
|
||||
// ---- ToString redaction (no-log invariant) ----
|
||||
|
||||
[Fact]
|
||||
public void ToString_redacts_Password_when_set()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.168.1.30",
|
||||
Password = "super-secret-123",
|
||||
ProtectionLevel = ProtectionLevel.Level3,
|
||||
};
|
||||
var s = opts.ToString();
|
||||
s.ShouldNotContain("super-secret-123");
|
||||
s.ShouldContain("***");
|
||||
s.ShouldContain("Level3");
|
||||
s.ShouldContain("192.168.1.30");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_emits_null_marker_when_Password_is_unset()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "192.168.1.30" };
|
||||
var s = opts.ToString();
|
||||
s.ShouldContain("Password = <null>");
|
||||
s.ShouldNotContain("***");
|
||||
}
|
||||
|
||||
// ---- DTO round-trip ----
|
||||
|
||||
[Fact]
|
||||
public void DTO_round_trip_preserves_Password_and_ProtectionLevel()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"Host": "192.168.1.30",
|
||||
"CpuType": "S71500",
|
||||
"Password": "p@ssw0rd",
|
||||
"ProtectionLevel": "ConnectionMechanism",
|
||||
"Tags": []
|
||||
}
|
||||
""";
|
||||
var drv = (S7Driver)S7DriverFactoryExtensions.CreateInstance("s7-pwd-dto", json);
|
||||
drv.ShouldNotBeNull();
|
||||
drv.DriverInstanceId.ShouldBe("s7-pwd-dto");
|
||||
drv.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DTO_round_trip_serialise_then_deserialise_preserves_Password_field()
|
||||
{
|
||||
var dto = new S7DriverFactoryExtensions.S7DriverConfigDto
|
||||
{
|
||||
Host = "10.0.0.5",
|
||||
Password = "rotational-secret",
|
||||
ProtectionLevel = "Level2",
|
||||
};
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
var back = JsonSerializer.Deserialize<S7DriverFactoryExtensions.S7DriverConfigDto>(json)!;
|
||||
back.Password.ShouldBe("rotational-secret");
|
||||
back.ProtectionLevel.ShouldBe("Level2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DTO_explicit_empty_Password_collapses_to_null()
|
||||
{
|
||||
// A typo'd "" Password must NOT try to send an empty password to the PLC.
|
||||
var json = """
|
||||
{
|
||||
"Host": "192.168.1.30",
|
||||
"Password": "",
|
||||
"Tags": []
|
||||
}
|
||||
""";
|
||||
// This goes through the factory -> options pipeline and would fail Init if the
|
||||
// empty-string slipped through. The factory is supposed to coerce to null; we
|
||||
// can't easily probe the bound options without InternalsVisibleTo to the test
|
||||
// factory, but we CAN verify the driver constructs successfully and Init-time
|
||||
// is the only place that would observe a non-null empty password.
|
||||
var drv = S7DriverFactoryExtensions.CreateInstance("s7-empty-pwd", json);
|
||||
drv.ShouldNotBeNull();
|
||||
drv.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DTO_unknown_ProtectionLevel_is_rejected()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"Host": "192.168.1.30",
|
||||
"ProtectionLevel": "MysteryMode",
|
||||
"Tags": []
|
||||
}
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
S7DriverFactoryExtensions.CreateInstance("s7-bad-level", json));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DTO_omitting_Password_and_ProtectionLevel_falls_back_to_defaults()
|
||||
{
|
||||
// Backwards compat: pre-PR-S7-E2 configs must keep loading.
|
||||
var json = """
|
||||
{
|
||||
"Host": "192.168.1.30",
|
||||
"Tags": []
|
||||
}
|
||||
""";
|
||||
var drv = S7DriverFactoryExtensions.CreateInstance("s7-legacy", json);
|
||||
drv.ShouldNotBeNull();
|
||||
drv.Dispose();
|
||||
}
|
||||
|
||||
// ---- Auth-gate dispatch contract ----
|
||||
|
||||
[Fact]
|
||||
public async Task Password_null_does_not_call_auth_gate()
|
||||
{
|
||||
var fake = new FakeAuthGate();
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(200),
|
||||
// Probe disabled so we don't need an open socket
|
||||
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-no-pwd") { AuthGate = fake };
|
||||
|
||||
// 192.0.2.1 (RFC 5737 TEST-NET-1) is unroutable, so OpenAsync will fail first.
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
// The point: even if Init failed at OpenAsync, the gate must NEVER have been
|
||||
// invoked because Password was null.
|
||||
fake.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_set_with_unsupported_gate_logs_warning_and_does_not_throw_at_password_step()
|
||||
{
|
||||
var fake = new FakeAuthGate { Supports = false };
|
||||
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||
const string secret = "ZZZ-distinct-secret-not-in-log-messages-ZZZ";
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(200),
|
||||
Password = secret,
|
||||
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||
};
|
||||
|
||||
// Drive the password code path directly using internals — the unit test seam
|
||||
// exposes Logger / AuthGate. We call the helper via reflection on the driver
|
||||
// method to keep coverage tight without standing up a real PLC.
|
||||
using var drv = new S7Driver(opts, "s7-pwd-no-support")
|
||||
{
|
||||
AuthGate = fake,
|
||||
Logger = capturingLogger,
|
||||
};
|
||||
|
||||
// 192.0.2.1 is unroutable, so InitializeAsync will fail at OpenAsync. The
|
||||
// password step is *after* OpenAsync, so we won't actually reach it through
|
||||
// InitializeAsync against a dead host. Instead drive the helper directly via
|
||||
// its reflection seam.
|
||||
var helper = typeof(S7Driver).GetMethod(
|
||||
"TrySendPlcPasswordAsync",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
helper.ShouldNotBeNull();
|
||||
|
||||
// Production helper expects a live S7.Net.Plc; pass null since the gate
|
||||
// override means we never dereference it. Method signature accepts Plc + ct.
|
||||
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||
await task;
|
||||
|
||||
fake.CallCount.ShouldBe(0); // gate.SupportsSendPassword=false → no call
|
||||
capturingLogger.Entries.ShouldContain(e =>
|
||||
e.Level == LogLevel.Warning &&
|
||||
e.Message.Contains("does not expose SendPassword"));
|
||||
// No-log invariant — secret value never appears.
|
||||
capturingLogger.Entries.ShouldNotContain(e => e.Message.Contains(secret));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_set_with_supported_gate_invokes_gate_and_logs_success()
|
||||
{
|
||||
var fake = new FakeAuthGate { Supports = true };
|
||||
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Password = "rotational-secret",
|
||||
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-pwd-ok")
|
||||
{
|
||||
AuthGate = fake,
|
||||
Logger = capturingLogger,
|
||||
};
|
||||
|
||||
var helper = typeof(S7Driver).GetMethod(
|
||||
"TrySendPlcPasswordAsync",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||
await task;
|
||||
|
||||
fake.CallCount.ShouldBe(1);
|
||||
fake.LastPassword.ShouldBe("rotational-secret");
|
||||
capturingLogger.Entries.ShouldContain(e =>
|
||||
e.Level == LogLevel.Information &&
|
||||
e.Message.Contains("S7 password sent"));
|
||||
// No-log invariant.
|
||||
capturingLogger.Entries.ShouldNotContain(e => e.Message.Contains("rotational-secret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_send_throwing_propagates_clean_InvalidOperationException()
|
||||
{
|
||||
var fake = new FakeAuthGate
|
||||
{
|
||||
Supports = true,
|
||||
ThrowOnSend = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongCPU_Type),
|
||||
};
|
||||
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Password = "wrong-pwd",
|
||||
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-pwd-bad")
|
||||
{
|
||||
AuthGate = fake,
|
||||
Logger = capturingLogger,
|
||||
};
|
||||
|
||||
var helper = typeof(S7Driver).GetMethod(
|
||||
"TrySendPlcPasswordAsync",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
// Direct invoke surfaces TargetInvocationException for synchronous throws; for
|
||||
// an async helper the exception flows through the returned Task, so await it.
|
||||
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () => await task);
|
||||
ex.Message.ShouldContain("password authentication failed");
|
||||
// Inner exception preserved for diagnostics.
|
||||
ex.InnerException.ShouldBeOfType<global::S7.Net.PlcException>();
|
||||
// No-log invariant on the exception message itself.
|
||||
ex.Message.ShouldNotContain("wrong-pwd");
|
||||
}
|
||||
|
||||
// ---- Reflection probe sanity (production gate against current S7netplus 0.20) ----
|
||||
|
||||
[Fact]
|
||||
public void Reflection_gate_against_S7netplus_0_20_reports_unsupported()
|
||||
{
|
||||
// PR-S7-E2 documented limitation: S7netplus 0.20 does not expose SendPassword.
|
||||
// The reflective probe must report SupportsSendPassword=false on a real Plc
|
||||
// instance built against the pinned package. This test pins the limitation —
|
||||
// when S7netplus ships SendPassword in a future minor release, the test breaks
|
||||
// and signals the team to remove the warning path.
|
||||
var plc = new global::S7.Net.Plc(global::S7.Net.CpuType.S71500, "127.0.0.1", 0, 0);
|
||||
var gate = new ReflectionS7PlcAuthGate(plc);
|
||||
gate.SupportsSendPassword.ShouldBeFalse(
|
||||
"S7netplus 0.20 does not expose SendPassword. If this assertion fails, " +
|
||||
"S7netplus has added the API — update docs/v2/s7.md \"PLC password / " +
|
||||
"protection levels\" library-limitation note and remove the warning path.");
|
||||
}
|
||||
|
||||
// ---- Test doubles ----
|
||||
|
||||
private sealed class FakeAuthGate : IS7PlcAuthGate
|
||||
{
|
||||
public bool Supports { get; init; } = true;
|
||||
public bool SupportsSendPassword => Supports;
|
||||
|
||||
public Exception? ThrowOnSend { get; init; }
|
||||
public int CallCount { get; private set; }
|
||||
public string? LastPassword { get; private set; }
|
||||
|
||||
public Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Supports) return Task.FromResult(false);
|
||||
CallCount++;
|
||||
LastPassword = password;
|
||||
if (ThrowOnSend is not null) throw ThrowOnSend;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CapturedLogEntry(LogLevel Level, string Message);
|
||||
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<CapturedLogEntry> Entries { get; } = new();
|
||||
|
||||
IDisposable? ILogger.BeginScope<TState>(TState state) => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add(new CapturedLogEntry(logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user