using System.Text.Json; namespace MBN_STOCK_WEBVIEW.LegacyApplication; public sealed record LegacyOperatorSettings( string? SceneDirectory, string? ResourceDirectory, string? BackgroundDirectory, bool NavigationExpanded) { public const int CurrentSchemaVersion = 1; public static LegacyOperatorSettings Default { get; } = new( SceneDirectory: null, ResourceDirectory: null, BackgroundDirectory: null, NavigationExpanded: true); } public enum LegacyOperatorSettingsFolderKind { Scene, Resource, Background } public enum LegacyOperatorFolderValidationFailure { None, InvalidPath, DirectoryNotFound, RootDirectory, NonFixedDrive, ReparsePoint, RequiredFileMissing, RequiredFileUnsafe, InvalidRuntimeCatalog, FileUnavailable } public sealed record LegacyOperatorValidatedFolder( string CanonicalPath, string AbbreviatedPath, bool DatabaseIniDetected); public sealed record LegacyOperatorFolderValidationResult( LegacyOperatorValidatedFolder? Folder, LegacyOperatorFolderValidationFailure Failure, string? WarningMessage) { public bool IsValid => Failure == LegacyOperatorFolderValidationFailure.None && Folder is not null; } /// /// Validates only native folder-picker results. The returned messages intentionally /// contain no source path; a separately bounded abbreviation is available for display. /// public static class LegacyOperatorFolderValidator { public const int MaximumPathCharacters = 1024; public const int DefaultDisplayCharacters = 48; public const string UnconfiguredDisplay = "선택되지 않음"; private const string InvalidFolderMessage = "선택한 폴더를 안전하게 사용할 수 없습니다."; private const string MissingSceneMessage = "선택한 디자인 폴더에 필수 장면 파일이 없습니다."; private const string InvalidResourceMessage = "선택한 설정 폴더의 UI 설정 파일을 검증할 수 없습니다."; private static readonly string[] ResourceFileNames = [ "종목.ini", "업종_코스피.ini", "업종_코스닥.ini", "해외.ini", "환율.ini", "지수.ini", "종목비교.ini" ]; public static bool TryValidate( LegacyOperatorSettingsFolderKind kind, string? path, out LegacyOperatorValidatedFolder? folder, out string? warningMessage) { var result = Validate(kind, path); folder = result.Folder; warningMessage = result.WarningMessage; return result.IsValid; } public static LegacyOperatorFolderValidationResult Validate( LegacyOperatorSettingsFolderKind kind, string? path) { var failure = LegacyOperatorFolderValidationFailure.InvalidPath; if (!Enum.IsDefined(kind) || !TryCanonicalizeDirectory( path, out var canonicalPath, out failure)) { return Invalid(failure, InvalidFolderMessage); } var databaseIniDetected = false; switch (kind) { case LegacyOperatorSettingsFolderKind.Scene: if (!TryValidateRegularFile( Path.Combine(canonicalPath, "5001.t2s"), required: true, out failure)) { return Invalid(failure, MissingSceneMessage); } break; case LegacyOperatorSettingsFolderKind.Resource: foreach (var fileName in ResourceFileNames) { if (!TryValidateRegularFile( Path.Combine(canonicalPath, fileName), required: true, out failure)) { return Invalid(failure, InvalidResourceMessage); } } var stockMenu = LegacyRuntimeStockCutMenuLoader .ResolveFromResourceDirectory(canonicalPath); if (stockMenu.UsesBuiltInCatalog || stockMenu.Failure != LegacyRuntimeStockCutMenuFailure.None) { return Invalid( LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog, InvalidResourceMessage); } var uiCatalogs = LegacyRuntimeUiCatalogLoader .ResolveFromResourceDirectory(canonicalPath); if (uiCatalogs.Warnings.Count != 0) { return Invalid( LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog, InvalidResourceMessage); } var databaseIniPath = Path.Combine(canonicalPath, "MmoneyCoder.ini"); if (!TryInspectOptionalRegularFile( databaseIniPath, out databaseIniDetected, out failure)) { return Invalid(failure, InvalidResourceMessage); } break; case LegacyOperatorSettingsFolderKind.Background: break; default: return Invalid( LegacyOperatorFolderValidationFailure.InvalidPath, InvalidFolderMessage); } return new LegacyOperatorFolderValidationResult( new LegacyOperatorValidatedFolder( canonicalPath, AbbreviateForDisplay(canonicalPath), databaseIniDetected), LegacyOperatorFolderValidationFailure.None, WarningMessage: null); } public static string AbbreviateForDisplay( string? path, int maximumCharacters = DefaultDisplayCharacters) { maximumCharacters = Math.Clamp(maximumCharacters, 12, 120); if (string.IsNullOrWhiteSpace(path) || path.Any(char.IsControl) || !Path.IsPathFullyQualified(path)) { return UnconfiguredDisplay; } try { var canonicalPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); var root = Path.GetPathRoot(canonicalPath); var leaf = Path.GetFileName(canonicalPath); if (string.IsNullOrEmpty(root) || string.IsNullOrEmpty(leaf)) { return UnconfiguredDisplay; } var display = string.Concat( Path.TrimEndingDirectorySeparator(root), Path.DirectorySeparatorChar, "…", Path.DirectorySeparatorChar, leaf); if (display.Length <= maximumCharacters) { return display; } var prefix = string.Concat( Path.TrimEndingDirectorySeparator(root), Path.DirectorySeparatorChar, "…", Path.DirectorySeparatorChar); var availableLeafCharacters = Math.Max( 1, maximumCharacters - prefix.Length - 1); return string.Concat( prefix, "…", leaf[^Math.Min(availableLeafCharacters, leaf.Length)..]); } catch (Exception exception) when ( exception is ArgumentException or IOException or NotSupportedException or System.Security.SecurityException) { return UnconfiguredDisplay; } } internal static bool TryCanonicalizeDirectory( string? path, out string canonicalPath, out LegacyOperatorFolderValidationFailure failure) { canonicalPath = string.Empty; if (string.IsNullOrWhiteSpace(path) || path.Length > MaximumPathCharacters || path.Any(char.IsControl) || !Path.IsPathFullyQualified(path)) { failure = LegacyOperatorFolderValidationFailure.InvalidPath; return false; } try { canonicalPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); var root = Path.GetPathRoot(canonicalPath); if (string.IsNullOrEmpty(root) || root.StartsWith("\\\\", StringComparison.Ordinal) || !string.Equals( Path.GetPathRoot(root), root, StringComparison.OrdinalIgnoreCase)) { failure = LegacyOperatorFolderValidationFailure.InvalidPath; canonicalPath = string.Empty; return false; } if (string.Equals( Path.TrimEndingDirectorySeparator(root), canonicalPath, StringComparison.OrdinalIgnoreCase)) { failure = LegacyOperatorFolderValidationFailure.RootDirectory; canonicalPath = string.Empty; return false; } var drive = new DriveInfo(root); if (!drive.IsReady || drive.DriveType != DriveType.Fixed) { failure = LegacyOperatorFolderValidationFailure.NonFixedDrive; canonicalPath = string.Empty; return false; } if (!Directory.Exists(canonicalPath)) { failure = LegacyOperatorFolderValidationFailure.DirectoryNotFound; canonicalPath = string.Empty; return false; } for (DirectoryInfo? current = new(canonicalPath); current is not null; current = current.Parent) { if ((current.Attributes & FileAttributes.ReparsePoint) != 0) { failure = LegacyOperatorFolderValidationFailure.ReparsePoint; canonicalPath = string.Empty; return false; } } failure = LegacyOperatorFolderValidationFailure.None; return true; } catch (Exception exception) when ( exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) { failure = LegacyOperatorFolderValidationFailure.FileUnavailable; canonicalPath = string.Empty; return false; } } private static bool TryValidateRegularFile( string path, bool required, out LegacyOperatorFolderValidationFailure failure) { try { if (!File.Exists(path)) { failure = required ? LegacyOperatorFolderValidationFailure.RequiredFileMissing : LegacyOperatorFolderValidationFailure.None; return !required; } var attributes = File.GetAttributes(path); if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) { failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe; return false; } failure = LegacyOperatorFolderValidationFailure.None; return true; } catch (Exception exception) when ( exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) { failure = LegacyOperatorFolderValidationFailure.FileUnavailable; return false; } } private static bool TryInspectOptionalRegularFile( string path, out bool detected, out LegacyOperatorFolderValidationFailure failure) { detected = false; try { var attributes = File.GetAttributes(path); if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) { failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe; return false; } detected = true; failure = LegacyOperatorFolderValidationFailure.None; return true; } catch (Exception exception) when ( exception is FileNotFoundException or DirectoryNotFoundException) { failure = LegacyOperatorFolderValidationFailure.None; return true; } catch (Exception exception) when ( exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) { failure = LegacyOperatorFolderValidationFailure.FileUnavailable; return false; } } private static LegacyOperatorFolderValidationResult Invalid( LegacyOperatorFolderValidationFailure failure, string warningMessage) => new( Folder: null, failure, warningMessage); } public enum LegacyOperatorSettingsStoreFailure { None, InvalidConfigurationPath, FileUnavailable, FileTooLarge, InvalidJson, UnsupportedSchema, InvalidSettings, SaveUnavailable } public sealed record LegacyOperatorSettingsLoadResult( LegacyOperatorSettings Settings, string? WarningMessage) { public LegacyOperatorSettingsStoreFailure Failure { get; init; } public bool LoadedFromDisk { get; init; } } public sealed record LegacyOperatorSettingsSaveResult( bool Succeeded, string? WarningMessage) { public LegacyOperatorSettingsStoreFailure Failure { get; init; } public LegacyOperatorSettings? SavedSettings { get; init; } } /// /// Stores only native-approved runtime folders and one presentation preference. /// Credentials, playout targets, live authorization and asset contents never enter /// this file. /// public sealed class LegacyOperatorSettingsStore { public const int MaximumFileBytes = 16 * 1024; public const string LoadWarningMessage = "저장된 폴더 설정을 읽지 못해 안전한 기본값을 사용합니다."; public const string SaveWarningMessage = "폴더 설정을 안전하게 저장하지 못했습니다."; private const string FileName = "runtime-folders.local.json"; private readonly string _configurationPath; public LegacyOperatorSettingsStore(string? configurationPath = null) { _configurationPath = configurationPath ?? DefaultPath; } public static string DefaultPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MBN_STOCK_WEBVIEW", "Config", FileName); public string ConfigurationPath => _configurationPath; public LegacyOperatorSettingsLoadResult Load() { if (!TryNormalizeConfigurationPath(_configurationPath, out var path)) { return FailedLoad(LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); } try { var directory = Path.GetDirectoryName(path); if (string.IsNullOrEmpty(directory)) { return FailedLoad( LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); } if (!Directory.Exists(directory)) { return new LegacyOperatorSettingsLoadResult( LegacyOperatorSettings.Default, WarningMessage: null) { Failure = LegacyOperatorSettingsStoreFailure.None, LoadedFromDisk = false }; } if (!LegacyOperatorFolderValidator.TryCanonicalizeDirectory( directory, out var canonicalDirectory, out _)) { return FailedLoad( LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); } path = Path.Combine(canonicalDirectory, Path.GetFileName(path)); if (!File.Exists(path)) { return new LegacyOperatorSettingsLoadResult( LegacyOperatorSettings.Default, WarningMessage: null) { Failure = LegacyOperatorSettingsStoreFailure.None, LoadedFromDisk = false }; } var attributes = File.GetAttributes(path); if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) { return FailedLoad(LegacyOperatorSettingsStoreFailure.FileUnavailable); } using var stream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, FileOptions.SequentialScan); if (stream.Length is <= 0 or > MaximumFileBytes) { return FailedLoad( stream.Length > MaximumFileBytes ? LegacyOperatorSettingsStoreFailure.FileTooLarge : LegacyOperatorSettingsStoreFailure.InvalidJson); } var json = new byte[(int)stream.Length]; stream.ReadExactly(json); if (!TryDeserialize( json, out var settings, out var failure) || !TryNormalizeSettings(settings, out var normalizedSettings)) { return FailedLoad( failure == LegacyOperatorSettingsStoreFailure.None ? LegacyOperatorSettingsStoreFailure.InvalidSettings : failure); } return new LegacyOperatorSettingsLoadResult( normalizedSettings, WarningMessage: null) { Failure = LegacyOperatorSettingsStoreFailure.None, LoadedFromDisk = true }; } catch (Exception exception) when ( exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) { return FailedLoad(LegacyOperatorSettingsStoreFailure.FileUnavailable); } } public LegacyOperatorSettingsSaveResult Save(LegacyOperatorSettings settings) { ArgumentNullException.ThrowIfNull(settings); if (!TryNormalizeSettings(settings, out var normalizedSettings)) { return FailedSave(LegacyOperatorSettingsStoreFailure.InvalidSettings); } if (!TryNormalizeConfigurationPath(_configurationPath, out var path)) { return FailedSave( LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); } string? temporaryPath = null; try { var directory = Path.GetDirectoryName(path); if (string.IsNullOrEmpty(directory)) { return FailedSave( LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); } if (!TryCreateSafeConfigurationDirectory( directory, out var canonicalDirectory)) { return FailedSave( LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath); } path = Path.Combine(canonicalDirectory, Path.GetFileName(path)); if (File.Exists(path)) { var attributes = File.GetAttributes(path); if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) { return FailedSave( LegacyOperatorSettingsStoreFailure.SaveUnavailable); } } var json = Serialize(normalizedSettings); if (json.Length > MaximumFileBytes) { return FailedSave(LegacyOperatorSettingsStoreFailure.FileTooLarge); } temporaryPath = Path.Combine( canonicalDirectory, $".{FileName}.{Guid.NewGuid():N}.tmp"); using (var stream = new FileStream( temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 4096, FileOptions.WriteThrough)) { stream.Write(json); stream.Flush(flushToDisk: true); } File.Move(temporaryPath, path, overwrite: true); temporaryPath = null; return new LegacyOperatorSettingsSaveResult( Succeeded: true, WarningMessage: null) { Failure = LegacyOperatorSettingsStoreFailure.None, SavedSettings = normalizedSettings }; } catch (Exception exception) when ( exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) { return FailedSave(LegacyOperatorSettingsStoreFailure.SaveUnavailable); } finally { if (temporaryPath is not null) { try { File.Delete(temporaryPath); } catch (Exception exception) when ( exception is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) { // A failed save remains failed. Never replace that result with a // cleanup exception or expose the temporary path. } } } } private static bool TryNormalizeSettings( LegacyOperatorSettings settings, out LegacyOperatorSettings normalizedSettings) { normalizedSettings = LegacyOperatorSettings.Default; if (!TryNormalizeOptionalFolder( LegacyOperatorSettingsFolderKind.Scene, settings.SceneDirectory, out var sceneDirectory) || !TryNormalizeOptionalFolder( LegacyOperatorSettingsFolderKind.Resource, settings.ResourceDirectory, out var resourceDirectory) || !TryNormalizeOptionalFolder( LegacyOperatorSettingsFolderKind.Background, settings.BackgroundDirectory, out var backgroundDirectory)) { return false; } normalizedSettings = new LegacyOperatorSettings( sceneDirectory, resourceDirectory, backgroundDirectory, settings.NavigationExpanded); return true; } private static bool TryNormalizeOptionalFolder( LegacyOperatorSettingsFolderKind kind, string? path, out string? canonicalPath) { canonicalPath = null; if (path is null) { return true; } if (!LegacyOperatorFolderValidator.TryValidate( kind, path, out var folder, out _)) { return false; } canonicalPath = folder!.CanonicalPath; return true; } private static byte[] Serialize(LegacyOperatorSettings settings) { using var stream = new MemoryStream(capacity: 512); using (var writer = new Utf8JsonWriter( stream, new JsonWriterOptions { Indented = true })) { writer.WriteStartObject(); writer.WriteNumber("schemaVersion", LegacyOperatorSettings.CurrentSchemaVersion); WriteOptionalString(writer, "sceneDirectory", settings.SceneDirectory); WriteOptionalString(writer, "resourceDirectory", settings.ResourceDirectory); WriteOptionalString(writer, "backgroundDirectory", settings.BackgroundDirectory); writer.WriteBoolean("navigationExpanded", settings.NavigationExpanded); writer.WriteEndObject(); } return stream.ToArray(); } private static void WriteOptionalString( Utf8JsonWriter writer, string name, string? value) { if (value is null) { writer.WriteNull(name); } else { writer.WriteString(name, value); } } private static bool TryDeserialize( ReadOnlyMemory json, out LegacyOperatorSettings settings, out LegacyOperatorSettingsStoreFailure failure) { settings = LegacyOperatorSettings.Default; failure = LegacyOperatorSettingsStoreFailure.InvalidJson; try { using var document = JsonDocument.Parse( json, new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow, MaxDepth = 4 }); if (document.RootElement.ValueKind != JsonValueKind.Object) { return false; } var properties = new Dictionary(StringComparer.Ordinal); foreach (var property in document.RootElement.EnumerateObject()) { if (!IsExpectedProperty(property.Name) || !properties.TryAdd(property.Name, property.Value)) { return false; } } if (properties.Count != 5 || !TryReadInt32(properties, "schemaVersion", out var schemaVersion) || !TryReadOptionalString(properties, "sceneDirectory", out var sceneDirectory) || !TryReadOptionalString( properties, "resourceDirectory", out var resourceDirectory) || !TryReadOptionalString( properties, "backgroundDirectory", out var backgroundDirectory) || !TryReadBoolean( properties, "navigationExpanded", out var navigationExpanded)) { return false; } if (schemaVersion != LegacyOperatorSettings.CurrentSchemaVersion) { failure = LegacyOperatorSettingsStoreFailure.UnsupportedSchema; return false; } settings = new LegacyOperatorSettings( sceneDirectory, resourceDirectory, backgroundDirectory, navigationExpanded); failure = LegacyOperatorSettingsStoreFailure.None; return true; } catch (JsonException) { return false; } catch (InvalidOperationException) { return false; } catch (FormatException) { return false; } catch (OverflowException) { return false; } } private static bool IsExpectedProperty(string name) => name is "schemaVersion" or "sceneDirectory" or "resourceDirectory" or "backgroundDirectory" or "navigationExpanded"; private static bool TryReadInt32( IReadOnlyDictionary properties, string name, out int value) { value = default; return properties.TryGetValue(name, out var element) && element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value); } private static bool TryReadBoolean( IReadOnlyDictionary properties, string name, out bool value) { value = default; if (!properties.TryGetValue(name, out var element) || element.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) { return false; } value = element.GetBoolean(); return true; } private static bool TryReadOptionalString( IReadOnlyDictionary properties, string name, out string? value) { value = null; if (!properties.TryGetValue(name, out var element)) { return false; } if (element.ValueKind == JsonValueKind.Null) { return true; } if (element.ValueKind != JsonValueKind.String) { return false; } value = element.GetString(); return value is not null; } private static bool TryNormalizeConfigurationPath( string? path, out string normalizedPath) { normalizedPath = string.Empty; if (string.IsNullOrWhiteSpace(path) || path.Length > LegacyOperatorFolderValidator.MaximumPathCharacters || path.Any(char.IsControl) || !Path.IsPathFullyQualified(path)) { return false; } try { normalizedPath = Path.GetFullPath(path); var root = Path.GetPathRoot(normalizedPath); var fileName = Path.GetFileName(normalizedPath); return !string.IsNullOrEmpty(root) && !root.StartsWith("\\\\", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(fileName) && !fileName.Any(char.IsControl); } catch (Exception exception) when ( exception is ArgumentException or IOException or NotSupportedException or System.Security.SecurityException) { normalizedPath = string.Empty; return false; } } private static bool TryCreateSafeConfigurationDirectory( string directory, out string canonicalDirectory) { canonicalDirectory = string.Empty; try { var existingAncestor = directory; while (!Directory.Exists(existingAncestor)) { var parent = Path.GetDirectoryName(existingAncestor); if (string.IsNullOrEmpty(parent) || string.Equals(parent, existingAncestor, StringComparison.OrdinalIgnoreCase)) { return false; } existingAncestor = parent; } if (!LegacyOperatorFolderValidator.TryCanonicalizeDirectory( existingAncestor, out _, out _)) { return false; } Directory.CreateDirectory(directory); return LegacyOperatorFolderValidator.TryCanonicalizeDirectory( directory, out canonicalDirectory, out _); } catch (Exception exception) when ( exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) { canonicalDirectory = string.Empty; return false; } } private static LegacyOperatorSettingsLoadResult FailedLoad( LegacyOperatorSettingsStoreFailure failure) => new( LegacyOperatorSettings.Default, LoadWarningMessage) { Failure = failure, LoadedFromDisk = false }; private static LegacyOperatorSettingsSaveResult FailedSave( LegacyOperatorSettingsStoreFailure failure) => new( Succeeded: false, SaveWarningMessage) { Failure = failure, SavedSettings = null }; }