Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

View File

@@ -0,0 +1,369 @@
#nullable enable
using System.Data;
using System.Data.Common;
using MMoneyCoderSharp.Data;
using MySqlConnector;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Executes the closed SB_LIST/SB_ITEM and EXPERT_LIST/RECOMMEND_LIST command
/// sets exactly once. Every operation uses one serializable transaction. There
/// is intentionally no transient retry path for state-changing commands.
/// </summary>
public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly TimeSpan _operationTimeout;
private readonly Action<DataSourceKind, DbCommand> _configureCommand;
public OperatorCatalogMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector = null)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureProviderCommand)
{
}
internal OperatorCatalogMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DataSourceKind, DbCommand> configureCommand)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
}
public async Task<OperatorCatalogDbTransactionResult> ExecuteTransactionAsync(
OperatorCatalogDbTransaction transaction,
CancellationToken cancellationToken = default)
{
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
var commandTimeoutSeconds = _connectionFactory.GetCommandTimeoutSeconds(transaction.Source);
if (commandTimeoutSeconds is < 1 or > 600)
{
throw new DatabaseConfigurationException(
"The operator-catalog command timeout must be between 1 and 600 seconds.");
}
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
DbConnection? connection = null;
DbTransaction? databaseTransaction = null;
var commitStarted = false;
try
{
connection = _connectionFactory.Create(transaction.Source);
await connection.OpenAsync(operationToken).ConfigureAwait(false);
databaseTransaction = await connection.BeginTransactionAsync(
IsolationLevel.Serializable,
operationToken)
.ConfigureAwait(false);
var affectedRows = new List<int>(transaction.Commands.Count);
foreach (var commandSpec in transaction.Commands)
{
operationToken.ThrowIfCancellationRequested();
await using var command = connection.CreateCommand();
command.CommandText = commandSpec.Sql;
command.CommandType = CommandType.Text;
command.CommandTimeout = commandTimeoutSeconds;
command.Transaction = databaseTransaction;
_configureCommand(transaction.Source, command);
foreach (var parameterSpec in commandSpec.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.DbType = parameterSpec.DbType;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
// Execute exactly once and enforce the optimistic/uniqueness
// precondition before any later command can run.
var count = await command.ExecuteNonQueryAsync(operationToken)
.ConfigureAwait(false);
if (count < 0 ||
commandSpec.AffectedRowsRule ==
OperatorCatalogAffectedRowsRule.ExactlyOne && count != 1)
{
throw new AffectedRowsConflictException(commandSpec.Kind, count);
}
affectedRows.Add(count);
}
commitStarted = true;
await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false);
return new OperatorCatalogDbTransactionResult(
transaction.OperationId,
Array.AsReadOnly(affectedRows.ToArray()),
DateTimeOffset.UtcNow);
}
catch (Exception exception)
{
if (commitStarted)
{
// The provider may have committed even if the client observed a
// timeout or disconnect. Do not issue rollback or retry commands.
throw UnknownOutcome(transaction.Kind, exception);
}
if (databaseTransaction is null)
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction did not start and did not commit.",
outcomeUnknown: false,
exception);
}
var interrupted = exception is OperationCanceledException ||
_errorDetector.IsTimeout(transaction.Source, exception);
var rollbackSucceeded = await TryRollbackAsync(
databaseTransaction,
commandTimeoutSeconds)
.ConfigureAwait(false);
// Once a command is in flight, cancellation/timeout is treated as
// OutcomeUnknown even when rollback returned. This is deliberately
// conservative and prohibits an automatic retry.
if (interrupted || !rollbackSucceeded)
{
throw UnknownOutcome(transaction.Kind, exception);
}
if (exception is AffectedRowsConflictException conflict)
{
throw new OperatorCatalogConflictException(
transaction.Kind,
conflict.CommandKind,
exception);
}
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction was rolled back and did not commit.",
outcomeUnknown: false,
exception);
}
finally
{
await DisposeQuietlyAsync(databaseTransaction).ConfigureAwait(false);
await DisposeQuietlyAsync(connection).ConfigureAwait(false);
}
}
internal static void ConfigureProviderCommand(
DataSourceKind source,
DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
switch (source)
{
case DataSourceKind.Oracle when command is OracleCommand oracleCommand:
oracleCommand.BindByName = true;
break;
case DataSourceKind.MariaDb when command is MySqlCommand:
break;
case DataSourceKind.Oracle:
case DataSourceKind.MariaDb:
throw new DatabaseConfigurationException(
"The operator-catalog connection created the wrong provider command.");
default:
throw new ArgumentOutOfRangeException(nameof(source), source, null);
}
}
private static async Task<bool> TryRollbackAsync(
DbTransaction transaction,
int timeoutSeconds)
{
try
{
using var rollbackTimeout = new CancellationTokenSource(
TimeSpan.FromSeconds(timeoutSeconds));
await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false);
return true;
}
catch
{
return false;
}
}
private static async Task DisposeQuietlyAsync(IAsyncDisposable? disposable)
{
if (disposable is null)
{
return;
}
try
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Commit/rollback outcome has already been classified. Disposal must
// not replace that evidence or trigger another database operation.
}
}
private static void ValidateTransaction(OperatorCatalogDbTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
if (transaction.OperationId == Guid.Empty ||
!Enum.IsDefined(transaction.Kind) ||
!Enum.IsDefined(transaction.Source) ||
transaction.Commands.Count == 0 ||
transaction.Commands.Any(static command => command is null))
{
throw new ArgumentException(
"The operator-catalog transaction is invalid.",
nameof(transaction));
}
var themeMutation = transaction.Kind is
OperatorCatalogMutationKind.CreateTheme or
OperatorCatalogMutationKind.ReplaceTheme or
OperatorCatalogMutationKind.DeleteTheme;
var identityLength = themeMutation ? 8 : 4;
if (transaction.IdentityCode.Length != identityLength ||
transaction.IdentityCode.Any(static character => !char.IsAsciiDigit(character)) ||
!themeMutation && transaction.Source != DataSourceKind.Oracle)
{
throw new ArgumentException(
"The operator-catalog transaction identity or source is invalid.",
nameof(transaction));
}
var commandKinds = transaction.Commands.Select(static command => command.Kind).ToArray();
var validShape = transaction.Kind switch
{
OperatorCatalogMutationKind.CreateTheme =>
commandKinds[0] == OperatorCatalogDbCommandKind.InsertTheme &&
commandKinds.Skip(1).All(static kind =>
kind == OperatorCatalogDbCommandKind.InsertThemeItem),
OperatorCatalogMutationKind.ReplaceTheme =>
commandKinds.Length >= 2 &&
commandKinds[0] == OperatorCatalogDbCommandKind.UpdateTheme &&
commandKinds[1] == OperatorCatalogDbCommandKind.DeleteThemeItems &&
commandKinds.Skip(2).All(static kind =>
kind == OperatorCatalogDbCommandKind.InsertThemeItem),
OperatorCatalogMutationKind.DeleteTheme =>
commandKinds.SequenceEqual(
[
OperatorCatalogDbCommandKind.DeleteThemeItems,
OperatorCatalogDbCommandKind.DeleteTheme
]),
OperatorCatalogMutationKind.CreateExpert =>
commandKinds[0] == OperatorCatalogDbCommandKind.InsertExpert &&
commandKinds.Skip(1).All(static kind =>
kind == OperatorCatalogDbCommandKind.InsertRecommendation),
OperatorCatalogMutationKind.ReplaceExpert =>
commandKinds.Length >= 2 &&
commandKinds[0] == OperatorCatalogDbCommandKind.UpdateExpert &&
commandKinds[1] == OperatorCatalogDbCommandKind.DeleteRecommendations &&
commandKinds.Skip(2).All(static kind =>
kind == OperatorCatalogDbCommandKind.InsertRecommendation),
OperatorCatalogMutationKind.DeleteExpert =>
commandKinds.SequenceEqual(
[
OperatorCatalogDbCommandKind.DeleteRecommendations,
OperatorCatalogDbCommandKind.DeleteExpert
]),
_ => false
};
if (!validShape)
{
throw new ArgumentException(
"The operator-catalog transaction command order is invalid.",
nameof(transaction));
}
var marker = transaction.Source == DataSourceKind.Oracle ? ':' : '@';
var identityParameterName = themeMutation ? "theme_code" : "expert_code";
foreach (var command in transaction.Commands)
{
if (string.IsNullOrWhiteSpace(command.Sql) ||
command.Sql.Contains(';', StringComparison.Ordinal) ||
!Enum.IsDefined(command.Kind) ||
!Enum.IsDefined(command.AffectedRowsRule) ||
command.Parameters.Count == 0 ||
command.Parameters.Any(static parameter => parameter is null))
{
throw new ArgumentException(
"An operator-catalog command is invalid.",
nameof(transaction));
}
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var parameter in command.Parameters)
{
if (!names.Add(parameter.Name) ||
command.Sql.IndexOf(
string.Concat(marker, parameter.Name),
StringComparison.OrdinalIgnoreCase) < 0)
{
throw new ArgumentException(
"An operator-catalog command parameter is invalid.",
nameof(transaction));
}
}
var identityParameter = command.Parameters.SingleOrDefault(parameter =>
string.Equals(
parameter.Name,
identityParameterName,
StringComparison.OrdinalIgnoreCase));
if (identityParameter?.Value is not string commandIdentity ||
!string.Equals(
commandIdentity,
transaction.IdentityCode,
StringComparison.Ordinal))
{
throw new ArgumentException(
"An operator-catalog command identity is invalid.",
nameof(transaction));
}
}
}
private static OperatorCatalogMutationException UnknownOutcome(
OperatorCatalogMutationKind kind,
Exception exception) =>
new(
$"The {kind} transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
private sealed class AffectedRowsConflictException(
OperatorCatalogDbCommandKind commandKind,
int affectedRows)
: Exception("An operator-catalog affected-row precondition failed.")
{
internal OperatorCatalogDbCommandKind CommandKind { get; } = commandKind;
internal int AffectedRows { get; } = affectedRows;
}
}

