Migrate remaining legacy operator workflows

This commit is contained in:
2026-07-12 05:39:27 +09:00
parent ffb8f43c19
commit a01836a2d7
132 changed files with 20566 additions and 720 deletions

View File

@@ -26,7 +26,7 @@ public sealed class PlayoutOptions
/// <summary>
/// Trusted MainForm-level background selection. The asset is always a path relative
/// to SceneDirectory and is never accepted from Web content.
/// to LegacyBackgroundDirectory and is never accepted from Web content.
/// </summary>
public LegacySceneBackgroundKind LegacySceneBackgroundKind { get; set; } =
LegacySceneBackgroundKind.None;
@@ -37,6 +37,14 @@ public sealed class PlayoutOptions
public bool LegacySceneBackgroundVideoLoopInfinite { get; set; } = true;
/// <summary>
/// External absolute root containing operator-selectable MainForm backgrounds.
/// When omitted, the original layout is retained by deriving a sibling "배경"
/// directory from SceneDirectory (Cuts\..\배경). This root is never merged with
/// the trusted scene/Cuts root.
/// </summary>
public string? LegacyBackgroundDirectory { get; set; }
/// <summary>
/// External absolute root containing vendor .t2s scenes. Required in Test and Live modes.
/// </summary>

View File

@@ -98,6 +98,9 @@ public static class PlayoutOptionsLoader
ApplyBool(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE",
value => options.LegacySceneBackgroundVideoLoopInfinite = value);
ApplyString(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY",
value => options.LegacyBackgroundDirectory = value);
ApplyString("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", value => options.SceneDirectory = value);
ApplyString(
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",

View File

@@ -1,3 +1,4 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
@@ -8,6 +9,8 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
/// </summary>
public static class PlayoutSceneCompositionFactory
{
public const string LegacyDefaultBackgroundFileName = "기본.vrv";
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
[
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
@@ -17,12 +20,7 @@ public static class PlayoutSceneCompositionFactory
public static LegacySceneCueCompositionOptions Create(PlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.LegacySceneFadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
{
throw Invalid();
}
ValidateCommon(options.LegacySceneFadeDuration, options);
if (options.LegacySceneBackgroundKind == LegacySceneBackgroundKind.None)
{
@@ -36,74 +34,180 @@ public static class PlayoutSceneCompositionFactory
LegacySceneBackgroundKind.None);
}
var rootText = options.SceneDirectory?.Trim();
var relativeText = options.LegacySceneBackgroundAssetPath?.Trim();
if (string.IsNullOrEmpty(rootText) || !Path.IsPathFullyQualified(rootText) ||
string.IsNullOrEmpty(relativeText) || Path.IsPathRooted(relativeText) ||
relativeText.Contains("..", StringComparison.Ordinal) ||
relativeText.Contains(':') || relativeText.Any(char.IsControl) ||
!AllowedExtensions.Contains(Path.GetExtension(relativeText)))
{
throw Invalid();
}
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText));
if (!Directory.Exists(root) || IsReparsePoint(root))
{
throw Invalid();
}
var candidate = Path.GetFullPath(Path.Combine(root, relativeText));
var rootPrefix = root + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
{
throw Invalid();
}
var normalizedRelative = Path.GetRelativePath(root, candidate);
return new LegacySceneCueCompositionOptions(
options.LegacySceneFadeDuration,
options.LegacySceneBackgroundKind,
normalizedRelative,
options.LegacySceneBackgroundVideoLoopCount,
options.LegacySceneBackgroundVideoLoopInfinite);
}
private static bool HasReparsePointBetween(string root, string file)
{
if (IsReparsePoint(file))
{
return true;
}
var directory = Path.GetDirectoryName(file);
while (!string.IsNullOrEmpty(directory) &&
!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase))
{
if (IsReparsePoint(directory))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static bool IsReparsePoint(string path)
{
try
{
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
var root = RequireTrustedBackgroundDirectory(options);
var candidate = TrustedPlayoutAssetPath.ResolveRelativeFile(
root,
options.LegacySceneBackgroundAssetPath ?? string.Empty,
AllowedExtensions);
return CreateResult(
options,
options.LegacySceneFadeDuration,
options.LegacySceneBackgroundKind,
Path.GetRelativePath(root, candidate));
}
catch (TrustedPlayoutAssetException)
{
throw Invalid();
}
}
/// <summary>
/// Validates one F2 picker result against the separate background root. An
/// absolute path is accepted only at this native boundary and is converted back
/// to a closed relative path before it reaches the scene builder.
/// </summary>
public static LegacySceneCueCompositionOptions CreateOperatorSelection(
PlayoutOptions options,
int fadeDuration,
string selectedAbsolutePath)
{
ArgumentNullException.ThrowIfNull(options);
ValidateCommon(fadeDuration, options);
try
{
var root = RequireTrustedBackgroundDirectory(options);
var candidate = TrustedPlayoutAssetPath.ResolveAbsoluteFile(
root,
selectedAbsolutePath,
AllowedExtensions);
var extension = Path.GetExtension(candidate);
var kind = string.Equals(extension, ".vrv", StringComparison.OrdinalIgnoreCase)
? LegacySceneBackgroundKind.Video
: LegacySceneBackgroundKind.Texture;
return CreateResult(
options,
fadeDuration,
kind,
Path.GetRelativePath(root, candidate));
}
catch (TrustedPlayoutAssetException)
{
throw Invalid();
}
}
/// <summary>Restores the original F3-on default, 배경\기본.vrv.</summary>
public static LegacySceneCueCompositionOptions CreateLegacyDefault(
PlayoutOptions options,
int fadeDuration)
{
ArgumentNullException.ThrowIfNull(options);
ValidateCommon(fadeDuration, options);
try
{
var root = RequireTrustedBackgroundDirectory(options);
var candidate = TrustedPlayoutAssetPath.ResolveRelativeFile(
root,
LegacyDefaultBackgroundFileName,
AllowedExtensions);
return CreateResult(
options,
fadeDuration,
LegacySceneBackgroundKind.Video,
Path.GetRelativePath(root, candidate));
}
catch (TrustedPlayoutAssetException)
{
throw Invalid();
}
}
public static bool TryGetTrustedBackgroundDirectory(
PlayoutOptions options,
out string directory)
{
ArgumentNullException.ThrowIfNull(options);
directory = string.Empty;
try
{
directory = TrustedPlayoutAssetPath.ValidateRoot(
RequireConfiguredBackgroundDirectory(options));
return true;
}
catch
{
directory = string.Empty;
return false;
}
}
internal static bool TryGetConfiguredBackgroundDirectory(
PlayoutOptions options,
out string directory)
{
ArgumentNullException.ThrowIfNull(options);
directory = string.Empty;
try
{
directory = RequireConfiguredBackgroundDirectory(options);
return true;
}
catch
{
directory = string.Empty;
return false;
}
}
private static string RequireTrustedBackgroundDirectory(PlayoutOptions options) =>
TrustedPlayoutAssetPath.ValidateRoot(RequireConfiguredBackgroundDirectory(options));
private static string RequireConfiguredBackgroundDirectory(PlayoutOptions options)
{
var configured = options.LegacyBackgroundDirectory?.Trim();
if (string.IsNullOrEmpty(configured))
{
var sceneDirectory = options.SceneDirectory?.Trim();
if (string.IsNullOrEmpty(sceneDirectory) ||
!Path.IsPathFullyQualified(sceneDirectory))
{
throw new TrustedPlayoutAssetException();
}
var cuts = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sceneDirectory));
var parent = Path.GetDirectoryName(cuts);
if (string.IsNullOrEmpty(parent))
{
throw new TrustedPlayoutAssetException();
}
configured = Path.Combine(parent, "배경");
}
if (!Path.IsPathFullyQualified(configured) || configured.Any(char.IsControl))
{
throw new TrustedPlayoutAssetException();
}
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(configured));
}
private static LegacySceneCueCompositionOptions CreateResult(
PlayoutOptions options,
int fadeDuration,
LegacySceneBackgroundKind kind,
string relativePath) => new(
fadeDuration,
kind,
relativePath,
options.LegacySceneBackgroundVideoLoopCount,
options.LegacySceneBackgroundVideoLoopInfinite,
PlayoutAssetRoot.OperatorBackgroundDirectory);
private static void ValidateCommon(int fadeDuration, PlayoutOptions options)
{
if (fadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
{
throw Invalid();
}
}
private static PlayoutConfigurationException Invalid() => new(
"공통 장면 배경 설정이 올바르지 않거나 허용된 scene 폴더의 자산을 찾을 수 없습니다.");
"신뢰 배경 루트 안의 일반 배경 파일을 확인할 수 없습니다.");
}

