feat: add audited one-shot prepare gate

This commit is contained in:
2026-07-18 12:29:09 +09:00
parent 1302b1b92f
commit 5379511b8a
25 changed files with 6188 additions and 41 deletions

View File

@@ -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
{

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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
};