508 lines
18 KiB
C#
508 lines
18 KiB
C#
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;
|
|
|
|
internal const int ExpectedRowCount = 5;
|
|
internal const int ExpectedColumnCount = 4;
|
|
internal const int MaximumCellLength = 256;
|
|
|
|
internal 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 = GetFileName(audience);
|
|
|
|
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))
|
|
{
|
|
TrustedDirectoryLease.EnsureRegularFileHandle(
|
|
stream.SafeFileHandle,
|
|
path,
|
|
MaximumFileSizeBytes,
|
|
allowEmpty: false);
|
|
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();
|
|
}
|
|
}
|
|
|
|
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)
|
|
? _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());
|
|
}
|
|
}
|
|
|
|
// 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++)
|
|
{
|
|
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();
|
|
}
|
|
}
|
|
|
|
internal 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;
|
|
}
|
|
}
|
|
|
|
internal 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();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|