feat: restore legacy named playlist workflows

This commit is contained in:
2026-07-12 11:59:18 +09:00
parent a481c96c39
commit af80c36cde
52 changed files with 9261 additions and 83 deletions

View File

@@ -0,0 +1,571 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
public sealed record NamedManualRestoreReadOnlyAuditSummary(
int CandidateCount,
int FreshMaterializedCount,
int BlockedCount,
LegacyManualOperatorDataRuntimeState RuntimeState,
bool OperatorFilesStable,
IReadOnlyDictionary<LegacyNamedManualRestoreKind, int> KindCounts,
IReadOnlyDictionary<string, int> BlockReasons);
/// <summary>
/// Production-data audit for the manual subset of PLAY_LIST. Every database
/// command is a SELECT through an IDataQueryExecutor. Operator files are opened
/// only through their production read paths; the before/after snapshot proves
/// that no file, marker, or intent entry was created or changed.
/// </summary>
public static class NamedManualRestoreReadOnlyAudit
{
private const string QueryName = "AUDIT_NAMED_MANUAL_RESTORE_ROWS";
private const int MaximumRows = 20_000;
private const int BrowserStockSearchLimit = 200;
private const string Sql = """
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
FROM (
SELECT TRIM(d.DC_CODE) DC_CODE,
d.DC_TITLE DC_TITLE,
p.LIST_TEXT LIST_TEXT,
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
FROM DC_LIST d
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
)
WHERE ROWNUM <= :row_limit
""";
public static async Task<NamedManualRestoreReadOnlyAuditSummary> RunAsync(
IDataQueryExecutor executor,
string trustedDirectory,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(executor);
if (string.IsNullOrWhiteSpace(trustedDirectory) ||
!Path.IsPathFullyQualified(trustedDirectory))
{
throw InvalidBoundary("trusted directory composition");
}
cancellationToken.ThrowIfCancellationRequested();
var candidates = await LoadCandidatesAsync(executor, cancellationToken)
.ConfigureAwait(false);
var runtime = LegacyManualOperatorDataRuntimeGuard.Inspect(trustedDirectory);
if (!runtime.IsReady)
{
throw InvalidBoundary("trusted runtime state");
}
var before = SnapshotDirectory(trustedDirectory);
var results = new List<RestoreResult>(candidates.Count);
var financialService = new LegacyManualFinancialScreenService(executor);
var stockSearchService = new LegacyStockSearchService(executor);
var netSellSource = new S5025TrustedManualFileDataSource(trustedDirectory);
TrustedSourceEvidence trustedSources;
using (var viSource = new ViTrustedManualFileStore(trustedDirectory))
{
trustedSources = await ReadTrustedSourcesAsync(
netSellSource,
viSource,
cancellationToken)
.ConfigureAwait(false);
foreach (var candidate in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
results.Add(candidate.Kind switch
{
LegacyNamedManualRestoreKind.Financial =>
await ResolveFinancialAsync(
candidate,
financialService,
stockSearchService,
cancellationToken)
.ConfigureAwait(false),
LegacyNamedManualRestoreKind.NetSell =>
await ResolveNetSellAsync(candidate, netSellSource, cancellationToken)
.ConfigureAwait(false),
LegacyNamedManualRestoreKind.Vi =>
await ResolveViAsync(candidate, viSource, cancellationToken)
.ConfigureAwait(false),
_ => throw InvalidBoundary("manual candidate kind")
});
}
}
var after = SnapshotDirectory(trustedDirectory);
var filesStable = before.Matches(after);
if (!filesStable)
{
throw InvalidBoundary("operator file immutability");
}
var kindCounts = candidates
.GroupBy(static candidate => candidate.Kind)
.ToDictionary(static group => group.Key, static group => group.Count());
var screenCounts = candidates
.Where(static candidate =>
candidate.Kind == LegacyNamedManualRestoreKind.Financial)
.GroupBy(static candidate => candidate.FinancialScreen!.Value)
.OrderBy(static group => group.Key)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var audienceCounts = candidates
.Where(static candidate => candidate.Kind == LegacyNamedManualRestoreKind.NetSell)
.GroupBy(static candidate => candidate.Audience!.Value)
.OrderBy(static group => group.Key)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var viRows = candidates
.Where(static candidate => candidate.Kind == LegacyNamedManualRestoreKind.Vi)
.ToArray();
var financialOutcomes = candidates
.Select((candidate, index) => (Candidate: candidate, Result: results[index]))
.Where(static pair =>
pair.Candidate.Kind == LegacyNamedManualRestoreKind.Financial)
.GroupBy(static pair => pair.Candidate.FinancialScreen!.Value)
.OrderBy(static group => group.Key)
.Select(group =>
{
var freshCount = group.Count(static pair => pair.Result.IsFresh);
var blocked = group
.Where(static pair => !pair.Result.IsFresh)
.GroupBy(static pair => pair.Result.Reason, StringComparer.Ordinal)
.OrderBy(static reason => reason.Key, StringComparer.Ordinal)
.Select(reason =>
$"{reason.Key}:{reason.Count().ToString(CultureInfo.InvariantCulture)}");
return $"{group.Key}=fresh:{freshCount.ToString(CultureInfo.InvariantCulture)}" +
(freshCount == group.Count()
? string.Empty
: $"/blocked:{string.Join('+', blocked)}");
});
var blockReasons = results
.Where(static result => !result.IsFresh)
.GroupBy(static result => result.Reason, StringComparer.Ordinal)
.ToDictionary(static group => group.Key, static group => group.Count(), StringComparer.Ordinal);
var fresh = results.Count(static result => result.IsFresh);
Console.WriteLine(
"PLIST_MANUAL_GRAMMAR_AUDIT: " +
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
$"financial={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.Financial).ToString(CultureInfo.InvariantCulture)} " +
$"netSell={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.NetSell).ToString(CultureInfo.InvariantCulture)} " +
$"vi={kindCounts.GetValueOrDefault(LegacyNamedManualRestoreKind.Vi).ToString(CultureInfo.InvariantCulture)} " +
$"historical={candidates.Count(static candidate => candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)} " +
$"canonical={candidates.Count(static candidate => !candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine("PLIST_MANUAL_FINANCIAL_SCREENS: " + string.Join(',', screenCounts));
Console.WriteLine(
"PLIST_MANUAL_FINANCIAL_OUTCOMES: " + string.Join(',', financialOutcomes));
Console.WriteLine("PLIST_MANUAL_NET_SELL_AUDIENCES: " + string.Join(',', audienceCounts));
Console.WriteLine(
"PLIST_MANUAL_VI_GRAMMAR: " +
$"historical={viRows.Count(static candidate => candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)} " +
$"versioned={viRows.Count(static candidate => !candidate.IsHistorical).ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"PLIST_MANUAL_TRUSTED_SOURCES: " +
$"netSellAudiencesReady={trustedSources.NetSellAudiencesReady.ToString(CultureInfo.InvariantCulture)} " +
$"viSnapshotReady={trustedSources.ViSnapshotReady.ToString().ToLowerInvariant()} " +
$"viItems={trustedSources.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
$"viPages={trustedSources.ViPageCount.ToString(CultureInfo.InvariantCulture)} " +
"openedHandle=true");
Console.WriteLine(
"PLIST_MANUAL_FRESH_PROOF: " +
$"freshMaterialized={fresh.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={(results.Count - fresh).ToString(CultureInfo.InvariantCulture)} " +
"graphE=double-read-row-version+fresh-stock-identity " +
"netSell=opened-handle-production-parser " +
"vi=opened-handle-version+identity+page-check " +
"sceneDataAndAssetsNotAsserted=true");
Console.WriteLine(
"PLIST_MANUAL_BLOCK_REASONS: " +
(blockReasons.Count == 0
? "none"
: string.Join(
',',
blockReasons.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(pair =>
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
Console.WriteLine(
"PLIST_MANUAL_LOCAL_READ_AUDIT: " +
$"runtimeState={runtime.State} " +
$"entries={before.Entries.Count.ToString(CultureInfo.InvariantCulture)} " +
"filesStable=true writesAttempted=false markerOrIntentCreated=false");
return new NamedManualRestoreReadOnlyAuditSummary(
candidates.Count,
fresh,
results.Count - fresh,
runtime.State,
filesStable,
kindCounts,
blockReasons);
}
public static string DefaultTrustedDirectory()
{
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(localAppData))
{
throw InvalidBoundary("local application-data root");
}
return Path.GetFullPath(Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"OperatorData"));
}
private static async Task<IReadOnlyList<LegacyNamedManualRestoreCandidate>> LoadCandidatesAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
Sql,
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await executor.ExecuteAsync(
DataSourceKind.Oracle,
QueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
if (table is null ||
table.Columns.Count != columns.Length ||
table.Rows.Count is < 1 or > MaximumRows)
{
throw InvalidBoundary("PList query schema or row bound");
}
for (var index = 0; index < columns.Length; index++)
{
if (table.Columns[index].ColumnName != columns[index] ||
table.Columns[index].DataType != typeof(string))
{
throw InvalidBoundary("PList query schema");
}
}
var candidates = new List<LegacyNamedManualRestoreCandidate>();
var routedManualCount = 0;
foreach (DataRow dataRow in table.Rows)
{
var parsed = LegacyPListReadAuditParser.Parse(
dataRow[0] as string,
dataRow[1] as string,
dataRow[3] as string,
dataRow[2] as string);
if (!parsed.IsSuccess)
{
throw InvalidBoundary("PList row parse");
}
var row = parsed.Row!;
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
if (route == LegacyPListAuditRoute.ManualFreshMaterialization)
{
routedManualCount++;
}
var candidate = LegacyNamedManualRestoreClassifier.Classify(row);
if (candidate is not null)
{
candidates.Add(candidate);
}
else if (route == LegacyPListAuditRoute.ManualFreshMaterialization)
{
throw InvalidBoundary("manual route closed grammar");
}
}
if (candidates.Count != routedManualCount)
{
throw InvalidBoundary("manual route/classifier agreement");
}
return candidates.AsReadOnly();
}
private static async Task<RestoreResult> ResolveFinancialAsync(
LegacyNamedManualRestoreCandidate candidate,
LegacyManualFinancialScreenService financialService,
LegacyStockSearchService stockSearchService,
CancellationToken cancellationToken)
{
try
{
var identity = new ManualFinancialIdentity(
candidate.FinancialScreen!.Value,
candidate.StockName!);
var firstRead = await financialService.GetAsync(identity, cancellationToken)
.ConfigureAwait(false);
var query = LegacyNamedManualFreshProof.FinancialStockSearchQuery(candidate);
var stockSearch = await stockSearchService.SearchAsync(
query,
BrowserStockSearchLimit,
cancellationToken)
.ConfigureAwait(false);
var secondRead = await financialService.GetAsync(identity, cancellationToken)
.ConfigureAwait(false);
var failure = LegacyNamedManualFreshProof.EvaluateFinancial(
candidate,
firstRead,
secondRead,
stockSearch);
return RestoreResult.From(failure);
}
catch (ManualFinancialNotFoundException)
{
return RestoreResult.Blocked("GRAPH_E_NOT_FOUND");
}
catch (ManualFinancialAmbiguousIdentityException)
{
return RestoreResult.Blocked("GRAPH_E_AMBIGUOUS");
}
catch (ManualFinancialDataException)
{
return RestoreResult.Blocked("GRAPH_E_DATA_INVALID");
}
catch (StockSearchDataException)
{
return RestoreResult.Blocked("STOCK_MASTER_DATA_INVALID");
}
catch (ArgumentException)
{
return RestoreResult.Blocked("GRAPH_E_IDENTITY_INVALID");
}
}
private static async Task<RestoreResult> ResolveNetSellAsync(
LegacyNamedManualRestoreCandidate candidate,
S5025TrustedManualFileDataSource source,
CancellationToken cancellationToken)
{
try
{
var rows = await source.ReadAsync(candidate.Audience!.Value, cancellationToken)
.ConfigureAwait(false);
return RestoreResult.From(
LegacyNamedManualFreshProof.EvaluateNetSell(candidate, rows));
}
catch (S5025TrustedManualDataException)
{
return RestoreResult.Blocked("NET_SELL_TRUSTED_READ_INVALID");
}
}
private static async Task<TrustedSourceEvidence> ReadTrustedSourcesAsync(
S5025TrustedManualFileDataSource netSellSource,
ViTrustedManualFileStore viSource,
CancellationToken cancellationToken)
{
var audiencesReady = 0;
foreach (var audience in Enum.GetValues<S5025ManualAudience>())
{
var rows = await netSellSource.ReadAsync(audience, cancellationToken)
.ConfigureAwait(false);
if (rows.Count != 5)
{
throw InvalidBoundary("net-sell production parser");
}
audiencesReady++;
}
var vi = await viSource.ReadStockNameSnapshotAsync(cancellationToken)
.ConfigureAwait(false);
if (!TrustedViManualReference.IsRowVersion(vi.RowVersion) ||
vi.StockNames.Count is < 1 or > TrustedViManualReference.MaximumItemCount)
{
throw InvalidBoundary("VI production parser");
}
var canonical = new LegacySceneSelection(
"PAGED_VI",
TrustedViManualReference.SubjectSentinel,
"INPUT_ORDER",
"CURRENT",
vi.RowVersion);
var secondRead = await TrustedViManualReference.ResolveAsync(
canonical,
pageIndexZeroBased: 0,
viSource,
cancellationToken)
.ConfigureAwait(false);
if (!secondRead.StockNames.SequenceEqual(vi.StockNames, StringComparer.Ordinal))
{
throw InvalidBoundary("VI stable second read");
}
return new TrustedSourceEvidence(
audiencesReady,
ViSnapshotReady: true,
vi.StockNames.Count,
checked((vi.StockNames.Count + 4) / 5));
}
private static async Task<RestoreResult> ResolveViAsync(
LegacyNamedManualRestoreCandidate candidate,
ViTrustedManualFileStore source,
CancellationToken cancellationToken)
{
try
{
var snapshot = await source.ReadStockNameSnapshotAsync(cancellationToken)
.ConfigureAwait(false);
var failure = LegacyNamedManualFreshProof.EvaluateVi(candidate, snapshot);
if (failure != LegacyNamedManualFreshProofFailure.None)
{
return RestoreResult.From(failure);
}
var canonical = new LegacySceneSelection(
"PAGED_VI",
TrustedViManualReference.SubjectSentinel,
"INPUT_ORDER",
"CURRENT",
snapshot.RowVersion);
var resolved = await TrustedViManualReference.ResolveAsync(
canonical,
pageIndexZeroBased: 0,
source,
cancellationToken)
.ConfigureAwait(false);
return resolved.PageNumberOneBased == 1 &&
resolved.StockNames.SequenceEqual(
snapshot.StockNames,
StringComparer.Ordinal)
? RestoreResult.Fresh()
: RestoreResult.Blocked("VI_SECOND_READ_CHANGED");
}
catch (ViTrustedManualDataException)
{
return RestoreResult.Blocked("VI_TRUSTED_READ_INVALID");
}
catch (LegacySceneDataException)
{
return RestoreResult.Blocked("VI_SECOND_READ_CHANGED");
}
}
private static DirectorySnapshot SnapshotDirectory(string trustedDirectory)
{
try
{
var directory = new DirectoryInfo(trustedDirectory);
if (!directory.Exists ||
(directory.Attributes & (FileAttributes.ReparsePoint | FileAttributes.Directory)) !=
FileAttributes.Directory)
{
throw InvalidBoundary("operator directory");
}
var entries = new List<FileSnapshot>();
foreach (var path in Directory.EnumerateFileSystemEntries(trustedDirectory)
.OrderBy(static value => Path.GetFileName(value), StringComparer.Ordinal))
{
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw InvalidBoundary("operator directory entry");
}
var before = new FileInfo(path);
byte[] digest;
long length;
using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4_096,
FileOptions.SequentialScan))
{
length = stream.Length;
digest = SHA256.HashData(stream);
if (stream.Length != length)
{
throw InvalidBoundary("operator file stable read");
}
}
var after = new FileInfo(path);
if (after.Length != length ||
before.LastWriteTimeUtc != after.LastWriteTimeUtc ||
before.CreationTimeUtc != after.CreationTimeUtc)
{
throw InvalidBoundary("operator file stable read");
}
entries.Add(new FileSnapshot(
Path.GetFileName(path),
length,
after.CreationTimeUtc,
after.LastWriteTimeUtc,
Convert.ToHexString(digest)));
}
directory.Refresh();
return new DirectorySnapshot(directory.LastWriteTimeUtc, entries.AsReadOnly());
}
catch (InvalidDataException)
{
throw;
}
catch (Exception exception) when (exception is
IOException or
UnauthorizedAccessException or
ArgumentException or
NotSupportedException)
{
throw InvalidBoundary("operator file snapshot");
}
}
private static InvalidDataException InvalidBoundary(string detail) =>
new($"The read-only named manual restore audit failed its {detail} boundary. " +
"No source values were printed.");
private sealed record RestoreResult(bool IsFresh, string Reason)
{
public static RestoreResult Fresh() => new(true, "NONE");
public static RestoreResult Blocked(string reason) => new(false, reason);
public static RestoreResult From(LegacyNamedManualFreshProofFailure failure) =>
failure == LegacyNamedManualFreshProofFailure.None
? Fresh()
: Blocked(failure.ToString().ToUpperInvariant());
}
private sealed record TrustedSourceEvidence(
int NetSellAudiencesReady,
bool ViSnapshotReady,
int ViItemCount,
int ViPageCount);
private sealed record FileSnapshot(
string Name,
long Length,
DateTime CreationTimeUtc,
DateTime LastWriteTimeUtc,
string Sha256);
private sealed record DirectorySnapshot(
DateTime LastWriteTimeUtc,
IReadOnlyList<FileSnapshot> Entries)
{
public bool Matches(DirectorySnapshot other) =>
other is not null &&
LastWriteTimeUtc == other.LastWriteTimeUtc &&
Entries.SequenceEqual(other.Entries);
}
}

