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)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 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().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> 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(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 ResolveMaterializationAsync( IDataQueryExecutor executor, IReadOnlyList routedRows, IReadOnlyDictionary 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 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 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 RowsFor( IReadOnlyList rows, LegacyPListAuditRoute route, Func 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 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 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(); 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 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 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 FixedSources = [ new( "해외.ini", "overseas", "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"), new( "환율.ini", "exchange", "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"), new( "지수.ini", "index", "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6") ]; }