Complete legacy operator UI and playout migration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Infrastructure.Tests")]
|
||||
Reference in New Issue
Block a user