feat: add audited one-shot prepare gate
This commit is contained in:
@@ -71,12 +71,23 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
|
||||
private readonly ILateBoundComActivator _activator;
|
||||
private readonly ILateBoundComMethodInvoker _invoker;
|
||||
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
|
||||
private readonly bool _rollbackOnPrepareFailure;
|
||||
|
||||
public DynamicK3dSessionFactory()
|
||||
: this(
|
||||
new InstalledK3dInteropComActivator(),
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new InstalledK3dEventHandlerFactory())
|
||||
new InstalledK3dEventHandlerFactory(),
|
||||
rollbackOnPrepareFailure: true)
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSessionFactory(bool rollbackOnPrepareFailure)
|
||||
: this(
|
||||
new InstalledK3dInteropComActivator(),
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new InstalledK3dEventHandlerFactory(),
|
||||
rollbackOnPrepareFailure)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -84,32 +95,40 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
|
||||
: this(
|
||||
activator,
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new ActivatorK3dEventHandlerFactory(activator))
|
||||
new ActivatorK3dEventHandlerFactory(activator),
|
||||
rollbackOnPrepareFailure: true)
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSessionFactory(
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComMethodInvoker invoker)
|
||||
: this(activator, invoker, new ActivatorK3dEventHandlerFactory(activator))
|
||||
: this(
|
||||
activator,
|
||||
invoker,
|
||||
new ActivatorK3dEventHandlerFactory(activator),
|
||||
rollbackOnPrepareFailure: true)
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSessionFactory(
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComMethodInvoker invoker,
|
||||
IK3dEventHandlerFactory eventHandlerFactory)
|
||||
IK3dEventHandlerFactory eventHandlerFactory,
|
||||
bool rollbackOnPrepareFailure = true)
|
||||
{
|
||||
_activator = activator;
|
||||
_invoker = invoker;
|
||||
_eventHandlerFactory = eventHandlerFactory;
|
||||
_rollbackOnPrepareFailure = rollbackOnPrepareFailure;
|
||||
}
|
||||
|
||||
public IK3dSession Create() => new DynamicK3dSession(
|
||||
_activator,
|
||||
new RuntimeComObjectReleaser(),
|
||||
_invoker,
|
||||
_eventHandlerFactory);
|
||||
_eventHandlerFactory,
|
||||
_rollbackOnPrepareFailure);
|
||||
}
|
||||
|
||||
internal interface ILateBoundComActivator
|
||||
@@ -155,6 +174,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
||||
private readonly ILateBoundComObjectReleaser _releaser;
|
||||
private readonly ILateBoundComMethodInvoker _invoker;
|
||||
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
|
||||
private readonly bool _rollbackOnPrepareFailure;
|
||||
private readonly K3dEventQueue _eventQueue = new();
|
||||
private object? _engine;
|
||||
private object? _eventHandler;
|
||||
@@ -177,7 +197,8 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
||||
activator,
|
||||
new RuntimeComObjectReleaser(),
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new ActivatorK3dEventHandlerFactory(activator))
|
||||
new ActivatorK3dEventHandlerFactory(activator),
|
||||
rollbackOnPrepareFailure: true)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -204,12 +225,14 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComObjectReleaser releaser,
|
||||
ILateBoundComMethodInvoker invoker,
|
||||
IK3dEventHandlerFactory eventHandlerFactory)
|
||||
IK3dEventHandlerFactory eventHandlerFactory,
|
||||
bool rollbackOnPrepareFailure = true)
|
||||
{
|
||||
_activator = activator;
|
||||
_releaser = releaser;
|
||||
_invoker = invoker;
|
||||
_eventHandlerFactory = eventHandlerFactory;
|
||||
_rollbackOnPrepareFailure = rollbackOnPrepareFailure;
|
||||
}
|
||||
|
||||
public bool IsConnected => _engine is not null && _player is not null;
|
||||
@@ -484,7 +507,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transactionStarted)
|
||||
if (transactionStarted && _rollbackOnPrepareFailure)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,32 +1,135 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
using System.Net;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout;
|
||||
|
||||
public static class PlayoutEngineFactory
|
||||
{
|
||||
public static IPlayoutEngine CreateDefault() =>
|
||||
Create(PlayoutOptionsLoader.Load());
|
||||
Create(
|
||||
PlayoutOptionsLoader.Load(),
|
||||
PlayoutLaunchAuthorization.CaptureFromEnvironment());
|
||||
|
||||
public static IPlayoutEngine Create(PlayoutOptions options)
|
||||
public static IPlayoutEngine Create(PlayoutOptions options) =>
|
||||
Create(options, PlayoutLaunchAuthorization.CaptureFromEnvironment());
|
||||
|
||||
public static IPlayoutEngine Create(
|
||||
PlayoutOptions options,
|
||||
PlayoutLaunchAuthorization launchAuthorization)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(launchAuthorization);
|
||||
ValidateGateAOptions(options, launchAuthorization);
|
||||
var validated = ValidatedPlayoutOptions.Create(options);
|
||||
ITornadoProcessProbe processProbe = new TornadoProcessProbe();
|
||||
Func<bool>? finalConnectSafetyCheck = null;
|
||||
var abandonOnTargetMismatch = false;
|
||||
var startMonitor = validated.Mode != PlayoutMode.Disabled;
|
||||
|
||||
if (launchAuthorization.IsGateA)
|
||||
{
|
||||
var safetyProbe = new WindowsPgmDiagnosticSafetyProbe();
|
||||
var request = new PgmConnectDiagnosticRequest(
|
||||
validated.Host,
|
||||
validated.Port,
|
||||
"PGM");
|
||||
var parsedHost = IPAddress.Parse(validated.Host);
|
||||
var baseline = safetyProbe.Capture(request, parsedHost);
|
||||
if (!baseline.IsEligible ||
|
||||
baseline.Identity is not { } identity ||
|
||||
identity.ProcessId != launchAuthorization.ExpectedPgmProcessId ||
|
||||
identity.StartTimeUtcTicks !=
|
||||
launchAuthorization.ExpectedPgmStartTimeUtcTicks)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"Gate A could not confirm the exact approved PGM process and listener.");
|
||||
}
|
||||
|
||||
bool HasExactTarget()
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = safetyProbe.Capture(request, parsedHost);
|
||||
return current.IsEligible && baseline.HasSameTarget(current);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
processProbe = new PgmCutsSequenceProcessProbe(
|
||||
safetyProbe,
|
||||
request,
|
||||
parsedHost,
|
||||
baseline);
|
||||
finalConnectSafetyCheck = HasExactTarget;
|
||||
abandonOnTargetMismatch = true;
|
||||
startMonitor = false;
|
||||
}
|
||||
|
||||
IStaDispatcher? dispatcher = validated.Mode is PlayoutMode.Test or PlayoutMode.Live
|
||||
? new StaDispatcher(validated.QueueCapacity)
|
||||
: null;
|
||||
return new TornadoPlayoutEngine(
|
||||
validated,
|
||||
new DynamicK3dSessionFactory(),
|
||||
new DynamicK3dSessionFactory(
|
||||
rollbackOnPrepareFailure: !launchAuthorization.IsGateA),
|
||||
new K3dRegistrationProbe(),
|
||||
new TornadoProcessProbe(),
|
||||
processProbe,
|
||||
dispatcher,
|
||||
new EnvironmentLiveAuthorization(),
|
||||
TimeProvider.System);
|
||||
launchAuthorization,
|
||||
TimeProvider.System,
|
||||
startMonitor,
|
||||
finalConnectSafetyCheck,
|
||||
abandonOnTargetMismatch,
|
||||
beforeSdkDispatchClaim: launchAuthorization.IsGateA
|
||||
? launchAuthorization.EnsureGateASdkDispatchAuthorized
|
||||
: null,
|
||||
launchAuthorization: launchAuthorization);
|
||||
}
|
||||
|
||||
internal static void ValidateGateAOptions(
|
||||
PlayoutOptions options,
|
||||
PlayoutLaunchAuthorization launchAuthorization)
|
||||
{
|
||||
if (!launchAuthorization.IsGateA)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var allowlist = (options.TestSceneAllowlist ?? [])
|
||||
.Select(static value => value?.Trim())
|
||||
.ToArray();
|
||||
if (options.Mode != PlayoutMode.Live ||
|
||||
!options.TrustedLiveOutputEnabled ||
|
||||
!string.Equals(options.Host?.Trim(), "127.0.0.1", StringComparison.Ordinal) ||
|
||||
options.Port != 30001 ||
|
||||
options.TcpMode != 1 ||
|
||||
options.ClientPort != 0 ||
|
||||
options.OutputChannel is not null ||
|
||||
options.LayoutIndex != 10 ||
|
||||
!PlayoutLaunchAuthorization.IsExactGateASceneDirectory(
|
||||
options.SceneDirectory) ||
|
||||
allowlist.Length != 1 ||
|
||||
!string.Equals(allowlist[0], "5001", StringComparison.Ordinal) ||
|
||||
options.ReconnectEnabled ||
|
||||
options.MaximumReconnectAttempts != 0 ||
|
||||
options.MaximumAutomaticRefreshesPerTakeIn != 0 ||
|
||||
options.LegacySceneFadeDuration != 6 ||
|
||||
options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None ||
|
||||
!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"Gate A requires the exact closed 5001-only Live configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
/// <summary>
|
||||
/// Process-lifetime authorization captured before WebView2 is created. Gate A is
|
||||
/// deliberately narrower than normal Live mode: one CONNECT and one claimed
|
||||
/// PREPARE of the approved 5001 cut are the only requests that can cross the
|
||||
/// native engine boundary.
|
||||
/// </summary>
|
||||
public sealed class PlayoutLaunchAuthorization : ILiveAuthorization, IDisposable
|
||||
{
|
||||
public const string GateACapabilityEnvironmentVariable =
|
||||
"MBN_LEGACY_PGM_GATE_A_PREPARE_CAPABILITY";
|
||||
|
||||
public const string GateAPgmProcessIdEnvironmentVariable =
|
||||
"MBN_LEGACY_PGM_GATE_A_PGM_PID";
|
||||
|
||||
public const string GateAPgmStartTimeUtcTicksEnvironmentVariable =
|
||||
"MBN_LEGACY_PGM_GATE_A_PGM_START_TIME_UTC_TICKS";
|
||||
|
||||
public const string GateAExpiresAtUtcTicksEnvironmentVariable =
|
||||
"MBN_LEGACY_PGM_GATE_A_EXPIRES_AT_UTC_TICKS";
|
||||
|
||||
public const string GateAApprovedSceneFilePath =
|
||||
@"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts\5001.t2s";
|
||||
|
||||
public const string GateAApprovedSceneSha256 =
|
||||
"99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7";
|
||||
|
||||
private const int GateAArmed = 0;
|
||||
private const int GateAOperatorClaimed = 1;
|
||||
private const int GateAEnginePrepareClaimed = 2;
|
||||
private const int GateATerminal = 3;
|
||||
private const int CapabilityLength = 64;
|
||||
private static readonly long MaximumGateALifetimeTicks = TimeSpan.FromMinutes(15).Ticks;
|
||||
private static readonly object GateACaptureSync = new();
|
||||
private static bool _gateAObservedForProcess;
|
||||
|
||||
private readonly object _gateASync = new();
|
||||
private readonly byte[]? _gateACapabilityHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly long _authorizationCapturedTimestamp;
|
||||
private readonly TimeSpan _authorizedLifetime;
|
||||
private FileStream? _gateASceneLease;
|
||||
private int _gateAState;
|
||||
private bool _connectClaimed;
|
||||
private bool _capabilityHashCleared;
|
||||
|
||||
private PlayoutLaunchAuthorization(
|
||||
bool liveAuthorized,
|
||||
byte[]? gateACapabilityHash,
|
||||
int? expectedPgmProcessId,
|
||||
long? expectedPgmStartTimeUtcTicks,
|
||||
long? expiresAtUtcTicks,
|
||||
TimeProvider timeProvider,
|
||||
long authorizationCapturedTimestamp,
|
||||
TimeSpan authorizedLifetime,
|
||||
FileStream? gateASceneLease)
|
||||
{
|
||||
IsAuthorizedForThisLaunch = liveAuthorized;
|
||||
_gateACapabilityHash = gateACapabilityHash;
|
||||
ExpectedPgmProcessId = expectedPgmProcessId;
|
||||
ExpectedPgmStartTimeUtcTicks = expectedPgmStartTimeUtcTicks;
|
||||
ExpiresAtUtcTicks = expiresAtUtcTicks;
|
||||
_timeProvider = timeProvider;
|
||||
_authorizationCapturedTimestamp = authorizationCapturedTimestamp;
|
||||
_authorizedLifetime = authorizedLifetime;
|
||||
_gateASceneLease = gateASceneLease;
|
||||
}
|
||||
|
||||
public bool IsAuthorizedForThisLaunch { get; }
|
||||
|
||||
public bool IsGateA => _gateACapabilityHash is not null;
|
||||
|
||||
public bool AllowsDatabaseWrites => !IsGateA;
|
||||
|
||||
public bool AllowsCompositionMutation => !IsGateA;
|
||||
|
||||
public int? ExpectedPgmProcessId { get; }
|
||||
|
||||
public long? ExpectedPgmStartTimeUtcTicks { get; }
|
||||
|
||||
public long? ExpiresAtUtcTicks { get; }
|
||||
|
||||
public static string GateAApprovedSceneDirectory =>
|
||||
Path.GetDirectoryName(GateAApprovedSceneFilePath)!;
|
||||
|
||||
internal bool AllowsAutomaticReconnect => !IsGateA;
|
||||
|
||||
internal bool AllowsSdkDisconnect => !IsGateA;
|
||||
|
||||
internal static PlayoutLaunchAuthorization Unrestricted { get; } =
|
||||
new(
|
||||
liveAuthorized: false,
|
||||
gateACapabilityHash: null,
|
||||
expectedPgmProcessId: null,
|
||||
expectedPgmStartTimeUtcTicks: null,
|
||||
expiresAtUtcTicks: null,
|
||||
TimeProvider.System,
|
||||
authorizationCapturedTimestamp: 0,
|
||||
authorizedLifetime: TimeSpan.Zero,
|
||||
gateASceneLease: null);
|
||||
|
||||
/// <summary>
|
||||
/// Captures all authorization material once, removes the bearer values from
|
||||
/// the process environment, then acquires and hashes the approved cut through
|
||||
/// a read-only/non-write-sharing handle. The handle remains owned by this
|
||||
/// authorization for the native application lifetime.
|
||||
/// </summary>
|
||||
public static PlayoutLaunchAuthorization CaptureFromEnvironment() =>
|
||||
CaptureFromEnvironment(
|
||||
TimeProvider.System,
|
||||
GateAApprovedSceneFilePath,
|
||||
GateAApprovedSceneSha256);
|
||||
|
||||
internal static PlayoutLaunchAuthorization CaptureFromEnvironment(
|
||||
TimeProvider timeProvider,
|
||||
string approvedSceneFilePath,
|
||||
string approvedSceneSha256)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(approvedSceneFilePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(approvedSceneSha256);
|
||||
|
||||
lock (GateACaptureSync)
|
||||
{
|
||||
return CaptureFromEnvironmentLocked(
|
||||
timeProvider,
|
||||
approvedSceneFilePath,
|
||||
approvedSceneSha256);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ResetGateACaptureLatchForTests()
|
||||
{
|
||||
lock (GateACaptureSync)
|
||||
{
|
||||
_gateAObservedForProcess = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayoutLaunchAuthorization CaptureFromEnvironmentLocked(
|
||||
TimeProvider timeProvider,
|
||||
string approvedSceneFilePath,
|
||||
string approvedSceneSha256)
|
||||
{
|
||||
var liveAuthorized = PlayoutOptionsLoader.IsLiveAuthorizedForThisLaunch();
|
||||
var capability = Environment.GetEnvironmentVariable(
|
||||
GateACapabilityEnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process);
|
||||
var processIdText = Environment.GetEnvironmentVariable(
|
||||
GateAPgmProcessIdEnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process);
|
||||
var startTimeText = Environment.GetEnvironmentVariable(
|
||||
GateAPgmStartTimeUtcTicksEnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process);
|
||||
var expiresAtText = Environment.GetEnvironmentVariable(
|
||||
GateAExpiresAtUtcTicksEnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process);
|
||||
var anyGateAValue = capability is not null ||
|
||||
processIdText is not null ||
|
||||
startTimeText is not null ||
|
||||
expiresAtText is not null;
|
||||
var gateAWasAlreadyObserved = _gateAObservedForProcess;
|
||||
if (anyGateAValue)
|
||||
{
|
||||
// Latch before validation, cleanup, or file I/O so even an exception in
|
||||
// those paths cannot make a second process-local capture eligible.
|
||||
_gateAObservedForProcess = true;
|
||||
}
|
||||
|
||||
ClearGateAEnvironment();
|
||||
if (gateAWasAlreadyObserved || anyGateAValue)
|
||||
{
|
||||
ClearLiveAuthorizationEnvironment();
|
||||
}
|
||||
|
||||
if (gateAWasAlreadyObserved)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"Gate A launch authorization was already observed by this process.");
|
||||
}
|
||||
|
||||
if (!anyGateAValue)
|
||||
{
|
||||
return new PlayoutLaunchAuthorization(
|
||||
liveAuthorized,
|
||||
gateACapabilityHash: null,
|
||||
expectedPgmProcessId: null,
|
||||
expectedPgmStartTimeUtcTicks: null,
|
||||
expiresAtUtcTicks: null,
|
||||
timeProvider,
|
||||
authorizationCapturedTimestamp: 0,
|
||||
authorizedLifetime: TimeSpan.Zero,
|
||||
gateASceneLease: null);
|
||||
}
|
||||
|
||||
// Observing any Gate A launch value permanently consumes the process-wide
|
||||
// capture slot before validation or file I/O. A malformed, expired, or
|
||||
// otherwise rejected first attempt must never be corrected or re-injected
|
||||
// within the same native process.
|
||||
// The already-captured Live decision is retained only in the returned native
|
||||
// object. Its static bearer was removed above before validation or file I/O.
|
||||
|
||||
var nowUtcTicks = timeProvider.GetUtcNow().UtcTicks;
|
||||
var capturedTimestamp = timeProvider.GetTimestamp();
|
||||
if (!IsExactCapability(capability) ||
|
||||
!int.TryParse(
|
||||
processIdText,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var processId) ||
|
||||
processId <= 0 ||
|
||||
!long.TryParse(
|
||||
startTimeText,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var startTimeUtcTicks) ||
|
||||
startTimeUtcTicks <= 0 ||
|
||||
!long.TryParse(
|
||||
expiresAtText,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var expiresAtUtcTicks) ||
|
||||
expiresAtUtcTicks <= nowUtcTicks ||
|
||||
expiresAtUtcTicks - nowUtcTicks > MaximumGateALifetimeTicks)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"Gate A launch authorization is incomplete, expired, or invalid.");
|
||||
}
|
||||
|
||||
var sceneLease = AcquireApprovedSceneLease(
|
||||
approvedSceneFilePath,
|
||||
approvedSceneSha256);
|
||||
return new PlayoutLaunchAuthorization(
|
||||
liveAuthorized,
|
||||
HashCapability(capability!),
|
||||
processId,
|
||||
startTimeUtcTicks,
|
||||
expiresAtUtcTicks,
|
||||
timeProvider,
|
||||
capturedTimestamp,
|
||||
TimeSpan.FromTicks(expiresAtUtcTicks - nowUtcTicks),
|
||||
sceneLease);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically consumes the browser-to-native PREPARE capability. Invalid
|
||||
/// values do not consume it. Expiry is checked immediately before the state
|
||||
/// transition and permanently closes the gate.
|
||||
/// </summary>
|
||||
public bool TryClaimGateAPrepare(string? capability)
|
||||
{
|
||||
if (!IsGateA || !IsExactCapability(capability))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidateHash = HashCapability(capability!);
|
||||
try
|
||||
{
|
||||
lock (_gateASync)
|
||||
{
|
||||
if (TerminateIfExpiredLocked() ||
|
||||
_gateAState != GateAArmed ||
|
||||
!CryptographicOperations.FixedTimeEquals(
|
||||
candidateHash,
|
||||
_gateACapabilityHash!))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-read native time at the exact state boundary. A claim at the
|
||||
// expiry tick is rejected and can never be rearmed.
|
||||
if (TerminateIfExpiredLocked())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_gateAState = GateAOperatorClaimed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(candidateHash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently closes the PREPARE capability after the native request,
|
||||
/// regardless of success, rejection, cancellation, timeout, or unknown outcome.
|
||||
/// The approved cut lease intentionally remains held until native shutdown.
|
||||
/// </summary>
|
||||
public void CompleteGateAPrepare()
|
||||
{
|
||||
if (!IsGateA)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gateASync)
|
||||
{
|
||||
EnterTerminalLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryClaimEngineCommand(
|
||||
PlayoutOperation operation,
|
||||
PlayoutCue? cue)
|
||||
{
|
||||
if (!IsGateA)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
lock (_gateASync)
|
||||
{
|
||||
if (TerminateIfExpiredLocked())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operation)
|
||||
{
|
||||
case PlayoutOperation.Connect when !_connectClaimed:
|
||||
_connectClaimed = true;
|
||||
return true;
|
||||
case PlayoutOperation.Prepare:
|
||||
return TryClaimEnginePrepareLocked(cue);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final native guard invoked on the STA immediately before an SDK dispatch
|
||||
/// latch can be claimed. This closes the registration/queue-delay window after
|
||||
/// the outer command claim. Expiry or a terminal gate is reported as a safety
|
||||
/// exception so the engine quarantines without issuing cleanup commands.
|
||||
/// </summary>
|
||||
internal void EnsureGateASdkDispatchAuthorized()
|
||||
{
|
||||
if (!IsGateA)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gateASync)
|
||||
{
|
||||
if (TerminateIfExpiredLocked() || _gateAState == GateATerminal)
|
||||
{
|
||||
throw new K3dConnectSafetyException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal bool CanTakeIn => !IsGateA;
|
||||
|
||||
internal string RejectionMessage(PlayoutOperation operation) =>
|
||||
IsGateA
|
||||
? $"Gate A does not authorize another {operation} command for this process."
|
||||
: "The playout command is not authorized for this launch.";
|
||||
|
||||
internal static PlayoutLaunchAuthorization CreateGateAForTests(
|
||||
string capability,
|
||||
bool liveAuthorized = true,
|
||||
int expectedPgmProcessId = 1,
|
||||
long expectedPgmStartTimeUtcTicks = 1,
|
||||
TimeProvider? timeProvider = null,
|
||||
long? expiresAtUtcTicks = null,
|
||||
FileStream? gateASceneLease = null)
|
||||
{
|
||||
if (!IsExactCapability(capability))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A test Gate A capability must be exactly 64 uppercase hexadecimal characters.",
|
||||
nameof(capability));
|
||||
}
|
||||
|
||||
var clock = timeProvider ?? TimeProvider.System;
|
||||
var nowUtcTicks = clock.GetUtcNow().UtcTicks;
|
||||
var expiry = expiresAtUtcTicks ??
|
||||
nowUtcTicks + MaximumGateALifetimeTicks;
|
||||
return new PlayoutLaunchAuthorization(
|
||||
liveAuthorized,
|
||||
HashCapability(capability),
|
||||
expectedPgmProcessId,
|
||||
expectedPgmStartTimeUtcTicks,
|
||||
expiry,
|
||||
clock,
|
||||
clock.GetTimestamp(),
|
||||
TimeSpan.FromTicks(Math.Max(0, expiry - nowUtcTicks)),
|
||||
gateASceneLease);
|
||||
}
|
||||
|
||||
internal static bool IsExactGateASceneDirectory(string? directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)),
|
||||
Path.TrimEndingDirectorySeparator(
|
||||
Path.GetFullPath(GateAApprovedSceneDirectory)),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception exception) when (exception is
|
||||
ArgumentException or
|
||||
NotSupportedException or
|
||||
PathTooLongException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (IsGateA)
|
||||
{
|
||||
lock (_gateASync)
|
||||
{
|
||||
EnterTerminalLocked();
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _gateASceneLease, null)?.Dispose();
|
||||
}
|
||||
|
||||
private bool TryClaimEnginePrepareLocked(PlayoutCue? cue)
|
||||
{
|
||||
if (_gateAState != GateAOperatorClaimed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsExactGateAPrepareCue(cue))
|
||||
{
|
||||
EnterTerminalLocked();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TerminateIfExpiredLocked())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_gateAState = GateAEnginePrepareClaimed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TerminateIfExpiredLocked()
|
||||
{
|
||||
if (ExpiresAtUtcTicks is not { } expiry)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expired = _timeProvider.GetUtcNow().UtcTicks >= expiry;
|
||||
if (!expired)
|
||||
{
|
||||
try
|
||||
{
|
||||
expired = _timeProvider.GetElapsedTime(
|
||||
_authorizationCapturedTimestamp,
|
||||
_timeProvider.GetTimestamp()) >= _authorizedLifetime;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A broken monotonic clock must never extend live authority.
|
||||
expired = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!expired)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnterTerminalLocked();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EnterTerminalLocked()
|
||||
{
|
||||
_gateAState = GateATerminal;
|
||||
if (!_capabilityHashCleared && _gateACapabilityHash is not null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(_gateACapabilityHash);
|
||||
_capabilityHashCleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExactGateAPrepareCue(PlayoutCue? cue)
|
||||
{
|
||||
if (cue is null ||
|
||||
!string.Equals(cue.SceneName, "5001", StringComparison.Ordinal) ||
|
||||
!string.Equals(cue.SceneFile, "5001.t2s", StringComparison.Ordinal) ||
|
||||
cue.FadeDuration != 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var disabledBackgroundCount = 0;
|
||||
foreach (var mutation in cue.Mutations ?? [])
|
||||
{
|
||||
switch (mutation)
|
||||
{
|
||||
case PlayoutUseBackground
|
||||
{
|
||||
IsEnabled: false,
|
||||
Timing: PlayoutMutationTiming.BeforeTransaction
|
||||
}:
|
||||
disabledBackgroundCount++;
|
||||
break;
|
||||
case PlayoutUseBackground or
|
||||
PlayoutSetBackgroundTexture or
|
||||
PlayoutSetBackgroundVideo:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return disabledBackgroundCount == 1;
|
||||
}
|
||||
|
||||
private static bool IsExactCapability(string? value) =>
|
||||
value is { Length: CapabilityLength } && value.All(static character =>
|
||||
character is >= '0' and <= '9' or >= 'A' and <= 'F');
|
||||
|
||||
private static byte[] HashCapability(string capability) =>
|
||||
SHA256.HashData(Encoding.ASCII.GetBytes(capability));
|
||||
|
||||
private static FileStream AcquireApprovedSceneLease(
|
||||
string sceneFilePath,
|
||||
string expectedSha256)
|
||||
{
|
||||
if (!IsExactCapability(expectedSha256))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"The approved Gate A scene hash is invalid.");
|
||||
}
|
||||
|
||||
FileStream? lease = null;
|
||||
byte[]? expectedHash = null;
|
||||
byte[]? actualHash = null;
|
||||
try
|
||||
{
|
||||
lease = new FileStream(
|
||||
Path.GetFullPath(sceneFilePath),
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 64 * 1024,
|
||||
FileOptions.SequentialScan);
|
||||
expectedHash = Convert.FromHexString(expectedSha256);
|
||||
actualHash = SHA256.HashData(lease);
|
||||
lease.Position = 0;
|
||||
if (!CryptographicOperations.FixedTimeEquals(actualHash, expectedHash))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"The approved Gate A scene hash does not match the leased file.");
|
||||
}
|
||||
|
||||
return lease;
|
||||
}
|
||||
catch (PlayoutConfigurationException)
|
||||
{
|
||||
lease?.Dispose();
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception) when (exception is
|
||||
IOException or
|
||||
UnauthorizedAccessException or
|
||||
ArgumentException or
|
||||
NotSupportedException)
|
||||
{
|
||||
lease?.Dispose();
|
||||
throw new PlayoutConfigurationException(
|
||||
"The approved Gate A scene could not be leased and verified.",
|
||||
exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (expectedHash is not null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(expectedHash);
|
||||
}
|
||||
|
||||
if (actualHash is not null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(actualHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearGateAEnvironment()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
GateACapabilityEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable(
|
||||
GateAPgmProcessIdEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable(
|
||||
GateAPgmStartTimeUtcTicksEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable(
|
||||
GateAExpiresAtUtcTicksEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
|
||||
private static void ClearLiveAuthorizationEnvironment() =>
|
||||
Environment.SetEnvironmentVariable(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
null,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
private readonly ITornadoProcessProbe _processProbe;
|
||||
private readonly IStaDispatcher? _dispatcher;
|
||||
private readonly ILiveAuthorization _liveAuthorization;
|
||||
private readonly PlayoutLaunchAuthorization _launchAuthorization;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Func<bool>? _finalConnectSafetyCheck;
|
||||
private readonly bool _abandonOnTargetMismatch;
|
||||
@@ -55,7 +56,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
bool startMonitor = true,
|
||||
Func<bool>? finalConnectSafetyCheck = null,
|
||||
bool abandonOnTargetMismatch = false,
|
||||
Action? beforeSdkDispatchClaim = null)
|
||||
Action? beforeSdkDispatchClaim = null,
|
||||
PlayoutLaunchAuthorization? launchAuthorization = null)
|
||||
{
|
||||
_options = options;
|
||||
_sessionFactory = sessionFactory;
|
||||
@@ -63,6 +65,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
_processProbe = processProbe;
|
||||
_dispatcher = dispatcher;
|
||||
_liveAuthorization = liveAuthorization;
|
||||
_launchAuthorization = launchAuthorization ??
|
||||
PlayoutLaunchAuthorization.Unrestricted;
|
||||
_timeProvider = timeProvider;
|
||||
_finalConnectSafetyCheck = finalConnectSafetyCheck;
|
||||
_abandonOnTargetMismatch = abandonOnTargetMismatch;
|
||||
@@ -102,7 +106,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return SerializedAsync(
|
||||
PlayoutOperation.Prepare,
|
||||
cancellationToken,
|
||||
token => PrepareCoreAsync(cue, token));
|
||||
token => PrepareCoreAsync(cue, token),
|
||||
cue);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default) =>
|
||||
@@ -116,7 +121,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return SerializedAsync(
|
||||
PlayoutOperation.Next,
|
||||
cancellationToken,
|
||||
token => NextCoreAsync(cue, token));
|
||||
token => NextCoreAsync(cue, token),
|
||||
cue);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> UpdateOnAirAsync(
|
||||
@@ -127,7 +133,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return SerializedAsync(
|
||||
PlayoutOperation.UpdateOnAir,
|
||||
cancellationToken,
|
||||
token => UpdateOnAirCoreAsync(cue, token));
|
||||
token => UpdateOnAirCoreAsync(cue, token),
|
||||
cue);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> TakeOutAsync(
|
||||
@@ -337,7 +344,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return;
|
||||
}
|
||||
|
||||
if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled ||
|
||||
if (_session is not null || !_connectionRequested ||
|
||||
!_launchAuthorization.AllowsAutomaticReconnect ||
|
||||
!_options.ReconnectEnabled ||
|
||||
_outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts ||
|
||||
_timeProvider.GetUtcNow() < _nextReconnectAt)
|
||||
{
|
||||
@@ -362,7 +371,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
private async Task<PlayoutResult> SerializedAsync(
|
||||
PlayoutOperation operation,
|
||||
CancellationToken cancellationToken,
|
||||
Func<CancellationToken, Task<PlayoutResult>> callback)
|
||||
Func<CancellationToken, Task<PlayoutResult>> callback,
|
||||
PlayoutCue? cue = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -407,6 +417,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return ExistingOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
if (!_launchAuthorization.TryClaimEngineCommand(operation, cue))
|
||||
{
|
||||
return Result(
|
||||
operation,
|
||||
PlayoutResultCode.Rejected,
|
||||
_launchAuthorization.RejectionMessage(operation));
|
||||
}
|
||||
|
||||
return await callback(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
@@ -1412,6 +1430,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_session is not null && !_launchAuthorization.AllowsSdkDisconnect)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var session = _session;
|
||||
if (session?.HasPendingLifecycleCallbacks == true)
|
||||
{
|
||||
@@ -1526,6 +1550,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
IK3dSession session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_launchAuthorization.AllowsSdkDisconnect)
|
||||
{
|
||||
throw new K3dConnectSafetyException();
|
||||
}
|
||||
|
||||
var dispatchLatch = new SdkDispatchLatch(_beforeSdkDispatchClaim);
|
||||
return _dispatcher!.InvokeAsync(
|
||||
() =>
|
||||
@@ -1759,7 +1788,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
private bool CanTakeIn() => _options.Mode switch
|
||||
{
|
||||
PlayoutMode.Test => true,
|
||||
PlayoutMode.Live => IsLiveAuthorized(),
|
||||
PlayoutMode.Live =>
|
||||
IsLiveAuthorized() && _launchAuthorization.CanTakeIn,
|
||||
_ => false
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user