feat: load legacy Cuts and Res runtime assets

This commit is contained in:
2026-07-18 14:32:04 +09:00
parent da8da06fac
commit 5eb4054120
32 changed files with 2893 additions and 120 deletions

View File

@@ -44,10 +44,7 @@ public sealed class DatabaseOptionsLoader
{
var path = jsonPath ?? DefaultConfigurationPath;
var options = LoadJson(path);
ApplyEnvironmentOverrides(options);
options.Normalize();
options.ValidateRuntimeSettings();
return options;
return ApplyEnvironmentOverridesAndValidate(options);
}
public async Task<DatabaseOptions> LoadAsync(
@@ -56,6 +53,12 @@ public sealed class DatabaseOptionsLoader
{
var path = jsonPath ?? DefaultConfigurationPath;
var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false);
return ApplyEnvironmentOverridesAndValidate(options);
}
internal DatabaseOptions ApplyEnvironmentOverridesAndValidate(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);
ApplyEnvironmentOverrides(options);
options.Normalize();
options.ValidateRuntimeSettings();

View File

@@ -0,0 +1,287 @@
using System.Security;
using System.Text;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Reads the legacy executable-relative Res\MmoneyCoder.ini contract without
/// exposing any of its values through diagnostics or exception messages.
/// </summary>
public sealed class LegacyIniDatabaseOptionsLoader
{
public const int MaximumFileBytes = 64 * 1024;
private static readonly UTF8Encoding StrictUtf8 =
new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
private static readonly Encoding StrictCp949 = CreateCp949Encoding();
private readonly DatabaseOptionsLoader _optionsLoader;
public LegacyIniDatabaseOptionsLoader(
Func<string, string?>? readEnvironmentVariable = null)
{
_optionsLoader = new DatabaseOptionsLoader(readEnvironmentVariable);
}
public DatabaseOptions Load(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
byte[] bytes;
try
{
var fullPath = Path.GetFullPath(path);
var attributes = File.GetAttributes(fullPath);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw new InvalidDataException();
}
using var stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan);
if (stream.Length is <= 0 or > MaximumFileBytes)
{
throw new InvalidDataException();
}
bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);
}
catch (Exception exception) when (IsExpectedFileFailure(exception))
{
throw InvalidConfiguration();
}
var text = Decode(bytes);
var values = Parse(text);
var options = CreateOptions(values);
options = _optionsLoader.ApplyEnvironmentOverridesAndValidate(options);
if (!options.IsConfigured(DataSourceKind.Oracle) ||
!options.IsConfigured(DataSourceKind.MariaDb))
{
throw InvalidConfiguration();
}
return options;
}
private static string Decode(byte[] bytes)
{
string text;
try
{
text = StrictUtf8.GetString(bytes);
}
catch (DecoderFallbackException)
{
try
{
text = StrictCp949.GetString(bytes);
}
catch (DecoderFallbackException)
{
throw InvalidConfiguration();
}
}
return text.Length > 0 && text[0] == '\uFEFF'
? text[1..]
: text;
}
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Parse(
string text)
{
var sections = new Dictionary<string, Dictionary<string, string>>(
StringComparer.Ordinal);
Dictionary<string, string>? current = null;
using var reader = new StringReader(text);
while (reader.ReadLine() is { } sourceLine)
{
var line = sourceLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line.StartsWith("[", StringComparison.Ordinal))
{
if (line.Length < 3 || line[^1] != ']')
{
throw InvalidConfiguration();
}
var sectionName = line[1..^1];
if (!sections.TryGetValue(sectionName, out current))
{
current = new Dictionary<string, string>(StringComparer.Ordinal);
sections.Add(sectionName, current);
}
continue;
}
if (line.StartsWith("//", StringComparison.Ordinal))
{
continue;
}
if (current is null)
{
throw InvalidConfiguration();
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
if (key is not ("ConnectionName" or "ID" or "Pass"))
{
continue;
}
var value = line[(separatorIndex + 1)..].Trim();
if (value.Length == 0 || value.Any(char.IsControl))
{
throw InvalidConfiguration();
}
// The original reader keeps the last occurrence in file order.
current[key] = value;
}
return sections.ToDictionary(
static pair => pair.Key,
static pair => (IReadOnlyDictionary<string, string>)pair.Value,
StringComparer.Ordinal);
}
private static DatabaseOptions CreateOptions(
IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> sections)
{
if (!sections.TryGetValue("Oracle", out var oracle) ||
!sections.TryGetValue("Maria", out var maria) ||
!TryGetRequired(oracle, "ConnectionName", out var oracleConnection) ||
!TryGetRequired(oracle, "ID", out var oracleId) ||
!TryGetRequired(oracle, "Pass", out var oraclePassword) ||
!TryGetRequired(maria, "ConnectionName", out var mariaConnection) ||
!TryGetRequired(maria, "ID", out var mariaId) ||
!TryGetRequired(maria, "Pass", out var mariaPassword) ||
!TryParseConnectionName(
oracleConnection,
out var oracleHost,
out var oraclePort,
out var oracleSid) ||
!TryParseConnectionName(
mariaConnection,
out var mariaHost,
out var mariaPort,
out var mariaDatabase))
{
throw InvalidConfiguration();
}
return new DatabaseOptions
{
Oracle = new OracleDatabaseOptions
{
Host = oracleHost,
Port = oraclePort,
Sid = oracleSid,
ServiceName = null,
UserName = oracleId,
Password = oraclePassword
},
MariaDb = new MariaDbDatabaseOptions
{
Host = mariaHost,
Port = mariaPort,
Database = mariaDatabase,
UserName = mariaId,
Password = mariaPassword,
// The legacy MySQL connection string did not request TLS.
TlsMode = MariaDbTlsMode.Disabled
}
};
}
private static bool TryGetRequired(
IReadOnlyDictionary<string, string> section,
string key,
out string value)
{
if (section.TryGetValue(key, out var candidate) &&
!string.IsNullOrWhiteSpace(candidate))
{
value = candidate;
return true;
}
value = string.Empty;
return false;
}
private static bool TryParseConnectionName(
string value,
out string host,
out int port,
out string database)
{
host = string.Empty;
port = 0;
database = string.Empty;
var colonIndex = value.IndexOf(':');
var slashIndex = value.IndexOf('/', colonIndex + 1);
if (colonIndex <= 0 || slashIndex <= colonIndex + 1 ||
slashIndex == value.Length - 1 ||
value.IndexOf(':', colonIndex + 1) >= 0 ||
value.IndexOf('/', slashIndex + 1) >= 0)
{
return false;
}
var parsedHost = value[..colonIndex].Trim();
var portText = value[(colonIndex + 1)..slashIndex].Trim();
var parsedDatabase = value[(slashIndex + 1)..].Trim();
if (parsedHost.Length == 0 || parsedDatabase.Length == 0 ||
parsedHost.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)) ||
parsedDatabase.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)) ||
!int.TryParse(portText, out var parsedPort) ||
parsedPort is < 1 or > 65_535)
{
return false;
}
host = parsedHost;
port = parsedPort;
database = parsedDatabase;
return true;
}
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private static bool IsExpectedFileFailure(Exception exception) =>
exception is IOException or UnauthorizedAccessException or SecurityException or
ArgumentException or NotSupportedException;
private static DatabaseConfigurationException InvalidConfiguration() =>
new("실행 폴더의 레거시 데이터베이스 설정을 읽을 수 없습니다.");
}

View File

@@ -30,6 +30,43 @@ public sealed class DatabaseRuntime
return Create(options);
}
public static DatabaseRuntime CreateLegacyExecutableDefault(
string baseDirectory,
string? fallbackConfigurationPath = null,
string? legacyOverridePath = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
var legacyIniPath = Path.Combine(
Path.GetFullPath(baseDirectory),
"Res",
"MmoneyCoder.ini");
if (File.Exists(legacyIniPath))
{
return Create(new LegacyIniDatabaseOptionsLoader().Load(legacyIniPath));
}
var localOverride = legacyOverridePath ?? ResolveLegacyOverridePath();
if (!string.IsNullOrWhiteSpace(localOverride) && File.Exists(localOverride))
{
return Create(new LegacyIniDatabaseOptionsLoader().Load(localOverride));
}
return CreateDefault(fallbackConfigurationPath);
}
private static string? ResolveLegacyOverridePath()
{
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
return string.IsNullOrWhiteSpace(localAppData)
? null
: Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"Res",
"MmoneyCoder.ini");
}
public static DatabaseRuntime Create(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);