feat: migrate legacy playout workflow and scenes

This commit is contained in:
2026-07-10 23:58:45 +09:00
parent 491a740505
commit 8dae7b8e0d
128 changed files with 39177 additions and 205 deletions

View File

@@ -35,7 +35,7 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
.GetAwaiter()
.GetResult();
public async Task<DataTable> ExecuteAsync(
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
@@ -43,6 +43,38 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
return ExecuteAsyncCore(
source,
tableName,
query,
parameters: null,
cancellationToken);
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
query.ValidateFor(source);
return ExecuteAsyncCore(
source,
tableName,
query.Sql,
query.Parameters,
cancellationToken);
}
private async Task<DataTable> ExecuteAsyncCore(
DataSourceKind source,
string tableName,
string query,
IReadOnlyList<DataQueryParameter>? parameters,
CancellationToken cancellationToken)
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
@@ -52,12 +84,19 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
{
try
{
return await ExecuteAttemptAsync(source, tableName, query, operationToken)
return await ExecuteAttemptAsync(
source,
tableName,
query,
parameters,
operationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
throw new OperationCanceledException(
"The database query was canceled.",
cancellationToken);
}
catch (OperationCanceledException)
{
@@ -98,6 +137,7 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
DataSourceKind source,
string tableName,
string query,
IReadOnlyList<DataQueryParameter>? parameters,
CancellationToken cancellationToken)
{
await using var connection = _connectionFactory.Create(source);
@@ -108,6 +148,23 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
command.CommandType = CommandType.Text;
command.CommandTimeout = _connectionFactory.GetCommandTimeoutSeconds(source);
if (parameters is not null)
{
foreach (var parameterSpec in parameters)
{
var parameter = command.CreateParameter();
parameter.ParameterName = parameterSpec.Name;
parameter.Direction = ParameterDirection.Input;
parameter.Value = parameterSpec.Value ?? DBNull.Value;
if (parameterSpec.DbType.HasValue)
{
parameter.DbType = parameterSpec.DbType.Value;
}
command.Parameters.Add(parameter);
}
}
await using var reader = await command
.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken)
.ConfigureAwait(false);
@@ -218,15 +275,31 @@ public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
"INSERT",
"UPDATE",
"DELETE",
"REPLACE",
"MERGE",
"CALL",
"EXEC",
"EXECUTE",
"CREATE",
"ALTER",
"DROP",
"TRUNCATE",
"GRANT",
"REVOKE"
"REVOKE",
"INTO",
"LOCK",
"SET",
"USE",
"DO",
"LOAD",
"BEGIN",
"DECLARE",
"COMMIT",
"ROLLBACK",
"SAVEPOINT",
"FUNCTION",
"PROCEDURE",
"PRAGMA"
];
foreach (var token in stateChangingTokens)

View File

@@ -0,0 +1,317 @@
using System.Security;
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Raised when the trusted s5025 manual-data boundary cannot prove that its
/// configuration or input file satisfies the closed production contract.
/// The exception deliberately does not retain filesystem exceptions, which can
/// contain the configured external path.
/// </summary>
public sealed class S5025TrustedManualDataException : Exception
{
internal S5025TrustedManualDataException()
: base("The trusted s5025 manual-data source is unavailable or invalid.")
{
}
}
/// <summary>
/// Reads the three legacy CP949 s5025 manual-data files from one directory
/// selected only by the application composition root. Callers select a closed
/// audience value and can never supply a path or filename.
/// </summary>
public sealed class S5025TrustedManualFileDataSource : IS5025TrustedManualDataSource
{
public const string DirectoryEnvironmentVariable =
"MBN_STOCK_S5025_MANUAL_DATA_DIRECTORY";
public const int MaximumFileSizeBytes = 32 * 1024;
private const int ExpectedRowCount = 5;
private const int ExpectedColumnCount = 4;
private const int MaximumCellLength = 256;
private static readonly Encoding Cp949Encoding = CreateCp949Encoding();
private readonly string _trustedDirectory;
private readonly StringComparison _pathComparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
/// <summary>
/// Creates the source from an explicit composition-root setting. The
/// directory must already exist and be an absolute, non-reparse path.
/// </summary>
public S5025TrustedManualFileDataSource(string trustedDirectory)
{
_trustedDirectory = NormalizeAndValidateTrustedDirectory(trustedDirectory);
}
/// <summary>
/// Creates the source from the process environment. The injectable reader
/// is intended for composition-root tests and does not alter the closed
/// runtime data-source contract.
/// </summary>
public static S5025TrustedManualFileDataSource CreateFromEnvironment(
Func<string, string?>? readEnvironmentVariable = null)
{
var read = readEnvironmentVariable ?? Environment.GetEnvironmentVariable;
return new S5025TrustedManualFileDataSource(
read(DirectoryEnvironmentVariable) ?? string.Empty);
}
public async Task<IReadOnlyList<S5025TrustedManualRow>> ReadAsync(
S5025ManualAudience audience,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var fileName = audience switch
{
S5025ManualAudience.Individual => "개인.dat",
S5025ManualAudience.Foreign => "외국인.dat",
S5025ManualAudience.Institution => "기관.dat",
_ => throw InvalidData()
};
try
{
EnsureDirectoryIsTrusted(_trustedDirectory);
var path = GetContainedFilePath(fileName);
EnsureRegularNonReparseFile(path);
byte[] bytes;
await using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
if (stream.Length <= 0 || 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;
}
if (await stream.ReadAsync(
new byte[1],
cancellationToken).ConfigureAwait(false) != 0)
{
throw InvalidData();
}
}
// Re-check the closed path after reading. FileShare.Read prevents a
// conforming Windows writer from replacing or modifying the file
// while the handle is open; this second check also detects a path
// replacement immediately after close.
EnsureDirectoryIsTrusted(_trustedDirectory);
EnsureRegularNonReparseFile(path);
return Parse(bytes);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (S5025TrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
SecurityException or
ArgumentException or
NotSupportedException or
DecoderFallbackException)
{
throw InvalidData();
}
}
private string GetContainedFilePath(string fileName)
{
var candidate = Path.GetFullPath(Path.Combine(_trustedDirectory, fileName));
var prefix = Path.EndsInDirectorySeparator(_trustedDirectory)
? _trustedDirectory
: _trustedDirectory + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(prefix, _pathComparison))
{
throw InvalidData();
}
var relative = Path.GetRelativePath(_trustedDirectory, candidate);
if (Path.IsPathRooted(relative)
|| relative.Equals("..", _pathComparison)
|| relative.StartsWith(".." + Path.DirectorySeparatorChar, _pathComparison)
|| relative.StartsWith(".." + Path.AltDirectorySeparatorChar, _pathComparison)
|| !relative.Equals(fileName, _pathComparison))
{
throw InvalidData();
}
return candidate;
}
private static IReadOnlyList<S5025TrustedManualRow> Parse(byte[] bytes)
{
RejectUnicodeBom(bytes);
string text;
try
{
text = Cp949Encoding.GetString(bytes);
}
catch (DecoderFallbackException)
{
throw InvalidData();
}
var lines = new List<string>(ExpectedRowCount);
using (var reader = new StringReader(text))
{
while (reader.ReadLine() is { } line)
{
lines.Add(line.Trim());
}
}
// 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)
{
throw InvalidData();
}
var rows = new S5025TrustedManualRow[ExpectedRowCount];
for (var rowIndex = 0; rowIndex < lines.Count; rowIndex++)
{
var columns = lines[rowIndex].Split('^');
if (columns.Length != ExpectedColumnCount
|| columns.Any(column =>
column.Length > MaximumCellLength || column.Any(char.IsControl)))
{
throw InvalidData();
}
rows[rowIndex] = new S5025TrustedManualRow(
columns[0],
columns[1],
columns[2],
columns[3]);
}
return rows;
}
private static string NormalizeAndValidateTrustedDirectory(string trustedDirectory)
{
if (string.IsNullOrWhiteSpace(trustedDirectory)
|| trustedDirectory.IndexOf('\0') >= 0
|| !Path.IsPathFullyQualified(trustedDirectory))
{
throw InvalidData();
}
try
{
var fullPath = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(trustedDirectory));
EnsureDirectoryIsTrusted(fullPath);
return fullPath;
}
catch (S5025TrustedManualDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
SecurityException or
ArgumentException or
NotSupportedException)
{
throw InvalidData();
}
}
private static void EnsureDirectoryIsTrusted(string directory)
{
var current = new DirectoryInfo(directory);
while (current is not null)
{
var attributes = File.GetAttributes(current.FullName);
if ((attributes & FileAttributes.Directory) == 0
|| (attributes & FileAttributes.ReparsePoint) != 0)
{
throw InvalidData();
}
current = current.Parent;
}
}
private static void EnsureRegularNonReparseFile(string path)
{
var attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.Directory) != 0
|| (attributes & FileAttributes.ReparsePoint) != 0)
{
throw InvalidData();
}
var length = new FileInfo(path).Length;
if (length <= 0 || length > MaximumFileSizeBytes)
{
throw InvalidData();
}
}
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 S5025TrustedManualDataException InvalidData() => new();
}