using System.Security; using System.Text; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.Infrastructure; /// /// 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. /// public sealed class S5025TrustedManualDataException : Exception { internal S5025TrustedManualDataException() : base("The trusted s5025 manual-data source is unavailable or invalid.") { } } /// /// 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. /// 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; /// /// Creates the source from an explicit composition-root setting. The /// directory must already exist and be an absolute, non-reparse path. /// public S5025TrustedManualFileDataSource(string trustedDirectory) { _trustedDirectory = NormalizeAndValidateTrustedDirectory(trustedDirectory); } /// /// 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. /// public static S5025TrustedManualFileDataSource CreateFromEnvironment( Func? readEnvironmentVariable = null) { var read = readEnvironmentVariable ?? Environment.GetEnvironmentVariable; return new S5025TrustedManualFileDataSource( read(DirectoryEnvironmentVariable) ?? string.Empty); } public async Task> 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 Parse(byte[] bytes) { RejectUnicodeBom(bytes); string text; try { text = Cp949Encoding.GetString(bytes); } catch (DecoderFallbackException) { throw InvalidData(); } var lines = new List(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 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(); } /// /// 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. /// 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? readEnvironmentVariable = null) { var read = readEnvironmentVariable ?? Environment.GetEnvironmentVariable; return new S5025TrustedManualFileStore( read(S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable) ?? string.Empty); } public Task> ReadAsync( S5025ManualAudience audience, CancellationToken cancellationToken = default) => _source.ReadAsync(audience, cancellationToken); public async Task WriteAsync( S5025ManualAudience audience, IReadOnlyList 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? 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(); } } } }