Complete legacy operator UI and playout migration
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user