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

@@ -0,0 +1,100 @@
#nullable enable
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Identifies the native database-write boundary that is requesting
/// authorization. The request deliberately contains no operator-entered data.
/// </summary>
public enum DatabaseMutationTarget
{
OperatorCatalog,
NamedPlaylist,
ManualFinancial
}
/// <summary>
/// Describes one validated transaction immediately before its executor can
/// create a database connection.
/// </summary>
public sealed record DatabaseMutationAuthorizationRequest(
DatabaseMutationTarget Target,
DataSourceKind Source,
Guid OperationId,
string MutationKind);
/// <summary>
/// Final authorization seam for state-changing database operations. Returning
/// false denies the transaction before a connection or transaction is created.
/// Throwing also fails closed and is never converted into an allow decision.
/// </summary>
public interface IDatabaseMutationAuthorization
{
bool IsAuthorized(DatabaseMutationAuthorizationRequest request);
}
/// <summary>
/// Provides the compatibility policy used when existing constructors do not
/// supply an explicit database-mutation authorization policy.
/// </summary>
public static class DatabaseMutationAuthorization
{
public static IDatabaseMutationAuthorization AllowAll { get; } =
new AllowAllDatabaseMutationAuthorization();
private sealed class AllowAllDatabaseMutationAuthorization
: IDatabaseMutationAuthorization
{
public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return true;
}
}
}
/// <summary>
/// Raised when the active policy denies a database mutation before any native
/// database connection is created.
/// </summary>
public sealed class DatabaseMutationAuthorizationException : InvalidOperationException
{
public DatabaseMutationAuthorizationException(
DatabaseMutationAuthorizationRequest request,
Exception? innerException = null)
: base(
$"The {request?.Target} database mutation was denied before a connection was opened.",
innerException)
{
Request = request ?? throw new ArgumentNullException(nameof(request));
}
public DatabaseMutationAuthorizationRequest Request { get; }
}
internal static class DatabaseMutationAuthorizationGuard
{
internal static void Demand(
IDatabaseMutationAuthorization authorization,
DatabaseMutationAuthorizationRequest request)
{
ArgumentNullException.ThrowIfNull(authorization);
ArgumentNullException.ThrowIfNull(request);
bool authorized;
try
{
authorized = authorization.IsAuthorized(request);
}
catch (Exception exception)
{
throw new DatabaseMutationAuthorizationException(request, exception);
}
if (!authorized)
{
throw new DatabaseMutationAuthorizationException(request);
}
}
}

View File

@@ -17,6 +17,7 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly Action<DataSourceKind, DbCommand> _configureCommand;
@@ -28,7 +29,22 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureProviderCommand)
ConfigureProviderCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
public OperatorCatalogMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector,
IDatabaseMutationAuthorization mutationAuthorization)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureProviderCommand,
mutationAuthorization)
{
}
@@ -37,6 +53,21 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DataSourceKind, DbCommand> configureCommand)
: this(
connectionFactory,
resilienceOptions,
errorDetector,
configureCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
internal OperatorCatalogMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DataSourceKind, DbCommand> configureCommand,
IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
@@ -44,6 +75,8 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
_mutationAuthorization = mutationAuthorization ??
throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -56,6 +89,23 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
{
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
try
{
DatabaseMutationAuthorizationGuard.Demand(
_mutationAuthorization,
new DatabaseMutationAuthorizationRequest(
DatabaseMutationTarget.OperatorCatalog,
transaction.Source,
transaction.OperationId,
transaction.Kind.ToString()));
}
catch (DatabaseMutationAuthorizationException exception)
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
outcomeUnknown: false,
exception);
}
var commandTimeoutSeconds = _connectionFactory.GetCommandTimeoutSeconds(transaction.Source);
if (commandTimeoutSeconds is < 1 or > 600)
{

View File

@@ -19,6 +19,7 @@ public sealed partial class OracleManualFinancialMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
@@ -31,7 +32,22 @@ public sealed partial class OracleManualFinancialMutationExecutor
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
ConfigureOracleCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
public OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector,
IDatabaseMutationAuthorization mutationAuthorization)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand,
mutationAuthorization)
{
}
@@ -40,12 +56,29 @@ public sealed partial class OracleManualFinancialMutationExecutor
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
: this(
connectionFactory,
resilienceOptions,
errorDetector,
configureCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
internal OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand,
IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand));
_mutationAuthorization = mutationAuthorization ??
throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -73,6 +106,23 @@ public sealed partial class OracleManualFinancialMutationExecutor
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
try
{
DatabaseMutationAuthorizationGuard.Demand(
_mutationAuthorization,
new DatabaseMutationAuthorizationRequest(
DatabaseMutationTarget.ManualFinancial,
DataSourceKind.Oracle,
transaction.OperationId,
transaction.Kind.ToString()));
}
catch (DatabaseMutationAuthorizationException exception)
{
throw new ManualFinancialMutationException(
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
outcomeUnknown: false,
exception);
}
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;

