diff --git a/docs/Authentication.md b/docs/Authentication.md index 3fc0074..d864bb3 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -105,6 +105,8 @@ authorization code consumes. The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data. +The database path is `GatewayOptions.Authentication.SqlitePath`. Its code default is derived from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) so the credential store is never written relative to the launch working directory on a non-Windows host. The production hosts pin the explicit Windows path in `appsettings.json`. `GatewayOptionsValidator` rejects a non-rooted (relative) `SqlitePath` so a bad override fails fast at startup rather than scattering the store by launch CWD (SEC-01). + ### Connection factory `AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`: diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 005eb5a..7304b7e 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -93,12 +93,15 @@ Environment variables use the normal .NET double-underscore form. For example, | Option | Default | Description | |--------|---------|-------------| | `MxGateway:Authentication:Mode` | `ApiKey` | Selects public gRPC authentication. Supported values are `ApiKey` and `Disabled`. `Disabled` bypasses API-key verification and is for local development only. | -| `MxGateway:Authentication:SqlitePath` | `C:\ProgramData\MxGateway\gateway-auth.db` | SQLite database path for API-key records and audit rows when API-key authentication is enabled. | +| `MxGateway:Authentication:SqlitePath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) | SQLite database path for API-key records and audit rows when API-key authentication is enabled. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so the credential store never lands in the launch working directory on a non-Windows host; the production hosts pin the explicit Windows path in `appsettings.json`, which overrides the code default. | | `MxGateway:Authentication:PepperSecretName` | `MxGateway:ApiKeyPepper` | Configuration key used to read the HMAC pepper for API-key secret hashing. The dashboard effective configuration redacts this value. | | `MxGateway:Authentication:RunMigrationsOnStartup` | `true` | Runs SQLite auth schema migrations at gateway startup when API-key authentication is enabled. | When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present. -`SqlitePath` must be a valid filesystem path. +`SqlitePath` must be a valid filesystem path and must be **rooted** (absolute): +the validator rejects a non-rooted path so a relative override cannot silently +resolve against the working directory and scatter the credential store by launch +CWD (SEC-01). ## Worker Options @@ -177,7 +180,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed. | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:ShowTagValues` | `false` | Reserved display control for tag values. The dashboard does not show full tag values by default. | | `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | -| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. | +| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. | | `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. | `SnapshotIntervalMilliseconds` must be greater than zero. `RecentFaultLimit` @@ -219,6 +222,31 @@ When the dashboard is enabled, three hubs are mapped under `/hubs/*`: token for the calling user; the Blazor pages use it via `DashboardHubConnectionFactory` to authenticate the SignalR connection. +## LDAP Options (Dashboard Login) + +The `MxGateway:Ldap` section configures the LDAP bind used by dashboard `/login` +(the gRPC API-key model is separate). The shipped defaults are the shared +dev/test GLAuth posture (`glauth.md`), not a production posture. + +| Option | Default | Description | +|--------|---------|-------------| +| `MxGateway:Ldap:Enabled` | `true` | Enables LDAP-backed dashboard login. | +| `MxGateway:Ldap:Server` | `localhost` | LDAP server host. Dev points at the shared GLAuth instance (`10.100.0.35`). | +| `MxGateway:Ldap:Port` | `3893` | LDAP server port. Must be between `1` and `65535`. | +| `MxGateway:Ldap:Transport` | `None` | Connection transport: `None` (plaintext), `StartTls` (upgrade), or `Ldaps` (implicit TLS). The dev default is `None` because the shared GLAuth instance has LDAPS disabled and binds send cleartext. **Production hard-stop (SEC-06):** when the host runs in the `Production` environment, `Transport` must not be `None` — startup validation fails otherwise. Deployed hosts must set `Ldaps` or `StartTls`. | +| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. | +| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. | +| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. | +| `MxGateway:Ldap:ServiceAccountPassword` | `serviceaccount123` | Search-account password. **Dev-only committed value.** On the deployed hosts, do not ship this in `appsettings.json`; supply it out-of-band via the env-var override `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form, as set on the NSSM-wrapped services). The committed `serviceaccount123` is a shared dev credential for the local GLAuth instance and should be rotated; production must use a secret it does not share with dev. | +| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. | +| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. | +| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). | + +When LDAP is enabled, `Server`, `SearchBase`, `ServiceAccountDn`, +`ServiceAccountPassword`, and the attribute names must be non-blank, and `Port` +must be in range. See `glauth.md` for the shared dev instance and the +dev→production hardening posture. + ## Protocol Options | Option | Default | Description | @@ -238,7 +266,7 @@ at startup. | `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. | | `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. | | `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists the latest successful Galaxy browse dataset to disk. When `true`, the cache reloads that snapshot at startup so clients can still browse last-known data while the Galaxy database is unreachable. The restored data is served with `Stale` status until a live query confirms it. | -| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). | +| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). Set an **absolute** path — this option is bound by the shared `ZB.MOM.WW.GalaxyRepository` package (not by `GatewayOptions`), so the gateway validator does not enforce rooting on it; a relative value would resolve against the launch working directory (SEC-01). | See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and behavior. @@ -442,7 +470,7 @@ them. | Option | Default | Purpose | |---|---|---| -| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted | +| `Tls:SelfSignedCertPath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` on Windows, `/usr/share/MxGateway/certs/gateway-selfsigned.pfx` or the container equivalent elsewhere) | Where the generated certificate is persisted. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so a generated private key never lands in the launch working directory on a non-Windows host (SEC-01). Must be **rooted** (absolute); the validator rejects a non-rooted override. | | `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1–100) | | `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) | | `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing | diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs index 534cdef..767daf1 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs @@ -5,8 +5,18 @@ public sealed class AuthenticationOptions /// Gets the authentication mode. public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey; - /// Gets the SQLite database path for authentication credentials. - public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db"; + /// + /// Gets the SQLite database path for authentication credentials. The default is + /// derived from + /// (C:\ProgramData on Windows, /usr/share or the container equivalent + /// elsewhere) so the credential store never lands in the launch working directory on a + /// non-Windows host. The production hosts override this with an explicit path in + /// appsettings.json; the validator rejects a non-rooted override. + /// + public string SqlitePath { get; init; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "MxGateway", + "gateway-auth.db"); /// Gets the secret manager name for API key pepper. public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper"; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index e5b8813..686b168 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -9,15 +9,42 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase + /// Initializes a new instance of the class for the + /// dependency-injection path, deriving the production posture from the host environment. + /// + /// The host environment. + public GatewayOptionsValidator(IHostEnvironment environment) + { + ArgumentNullException.ThrowIfNull(environment); + _isProduction = environment.IsProduction(); + } + + /// + /// Initializes a new instance of the class for unit + /// tests and non-DI callers. Defaults to a non-production posture so the production-only + /// hard-stops do not fire; pass to exercise them. + /// + /// Whether to treat the host as running in Production. + internal GatewayOptionsValidator(bool isProduction = false) + { + _isProduction = isProduction; + } + /// protected override void Validate(ValidationBuilder builder, GatewayOptions options) { ValidateAuthentication(options.Authentication, builder); - ValidateLdap(options.Ldap, builder); + ValidateLdap(options.Ldap, builder, _isProduction); ValidateWorker(options.Worker, builder); ValidateSessions(options.Sessions, builder); ValidateEvents(options.Events, builder); - ValidateDashboard(options.Dashboard, builder); + ValidateDashboard(options.Dashboard, builder, _isProduction); ValidateProtocol(options.Protocol, builder); ValidateAlarms(options.Alarms, builder); ValidateTls(options.Tls, builder); @@ -41,6 +68,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase= 0, message); } + private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder) + { + // Security-sensitive paths (the auth DB, the self-signed private key) must be absolute: + // a non-rooted value silently resolves against the launch working directory, so the store + // moves with the CWD and can leak into the source tree. Reject rather than auto-root — + // silent relocation of a credential store is worse than a boot error. Blank is handled by + // AddIfBlank; an empty value is not treated as non-rooted here. + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (!Path.IsPathRooted(value)) + { + builder.Add(message); + } + } + private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs index 9be84a3..4099749 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs @@ -7,9 +7,19 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration; /// public sealed class TlsOptions { - /// Path to the persisted self-signed PFX. Reused across restarts. - public string SelfSignedCertPath { get; init; } = - @"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx"; + /// + /// Path to the persisted self-signed PFX. Reused across restarts. The default is derived + /// from (C:\ProgramData + /// on Windows, /usr/share or the container equivalent elsewhere) so a generated + /// private key never lands in the launch working directory on a non-Windows host. The + /// production hosts override this with an explicit path; the validator rejects a non-rooted + /// override. + /// + public string SelfSignedCertPath { get; init; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "MxGateway", + "certs", + "gateway-selfsigned.pfx"); /// Lifetime in years of a freshly generated certificate. public int ValidityYears { get; init; } = 10; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index e8646eb..159203c 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server.Configuration; +using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; namespace ZB.MOM.WW.MxGateway.Tests.Configuration; @@ -548,4 +549,138 @@ public sealed class GatewayOptionsValidatorTests ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); Assert.True(result.Succeeded); } + + // ------------------------------------------------------------------------- + // SEC-01: security-sensitive paths must be rooted (absolute) + // ------------------------------------------------------------------------- + + private static GatewayOptions CloneWithAuthentication(GatewayOptions source, AuthenticationOptions authentication) + => new() + { + Authentication = authentication, + Ldap = source.Ldap, + Worker = source.Worker, + Sessions = source.Sessions, + Events = source.Events, + Dashboard = source.Dashboard, + Protocol = source.Protocol, + Alarms = source.Alarms, + Tls = source.Tls, + }; + + /// Verifies the default (CommonApplicationData-derived) auth DB path is rooted and passes validation. + [Fact] + public void Validate_Succeeds_WithDefaultRootedSqlitePath() + { + // The default AuthenticationOptions.SqlitePath is derived from CommonApplicationData, + // which is rooted on every host OS. + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions()); + Assert.True(result.Succeeded); + } + + /// Verifies a non-rooted fails validation. + [Fact] + public void Validate_Fails_WhenSqlitePathNotRooted() + { + GatewayOptions options = CloneWithAuthentication( + ValidOptions(), + new AuthenticationOptions { SqlitePath = "gateway-auth.db" }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted")); + } + + /// Verifies a non-rooted fails validation. + [Fact] + public void Validate_Fails_WhenSelfSignedCertPathNotRooted() + { + GatewayOptions options = CloneWithTls( + ValidOptions(), + new TlsOptions { SelfSignedCertPath = "gateway-selfsigned.pfx" }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Tls:SelfSignedCertPath") && f.Contains("rooted")); + } + + // ------------------------------------------------------------------------- + // SEC-04: DisableLogin production guard + // ------------------------------------------------------------------------- + + private static GatewayOptions CloneWithDashboard(GatewayOptions source, DashboardOptions dashboard) + => new() + { + Authentication = source.Authentication, + Ldap = source.Ldap, + Worker = source.Worker, + Sessions = source.Sessions, + Events = source.Events, + Dashboard = dashboard, + Protocol = source.Protocol, + Alarms = source.Alarms, + Tls = source.Tls, + }; + + /// Verifies set to aborts startup in Production. + [Fact] + public void Validate_Fails_WhenDisableLoginTrueInProduction() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions { DisableLogin = true }); + ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Dashboard:DisableLogin") && f.Contains("Production")); + } + + /// Verifies set to is accepted outside Production. + [Fact] + public void Validate_Succeeds_WhenDisableLoginTrueInDevelopment() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions { DisableLogin = true }); + ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, options); + Assert.True(result.Succeeded); + } + + // ------------------------------------------------------------------------- + // SEC-06: LDAP plaintext transport production guard + // ------------------------------------------------------------------------- + + /// Verifies plaintext LDAP transport (None) aborts startup in Production. + [Fact] + public void Validate_Fails_WhenLdapTransportNoneInProduction() + { + // The class default LDAP options ship Transport=None + AllowInsecure=true (dev posture). + ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, ValidOptions()); + Assert.True(result.Failed); + Assert.Contains( + result.Failures!, + f => f.Contains("MxGateway:Ldap:Transport") && f.Contains("Production")); + } + + /// Verifies plaintext LDAP transport (None) is accepted outside Production. + [Fact] + public void Validate_Succeeds_WhenLdapTransportNoneInDevelopment() + { + ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, ValidOptions()); + Assert.True(result.Succeeded); + } + + /// Verifies secure LDAP transport (Ldaps) passes validation in Production. + [Fact] + public void Validate_Succeeds_WhenLdapTransportLdapsInProduction() + { + GatewayOptions options = CloneWithLdap( + ValidOptions(), + new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps, AllowInsecure = false }); + ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options); + Assert.True(result.Succeeded); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayTreeHygieneTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayTreeHygieneTests.cs new file mode 100644 index 0000000..4505874 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayTreeHygieneTests.cs @@ -0,0 +1,54 @@ +namespace ZB.MOM.WW.MxGateway.Tests.ProjectStructure; + +/// +/// Repo-hygiene guards over the source tree. See SEC-01: a Windows-absolute default path that +/// resolves relative to the launch working directory silently materializes a SQLite credential +/// store inside the tree. This test fails if any such *.db file appears under src/. +/// +public sealed class GatewayTreeHygieneTests +{ + /// Verifies no SQLite database files are committed/materialized under the source tree (build output excluded). + [Fact] + public void SourceTree_ContainsNoSqliteDatabaseFiles() + { + DirectoryInfo sourceRoot = FindSourceRoot(); + + string[] databaseFiles = Directory + .EnumerateFiles(sourceRoot.FullName, "*.db", SearchOption.AllDirectories) + .Where(path => !IsBuildOutput(path)) + .ToArray(); + + Assert.True( + databaseFiles.Length == 0, + "Unexpected *.db file(s) found under the source tree (build output excluded): " + + string.Join(", ", databaseFiles) + + ". A SQLite DB in the tree usually means a Windows-absolute default path resolved " + + "relative to the working directory; see SEC-01."); + } + + // Build output (bin/obj) is regenerated by test/compile runs and is gitignored, so exclude it. + private static bool IsBuildOutput(string path) + { + string normalized = path.Replace('\\', '/'); + return normalized.Contains("/bin/", StringComparison.Ordinal) + || normalized.Contains("/obj/", StringComparison.Ordinal); + } + + // The directory holding ZB.MOM.WW.MxGateway.slnx is the src/ root of the working tree. + private static DirectoryInfo FindSourceRoot() + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "ZB.MOM.WW.MxGateway.slnx"))) + { + return current; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.MxGateway.slnx from the test output directory."); + } +}