feat: add native runtime settings
This commit is contained in:
@@ -0,0 +1,979 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates only native folder-picker results. The returned messages intentionally
|
||||
/// contain no source path; a separately bounded abbreviation is available for display.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores only native-approved runtime folders and one presentation preference.
|
||||
/// Credentials, playout targets, live authorization and asset contents never enter
|
||||
/// this file.
|
||||
/// </summary>
|
||||
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<byte> 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<string, JsonElement>(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<string, JsonElement> 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<string, JsonElement> 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<string, JsonElement> 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user