View File

@@ -17,6 +17,7 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
@@ -29,7 +30,22 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
ConfigureOracleCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
public OracleNamedPlaylistMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector,
IDatabaseMutationAuthorization mutationAuthorization)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand,
mutationAuthorization)
{
}
@@ -38,6 +54,21 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
: this(
connectionFactory,
resilienceOptions,
errorDetector,
configureCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
internal OracleNamedPlaylistMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand,
IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
@@ -45,6 +76,8 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
_mutationAuthorization = mutationAuthorization ??
throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -72,6 +105,23 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
try
{
DatabaseMutationAuthorizationGuard.Demand(
_mutationAuthorization,
new DatabaseMutationAuthorizationRequest(
DatabaseMutationTarget.NamedPlaylist,
DataSourceKind.Oracle,
transaction.OperationId,
transaction.Kind.ToString()));
}
catch (DatabaseMutationAuthorizationException exception)
{
throw new NamedPlaylistMutationException(
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
outcomeUnknown: false,
exception);
}
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);

View File

@@ -6,6 +6,12 @@ namespace MBN_STOCK_WEBVIEW.LegacyBridge;
public sealed record LegacyExecutePlayoutIntent(LegacyOperatorPlayoutCommand Command)
: LegacyUiIntent;
/// <summary>
/// Process-scoped Gate A PREPARE request. The opaque capability is validated and
/// consumed by the native launch authorization; it is never emitted in state.
/// </summary>
public sealed record LegacyGateAPrepareIntent(string Capability) : LegacyUiIntent;
public sealed record LegacySetFadeDurationIntent(int Duration)
: LegacyUiIntent;
@@ -17,6 +23,7 @@ internal static class LegacyPlayoutUiIntentParser
{
public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch
{
"gate-a-prepare" => ParseGateAPrepare(payload),
"prepare-playout" => ParseEmpty(
payload,
static () => new LegacyExecutePlayoutIntent(
@@ -43,6 +50,29 @@ internal static class LegacyPlayoutUiIntentParser
_ => null
};
private static LegacyUiIntent? ParseGateAPrepare(JsonElement payload)
{
if (payload.ValueKind != JsonValueKind.Object ||
payload.GetRawText().Length > 128 ||
payload.EnumerateObject().Count() != 1 ||
!payload.TryGetProperty("capability", out var capabilityElement) ||
capabilityElement.ValueKind != JsonValueKind.String)
{
return null;
}
var capability = capabilityElement.GetString();
if (capability is null || capability.Length != 64 ||
capability.Any(character =>
character is not (>= '0' and <= '9') and
not (>= 'A' and <= 'F')))
{
return null;
}
return new LegacyGateAPrepareIntent(capability);
}
private static LegacyUiIntent? ParseFadeDuration(JsonElement payload)
{
if (payload.ValueKind != JsonValueKind.Object ||

View File

@@ -5,6 +5,7 @@ using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Safety;
using Microsoft.UI.Xaml;
using Windows.Storage.Pickers;
using WinRT.Interop;
@@ -45,7 +46,9 @@ public sealed partial class MainWindow
: null;
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
initialComposition);
_playoutEngine = PlayoutEngineFactory.Create(_playoutOptions);
_playoutEngine = PlayoutEngineFactory.Create(
_playoutOptions,
_playoutLaunchAuthorization);
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
if (_databaseRuntime is not null)
{
@@ -118,8 +121,19 @@ public sealed partial class MainWindow
private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(
LegacyOperatorPlayoutCommand command,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool gateAPrepareClaimed = false,
LegacyOperatorPlayoutRequest? gateAPrepareRequest = null)
{
if (_playoutLaunchAuthorization.IsGateA &&
(!gateAPrepareClaimed ||
command != LegacyOperatorPlayoutCommand.Prepare ||
gateAPrepareRequest is null))
{
return _controller.ReportError(
"Gate A 검증 회차에서는 승인된 5001 PREPARE 한 번만 실행할 수 있습니다.");
}
var engine = _playoutEngine;
var workflow = _playoutWorkflow;
if (engine is null || workflow is null)
@@ -163,8 +177,9 @@ public sealed partial class MainWindow
switch (command)
{
case LegacyOperatorPlayoutCommand.Prepare:
var request = _controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration);
var request = gateAPrepareRequest ??
_controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration);
result = await workflow.PrepareAsync(
request.Playlist,
request.SelectedIndexZeroBased,
@@ -265,10 +280,112 @@ public sealed partial class MainWindow
}
}
private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(
string capability,
CancellationToken cancellationToken)
{
if (!_playoutLaunchAuthorization.IsGateA ||
!_playoutLaunchAuthorization.TryClaimGateAPrepare(capability))
{
return _controller.ReportError(
"Gate A PREPARE 승인이 없거나 이미 사용되었습니다.");
}
try
{
if (!TryCreateExactGateAPrepareRequest(out var request))
{
return _controller.ReportError(
"Gate A PREPARE 직전 상태가 승인된 삼성전자 5001 page 1 조건과 일치하지 않습니다.");
}
return await ExecuteOperatorPlayoutAsync(
LegacyOperatorPlayoutCommand.Prepare,
cancellationToken,
gateAPrepareClaimed: true,
gateAPrepareRequest: request).ConfigureAwait(false);
}
finally
{
// Success, rejection, cancellation and unknown outcome all consume the
// one process-lifetime capability. There is deliberately no retry path.
_playoutLaunchAuthorization.CompleteGateAPrepare();
}
}
private bool TryCreateExactGateAPrepareRequest(
out LegacyOperatorPlayoutRequest? request)
{
request = null;
var snapshot = _controller.Current;
if (snapshot.Playlist.Count != 1 ||
_compositionOptions?.Current is not { } composition ||
composition.FadeDuration != 6 ||
composition.BackgroundKind != LegacySceneBackgroundKind.None)
{
return false;
}
var row = snapshot.Playlist[0];
if (!row.IsEnabled || !row.IsActive ||
!string.Equals(row.MarketText, "코스피", StringComparison.Ordinal) ||
!string.Equals(row.StockName, "삼성전자", StringComparison.Ordinal) ||
!string.Equals(row.GraphicType, "1열판기본", StringComparison.Ordinal) ||
!string.Equals(row.Subtype, "현재가", StringComparison.Ordinal) ||
!string.Equals(row.PageText, "1/1", StringComparison.Ordinal) ||
!string.Equals(row.StockCode, "005930", StringComparison.Ordinal))
{
return false;
}
LegacyOperatorPlayoutRequest candidate;
try
{
candidate = _controller.CreatePlayoutRequest(6);
}
catch (InvalidOperationException)
{
return false;
}
if (candidate.SelectedIndexZeroBased != 0 ||
candidate.Playlist.Count != 1)
{
return false;
}
var entry = candidate.Playlist[0];
var selection = entry.Selection;
if (!entry.IsEnabled ||
!string.Equals(entry.EntryId, row.RowId, StringComparison.Ordinal) ||
!string.Equals(entry.CutCode, "5001", StringComparison.Ordinal) ||
entry.FadeDuration != 6 ||
entry.PageNavigation?.IsEnabled == true ||
entry.MovingAverageSelectionSource is not null ||
selection is null ||
!string.Equals(selection.GroupCode, "코스피", StringComparison.Ordinal) ||
!string.Equals(selection.Subject, "삼성전자", StringComparison.Ordinal) ||
!string.Equals(selection.GraphicType, "1열판기본", StringComparison.Ordinal) ||
!string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) ||
!string.Equals(selection.DataCode, "005930", StringComparison.Ordinal))
{
return false;
}
request = candidate;
return true;
}
private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(
int fadeDuration,
CancellationToken cancellationToken)
{
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
{
return _controller.ReportError(
"Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다.");
}
if (fadeDuration is < 0 or > 19)
{
return _controller.ReportError("DissolveTime 값이 올바르지 않습니다.");
@@ -308,6 +425,12 @@ public sealed partial class MainWindow
private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(
CancellationToken cancellationToken)
{
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
{
return _controller.ReportError(
"Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다.");
}
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
@@ -349,6 +472,12 @@ public sealed partial class MainWindow
private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(
CancellationToken cancellationToken)
{
if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
{
return _controller.ReportError(
"Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다.");
}
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
@@ -534,6 +663,27 @@ public sealed partial class MainWindow
LegacyConfirmManualListIntent or
LegacyImportManualListsIntent;
private static bool IsPersistentWriteIntent(LegacyUiIntent intent) => intent is
LegacyDeleteUc4SelectedThemeIntent or
LegacyDeleteUc6SelectedExpertIntent or
LegacySaveOperatorCatalogIntent or
LegacyConfirmNativeDialogIntent or
LegacyAddComparisonPairIntent or
LegacyMoveComparisonPairIntent or
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
LegacySaveManualFinancialIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacySaveManualNetSellIntent or
LegacySaveManualViIntent or
LegacyConfirmManualListIntent or
LegacyImportManualListsIntent or
LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent;
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
LegacySetManualNetSellCellIntent or
LegacyAddManualViResultIntent or

View File

@@ -2,6 +2,7 @@ using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MBN_STOCK_WEBVIEW.Playout.Safety;
using Microsoft.UI.Windowing;
using Microsoft.Web.WebView2.Core;
using MMoneyCoderSharp.Data;
@@ -21,6 +22,7 @@ public sealed partial class MainWindow : Window
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorController _controller;
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
@@ -36,6 +38,10 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
// Capture and remove Gate A bearer material before XAML can create WebView2.
// Only the native process keeps the capability digest for this launch.
_playoutLaunchAuthorization =
PlayoutLaunchAuthorization.CaptureFromEnvironment();
_windowSubclassProcedure = OnWindowSubclassMessage;
InitializeComponent();
EnsureCloseConfirmationAttached();
@@ -72,6 +78,8 @@ public sealed partial class MainWindow : Window
{
_databaseRuntime = DatabaseRuntime.CreateDefault();
DataQueryExecutor.Configure(_databaseRuntime.Executor);
var databaseMutationAuthorization =
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
industrySelectionService = new LegacyIndustrySelectionService(
_databaseRuntime.Executor);
@@ -91,17 +99,23 @@ public sealed partial class MainWindow : Window
_databaseRuntime.Executor,
new OracleManualFinancialMutationExecutor(
_databaseRuntime.ConnectionFactory,
_databaseRuntime.Options.Resilience));
_databaseRuntime.Options.Resilience,
errorDetector: null,
mutationAuthorization: databaseMutationAuthorization));
manualFinancialStockSearchService = new LegacyStockSearchService(
_databaseRuntime.Executor);
namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
_databaseRuntime.Executor,
new OracleNamedPlaylistMutationExecutor(
_databaseRuntime.ConnectionFactory,
_databaseRuntime.Options.Resilience));
_databaseRuntime.Options.Resilience,
errorDetector: null,
mutationAuthorization: databaseMutationAuthorization));
var operatorCatalogMutationExecutor = new OperatorCatalogMutationExecutor(
_databaseRuntime.ConnectionFactory,
_databaseRuntime.Options.Resilience);
_databaseRuntime.Options.Resilience,
errorDetector: null,
mutationAuthorization: databaseMutationAuthorization);
themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService(
_databaseRuntime.Executor,
operatorCatalogMutationExecutor);
@@ -377,6 +391,15 @@ public sealed partial class MainWindow : Window
return;
}
if (_playoutLaunchAuthorization.IsGateA &&
IsPersistentWriteIntent(intent))
{
PostState(ReportIntentFailure(
intent,
"Gate A 검증 회차에서는 DB 및 로컬 영구 저장 변경이 허용되지 않습니다."));
return;
}
if (_controller.Current.Dialog?.IsConfirmation == true &&
intent is not LegacyReadyIntent and
not LegacyConfirmNativeDialogIntent and
@@ -469,6 +492,7 @@ public sealed partial class MainWindow : Window
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent or
LegacyExecutePlayoutIntent or
LegacyGateAPrepareIntent or
LegacySetFadeDurationIntent or
LegacyToggleBackgroundIntent or
LegacyChooseBackgroundIntent)
@@ -961,6 +985,10 @@ public sealed partial class MainWindow : Window
await _controller.ConfirmManualListsAsync(cancellationToken),
LegacyImportManualListsIntent =>
await _controller.ImportLegacyManualListsAsync(cancellationToken),
LegacyGateAPrepareIntent prepare =>
await ExecuteGateAPrepareAsync(
prepare.Capability,
cancellationToken),
LegacyExecutePlayoutIntent playout =>
await ExecuteOperatorPlayoutAsync(
playout.Command,
@@ -1038,6 +1066,25 @@ public sealed partial class MainWindow : Window
outcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
private sealed class LaunchDatabaseMutationAuthorization
: IDatabaseMutationAuthorization
{
private readonly PlayoutLaunchAuthorization _authorization;
public LaunchDatabaseMutationAuthorization(
PlayoutLaunchAuthorization authorization)
{
_authorization = authorization ??
throw new ArgumentNullException(nameof(authorization));
}
public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return _authorization.AllowsDatabaseWrites;
}
}
private void PostState(LegacyOperatorSnapshot snapshot)
{
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)

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