View File

@@ -0,0 +1,345 @@
#nullable enable
using System.Data;
using System.Data.Common;
using System.Text.RegularExpressions;
using MMoneyCoderSharp.Data;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Executes the closed GraphE INPUT_* write contract on one Oracle connection
/// and one transaction. Each operation takes the profile's exclusive table lock,
/// executes its bound command once, validates affected rows before commit, and
/// deliberately contains no retry loop.
/// </summary>
public sealed partial class OracleManualFinancialMutationExecutor
: IManualFinancialMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
public OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector = null)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
{
}
internal OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
_commandTimeoutSeconds = connectionFactory.GetCommandTimeoutSeconds(DataSourceKind.Oracle);
if (_commandTimeoutSeconds is < 1 or > 600)
{
throw new DatabaseConfigurationException(
"The Oracle manual-financial command timeout must be between 1 and 600 seconds.");
}
}
public async Task<ManualFinancialDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
ManualFinancialDbTransaction transaction,
CancellationToken cancellationToken = default)
{
if (source != DataSourceKind.Oracle)
{
throw new ArgumentOutOfRangeException(
nameof(source),
source,
"Manual-financial GraphE records are stored in Oracle.");
}
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
DbConnection? connection = null;
DbTransaction? databaseTransaction = null;
var commitStarted = false;
try
{
connection = _connectionFactory.Create(DataSourceKind.Oracle);
await connection.OpenAsync(operationToken).ConfigureAwait(false);
databaseTransaction = await connection.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
operationToken)
.ConfigureAwait(false);
await ExecuteLockAsync(
connection,
databaseTransaction,
transaction.ExclusiveTableLockSql,
operationToken).ConfigureAwait(false);
var affectedRows = new List<int>(transaction.Commands.Count);
foreach (var commandSpec in transaction.Commands)
{
operationToken.ThrowIfCancellationRequested();
var count = await ExecuteCommandAsync(
connection,
databaseTransaction,
commandSpec,
operationToken).ConfigureAwait(false);
if (!Matches(commandSpec.AffectedRowsRule, count))
{
throw Rejected(transaction.Kind, count);
}
affectedRows.Add(count);
}
commitStarted = true;
await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false);
return new ManualFinancialDbTransactionResult(
transaction.OperationId,
Array.AsReadOnly(affectedRows.ToArray()),
DateTimeOffset.UtcNow);
}
catch (Exception exception)
{
if (commitStarted)
{
// The client cannot prove whether Oracle committed. Issuing a
// rollback or repeating the command would make the situation worse.
throw UnknownOutcome(transaction.Kind, exception);
}
var isCancellationOrTimeout = exception is OperationCanceledException ||
_errorDetector.IsTimeout(DataSourceKind.Oracle, exception);
var rollbackFailed = false;
if (databaseTransaction is not null)
{
rollbackFailed = !await TryRollbackAsync(databaseTransaction)
.ConfigureAwait(false);
}
if (databaseTransaction is not null && (isCancellationOrTimeout || rollbackFailed))
{
throw UnknownOutcome(transaction.Kind, exception);
}
if (exception is ManualFinancialMutationRejectedException rejected)
{
throw rejected;
}
if (exception is ManualFinancialMutationException mutationException)
{
throw mutationException;
}
throw new ManualFinancialMutationException(
$"The {transaction.Kind} Oracle transaction did not commit.",
outcomeUnknown: false,
exception);
}
finally
{
if (databaseTransaction is not null)
{
await databaseTransaction.DisposeAsync().ConfigureAwait(false);
}
if (connection is not null)
{
await connection.DisposeAsync().ConfigureAwait(false);
}
}
}
internal static void ConfigureOracleCommand(DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
if (command is not OracleCommand oracleCommand)
{
throw new DatabaseConfigurationException(
"The manual-financial mutation connection did not create an Oracle command.");
}
oracleCommand.BindByName = true;
}
private async Task ExecuteLockAsync(
DbConnection connection,
DbTransaction transaction,
string sql,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
Configure(command, transaction, sql);
// Execute exactly once. LOCK TABLE has no operator value and no retry.
_ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<int> ExecuteCommandAsync(
DbConnection connection,
DbTransaction transaction,
ManualFinancialDbCommand commandSpec,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
Configure(command, transaction, commandSpec.Sql);
foreach (var parameterSpec in commandSpec.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.DbType = parameterSpec.DbType;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
// Execute exactly once. There is deliberately no transient retry.
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private void Configure(DbCommand command, DbTransaction transaction, string sql)
{
command.CommandText = sql;
command.CommandType = CommandType.Text;
command.CommandTimeout = _commandTimeoutSeconds;
command.Transaction = transaction;
_configureCommand(command);
}
private async Task<bool> TryRollbackAsync(DbTransaction transaction)
{
try
{
using var rollbackTimeout = new CancellationTokenSource(
TimeSpan.FromSeconds(_commandTimeoutSeconds));
await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false);
return true;
}
catch
{
return false;
}
}
private static void ValidateTransaction(ManualFinancialDbTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
var expected = transaction.Screen switch
{
ManualFinancialScreenKind.RevenueComposition =>
(Table: "INPUT_PIE", Lock: "LOCK TABLE INPUT_PIE IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.GrowthMetrics =>
(Table: "INPUT_GROW", Lock: "LOCK TABLE INPUT_GROW IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.Sales =>
(Table: "INPUT_SELL", Lock: "LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE"),
ManualFinancialScreenKind.OperatingProfit =>
(Table: "INPUT_PROFIT", Lock: "LOCK TABLE INPUT_PROFIT IN EXCLUSIVE MODE"),
_ => throw InvalidTransaction()
};
if (transaction.OperationId == Guid.Empty ||
!transaction.RequiresExclusiveTableLock ||
!string.Equals(transaction.TableName, expected.Table, StringComparison.Ordinal) ||
!string.Equals(transaction.ExclusiveTableLockSql, expected.Lock, StringComparison.Ordinal) ||
transaction.Commands.Count != 1 || transaction.Commands[0] is null)
{
throw InvalidTransaction();
}
var command = transaction.Commands[0];
var validShape = transaction.Kind switch
{
ManualFinancialMutationKind.Create =>
command.Kind == ManualFinancialDbCommandKind.Insert &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.Update =>
command.Kind == ManualFinancialDbCommandKind.Update &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.DeleteOne =>
command.Kind == ManualFinancialDbCommandKind.DeleteOne &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.ExactlyOne,
ManualFinancialMutationKind.DeleteAll =>
command.Kind == ManualFinancialDbCommandKind.DeleteAll &&
command.AffectedRowsRule == ManualFinancialAffectedRowsRule.AnyNonNegative,
_ => false
};
if (!validShape || string.IsNullOrWhiteSpace(command.Sql) || command.Sql.Contains(';'))
{
throw InvalidTransaction();
}
var placeholders = PlaceholderPattern()
.Matches(command.Sql)
.Select(static match => match.Groups[1].Value)
.ToArray();
var parameters = command.Parameters.ToArray();
if (parameters.Any(static parameter =>
parameter is null ||
parameter.DbType != DbType.String ||
parameter.Value is not string) ||
parameters.Select(static parameter => parameter.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count() != parameters.Length ||
!placeholders.SequenceEqual(
parameters.Select(static parameter => parameter.Name),
StringComparer.OrdinalIgnoreCase))
{
throw InvalidTransaction();
}
}
private static bool Matches(ManualFinancialAffectedRowsRule rule, int count) =>
rule switch
{
ManualFinancialAffectedRowsRule.ExactlyOne => count == 1,
ManualFinancialAffectedRowsRule.AnyNonNegative => count >= 0,
_ => false
};
private static ManualFinancialMutationRejectedException Rejected(
ManualFinancialMutationKind kind,
int affectedRows)
{
var reason = affectedRows is < 0 or > 1
? ManualFinancialMutationRejectionReason.AmbiguousIdentity
: kind == ManualFinancialMutationKind.Create
? ManualFinancialMutationRejectionReason.DuplicateIdentity
: ManualFinancialMutationRejectionReason.NotFoundOrChanged;
return new ManualFinancialMutationRejectedException(
reason,
$"The {kind} precondition failed and the Oracle transaction was rolled back.");
}
private static ManualFinancialMutationException UnknownOutcome(
ManualFinancialMutationKind kind,
Exception exception) =>
new(
$"The {kind} Oracle transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
private static ArgumentException InvalidTransaction() =>
new("The manual-financial transaction is invalid.", "transaction");
[GeneratedRegex(@":([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant)]
private static partial Regex PlaceholderPattern();
}

View File

@@ -0,0 +1,266 @@
#nullable enable
using System.Data;
using System.Data.Common;
using MMoneyCoderSharp.Data;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Executes the closed DC_LIST/PLAY_LIST mutation contract on one Oracle
/// connection and one transaction. Unlike the read executor, this class has no
/// retry loop: repeating a write after a timeout or ambiguous commit could
/// duplicate or destroy an operator playlist.
/// </summary>
public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
public OracleNamedPlaylistMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector = null)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
{
}
internal OracleNamedPlaylistMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
_commandTimeoutSeconds = connectionFactory.GetCommandTimeoutSeconds(DataSourceKind.Oracle);
if (_commandTimeoutSeconds is < 1 or > 600)
{
throw new DatabaseConfigurationException(
"The Oracle named-playlist command timeout must be between 1 and 600 seconds.");
}
}
public async Task<NamedPlaylistDbTransactionResult> ExecuteTransactionAsync(
DataSourceKind source,
NamedPlaylistDbTransaction transaction,
CancellationToken cancellationToken = default)
{
if (source != DataSourceKind.Oracle)
{
throw new ArgumentOutOfRangeException(
nameof(source),
source,
"Named playlists are stored in Oracle.");
}
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
DbConnection? connection = null;
DbTransaction? databaseTransaction = null;
var commitStarted = false;
try
{
connection = _connectionFactory.Create(DataSourceKind.Oracle);
await connection.OpenAsync(operationToken).ConfigureAwait(false);
databaseTransaction = await connection.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
operationToken)
.ConfigureAwait(false);
var affectedRows = new List<int>(transaction.Commands.Count);
foreach (var commandSpec in transaction.Commands)
{
operationToken.ThrowIfCancellationRequested();
await using var command = connection.CreateCommand();
command.CommandText = commandSpec.Sql;
command.CommandType = CommandType.Text;
command.CommandTimeout = _commandTimeoutSeconds;
command.Transaction = databaseTransaction;
_configureCommand(command);
foreach (var parameterSpec in commandSpec.Parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.DbType = parameterSpec.DbType;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
command.Parameters.Add(parameter);
}
// Execute exactly once. There is deliberately no transient retry.
var count = await command.ExecuteNonQueryAsync(operationToken)
.ConfigureAwait(false);
affectedRows.Add(count);
}
commitStarted = true;
await databaseTransaction.CommitAsync(operationToken).ConfigureAwait(false);
return new NamedPlaylistDbTransactionResult(
transaction.OperationId,
Array.AsReadOnly(affectedRows.ToArray()),
DateTimeOffset.UtcNow);
}
catch (Exception exception)
{
if (commitStarted)
{
// Commit may have reached Oracle even when the client observed a
// timeout/cancellation/disconnect. A rollback here cannot prove
// whether commit succeeded, so do not issue another DB command.
throw UnknownOutcome(transaction.Kind, exception);
}
var isCancellationOrTimeout = exception is OperationCanceledException ||
_errorDetector.IsTimeout(DataSourceKind.Oracle, exception);
var rollbackFailed = false;
if (databaseTransaction is not null)
{
rollbackFailed = !await TryRollbackAsync(databaseTransaction)
.ConfigureAwait(false);
}
var outcomeUnknown = databaseTransaction is not null &&
(isCancellationOrTimeout || rollbackFailed);
if (outcomeUnknown)
{
throw UnknownOutcome(transaction.Kind, exception);
}
throw new NamedPlaylistMutationException(
$"The {transaction.Kind} Oracle transaction did not commit.",
outcomeUnknown: false,
exception);
}
finally
{
if (databaseTransaction is not null)
{
await databaseTransaction.DisposeAsync().ConfigureAwait(false);
}
if (connection is not null)
{
await connection.DisposeAsync().ConfigureAwait(false);
}
}
}
internal static void ConfigureOracleCommand(DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
if (command is not OracleCommand oracleCommand)
{
throw new DatabaseConfigurationException(
"The named-playlist mutation connection did not create an Oracle command.");
}
oracleCommand.BindByName = true;
}
private async Task<bool> TryRollbackAsync(DbTransaction transaction)
{
try
{
using var rollbackTimeout = new CancellationTokenSource(
TimeSpan.FromSeconds(_commandTimeoutSeconds));
await transaction.RollbackAsync(rollbackTimeout.Token).ConfigureAwait(false);
return true;
}
catch
{
return false;
}
}
private static void ValidateTransaction(NamedPlaylistDbTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
if (transaction.OperationId == Guid.Empty ||
transaction.ProgramCode.Length != 8 ||
transaction.ProgramCode.Any(static character => !char.IsAsciiDigit(character)) ||
transaction.Commands.Count == 0 ||
transaction.Commands.Any(static command => command is null))
{
throw new ArgumentException(
"The named-playlist transaction is invalid.",
nameof(transaction));
}
var validShape = transaction.Kind switch
{
NamedPlaylistMutationKind.CreateDefinition =>
transaction.Commands.Count == 1 &&
transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.InsertDefinition,
NamedPlaylistMutationKind.ReplaceItems =>
transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.DeleteItems &&
transaction.Commands.Skip(1).All(static command =>
command.Kind == NamedPlaylistDbCommandKind.InsertItem),
NamedPlaylistMutationKind.DeletePlaylist =>
transaction.Commands.Count == 2 &&
transaction.Commands[0].Kind == NamedPlaylistDbCommandKind.DeleteItems &&
transaction.Commands[1].Kind == NamedPlaylistDbCommandKind.DeleteDefinition,
_ => false
};
if (!validShape)
{
throw new ArgumentException(
"The named-playlist transaction command order is invalid.",
nameof(transaction));
}
foreach (var command in transaction.Commands)
{
if (string.IsNullOrWhiteSpace(command.Sql) || command.Sql.Contains(';'))
{
throw new ArgumentException(
"The named-playlist transaction SQL is invalid.",
nameof(transaction));
}
var programCodeParameters = command.Parameters.Where(static parameter =>
string.Equals(parameter.Name, "program_code", StringComparison.OrdinalIgnoreCase));
var programCodeParameter = programCodeParameters.SingleOrDefault();
if (programCodeParameter?.Value is not string commandProgramCode ||
!string.Equals(
commandProgramCode,
transaction.ProgramCode,
StringComparison.Ordinal))
{
throw new ArgumentException(
"The named-playlist transaction program identity is invalid.",
nameof(transaction));
}
}
}
private static NamedPlaylistMutationException UnknownOutcome(
NamedPlaylistMutationKind kind,
Exception exception) =>
new(
$"The {kind} Oracle transaction outcome is unknown; do not retry automatically.",
outcomeUnknown: true,
exception);
}

View File

@@ -30,11 +30,11 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
public const int MaximumFileSizeBytes = 32 * 1024;
private const int ExpectedRowCount = 5;
private const int ExpectedColumnCount = 4;
private const int MaximumCellLength = 256;
internal const int ExpectedRowCount = 5;
internal const int ExpectedColumnCount = 4;
internal const int MaximumCellLength = 256;
private static readonly Encoding Cp949Encoding = CreateCp949Encoding();
internal static readonly Encoding Cp949Encoding = CreateCp949Encoding();
private readonly string _trustedDirectory;
private readonly StringComparison _pathComparison = OperatingSystem.IsWindows()
@@ -68,13 +68,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var fileName = audience switch
{
S5025ManualAudience.Individual => "개인.dat",
S5025ManualAudience.Foreign => "외국인.dat",
S5025ManualAudience.Institution => "기관.dat",
_ => throw InvalidData()
};
var fileName = GetFileName(audience);
try
{
@@ -91,6 +85,11 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
TrustedDirectoryLease.EnsureRegularFileHandle(
stream.SafeFileHandle,
path,
MaximumFileSizeBytes,
allowEmpty: false);
if (stream.Length <= 0 || stream.Length > MaximumFileSizeBytes)
{
throw InvalidData();
@@ -147,7 +146,17 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
private string GetContainedFilePath(string fileName)
internal string TrustedDirectory => _trustedDirectory;
internal static string GetFileName(S5025ManualAudience audience) => audience switch
{
S5025ManualAudience.Individual => "개인.dat",
S5025ManualAudience.Foreign => "외국인.dat",
S5025ManualAudience.Institution => "기관.dat",
_ => throw InvalidData()
};
internal string GetContainedFilePath(string fileName)
{
var candidate = Path.GetFullPath(Path.Combine(_trustedDirectory, fileName));
var prefix = Path.EndsInDirectorySeparator(_trustedDirectory)
@@ -195,19 +204,14 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
// The approved legacy files contain five data rows followed by one empty
// terminal record. s5025 intentionally rendered `row - 1`, thereby
// ignoring exactly that record. Preserve that contract without accepting
// internal blank rows or an arbitrary number of trailing records.
if (lines.Count == ExpectedRowCount + 1 && lines[^1].Length == 0)
{
lines.RemoveAt(lines.Count - 1);
}
if (lines.Count != ExpectedRowCount)
// FSell always wrote five rows plus exactly one empty terminal record.
// Original s5025 rendered `row - 1`; accepting a five-row file without
// that record would therefore render one more row than the original.
if (lines.Count != ExpectedRowCount + 1 || lines[^1].Length != 0)
{
throw InvalidData();
}
lines.RemoveAt(lines.Count - 1);
var rows = new S5025TrustedManualRow[ExpectedRowCount];
for (var rowIndex = 0; rowIndex < lines.Count; rowIndex++)
@@ -261,7 +265,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
private static void EnsureDirectoryIsTrusted(string directory)
internal static void EnsureDirectoryIsTrusted(string directory)
{
var current = new DirectoryInfo(directory);
while (current is not null)
@@ -277,7 +281,7 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
private static void EnsureRegularNonReparseFile(string path)
internal static void EnsureRegularNonReparseFile(string path)
{
var attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.Directory) != 0
@@ -313,5 +317,191 @@ public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSo
}
}
private static S5025TrustedManualDataException InvalidData() => new();
internal static S5025TrustedManualDataException InvalidData() => new();
}
/// <summary>
/// Closed read/write boundary used by the migrated FSell operator screen. The
/// browser selects only one of the three legacy audiences; it can never supply
/// a directory or file name. Writes are validated, CP949 encoded, and replaced
/// atomically inside the composition-root-owned trusted directory.
/// </summary>
public sealed class S5025TrustedManualFileStore : IS5025TrustedManualDataSource, IDisposable
{
private readonly TrustedDirectoryLease _directoryLease;
private readonly S5025TrustedManualFileDataSource _source;
public S5025TrustedManualFileStore(string trustedDirectory)
{
_directoryLease = TrustedManualDirectory.AcquirePrivateWritableLease(trustedDirectory);
_source = new S5025TrustedManualFileDataSource(trustedDirectory);
}
public static S5025TrustedManualFileStore CreateFromEnvironment(
Func<string, string?>? readEnvironmentVariable = null)
{
var read = readEnvironmentVariable ?? Environment.GetEnvironmentVariable;
return new S5025TrustedManualFileStore(
read(S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable) ?? string.Empty);
}
public Task<IReadOnlyList<S5025TrustedManualRow>> ReadAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default) =>
_source.ReadAsync(audience, cancellationToken);
public async Task WriteAsync(
S5025ManualAudience audience,
IReadOnlyList<S5025TrustedManualRow> rows,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var fileName = S5025TrustedManualFileDataSource.GetFileName(audience);
ValidateRows(rows);
string text;
byte[] bytes;
try
{
// FSell wrote five records and one empty terminal record. Preserve
// that exact shape because the scene reader deliberately validates it.
text = string.Join("\r\n", rows.Select(row => string.Join(
'^',
row.LeftName,
row.LeftAmount,
row.RightName,
row.RightAmount))) + "\r\n\r\n";
bytes = S5025TrustedManualFileDataSource.Cp949Encoding.GetBytes(text);
}
catch (EncoderFallbackException)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
if (bytes.Length <= 0 ||
bytes.Length > S5025TrustedManualFileDataSource.MaximumFileSizeBytes)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var directory = _source.TrustedDirectory;
var destination = _source.GetContainedFilePath(fileName);
var temporaryName = $".{fileName}.{Guid.NewGuid():N}.tmp";
var temporary = _source.GetContainedFilePath(temporaryName);
try
{
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
if (File.Exists(destination))
{
S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination);
}
await using (var stream = new FileStream(
temporary,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.WriteThrough))
{
TrustedDirectoryLease.EnsureRegularFileHandle(
stream.SafeFileHandle,
temporary,
S5025TrustedManualFileDataSource.MaximumFileSizeBytes,
allowEmpty: true);
await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
stream.Flush(flushToDisk: true);
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(temporary);
if (File.Exists(destination))
{
S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination);
}
File.Move(temporary, destination, overwrite: true);
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
S5025TrustedManualFileDataSource.EnsureRegularNonReparseFile(destination);
// Read through the production parser before reporting success. This
// makes a malformed or partially persisted file fail closed.
_ = await _source.ReadAsync(audience, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (S5025TrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
SecurityException or
ArgumentException or
NotSupportedException)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
finally
{
try
{
if (File.Exists(temporary))
{
var attributes = File.GetAttributes(temporary);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0)
{
File.Delete(temporary);
}
}
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
SecurityException)
{
// A failed cleanup does not alter the success/failure result. A
// hidden temporary file is never considered by the scene source.
}
}
}
public void Dispose() => _directoryLease.Dispose();
private static void ValidateRows(IReadOnlyList<S5025TrustedManualRow>? rows)
{
if (rows is null || rows.Count != S5025TrustedManualFileDataSource.ExpectedRowCount)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
foreach (var row in rows)
{
if (row is null)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var values = new[]
{
row.LeftName,
row.LeftAmount,
row.RightName,
row.RightAmount
};
if (values.Any(value =>
value is null ||
value.Length > S5025TrustedManualFileDataSource.MaximumCellLength ||
value.Contains('^') ||
value.Any(char.IsControl)))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
}
}

View File

@@ -0,0 +1,367 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Creates the app-owned operator-data directory without following an existing
/// reparse ancestor, replaces inherited ACLs with a closed per-user ACL, and pins
/// every directory component against rename/delete while a writable store is alive.
/// </summary>
public static class TrustedManualDirectory
{
public static string PreparePrivateWritableDirectory(string directory)
{
var fullPath = NormalizeAbsolutePath(directory);
ValidateExistingAncestorsBeforeCreate(fullPath);
Directory.CreateDirectory(fullPath);
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath);
if (OperatingSystem.IsWindows())
{
ApplyPrivateWindowsAcl(fullPath);
ValidatePrivateWindowsAcl(fullPath);
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath);
return fullPath;
}
internal static TrustedDirectoryLease AcquirePrivateWritableLease(string directory)
{
var fullPath = NormalizeAbsolutePath(directory);
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(fullPath);
if (OperatingSystem.IsWindows())
{
ValidatePrivateWindowsAcl(fullPath);
}
return TrustedDirectoryLease.Acquire(fullPath);
}
internal static string NormalizeAbsolutePath(string directory)
{
if (string.IsNullOrWhiteSpace(directory) ||
directory.IndexOf('\0') >= 0 ||
!Path.IsPathFullyQualified(directory))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
}
private static void ValidateExistingAncestorsBeforeCreate(string fullPath)
{
var current = new DirectoryInfo(fullPath);
while (!current.Exists)
{
if (File.Exists(current.FullName))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
current = current.Parent ??
throw S5025TrustedManualFileDataSource.InvalidData();
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(current.FullName);
}
[SupportedOSPlatform("windows")]
private static void ApplyPrivateWindowsAcl(string fullPath)
{
var currentUser = GetCurrentUserSid();
var security = new DirectorySecurity();
security.SetOwner(currentUser);
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
const InheritanceFlags inheritance =
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
AddFullControl(security, currentUser, inheritance);
AddFullControl(security, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), inheritance);
AddFullControl(
security,
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
inheritance);
new DirectoryInfo(fullPath).SetAccessControl(security);
}
[SupportedOSPlatform("windows")]
private static void ValidatePrivateWindowsAcl(string fullPath)
{
var currentUser = GetCurrentUserSid();
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var administrators = new SecurityIdentifier(
WellKnownSidType.BuiltinAdministratorsSid,
null);
var allowed = new HashSet<string>(StringComparer.Ordinal)
{
currentUser.Value,
system.Value,
administrators.Value
};
var security = new DirectoryInfo(fullPath).GetAccessControl(
AccessControlSections.Access | AccessControlSections.Owner);
var owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier;
if (!security.AreAccessRulesProtected ||
owner is null ||
!allowed.Contains(owner.Value))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var currentUserHasFullControl = false;
foreach (FileSystemAccessRule rule in security.GetAccessRules(
includeExplicit: true,
includeInherited: true,
targetType: typeof(SecurityIdentifier)))
{
var sid = (SecurityIdentifier)rule.IdentityReference;
if (rule.AccessControlType != AccessControlType.Allow ||
!allowed.Contains(sid.Value))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
if (sid.Equals(currentUser) &&
(rule.FileSystemRights & FileSystemRights.FullControl) ==
FileSystemRights.FullControl)
{
currentUserHasFullControl = true;
}
}
if (!currentUserHasFullControl)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
[SupportedOSPlatform("windows")]
private static SecurityIdentifier GetCurrentUserSid() =>
WindowsIdentity.GetCurrent().User ??
throw S5025TrustedManualFileDataSource.InvalidData();
[SupportedOSPlatform("windows")]
private static void AddFullControl(
DirectorySecurity security,
SecurityIdentifier identity,
InheritanceFlags inheritance) =>
security.AddAccessRule(new FileSystemAccessRule(
identity,
FileSystemRights.FullControl,
inheritance,
PropagationFlags.None,
AccessControlType.Allow));
}
internal sealed class TrustedDirectoryLease : IDisposable
{
private const uint GenericRead = 0x80000000;
private const uint OpenExisting = 3;
private const uint FileFlagBackupSemantics = 0x02000000;
private const uint FileFlagOpenReparsePoint = 0x00200000;
private const uint FileNameNormalized = 0;
private readonly IReadOnlyList<SafeFileHandle> _directoryHandles;
private TrustedDirectoryLease(IReadOnlyList<SafeFileHandle> directoryHandles)
{
_directoryHandles = directoryHandles;
}
public static TrustedDirectoryLease Acquire(string directory)
{
if (!OperatingSystem.IsWindows())
{
return new TrustedDirectoryLease(Array.Empty<SafeFileHandle>());
}
var ancestors = new List<string>();
for (var current = new DirectoryInfo(directory); current is not null; current = current.Parent)
{
ancestors.Add(current.FullName);
}
ancestors.Reverse();
var handles = new List<SafeFileHandle>(ancestors.Count);
try
{
foreach (var ancestor in ancestors)
{
var handle = CreateFileW(
ancestor,
GenericRead,
FileShare.Read | FileShare.Write,
IntPtr.Zero,
OpenExisting,
FileFlagBackupSemantics | FileFlagOpenReparsePoint,
IntPtr.Zero);
if (handle.IsInvalid)
{
handle.Dispose();
throw new Win32Exception(Marshal.GetLastWin32Error());
}
EnsureDirectoryHandleIsDirect(handle, ancestor);
handles.Add(handle);
}
return new TrustedDirectoryLease(handles.AsReadOnly());
}
catch
{
foreach (var handle in handles)
{
handle.Dispose();
}
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
public static void EnsureRegularFileHandle(
SafeFileHandle handle,
string expectedPath,
long maximumLength,
bool allowEmpty)
{
if (!OperatingSystem.IsWindows())
{
return;
}
if (!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes &
(FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
information.NumberOfLinks != 1)
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var length = ((long)information.FileSizeHigh << 32) | information.FileSizeLow;
if (length > maximumLength || (!allowEmpty && length <= 0))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
var resolved = GetFinalPath(handle);
var expected = Path.GetFullPath(expectedPath);
if (!string.Equals(resolved, expected, StringComparison.OrdinalIgnoreCase))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
public void Dispose()
{
foreach (var handle in _directoryHandles.Reverse())
{
handle.Dispose();
}
}
private static void EnsureDirectoryHandleIsDirect(SafeFileHandle handle, string expectedPath)
{
if (!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes & FileAttributes.Directory) == 0 ||
(information.FileAttributes & FileAttributes.ReparsePoint) != 0 ||
!string.Equals(
GetFinalPath(handle),
Path.GetFullPath(expectedPath),
StringComparison.OrdinalIgnoreCase))
{
throw S5025TrustedManualFileDataSource.InvalidData();
}
}
private static string GetFinalPath(SafeFileHandle handle)
{
var capacity = 512;
while (capacity <= 32_768)
{
var buffer = new StringBuilder(capacity);
var length = GetFinalPathNameByHandleW(
handle,
buffer,
(uint)buffer.Capacity,
FileNameNormalized);
if (length == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (length < buffer.Capacity)
{
var path = buffer.ToString();
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
{
path = "\\\\" + path[8..];
}
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
{
path = path[4..];
}
return Path.GetFullPath(path);
}
capacity = checked((int)length + 1);
}
throw S5025TrustedManualFileDataSource.InvalidData();
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string fileName,
uint desiredAccess,
FileShare shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file,
out ByHandleFileInformation information);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle file,
StringBuilder path,
uint pathLength,
uint flags);
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public FileAttributes FileAttributes;
public FileTime CreationTime;
public FileTime LastAccessTime;
public FileTime LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
}
}

View File

@@ -0,0 +1,377 @@
using System.Security;
using System.Security.Cryptography;
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public sealed record ViTrustedManualItem(string Code, string Name);
public sealed record ViTrustedManualSnapshot(
IReadOnlyList<ViTrustedManualItem> Items,
string RowVersion);
public sealed record ViTrustedManualWriteResult(
ViTrustedManualSnapshot Snapshot,
bool Persisted);
public sealed class ViTrustedManualDataException : Exception
{
internal ViTrustedManualDataException()
: base("The trusted VI manual-data store is unavailable or invalid.")
{
}
}
/// <summary>
/// Closed store for the original VI발동.dat operator list. The path remains a
/// composition-root decision; WebView callers can read or replace only the
/// validated code/name rows and cannot choose a file.
/// </summary>
public sealed class ViTrustedManualFileStore : ITrustedViStockNameSnapshotSource, IDisposable
{
public const int MaximumItemCount = 100;
public const int MaximumFileSizeBytes = 64 * 1024;
private const string FileName = "VI발동.dat";
private const int MaximumCodeLength = 64;
private const int MaximumNameLength = 200;
private static readonly Encoding Cp949 = CreateCp949Encoding();
private readonly TrustedDirectoryLease _directoryLease;
private readonly S5025TrustedManualFileDataSource _pathBoundary;
public ViTrustedManualFileStore(string trustedDirectory)
{
_directoryLease = TrustedManualDirectory.AcquirePrivateWritableLease(trustedDirectory);
_pathBoundary = new S5025TrustedManualFileDataSource(trustedDirectory);
}
public async Task<IReadOnlyList<ViTrustedManualItem>> ReadAsync(
CancellationToken cancellationToken = default) =>
(await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false)).Items;
public async Task<ViTrustedManualSnapshot> ReadSnapshotAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var directory = _pathBoundary.TrustedDirectory;
var path = _pathBoundary.GetContainedFilePath(FileName);
try
{
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
if (!File.Exists(path))
{
return CreateSnapshot(Array.Empty<ViTrustedManualItem>());
}
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw InvalidData();
}
byte[] bytes;
await using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
TrustedDirectoryLease.EnsureRegularFileHandle(
stream.SafeFileHandle,
path,
MaximumFileSizeBytes,
allowEmpty: true);
if (stream.Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
bytes = new byte[checked((int)stream.Length)];
var offset = 0;
while (offset < bytes.Length)
{
var read = await stream.ReadAsync(
bytes.AsMemory(offset), cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw InvalidData();
}
offset += read;
}
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
EnsureViFile(path);
if (bytes.Length == 0)
{
return CreateSnapshot(Array.Empty<ViTrustedManualItem>());
}
RejectUnicodeBom(bytes);
var text = Cp949.GetString(bytes);
var rows = new List<ViTrustedManualItem>();
using var reader = new StringReader(text);
while (reader.ReadLine() is { } rawLine)
{
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
var columns = line.Split('^');
if (columns.Length != 9 || columns.Skip(2).Any(value => value.Length != 0))
{
throw InvalidData();
}
var item = new ViTrustedManualItem(columns[0], columns[1]);
ValidateItem(item);
rows.Add(item);
if (rows.Count > MaximumItemCount)
{
throw InvalidData();
}
}
return CreateSnapshot(rows);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (ViTrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException or SecurityException or
ArgumentException or NotSupportedException or DecoderFallbackException)
{
throw InvalidData();
}
}
public async Task<ViTrustedManualWriteResult> WriteAsync(
IReadOnlyList<ViTrustedManualItem> items,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (items is null || items.Count > MaximumItemCount)
{
throw InvalidData();
}
foreach (var item in items)
{
ValidateItem(item);
}
// Original VILIST did not call WriteListFile when the grid was empty.
// Preserve the existing file and return its current version explicitly.
if (items.Count == 0)
{
return new ViTrustedManualWriteResult(
await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false),
Persisted: false);
}
byte[] bytes;
try
{
bytes = SerializeCanonical(items);
}
catch (EncoderFallbackException)
{
throw InvalidData();
}
if (bytes.Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
var directory = _pathBoundary.TrustedDirectory;
var destination = _pathBoundary.GetContainedFilePath(FileName);
var temporary = _pathBoundary.GetContainedFilePath(
$".{FileName}.{Guid.NewGuid():N}.tmp");
try
{
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
if (File.Exists(destination))
{
EnsureViFile(destination);
}
await using (var stream = new FileStream(
temporary,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.WriteThrough))
{
TrustedDirectoryLease.EnsureRegularFileHandle(
stream.SafeFileHandle,
temporary,
MaximumFileSizeBytes,
allowEmpty: true);
await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
stream.Flush(flushToDisk: true);
}
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
var temporaryAttributes = File.GetAttributes(temporary);
if ((temporaryAttributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
new FileInfo(temporary).Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
if (File.Exists(destination))
{
EnsureViFile(destination);
}
File.Move(temporary, destination, overwrite: true);
S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(directory);
var persisted = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false);
var expectedVersion = ComputeRowVersion(bytes);
if (!persisted.Items.SequenceEqual(items) ||
!string.Equals(persisted.RowVersion, expectedVersion, StringComparison.Ordinal))
{
throw InvalidData();
}
return new ViTrustedManualWriteResult(persisted, Persisted: true);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (ViTrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException or SecurityException or
ArgumentException or NotSupportedException)
{
throw InvalidData();
}
finally
{
try
{
if (File.Exists(temporary))
{
var attributes = File.GetAttributes(temporary);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0)
{
File.Delete(temporary);
}
}
}
catch (Exception exception) when (exception is
IOException or UnauthorizedAccessException or SecurityException)
{
// A leftover hidden temporary file is never consumed by the store.
}
}
}
public async Task<TrustedViStockNameSnapshot> ReadStockNameSnapshotAsync(
CancellationToken cancellationToken = default)
{
var snapshot = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false);
return new TrustedViStockNameSnapshot(
Array.AsReadOnly(snapshot.Items.Select(item => item.Name).ToArray()),
snapshot.RowVersion);
}
public void Dispose() => _directoryLease.Dispose();
private static void ValidateItem(ViTrustedManualItem? item)
{
if (item is null ||
!IsSafe(item.Code, MaximumCodeLength) ||
!IsSafe(item.Name, MaximumNameLength) ||
item.Name.Contains(','))
{
throw InvalidData();
}
}
private static bool IsSafe(string? value, int maximumLength) =>
value is not null &&
value.Length is > 0 &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
!value.Contains('^') &&
!value.Any(char.IsControl);
private static void EnsureViFile(string path)
{
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
new FileInfo(path).Length > MaximumFileSizeBytes)
{
throw InvalidData();
}
}
private static ViTrustedManualSnapshot CreateSnapshot(
IReadOnlyList<ViTrustedManualItem> items)
{
var immutable = Array.AsReadOnly(items.ToArray());
byte[] canonical;
try
{
canonical = SerializeCanonical(immutable);
}
catch (EncoderFallbackException)
{
throw InvalidData();
}
return new ViTrustedManualSnapshot(immutable, ComputeRowVersion(canonical));
}
private static byte[] SerializeCanonical(IEnumerable<ViTrustedManualItem> items)
{
var text = string.Concat(items.Select(item =>
$"{item.Code}^{item.Name}^^^^^^^\r\n"));
return Cp949.GetBytes(text);
}
private static string ComputeRowVersion(ReadOnlySpan<byte> canonicalBytes) =>
Convert.ToHexString(SHA256.HashData(canonicalBytes)).ToLowerInvariant();
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private static void RejectUnicodeBom(ReadOnlySpan<byte> bytes)
{
if (bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ||
bytes.StartsWith(new byte[] { 0xFF, 0xFE }) ||
bytes.StartsWith(new byte[] { 0xFE, 0xFF }) ||
bytes.StartsWith(new byte[] { 0x00, 0x00, 0xFE, 0xFF }))
{
throw InvalidData();
}
}
private static ViTrustedManualDataException InvalidData() => new();
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Infrastructure.Tests")]