View File

@@ -0,0 +1,400 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
/// <summary>
/// Resolves a read-only vendor asset from one already-trusted root. Windows handles
/// are used for the directory ancestry and the file itself so that path checks do not
/// silently follow a reparse point or accept a hard-linked file.
/// </summary>
internal static class TrustedPlayoutAssetPath
{
public static string ValidateRoot(string root)
{
if (string.IsNullOrWhiteSpace(root) || root.IndexOf('\0') >= 0 ||
!Path.IsPathFullyQualified(root))
{
throw Invalid();
}
var fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
if (!Directory.Exists(fullRoot))
{
throw Invalid();
}
using var lease = TrustedReadOnlyDirectoryLease.Acquire(fullRoot);
return fullRoot;
}
public static string ResolveRelativeFile(
string root,
string relativePath,
IReadOnlySet<string> allowedExtensions)
{
ArgumentNullException.ThrowIfNull(allowedExtensions);
relativePath = relativePath?.Trim() ?? string.Empty;
if (relativePath.Length == 0 || relativePath.Length > 512 ||
Path.IsPathRooted(relativePath) ||
relativePath.Contains("..", StringComparison.Ordinal) ||
relativePath.Contains(':') || relativePath.Any(char.IsControl) ||
!allowedExtensions.Contains(Path.GetExtension(relativePath)))
{
throw Invalid();
}
var fullRoot = ValidateRoot(root);
var candidate = Path.GetFullPath(Path.Combine(fullRoot, relativePath));
return ResolveCanonicalFile(fullRoot, candidate);
}
public static string ResolveAbsoluteFile(
string root,
string absolutePath,
IReadOnlySet<string> allowedExtensions)
{
ArgumentNullException.ThrowIfNull(allowedExtensions);
if (string.IsNullOrWhiteSpace(absolutePath) || absolutePath.Length > 32_767 ||
absolutePath.Any(char.IsControl) || !Path.IsPathFullyQualified(absolutePath) ||
!allowedExtensions.Contains(Path.GetExtension(absolutePath)))
{
throw Invalid();
}
var fullRoot = ValidateRoot(root);
var candidate = Path.GetFullPath(absolutePath);
return ResolveCanonicalFile(fullRoot, candidate);
}
internal static void EnsureOpenedRegularFile(
SafeFileHandle handle,
string expectedPath)
{
if (!OperatingSystem.IsWindows())
{
var attributes = File.GetAttributes(expectedPath);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw Invalid();
}
return;
}
if (handle.IsInvalid ||
!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes &
(FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
information.NumberOfLinks != 1 ||
(((long)information.FileSizeHigh << 32) | information.FileSizeLow) <= 0 ||
!string.Equals(
GetFinalPath(handle),
Path.GetFullPath(expectedPath),
StringComparison.OrdinalIgnoreCase))
{
throw Invalid();
}
}
private static string ResolveCanonicalFile(string fullRoot, string candidate)
{
var rootPrefix = fullRoot + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate))
{
throw Invalid();
}
try
{
// The lease excludes FILE_SHARE_DELETE for every ancestor while the file
// handle is checked. The file handle itself is also bound to the canonical
// final path and must have exactly one link.
using var lease = TrustedReadOnlyDirectoryLease.Acquire(
Path.GetDirectoryName(candidate) ?? throw Invalid());
using var stream = new FileStream(
candidate,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 1,
FileOptions.RandomAccess);
EnsureOpenedRegularFile(stream.SafeFileHandle, candidate);
return candidate;
}
catch (TrustedPlayoutAssetException)
{
throw;
}
catch
{
throw Invalid();
}
}
private static string GetFinalPath(SafeFileHandle handle)
{
var capacity = 512;
while (capacity <= 32_768)
{
var buffer = new StringBuilder(capacity);
var length = GetFinalPathNameByHandleW(
handle,
buffer,
(uint)buffer.Capacity,
FileNameNormalized);
if (length == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (length < buffer.Capacity)
{
var path = buffer.ToString();
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
{
path = "\\\\" + path[8..];
}
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
{
path = path[4..];
}
return Path.GetFullPath(path);
}
capacity = checked((int)length + 1);
}
throw Invalid();
}
private static TrustedPlayoutAssetException Invalid() => new();
private const uint FileNameNormalized = 0;
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file,
out ByHandleFileInformation information);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle file,
StringBuilder path,
uint pathLength,
uint flags);
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public FileAttributes FileAttributes;
public FileTime CreationTime;
public FileTime LastAccessTime;
public FileTime LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
}
}
internal sealed class TrustedReadOnlyDirectoryLease : IDisposable
{
private const uint GenericRead = 0x80000000;
private const uint OpenExisting = 3;
private const uint FileFlagBackupSemantics = 0x02000000;
private const uint FileFlagOpenReparsePoint = 0x00200000;
private const uint FileNameNormalized = 0;
private readonly IReadOnlyList<SafeFileHandle> _handles;
private TrustedReadOnlyDirectoryLease(IReadOnlyList<SafeFileHandle> handles)
{
_handles = handles;
}
public static TrustedReadOnlyDirectoryLease Acquire(string directory)
{
if (!OperatingSystem.IsWindows())
{
var attributes = File.GetAttributes(directory);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) !=
FileAttributes.Directory)
{
throw new TrustedPlayoutAssetException();
}
return new TrustedReadOnlyDirectoryLease(Array.Empty<SafeFileHandle>());
}
var ancestors = new List<string>();
for (var current = new DirectoryInfo(directory); current is not null; current = current.Parent)
{
ancestors.Add(current.FullName);
}
ancestors.Reverse();
var handles = new List<SafeFileHandle>(ancestors.Count);
try
{
foreach (var ancestor in ancestors)
{
var handle = CreateFileW(
ancestor,
GenericRead,
FileShare.Read | FileShare.Write,
IntPtr.Zero,
OpenExisting,
FileFlagBackupSemantics | FileFlagOpenReparsePoint,
IntPtr.Zero);
if (handle.IsInvalid)
{
handle.Dispose();
throw new Win32Exception(Marshal.GetLastWin32Error());
}
try
{
EnsureDirectDirectory(handle, ancestor);
}
catch
{
handle.Dispose();
throw;
}
handles.Add(handle);
}
return new TrustedReadOnlyDirectoryLease(handles.AsReadOnly());
}
catch
{
foreach (var handle in handles)
{
handle.Dispose();
}
throw new TrustedPlayoutAssetException();
}
}
public void Dispose()
{
foreach (var handle in _handles.Reverse())
{
handle.Dispose();
}
}
private static void EnsureDirectDirectory(SafeFileHandle handle, string expectedPath)
{
if (!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes & FileAttributes.Directory) == 0 ||
(information.FileAttributes & FileAttributes.ReparsePoint) != 0 ||
!string.Equals(
GetFinalPath(handle),
Path.GetFullPath(expectedPath),
StringComparison.OrdinalIgnoreCase))
{
throw new TrustedPlayoutAssetException();
}
}
private static string GetFinalPath(SafeFileHandle handle)
{
var capacity = 512;
while (capacity <= 32_768)
{
var buffer = new StringBuilder(capacity);
var length = GetFinalPathNameByHandleW(
handle,
buffer,
(uint)buffer.Capacity,
FileNameNormalized);
if (length == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (length < buffer.Capacity)
{
var path = buffer.ToString();
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
{
path = "\\\\" + path[8..];
}
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
{
path = path[4..];
}
return Path.GetFullPath(path);
}
capacity = checked((int)length + 1);
}
throw new TrustedPlayoutAssetException();
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string fileName,
uint desiredAccess,
FileShare shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file,
out ByHandleFileInformation information);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle file,
StringBuilder path,
uint pathLength,
uint flags);
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public FileAttributes FileAttributes;
public FileTime CreationTime;
public FileTime LastAccessTime;
public FileTime LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
}
}
internal sealed class TrustedPlayoutAssetException : Exception;

