Files
MBN_STOCK_WEBVIEW/tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs

572 lines
23 KiB
C#

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);
}
}