using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; /// /// Host-side, read-only file access behind the script Files global (design 2026-09-28). /// Scripts never touch System.IO themselves; every path they pass is validated here /// against . /// public interface IScriptFileReader { /// Reads the whole file as text (BOM-detected, UTF-8 by default). /// Absolute path under an allowed root. /// Cancels the read. /// The file's text. Task ReadTextAsync(string path, CancellationToken cancellationToken); /// True when the path names an existing file under an allowed root; false when it does not exist. /// Absolute path under an allowed root. /// Cancels the check. /// Whether the file exists. Task ExistsAsync(string path, CancellationToken cancellationToken); } /// /// Default . The guard, in order: access enabled (at least one /// root) → path well-formed and fully qualified (no device \\?\/\\.\ paths, no /// alternate data streams) → canonical path strictly inside an allowed root (directory-boundary /// match, so root C:\recipes does not admit C:\recipes2\x) → no symbolic link, /// junction or other reparse point on the file or any directory between the root and the file /// (so a link planted inside a root cannot point outside it) → size under /// , enforced again while reading in case the /// file grows. Every refusal and I/O failure is a . /// public sealed class ScriptFileReader : IScriptFileReader { private readonly IOptionsMonitor _options; private readonly ILogger _logger; /// Initializes a new instance. /// Site runtime options (read per call, so a config reload applies). /// Logger for refusals and reads. public ScriptFileReader(IOptionsMonitor options, ILogger logger) { _options = options; _logger = logger; } private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; /// public async Task ReadTextAsync(string path, CancellationToken cancellationToken) { var (full, root, settings) = Resolve(path); var info = new FileInfo(full); if (!info.Exists) throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'."); EnsureNoLinks(info, root); if (info.Length > settings.MaxFileBytes) throw Fail(ScriptFileAccessFailure.TooLarge, $"File '{full}' is {info.Length} bytes; the limit is {settings.MaxFileBytes} (ScriptFileAccess:MaxFileBytes)."); try { using var buffer = new MemoryStream(); await using (var stream = new FileStream(full, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: 16 * 1024, useAsync: true)) { var chunk = new byte[16 * 1024]; int n; while ((n = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0) { if (buffer.Length + n > settings.MaxFileBytes) throw Fail(ScriptFileAccessFailure.TooLarge, $"File '{full}' grew past the {settings.MaxFileBytes}-byte limit while it was being read."); buffer.Write(chunk, 0, n); } } buffer.Position = 0; using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); _logger.LogDebug("Script file read {Path} ({Bytes} bytes)", full, buffer.Length); return text; } catch (ScriptFileAccessException) { throw; } catch (OperationCanceledException) { throw; } catch (FileNotFoundException ex) { throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.", ex); } catch (DirectoryNotFoundException ex) { throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.", ex); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { throw Fail(ScriptFileAccessFailure.IoError, $"Could not read '{full}': {ex.Message}", ex); } } /// public Task ExistsAsync(string path, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var (full, root, _) = Resolve(path); var info = new FileInfo(full); if (!info.Exists) return Task.FromResult(false); EnsureNoLinks(info, root); return Task.FromResult(true); } private (string Full, string Root, ScriptFileAccessOptions Settings) Resolve(string path) { var settings = _options.CurrentValue.ScriptFileAccess ?? new ScriptFileAccessOptions(); var roots = settings.AllowedRoots.Where(r => !string.IsNullOrWhiteSpace(r)).ToList(); if (roots.Count == 0) throw Fail(ScriptFileAccessFailure.Disabled, "Script file access is disabled on this node (ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots is empty)."); if (string.IsNullOrWhiteSpace(path) || path.IndexOf('\0') >= 0) throw Fail(ScriptFileAccessFailure.InvalidPath, "A file path is required."); path = path.Trim(); if (path.StartsWith(@"\\?\", StringComparison.Ordinal) || path.StartsWith(@"\\.\", StringComparison.Ordinal) || path.StartsWith("//?/", StringComparison.Ordinal) || path.StartsWith("//./", StringComparison.Ordinal)) throw Fail(ScriptFileAccessFailure.InvalidPath, $"Device paths are not allowed: '{path}'."); if (!Path.IsPathFullyQualified(path)) throw Fail(ScriptFileAccessFailure.InvalidPath, $"The path must be absolute: '{path}'."); string full; try { full = Path.GetFullPath(path); } catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { throw Fail(ScriptFileAccessFailure.InvalidPath, $"Invalid path '{path}': {ex.Message}", ex); } // NTFS alternate data streams ("file.txt:hidden") — only the drive-letter colon is legal. if (OperatingSystem.IsWindows() && full.IndexOf(':', 2) >= 0) throw Fail(ScriptFileAccessFailure.InvalidPath, $"Alternate data streams are not allowed: '{path}'."); foreach (var configured in roots) { var root = NormalizeRoot(configured); if (full.Length > root.Length && full.StartsWith(root, PathComparison)) return (full, root, settings); } _logger.LogWarning("Script file access refused: '{Path}' is outside the allowed roots ({Roots})", full, string.Join(", ", roots)); throw Fail(ScriptFileAccessFailure.OutsideAllowedRoots, $"'{full}' is outside the allowed roots (ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots)."); } /// Canonical root with exactly one trailing separator, for a directory-boundary prefix match. private static string NormalizeRoot(string configured) { var root = Path.GetFullPath(configured.Trim()); return Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar; } /// /// Refuses a reparse point (symlink / junction / mount) on the file or on any directory strictly /// below the root: a link inside an allowed root could otherwise point anywhere. The root itself /// may be a link — an administrator configured it. /// private void EnsureNoLinks(FileInfo file, string root) { if (IsLink(file)) throw LinkRefused(file.FullName); for (var dir = file.Directory; dir != null; dir = dir.Parent) { var withSep = Path.EndsInDirectorySeparator(dir.FullName) ? dir.FullName : dir.FullName + Path.DirectorySeparatorChar; if (withSep.Length <= root.Length) break; if (IsLink(dir)) throw LinkRefused(dir.FullName); } } private static bool IsLink(FileSystemInfo info) => info.LinkTarget != null || (info.Exists && info.Attributes.HasFlag(FileAttributes.ReparsePoint)); private ScriptFileAccessException LinkRefused(string at) { _logger.LogWarning("Script file access refused: '{Path}' is a symbolic link / reparse point", at); return Fail(ScriptFileAccessFailure.LinkNotAllowed, $"'{at}' is a symbolic link, junction or reparse point; links below an allowed root are not followed."); } private static ScriptFileAccessException Fail(ScriptFileAccessFailure failure, string message, Exception? inner = null) => new(failure, message, inner); }