View File

@@ -0,0 +1,802 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using MMoneyCoderSharp.Data;
internal static class NamedPlaylistReadOnlyDbAudit
{
private const string QueryName = "AUDIT_ACTIVE_NAMED_PLAYLIST_ROWS";
private const int MaximumRows = 20_000;
private const string Sql = """
SELECT DC_CODE, DC_TITLE, LIST_TEXT, ITEM_INDEX
FROM (
SELECT TRIM(d.DC_CODE) DC_CODE,
d.DC_TITLE DC_TITLE,
p.LIST_TEXT LIST_TEXT,
TO_CHAR(TO_NUMBER(p.ITEM_INDEX)) ITEM_INDEX
FROM DC_LIST d
JOIN PLAY_LIST p ON p.PG_CODE = d.DC_CODE
ORDER BY TRIM(d.DC_CODE), TO_NUMBER(p.ITEM_INDEX)
)
WHERE ROWNUM <= :row_limit
""";
internal static async Task RunAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(executor);
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine("PLIST_READ_AUDIT: BEGIN");
var rawRows = await LoadRawRowsAsync(executor, cancellationToken).ConfigureAwait(false);
Console.WriteLine(
$"PLIST_READ_AUDIT_RAW: rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)}");
var persistence = new LegacyNamedPlaylistPersistenceService(executor);
var definitions = await persistence
.ListAsync(LegacyNamedPlaylistPersistenceService.MaximumDefinitions, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (definitions.IsTruncated || definitions.Items.Count == 0)
{
throw InvalidBoundary("definition cardinality");
}
Console.WriteLine(
$"PLIST_READ_AUDIT_DEFINITIONS: count={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)}");
var rawByProgram = rawRows
.GroupBy(static row => row.ProgramCode, StringComparer.Ordinal)
.ToDictionary(
static group => group.Key,
static group => (IReadOnlyList<LegacyPListAuditRow>)group.ToArray(),
StringComparer.Ordinal);
if (rawByProgram.Count != definitions.Items.Count ||
rawRows.Count != rawByProgram.Values.Sum(static rows => rows.Count))
{
throw InvalidBoundary("active definition join");
}
var loadedRows = 0;
foreach (var definition in definitions.Items)
{
cancellationToken.ThrowIfCancellationRequested();
if (!rawByProgram.TryGetValue(definition.ProgramCode, out var programRows) ||
!LegacyPListReadAuditEvidence.HasContiguousIndexes(programRows) ||
programRows.Any(row =>
!string.Equals(row.ProgramTitle, definition.Title, StringComparison.Ordinal)))
{
throw InvalidBoundary("program identity or item order");
}
var loaded = await persistence
.LoadAsync(definition.ProgramCode, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (loaded.Definition != definition || loaded.Items.Count != programRows.Count)
{
throw InvalidBoundary("typed load cardinality");
}
for (var index = 0; index < programRows.Count; index++)
{
if (!LegacyPListReadAuditEvidence.MatchesPersistenceResult(
programRows[index],
loaded.Items[index]))
{
throw InvalidBoundary("lossless typed load comparison");
}
}
loadedRows += loaded.Items.Count;
}
if (loadedRows != rawRows.Count)
{
throw InvalidBoundary("typed load total");
}
Console.WriteLine(
$"PLIST_READ_AUDIT_TYPED: rows={loadedRows.ToString(CultureInfo.InvariantCulture)}");
IReadOnlySet<SelectionKey> fixedSignatures;
try
{
fixedSignatures = LoadFixedSignatures();
}
catch (InvalidDataException exception) when (
exception.Message.StartsWith(
"The read-only PList audit failed its ",
StringComparison.Ordinal))
{
Console.WriteLine("PLIST_FIXED_MAPPER_BOUNDARY: " + exception.Message);
throw;
}
Console.WriteLine(
"PLIST_FIXED_MAPPER_AUDIT: " +
$"sourceActions=328 unavailable=6 " +
$"uniqueSignatures={fixedSignatures.Count.ToString(CultureInfo.InvariantCulture)} " +
"sourceHashes=verified");
var routedRows = rawRows
.Select(row => new RoutedRow(row, ClassifyRoute(row, fixedSignatures)))
.ToArray();
var routeCounts = routedRows
.GroupBy(static routed => routed.Route)
.ToDictionary(static group => group.Key, static group => group.Count());
MaterializationSummary outcomes;
try
{
outcomes = await ResolveMaterializationAsync(
executor,
routedRows,
routeCounts,
cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidDataException exception) when (
exception.Message.StartsWith(
"The read-only PList audit failed its ",
StringComparison.Ordinal))
{
Console.WriteLine("PLIST_MATERIALIZATION_BOUNDARY: " + exception.Message);
throw;
}
if (outcomes.Playable + outcomes.Blocked != rawRows.Count)
{
throw InvalidBoundary("materialization total");
}
Console.WriteLine(
"PLIST_READ_AUDIT: " +
$"definitions={definitions.Items.Count.ToString(CultureInfo.InvariantCulture)} " +
$"rows={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
$"parsed={rawRows.Count.ToString(CultureInfo.InvariantCulture)} " +
$"lossless={rawRows.Count(static row => row.IsLossless).ToString(CultureInfo.InvariantCulture)} " +
"replacement=0 mojibake=0 nonContiguousPrograms=0 typedMismatch=0");
Console.WriteLine(
"PLIST_ROUTE_AUDIT: " + string.Join(
",",
Enum.GetValues<LegacyPListAuditRoute>().Select(route =>
$"{route}={routeCounts.GetValueOrDefault(route).ToString(CultureInfo.InvariantCulture)}")));
Console.WriteLine(
"PLIST_MATERIALIZATION_AUDIT: " +
$"unique={outcomes.Unique.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={outcomes.Playable.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={outcomes.Blocked.ToString(CultureInfo.InvariantCulture)} " +
"sceneDataAndAssetsNotAsserted=true");
Console.WriteLine(
"PLIST_BLOCK_REASONS: " +
(outcomes.BlockReasons.Count == 0
? "none"
: string.Join(
",",
outcomes.BlockReasons
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(pair =>
$"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}"))));
}
private static async Task<IReadOnlyList<LegacyPListAuditRow>> LoadRawRowsAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
Sql,
[new DataQueryParameter("row_limit", MaximumRows + 1, DbType.Int32)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await executor
.ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var columns = new[] { "DC_CODE", "DC_TITLE", "LIST_TEXT", "ITEM_INDEX" };
if (table is null || table.Columns.Count != columns.Length ||
table.Rows.Count is < 1 or > MaximumRows)
{
throw InvalidBoundary("query schema or row bound");
}
for (var index = 0; index < columns.Length; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
columns[index],
StringComparison.Ordinal) ||
table.Columns[index].DataType != typeof(string))
{
throw InvalidBoundary("query schema");
}
}
var rows = new List<LegacyPListAuditRow>(table.Rows.Count);
foreach (DataRow dataRow in table.Rows)
{
var result = LegacyPListReadAuditParser.Parse(
dataRow[0] as string,
dataRow[1] as string,
dataRow[3] as string,
dataRow[2] as string);
if (!result.IsSuccess)
{
// The failure enum contains no source text. Never echo raw
// operator data or identifiers from this audit boundary.
throw InvalidBoundary($"row parse ({result.Failure})");
}
rows.Add(result.Row!);
}
return rows.AsReadOnly();
}
private static async Task<MaterializationSummary> ResolveMaterializationAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> routedRows,
IReadOnlyDictionary<LegacyPListAuditRoute, int> routeCounts,
CancellationToken cancellationToken)
{
var summary = new MaterializationSummary();
// These closed classifiers create one immutable selection candidate.
// This audit does not call the scene data loaders or assert asset and
// PREPARE readiness. Industry fixed rows are independently constrained
// to two markets and three actions by LegacyPListReadAuditEvidence.
summary.AddPlayable(routeCounts.GetValueOrDefault(
LegacyPListAuditRoute.ExistingClosedMapper));
summary.AddPlayable(routeCounts.GetValueOrDefault(
LegacyPListAuditRoute.IndustryFixedClosedMapper));
var comparisonRows = RowsFor(
routedRows,
LegacyPListAuditRoute.ComparisonFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToComparisonRow(row) with { ItemIndex = index });
if (comparisonRows.Count > 0)
{
var resolved = await new LegacyNamedComparisonRestoreService(executor)
.ResolveAsync(comparisonRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("COMPARISON_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
var foreignIndexRows = RowsFor(
routedRows,
LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToForeignIndexCandleRow(row) with { ItemIndex = index });
if (foreignIndexRows.Count > 0)
{
var resolved = await new LegacyForeignIndexCandleRestoreService(executor)
.ResolveAsync(foreignIndexRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("FOREIGN_INDEX_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
var nxtRows = RowsFor(
routedRows,
LegacyPListAuditRoute.NxtThemeFreshIdentity,
static (row, index) => LegacyPListReadAuditEvidence
.ToNxtThemeRow(row) with { ItemIndex = index });
if (nxtRows.Count > 0)
{
var resolved = await new LegacyNamedNxtThemeRestoreService(executor)
.ResolveAsync(nxtRows, cancellationToken)
.ConfigureAwait(false);
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
}
else
{
summary.AddBlocked("NXT_THEME_" + row.Failure!.Value.ToString().ToUpperInvariant());
}
}
}
await ResolveKrxThemesAsync(executor, routedRows, summary, cancellationToken)
.ConfigureAwait(false);
await ResolveForeignStocksAsync(executor, routedRows, summary, cancellationToken)
.ConfigureAwait(false);
// GraphE/FSell/VI require a fresh row-version or opened-handle trusted
// file read. The DB-only audit intentionally does not claim those
// external proofs and keeps every such row blocked with one reason.
summary.AddBlocked(
"MANUAL_TRUSTED_SOURCE_FRESH_READ_REQUIRED",
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.ManualFreshMaterialization));
summary.AddBlocked(
"NO_UNIQUE_CLOSED_MAPPER",
routeCounts.GetValueOrDefault(LegacyPListAuditRoute.Unmapped));
return summary;
}
private static async Task ResolveKrxThemesAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> rows,
MaterializationSummary summary,
CancellationToken cancellationToken)
{
var candidates = RowsFor(
rows,
LegacyPListAuditRoute.KrxThemeFreshIdentity,
static (row, index) => new LegacyNamedKrxThemeRestoreRow(
index,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode));
if (candidates.Count == 0)
{
return;
}
var resolved = await new LegacyNamedKrxThemeRestoreService(executor)
.ResolveAsync(candidates, cancellationToken)
.ConfigureAwait(false);
var mapped = 0;
var remapped = 0;
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
mapped++;
if (row.Identity!.CodeWasRemapped)
{
remapped++;
}
}
else
{
summary.AddBlocked("KRX_THEME_" + KrxThemeFailureToken(row.Failure!.Value));
}
}
Console.WriteLine(
"PLIST_KRX_THEME_RESTORE: " +
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
$"codeRemapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)}");
}
private static async Task ResolveForeignStocksAsync(
IDataQueryExecutor executor,
IReadOnlyList<RoutedRow> rows,
MaterializationSummary summary,
CancellationToken cancellationToken)
{
var candidates = RowsFor(
rows,
LegacyPListAuditRoute.ForeignStockFreshIdentity,
static (row, index) => new LegacyNamedForeignStockRestoreRow(
index,
row.IsEnabled,
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.PageTextForDisplay,
row.DataCode));
if (candidates.Count == 0)
{
return;
}
var resolved = await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync(candidates, cancellationToken)
.ConfigureAwait(false);
var mapped = 0;
foreach (var row in resolved.Rows)
{
if (row.IsResolved)
{
summary.AddPlayable(1);
mapped++;
}
else
{
summary.AddBlocked(
"FOREIGN_STOCK_" + ForeignStockFailureToken(row.Failure!.Value));
}
}
var actions = resolved.Rows
.Where(static row => row.IsResolved)
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var databaseIssues = resolved.Rows
.Where(static row =>
row.Failure == LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid)
.GroupBy(static row => row.DatabaseIssue?.ToString() ?? "UNCLASSIFIED", StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
var slashSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Contains('/'));
var nonAsciiSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Any(static character => !char.IsAscii(character)));
var hangulSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit));
var internalSpaceSymbols = resolved.Rows.Count(static row =>
row.IsResolved && row.Identity!.Symbol.Contains(' '));
var hangulActions = resolved.Rows
.Where(static row =>
row.IsResolved && row.Identity!.Symbol.Any(IsHangulCodeUnit))
.GroupBy(static row => row.Identity!.ActionId, StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.Select(group =>
$"{group.Key}={group.Count().ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"PLIST_FOREIGN_STOCK_RESTORE: " +
$"candidates={candidates.Count.ToString(CultureInfo.InvariantCulture)} " +
$"selectionMapped={mapped.ToString(CultureInfo.InvariantCulture)} " +
$"restoreBlocked={(candidates.Count - mapped).ToString(CultureInfo.InvariantCulture)} " +
$"slashSymbols={slashSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"nonAsciiSymbols={nonAsciiSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"hangulSymbols={hangulSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"internalSpaceSymbols={internalSpaceSymbols.ToString(CultureInfo.InvariantCulture)} " +
$"actions={string.Join(',', actions)} " +
$"hangulActions={string.Join(',', hangulActions)} " +
$"databaseIssues={(databaseIssues.Any() ? string.Join(',', databaseIssues) : "none")} " +
"rawValuesPrinted=false");
}
private static string KrxThemeFailureToken(LegacyNamedKrxThemeRestoreFailure failure) =>
failure switch
{
LegacyNamedKrxThemeRestoreFailure.InvalidRow => "INVALID_ROW",
LegacyNamedKrxThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION",
LegacyNamedKrxThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
LegacyNamedKrxThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY",
LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
_ => throw new ArgumentOutOfRangeException(nameof(failure))
};
private static string ForeignStockFailureToken(
LegacyNamedForeignStockRestoreFailure failure) =>
failure switch
{
LegacyNamedForeignStockRestoreFailure.InvalidRow => "INVALID_ROW",
LegacyNamedForeignStockRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid => "DATABASE_DATA_INVALID",
_ => throw new ArgumentOutOfRangeException(nameof(failure))
};
private static IReadOnlyList<T> RowsFor<T>(
IReadOnlyList<RoutedRow> rows,
LegacyPListAuditRoute route,
Func<LegacyPListAuditRow, int, T> convert) =>
rows
.Where(row => row.Route == route)
.Select(static row => row.Row)
.Select(convert)
.ToArray();
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsHangulCodeUnit(char value) =>
value is >= '\uAC00' and <= '\uD7AF' or
>= '\u1100' and <= '\u11FF' or
>= '\u3130' and <= '\u318F';
private static InvalidDataException InvalidBoundary(string detail) =>
new($"The read-only PList audit failed its {detail} boundary. No source values were printed.");
private static LegacyPListAuditRoute ClassifyRoute(
LegacyPListAuditRow row,
IReadOnlySet<SelectionKey> fixedSignatures)
{
var route = LegacyPListReadAuditEvidence.ClassifyRoute(row);
if (route != LegacyPListAuditRoute.Unmapped)
{
return route;
}
var key = new SelectionKey(
row.GroupCode,
row.Subject,
row.GraphicType,
row.Subtype,
row.DataCode);
return fixedSignatures.Contains(key)
? LegacyPListAuditRoute.ExistingClosedMapper
: LegacyPListAuditRoute.Unmapped;
}
private static IReadOnlySet<SelectionKey> LoadFixedSignatures()
{
var profile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(profile))
{
throw InvalidBoundary("legacy fixed-profile root");
}
var directory = Path.GetFullPath(Path.Combine(
profile,
"source",
"repos",
"MBN_STOCK_N",
"MBN_STOCK_N",
"bin",
"Debug",
"Res"));
if (!Directory.Exists(directory) ||
(File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0)
{
throw InvalidBoundary("legacy fixed source directory");
}
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var encoding = Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
var signatureCounts = new Dictionary<SelectionKey, int>();
var actionCount = 0;
foreach (var source in FixedSources)
{
var path = Path.GetFullPath(Path.Combine(directory, source.FileName));
if (!string.Equals(
Path.GetDirectoryName(path),
directory,
StringComparison.OrdinalIgnoreCase) ||
!File.Exists(path) ||
(File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
{
throw InvalidBoundary("legacy fixed source file");
}
byte[] bytes;
using (var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read))
{
if (stream.Length is < 1 or > 32_768)
{
throw InvalidBoundary("legacy fixed source size");
}
bytes = new byte[stream.Length];
stream.ReadExactly(bytes);
}
if (!string.Equals(
Convert.ToHexString(SHA256.HashData(bytes)),
source.ExpectedSha256,
StringComparison.Ordinal))
{
throw InvalidBoundary("legacy fixed source hash");
}
string text;
try
{
text = encoding.GetString(bytes);
}
catch (DecoderFallbackException)
{
throw InvalidBoundary("legacy fixed source encoding");
}
AppendFixedSignatures(signatureCounts, source.Market, text, ref actionCount);
}
// The three audited runtime INI files contain 328 source actions. Six
// unavailable manual placeholders are deliberately excluded below.
if (actionCount != 322)
{
throw InvalidBoundary("legacy fixed signature cardinality");
}
// A raw row must identify exactly one current action. If two source
// actions collapse to one historical five-field signature, retain no
// signature for that key and let the row fail closed as unmapped.
return signatureCounts
.Where(static pair => pair.Value == 1)
.Select(static pair => pair.Key)
.ToHashSet();
}
private static void AppendFixedSignatures(
IDictionary<SelectionKey, int> signatureCounts,
string market,
string text,
ref int actionCount)
{
string? section = null;
foreach (var rawLine in text.Split(["\r\n"], StringSplitOptions.None))
{
// MainForm's runtime INI loader calls Trim() on every line before
// it builds the tree and playlist signature.
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line.Length >= 3 && line[0] == '[' && line[^1] == ']')
{
section = line[1..^1];
if (section.Length == 0 || LegacyPListReadAuditParser.ContainsMojibakeSignal(section))
{
throw InvalidBoundary("legacy fixed section");
}
continue;
}
if (section is null || line.Contains('^') ||
LegacyPListReadAuditParser.ContainsMojibakeSignal(line))
{
throw InvalidBoundary("legacy fixed action");
}
if (line == "VI 발동(수동)" ||
section == "매매동향" && line is
("개인 순매도 상위(수동)" or
"외국인 순매도 상위(수동)" or
"기관 순매도 상위(수동)"))
{
continue;
}
actionCount = checked(actionCount + 1);
var separator = line.IndexOf('_');
var first = separator < 0 ? line : line[..separator];
var remainder = separator < 0 ? string.Empty : line[(separator + 1)..];
SelectionKey signature;
if (market is "overseas" or "exchange")
{
signature = new SelectionKey(
market == "overseas" ? "해외지수" : "환율",
first,
section,
remainder,
string.Empty);
}
else if (section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
{
signature = new SelectionKey(
FixedIndexGroup(line),
"지수",
section,
line,
string.Empty);
}
else if (section == "매매동향")
{
signature = new SelectionKey(
FixedIndexGroup(line),
"지수",
first,
remainder,
string.Empty);
}
else
{
signature = new SelectionKey(
section,
"지수",
first,
remainder,
string.Empty);
}
signatureCounts.TryGetValue(signature, out var existingCount);
signatureCounts[signature] = checked(existingCount + 1);
}
}
private static string FixedIndexGroup(string label)
{
if (label.Contains("코스피_NXT", StringComparison.Ordinal)) return "코스피_NXT";
if (label.Contains("코스닥_NXT", StringComparison.Ordinal)) return "코스닥_NXT";
if (label.Contains("코스피", StringComparison.Ordinal)) return "코스피";
if (label.Contains("코스닥", StringComparison.Ordinal)) return "코스닥";
return "전체";
}
private sealed class MaterializationSummary
{
public int Unique { get; private set; }
public int Playable { get; private set; }
public int Blocked { get; private set; }
public Dictionary<string, int> BlockReasons { get; } = new(StringComparer.Ordinal);
public void AddPlayable(int count)
{
if (count < 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
Unique = checked(Unique + count);
Playable = checked(Playable + count);
}
public void AddBlocked(string reason, int count = 1)
{
if (string.IsNullOrWhiteSpace(reason) || count < 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
Unique = checked(Unique + count);
Blocked = checked(Blocked + count);
BlockReasons[reason] = checked(BlockReasons.GetValueOrDefault(reason) + count);
}
}
private readonly record struct RoutedRow(
LegacyPListAuditRow Row,
LegacyPListAuditRoute Route);
private readonly record struct SelectionKey(
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string DataCode);
private sealed record FixedSource(
string FileName,
string Market,
string ExpectedSha256);
private static readonly IReadOnlyList<FixedSource> FixedSources =
[
new(
"해외.ini",
"overseas",
"DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"),
new(
"환율.ini",
"exchange",
"7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"),
new(
"지수.ini",
"index",
"E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6")
];
}

View File

@@ -1,9 +1,14 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
internal static class NxtThemeRestoreDbAudit
{
private const string OriginalConfigurationPath =
@"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\RES\MmoneyCoder.ini";
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
private const string ActiveRowsSql = """
@@ -20,8 +25,12 @@ internal static class NxtThemeRestoreDbAudit
internal static async Task RunAsync(
IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions,
CancellationToken cancellationToken)
{
await RunEndpointAuditAsync(executor, currentOptions, cancellationToken)
.ConfigureAwait(false);
const int maximumRows = 1_000;
var spec = new DataQuerySpec(
ActiveRowsSql,
@@ -116,9 +125,11 @@ internal static class NxtThemeRestoreDbAudit
$"remapped={remapped.ToString(CultureInfo.InvariantCulture)} " +
$"blocked={(rows.Count - resolved).ToString(CultureInfo.InvariantCulture)} " +
$"session={LegacyNamedNxtThemeRestoreService.SessionForLocalHour(DateTime.Now.Hour)}");
Console.WriteLine("NXT_THEME_RESTORE_GRAMMAR: " + string.Join(",", grammar
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value.ToString(CultureInfo.InvariantCulture)}")));
Console.WriteLine(
"NXT_THEME_RESTORE_GRAMMAR: " +
$"variants={grammar.Count.ToString(CultureInfo.InvariantCulture)} " +
$"rows={grammar.Values.Sum().ToString(CultureInfo.InvariantCulture)} " +
"rawValuesPrinted=false");
Console.WriteLine("NXT_THEME_RESTORE_ACTIONS: " +
(actions.Length == 0 ? "none" : string.Join(",", actions)));
Console.WriteLine("NXT_THEME_RESTORE_FAILURES: " +
@@ -129,5 +140,323 @@ internal static class NxtThemeRestoreDbAudit
: $"min={liveCounts.Min().ToString(CultureInfo.InvariantCulture)}," +
$"max={liveCounts.Max().ToString(CultureInfo.InvariantCulture)}," +
$"sum={liveCounts.Sum().ToString(CultureInfo.InvariantCulture)}"));
await RunJoinDiagnosticAsync(executor, rows, result, cancellationToken)
.ConfigureAwait(false);
}
private static async Task RunJoinDiagnosticAsync(
IDataQueryExecutor executor,
IReadOnlyList<LegacyNamedNxtThemeRestoreRow> sourceRows,
LegacyNamedNxtThemeRestoreResult restore,
CancellationToken cancellationToken)
{
var codes = new Dictionary<string, string>(StringComparer.Ordinal);
for (var index = 0; index < restore.Rows.Count; index++)
{
var current = restore.Rows[index].ObservedCurrentThemeCode;
if (current is null)
{
continue;
}
var stored = sourceRows[index].DataCode;
if (codes.TryGetValue(current, out var previous) &&
!string.Equals(previous, stored, StringComparison.Ordinal))
{
throw new InvalidDataException(
"The NXT join audit found conflicting stored codes for one current identity.");
}
codes[current] = stored;
}
if (codes.Count == 0)
{
Console.WriteLine("NXT_THEME_JOIN_DIAGNOSTIC: currentThemes=0");
return;
}
// Sort only to make repeated aggregate runs deterministic. Neither key
// is printed or included in an exception.
var identities = codes
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(static pair => new LegacyNxtThemeJoinAuditIdentity(pair.Key, pair.Value))
.ToArray();
var audit = await new LegacyNxtThemeJoinAuditService(executor)
.AuditAsync(identities, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var evidence = audit.Themes;
var rawCurrent = evidence.Sum(static row => row.RawCurrentItemCount);
var rawStored = evidence.Sum(static row => row.RawStoredItemCount);
var currentOriginal = evidence.Sum(static row => row.CurrentOriginalJoinCount);
var currentNonStopped = evidence.Sum(static row => row.CurrentNonStoppedJoinCount);
var currentLive = evidence.Sum(static row => row.CurrentLiveJoinCount);
var storedOriginal = evidence.Sum(static row => row.StoredOriginalJoinCount);
var storedNonStopped = evidence.Sum(static row => row.StoredNonStoppedJoinCount);
var storedLive = evidence.Sum(static row => row.StoredLiveJoinCount);
var exactCandidate = evidence.Sum(static row => row.CurrentExactJoinCount);
var stripBothCandidate = evidence.Sum(static row => row.CurrentStripBothJoinCount);
var rightSixCandidate = evidence.Sum(static row => row.CurrentRightSixJoinCount);
var validCodes = evidence.Sum(static row => row.CurrentValidItemCodeCount);
var kospiMaster = evidence.Sum(static row => row.CurrentKospiMasterJoinCount);
var kosdaqMaster = evidence.Sum(static row => row.CurrentKosdaqMasterJoinCount);
var kospiOnline = evidence.Sum(static row => row.CurrentKospiOnlineJoinCount);
var kosdaqOnline = evidence.Sum(static row => row.CurrentKosdaqOnlineJoinCount);
var rawCurrentZero = evidence.Count(static row => row.RawCurrentItemCount == 0);
var rawStoredPositive = evidence.Count(static row => row.RawStoredItemCount > 0);
var originalCurrentPositive = evidence.Count(static row => row.CurrentOriginalJoinCount > 0);
Console.WriteLine(
"NXT_THEME_JOIN_DIAGNOSTIC: " +
$"currentThemes={evidence.Count.ToString(CultureInfo.InvariantCulture)} " +
$"remappedThemes={identities.Count(identity => !string.Equals(identity.CurrentThemeCode, identity.StoredThemeCode, StringComparison.Ordinal)).ToString(CultureInfo.InvariantCulture)} " +
$"rawCurrent={rawCurrent.ToString(CultureInfo.InvariantCulture)} " +
$"rawStored={rawStored.ToString(CultureInfo.InvariantCulture)} " +
$"currentOriginalJoin={currentOriginal.ToString(CultureInfo.InvariantCulture)} " +
$"currentNonStopped={currentNonStopped.ToString(CultureInfo.InvariantCulture)} " +
$"currentLive={currentLive.ToString(CultureInfo.InvariantCulture)} " +
$"storedOriginalJoin={storedOriginal.ToString(CultureInfo.InvariantCulture)} " +
$"storedNonStopped={storedNonStopped.ToString(CultureInfo.InvariantCulture)} " +
$"storedLive={storedLive.ToString(CultureInfo.InvariantCulture)} " +
$"currentValidItemCodes={validCodes.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"NXT_THEME_MASTER_JOIN_DIAGNOSTIC: " +
$"kospiMaster={kospiMaster.ToString(CultureInfo.InvariantCulture)} " +
$"kosdaqMaster={kosdaqMaster.ToString(CultureInfo.InvariantCulture)} " +
$"kospiOnline={kospiOnline.ToString(CultureInfo.InvariantCulture)} " +
$"kosdaqOnline={kosdaqOnline.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"NXT_THEME_JOIN_CANDIDATES_DIAGNOSTIC_ONLY: " +
$"exact={exactCandidate.ToString(CultureInfo.InvariantCulture)} " +
$"stripBoth={stripBothCandidate.ToString(CultureInfo.InvariantCulture)} " +
$"rightSix={rightSixCandidate.ToString(CultureInfo.InvariantCulture)}");
Console.WriteLine(
"NXT_THEME_JOIN_CLASSIFICATION: " +
$"currentRawZeroThemes={rawCurrentZero.ToString(CultureInfo.InvariantCulture)} " +
$"storedRawPositiveThemes={rawStoredPositive.ToString(CultureInfo.InvariantCulture)} " +
$"currentOriginalPositiveThemes={originalCurrentPositive.ToString(CultureInfo.InvariantCulture)} " +
$"root={ClassifyRoot(evidence)} " +
"action=read-only-review-no-fallback");
}
private static string ClassifyRoot(
IReadOnlyList<LegacyNxtThemeJoinAuditEvidence> evidence)
{
if (evidence.All(static row => row.RawCurrentItemCount == 0) &&
evidence.All(static row => row.RawStoredItemCount > 0))
{
return "CurrentThemeCodeHasNoSbItemRowsStoredCodeHasRows";
}
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
evidence.All(static row =>
row.CurrentKospiMasterJoinCount == 0 &&
row.CurrentKosdaqMasterJoinCount == 0))
{
return "CurrentItemsMissingFromNxtStockMasters";
}
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
evidence.Any(static row =>
row.CurrentKospiMasterJoinCount > 0 ||
row.CurrentKosdaqMasterJoinCount > 0))
{
return "VAllStockExcludesCurrentNxtMasterRows";
}
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
evidence.All(static row => row.CurrentOriginalJoinCount == 0) &&
evidence.Any(static row =>
row.CurrentExactJoinCount > 0 ||
row.CurrentStripBothJoinCount > 0 ||
row.CurrentRightSixJoinCount > 0))
{
return "ItemCodeJoinShapeMismatch";
}
if (evidence.Any(static row => row.RawCurrentItemCount > 0) &&
evidence.All(static row => row.CurrentOriginalJoinCount == 0))
{
return "CurrentItemsHaveNoStockIdentityJoin";
}
return "MixedOrUnclassifiedReadOnlyEvidence";
}
private static async Task RunEndpointAuditAsync(
IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(executor);
ArgumentNullException.ThrowIfNull(currentOptions);
cancellationToken.ThrowIfCancellationRequested();
var original = ReadOriginalEndpoint(OriginalConfigurationPath);
var current = NormalizeEndpoint(
currentOptions.Host,
currentOptions.Port,
currentOptions.Database);
var spec = new DataQuerySpec(
"SELECT DATABASE() CURRENT_DATABASE, VERSION() SERVER_VERSION");
spec.ValidateFor(DataSourceKind.MariaDb);
var table = await executor.ExecuteAsync(
DataSourceKind.MariaDb,
"AUDIT_NXT_ENDPOINT_METADATA",
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (table is null || table.Columns.Count != 2 || table.Rows.Count != 1 ||
!HasStringColumn(table.Columns[0], "CURRENT_DATABASE") ||
!HasStringColumn(table.Columns[1], "SERVER_VERSION") ||
table.Rows[0][0] is not string actualDatabase ||
table.Rows[0][1] is not string serverVersion ||
string.IsNullOrWhiteSpace(actualDatabase) ||
string.IsNullOrWhiteSpace(serverVersion))
{
throw new InvalidDataException(
"The NXT endpoint metadata audit returned an invalid closed result.");
}
var originalHash = HashEndpoint(original);
var currentHash = HashEndpoint(current);
var configuredEqual = string.Equals(original, current, StringComparison.Ordinal);
var actualDatabaseMatches = string.Equals(
NormalizeDatabase(actualDatabase),
NormalizeDatabase(currentOptions.Database),
StringComparison.Ordinal);
var serverFamily = serverVersion.Contains(
"MariaDB",
StringComparison.OrdinalIgnoreCase)
? "MariaDB"
: "MySqlCompatible";
var versionParts = serverVersion.Split('.', '-', '+');
var versionMajorMinor = versionParts.Length >= 2 &&
int.TryParse(versionParts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) &&
int.TryParse(versionParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
? $"{major.ToString(CultureInfo.InvariantCulture)}.{minor.ToString(CultureInfo.InvariantCulture)}"
: "unclassified";
Console.WriteLine(
"NXT_ENDPOINT_AUDIT: " +
$"originalSha256={originalHash} " +
$"currentSha256={currentHash} " +
$"configuredEqual={configuredEqual.ToString().ToLowerInvariant()} " +
$"actualDatabaseMatchesCurrent={actualDatabaseMatches.ToString().ToLowerInvariant()} " +
$"serverFamily={serverFamily} " +
$"serverMajorMinor={versionMajorMinor}");
}
private static string ReadOriginalEndpoint(string path)
{
if (!File.Exists(path))
{
throw new InvalidDataException(
"The fixed read-only original MariaDB configuration is unavailable.");
}
var source = new FileInfo(path);
if ((source.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
source.Length is < 1 or > 64 * 1024)
{
throw new InvalidDataException(
"The fixed read-only original MariaDB configuration failed its file boundary.");
}
var inMariaSection = false;
string? connectionName = null;
foreach (var rawLine in File.ReadLines(path))
{
var line = rawLine.Trim();
if (line.StartsWith("[", StringComparison.Ordinal) &&
line.EndsWith("]", StringComparison.Ordinal))
{
inMariaSection = string.Equals(line, "[Maria]", StringComparison.Ordinal);
continue;
}
if (!inMariaSection || line.Length == 0 ||
line.StartsWith("//", StringComparison.Ordinal))
{
continue;
}
const string prefix = "ConnectionName=";
if (line.StartsWith(prefix, StringComparison.Ordinal))
{
if (connectionName is not null)
{
throw new InvalidDataException(
"The original MariaDB endpoint is ambiguous.");
}
connectionName = line[prefix.Length..];
}
}
if (connectionName is null)
{
throw new InvalidDataException(
"The original MariaDB endpoint is unavailable.");
}
var slash = connectionName.LastIndexOf('/');
var colon = connectionName.LastIndexOf(':', slash - 1);
if (slash <= 0 || colon <= 0 || colon >= slash - 1 || slash == connectionName.Length - 1 ||
!int.TryParse(
connectionName.AsSpan(colon + 1, slash - colon - 1),
NumberStyles.None,
CultureInfo.InvariantCulture,
out var port))
{
throw new InvalidDataException(
"The original MariaDB endpoint has an invalid closed shape.");
}
return NormalizeEndpoint(
connectionName[..colon],
port,
connectionName[(slash + 1)..]);
}
private static string NormalizeEndpoint(string? host, int port, string? database)
{
var normalizedHost = (host ?? string.Empty)
.Trim()
.TrimEnd('.')
.ToLowerInvariant();
var normalizedDatabase = NormalizeDatabase(database);
if (normalizedHost.Length == 0 || normalizedHost.Length > 253 ||
normalizedDatabase.Length == 0 || normalizedDatabase.Length > 128 ||
port is < 1 or > 65_535 ||
normalizedHost.Any(static value => char.IsControl(value)) ||
normalizedDatabase.Any(static value => char.IsControl(value)))
{
throw new InvalidDataException(
"A MariaDB endpoint is invalid at the closed audit boundary.");
}
return string.Join(
'\u001f',
normalizedHost,
port.ToString(CultureInfo.InvariantCulture),
normalizedDatabase);
}
private static string NormalizeDatabase(string? database) =>
(database ?? string.Empty).Trim().Normalize(NormalizationForm.FormC);
private static string HashEndpoint(string endpoint) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(endpoint)));
private static bool HasStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
}

View File

@@ -1,10 +1,23 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
Console.OutputEncoding = Encoding.UTF8;
if (args.Length == 1 &&
args[0].Equals("--manual-import-audit", StringComparison.OrdinalIgnoreCase))
{
return RunManualImportAudit();
}
if (args.Length == 1 &&
args[0].Equals("--manual-import-fixture", StringComparison.OrdinalIgnoreCase))
{
return await RunManualImportFixtureAsync();
}
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10));
Console.CancelKeyPress += (_, eventArgs) =>
{
@@ -38,7 +51,16 @@ try
await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token);
await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token);
await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token);
await NxtThemeRestoreDbAudit.RunAsync(runtime.Executor, cancellation.Token);
await NamedPlaylistReadOnlyDbAudit.RunAsync(runtime.Executor, cancellation.Token);
await NamedManualRestoreReadOnlyAudit.RunAsync(
runtime.Executor,
NamedManualRestoreReadOnlyAudit.DefaultTrustedDirectory(),
cancellation.Token);
await NxtThemeRestoreDbAudit.RunAsync(
runtime.Executor,
runtime.Options.MariaDb,
cancellation.Token);
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
var service = new LegacyMarketDataService(runtime.Executor);
await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token);
@@ -123,7 +145,362 @@ static string? GetConfigurationPath(string[] arguments)
}
throw new DatabaseConfigurationException(
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>]");
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--manual-import-audit|--manual-import-fixture]");
}
static async Task VerifyLegacyComparisonImportAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
const string expectedSourceSha256 =
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
var result = await new LegacyComparisonPairImportService(
executor,
TrustedLegacyComparisonFileSource.CreateDefault())
.ImportAsync(cancellationToken);
if (!string.Equals(
result.SourceSha256,
expectedSourceSha256,
StringComparison.Ordinal) ||
result.SourceRowCount != 8 ||
result.Pairs.Count != 8 ||
result.Pairs.Where((pair, index) => pair.RowNumber != index + 1).Any())
{
throw new DatabaseConfigurationException(
"The fixed legacy comparison import did not reproduce its audited 8-row identity set.");
}
Console.WriteLine(
$"LegacyComparisonImport: rows={result.SourceRowCount.ToString(CultureInfo.InvariantCulture)}, " +
$"sha256={result.SourceSha256}, identities=fresh-read-only");
}
static async Task<int> RunManualImportFixtureAsync()
{
const string expectedSourceSha256 =
"fd39a3639e574a19f174225e8e65234d6bdd4c58700bb5d10230a779a03a2114";
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
var profile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolderOption.DoNotVerify);
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
{
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: FAIL - required profile roots are unavailable.");
return 1;
}
var sourceDirectory = Path.Combine(
profile,
"source",
"repos",
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
var operatingDirectory = Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"OperatorData");
var temporaryDirectory = Path.GetFullPath(Path.Combine(
Path.GetTempPath(),
"MBN_STOCK_WEBVIEW-manual-import-fixture-" + Guid.NewGuid().ToString("N")));
var expectedDataFiles = new[]
{
"개인.dat",
"외국인.dat",
"기관.dat",
"VI발동.dat"
};
var trackedOperatingFiles = expectedDataFiles.Concat(new[]
{
LegacyManualOperatorDataImporter.MarkerFileName,
LegacyManualOperatorDataImporter.IntentFileName
}).ToArray();
var sourceBefore = SnapshotFiles(sourceDirectory, expectedDataFiles);
var operatingBefore = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
try
{
var destination = TrustedManualDirectory.PreparePrivateWritableDirectory(
temporaryDirectory);
LegacyManualOperatorImportResult result;
LegacyManualOperatorImportException? secondFailure = null;
using (var importer = new LegacyManualOperatorDataImporter(
destination,
new FixedLegacyManualOperatorDataSource()))
{
result = await importer.ImportAsync(cancellation.Token);
try
{
_ = await importer.ImportAsync(cancellation.Token);
}
catch (LegacyManualOperatorImportException exception)
{
secondFailure = exception;
}
}
var sourceAfter = SnapshotFiles(sourceDirectory, expectedDataFiles);
var operatingAfter = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
var fixtureFiles = SnapshotFiles(destination, trackedOperatingFiles);
var dataHashesMatch = expectedDataFiles.All(name =>
sourceBefore.TryGetValue(name, out var sourceValue) &&
fixtureFiles.TryGetValue(name, out var fixtureValue) &&
string.Equals(sourceValue, fixtureValue, StringComparison.Ordinal));
var markerPresent = fixtureFiles.TryGetValue(
LegacyManualOperatorDataImporter.MarkerFileName,
out var markerValue) && markerValue != "missing";
var intentAbsent = fixtureFiles.TryGetValue(
LegacyManualOperatorDataImporter.IntentFileName,
out var intentValue) && intentValue == "missing";
var runtimeState = LegacyManualOperatorDataRuntimeGuard.Inspect(destination).State;
if (!SnapshotsEqual(sourceBefore, sourceAfter) ||
!SnapshotsEqual(operatingBefore, operatingAfter) ||
!dataHashesMatch || !markerPresent || !intentAbsent ||
result.MarkerVersion != LegacyManualOperatorDataImporter.MarkerVersion ||
result.NetSellRowCount != 15 ||
result.ViItemCount != 9 ||
!string.Equals(
result.SourceSha256,
expectedSourceSha256,
StringComparison.Ordinal) ||
runtimeState != LegacyManualOperatorDataRuntimeState.ReadyImportedData ||
secondFailure is null ||
secondFailure.Failure != LegacyManualOperatorImportFailure.AlreadyImported ||
secondFailure.OutcomeUnknown)
{
Console.Error.WriteLine(
"MANUAL_IMPORT_FIXTURE: FAIL - an isolated import invariant was not reproduced.");
return 1;
}
Console.WriteLine(
$"MANUAL_IMPORT_FIXTURE: PASS rows={result.NetSellRowCount.ToString(CultureInfo.InvariantCulture)} " +
$"vi={result.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
$"sourceSha256={result.SourceSha256} state={runtimeState} " +
"secondAttempt=AlreadyImported outcomeUnknown=false " +
"sourceUnchanged=true operatingImportSetUnchanged=true");
return 0;
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: CANCELED_OR_TIMED_OUT");
return 3;
}
catch (LegacyManualOperatorImportException exception)
{
Console.Error.WriteLine(
$"MANUAL_IMPORT_FIXTURE: FAIL - failure={exception.Failure} " +
$"outcomeUnknown={exception.OutcomeUnknown.ToString().ToLowerInvariant()}");
return exception.OutcomeUnknown ? 5 : 1;
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"MANUAL_IMPORT_FIXTURE: FAIL - {exception.GetType().Name}");
return 1;
}
finally
{
RemoveOwnedFixtureDirectory(temporaryDirectory);
}
}
static int RunManualImportAudit()
{
try
{
var profile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolderOption.DoNotVerify);
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
{
Console.Error.WriteLine("MANUAL_IMPORT_AUDIT: FAIL - required profile roots are unavailable.");
return 1;
}
var sourceDirectory = Path.Combine(
profile,
"source",
"repos",
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
var operatingDirectory = Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"OperatorData");
var expectedDataFiles = new[]
{
"개인.dat",
"외국인.dat",
"기관.dat",
"VI발동.dat"
};
var source = SnapshotFiles(sourceDirectory, expectedDataFiles);
var operating = SnapshotFiles(
operatingDirectory,
expectedDataFiles.Concat(new[]
{
LegacyManualOperatorDataImporter.MarkerFileName,
LegacyManualOperatorDataImporter.IntentFileName
}));
var markerPresent = operating[LegacyManualOperatorDataImporter.MarkerFileName] != "missing";
var intentPresent = operating[LegacyManualOperatorDataImporter.IntentFileName] != "missing";
var dataPresent = expectedDataFiles.Count(name => operating[name] != "missing");
var sourcePresent = expectedDataFiles.Count(name => source[name] != "missing");
var byteIdentical = expectedDataFiles.All(name =>
source[name] != "missing" &&
string.Equals(source[name], operating[name], StringComparison.Ordinal));
var stagedPresent = Directory.Exists(operatingDirectory) &&
Directory.EnumerateFileSystemEntries(
operatingDirectory,
".legacy-manual-import-v1-*",
SearchOption.TopDirectoryOnly).Any();
var runtimeState = LegacyManualOperatorDataRuntimeGuard
.Inspect(operatingDirectory)
.State;
var (decision, outcomeUnknown) = runtimeState switch
{
LegacyManualOperatorDataRuntimeState.InterruptedImport =>
("InterruptedImport", true),
LegacyManualOperatorDataRuntimeState.InvalidImportedState =>
("InvalidImportedState", false),
LegacyManualOperatorDataRuntimeState.ReadyImportedData =>
("AlreadyImported", false),
_ when dataPresent > 0 || stagedPresent =>
("DestinationNotEmpty", false),
_ when sourcePresent == expectedDataFiles.Length =>
("Eligible", false),
_ => ("SourceUnavailable", false)
};
Console.WriteLine(
$"MANUAL_IMPORT_AUDIT: PASS sourceFiles={sourcePresent.ToString(CultureInfo.InvariantCulture)}/4 " +
$"operatingFiles={dataPresent.ToString(CultureInfo.InvariantCulture)}/4 " +
$"sourceByteIdentical={byteIdentical.ToString().ToLowerInvariant()} " +
$"marker={markerPresent.ToString().ToLowerInvariant()} " +
$"intent={intentPresent.ToString().ToLowerInvariant()} " +
$"staged={stagedPresent.ToString().ToLowerInvariant()} " +
$"runtime={runtimeState} " +
$"decision={decision} outcomeUnknown={outcomeUnknown.ToString().ToLowerInvariant()} " +
"writesAttempted=false");
return 0;
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"MANUAL_IMPORT_AUDIT: FAIL - {exception.GetType().Name}");
return 1;
}
}
static IReadOnlyDictionary<string, string> SnapshotFiles(
string directory,
IEnumerable<string> fileNames)
{
const long maximumAuditFileBytes = 32 * 1024;
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var fileName in fileNames)
{
var path = Path.Combine(directory, fileName);
if (!File.Exists(path))
{
result.Add(fileName, "missing");
continue;
}
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
if (stream.Length is < 0 or > maximumAuditFileBytes)
{
throw new InvalidDataException(
"A manual-import audit file exceeded its closed size boundary.");
}
var bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);
if (stream.ReadByte() != -1)
{
throw new InvalidDataException(
"A manual-import audit file changed beyond its closed size boundary.");
}
var hash = Convert.ToHexString(SHA256.HashData(bytes));
result.Add(
fileName,
bytes.Length.ToString(CultureInfo.InvariantCulture) + ":" + hash);
}
return result;
}
static bool SnapshotsEqual(
IReadOnlyDictionary<string, string> first,
IReadOnlyDictionary<string, string> second) =>
first.Count == second.Count && first.All(pair =>
second.TryGetValue(pair.Key, out var value) &&
string.Equals(pair.Value, value, StringComparison.Ordinal));
static void RemoveOwnedFixtureDirectory(string directory)
{
if (!Directory.Exists(directory))
{
return;
}
var temporaryRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(Path.GetTempPath()));
var resolved = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
var prefix = temporaryRoot + Path.DirectorySeparatorChar;
const string ownedNamePrefix = "MBN_STOCK_WEBVIEW-manual-import-fixture-";
var ownedName = Path.GetFileName(resolved);
var ownedSuffix = ownedName.StartsWith(ownedNamePrefix, StringComparison.Ordinal)
? ownedName[ownedNamePrefix.Length..]
: string.Empty;
if (!resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
ownedSuffix.Length != 32 ||
ownedSuffix.Any(static value => !char.IsAsciiHexDigit(value)) ||
(File.GetAttributes(resolved) & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidOperationException(
"Refusing to remove a directory outside the owned fixture boundary.");
}
var pendingDirectories = new Stack<string>();
pendingDirectories.Push(resolved);
while (pendingDirectories.TryPop(out var current))
{
foreach (var entry in Directory.EnumerateFileSystemEntries(
current,
"*",
new EnumerationOptions
{
RecurseSubdirectories = false,
ReturnSpecialDirectories = false,
AttributesToSkip = 0
}))
{
var attributes = File.GetAttributes(entry);
if ((attributes & FileAttributes.ReparsePoint) != 0)
{
throw new InvalidOperationException(
"Refusing to recursively remove a fixture containing a reparse point.");
}
if ((attributes & FileAttributes.Directory) != 0)
{
pendingDirectories.Push(entry);
}
}
}
Directory.Delete(resolved, recursive: true);
}
static async Task VerifyOracleServerVersionAsync(