View File

@@ -15,6 +15,7 @@ internal sealed record ValidatedPlayoutOptions(
int? OutputChannel,
int LayoutIndex,
string? SceneDirectory,
string? OperatorBackgroundDirectory,
Regex? TestProcessWindowTitleRegex,
IReadOnlySet<string> TestSceneAllowlist,
bool TrustedLiveOutputEnabled,
@@ -59,6 +60,13 @@ internal sealed record ValidatedPlayoutOptions(
sceneDirectory = ValidateSceneDirectory(options.SceneDirectory);
}
var operatorBackgroundDirectory =
PlayoutSceneCompositionFactory.TryGetConfiguredBackgroundDirectory(
options,
out var configuredBackgroundDirectory)
? configuredBackgroundDirectory
: null;
RequireRange(options.QueueCapacity, 1, 4_096, nameof(options.QueueCapacity));
RequireRange(
options.ConnectTimeoutMilliseconds,
@@ -156,6 +164,7 @@ internal sealed record ValidatedPlayoutOptions(
options.OutputChannel,
options.LayoutIndex,
sceneDirectory,
operatorBackgroundDirectory,
titleRegex,
allowlist,
options.TrustedLiveOutputEnabled,
@@ -356,9 +365,14 @@ internal sealed record ValidatedPlayoutOptions(
return background;
case PlayoutSetBackgroundTexture texture:
ValidateMutationTiming(texture.Timing);
return texture with { AssetPath = ResolveAssetPath(texture.AssetPath) };
ValidateAssetRoot(texture.AssetRoot);
return texture with
{
AssetPath = ResolveAssetPath(texture.AssetPath, texture.AssetRoot)
};
case PlayoutSetBackgroundVideo video:
ValidateMutationTiming(video.Timing);
ValidateAssetRoot(video.AssetRoot);
// The legacy MainForm intentionally uses 2004 for the common studio
// background. Keep the input bounded while retaining that vendor value.
if (video.LoopCount is < 0 or > 10_000)
@@ -366,16 +380,28 @@ internal sealed record ValidatedPlayoutOptions(
throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다.");
}
return video with { AssetPath = ResolveAssetPath(video.AssetPath) };
return video with
{
AssetPath = ResolveAssetPath(video.AssetPath, video.AssetRoot)
};
default:
throw new PlayoutRequestException("지원하지 않는 장면 변경 항목입니다.");
}
}
private string ResolveAssetPath(string? relativePath)
=> ResolveAssetPath(relativePath, PlayoutAssetRoot.SceneDirectory);
private string ResolveAssetPath(string? relativePath, PlayoutAssetRoot assetRoot)
{
relativePath = relativePath?.Trim();
if (SceneDirectory is null || string.IsNullOrEmpty(relativePath) ||
var root = assetRoot switch
{
PlayoutAssetRoot.SceneDirectory => SceneDirectory,
PlayoutAssetRoot.OperatorBackgroundDirectory => OperatorBackgroundDirectory,
_ => null
};
if (root is null || string.IsNullOrEmpty(relativePath) ||
Path.IsPathRooted(relativePath) || relativePath.Contains("..", StringComparison.Ordinal) ||
relativePath.Any(char.IsControl))
{
@@ -393,12 +419,28 @@ internal sealed record ValidatedPlayoutOptions(
throw new PlayoutRequestException("지원하지 않는 장면 자산 형식입니다.");
}
var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath));
var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar)
? SceneDirectory
: SceneDirectory + Path.DirectorySeparatorChar;
if (assetRoot == PlayoutAssetRoot.OperatorBackgroundDirectory)
{
try
{
return TrustedPlayoutAssetPath.ResolveRelativeFile(
root,
relativePath,
new HashSet<string>(allowedExtensions, StringComparer.OrdinalIgnoreCase));
}
catch (TrustedPlayoutAssetException)
{
throw new PlayoutRequestException(
"신뢰 배경 파일을 확인할 수 없습니다.");
}
}
var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(SceneDirectory, candidate))
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
{
throw new PlayoutRequestException("허용된 장면 자산을 찾을 수 없습니다.");
}
@@ -469,6 +511,14 @@ internal sealed record ValidatedPlayoutOptions(
}
}
private static void ValidateAssetRoot(PlayoutAssetRoot assetRoot)
{
if (!Enum.IsDefined(assetRoot))
{
throw new PlayoutRequestException("배경 자산 루트가 올바르지 않습니다.");
}
}
private static bool IsByte(int value) => value is >= byte.MinValue and <= byte.MaxValue;
private static bool CanMatchProgramTitle(Regex regex)