feat: load legacy Cuts and Res runtime assets
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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("실행 폴더의 레거시 데이터베이스 설정을 읽을 수 없습니다.");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user