feat(scripts): read-only, allow-listed Files global for site scripts; Delmia test receiver parses recipe files
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync / ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no reparse point on the file or any directory below the root, size cap before and during the read. Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts joins the default script imports on every surface so it binds by short name. Mirrored on ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled). Inbound (central) scripts do not get it. Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS selects groups (rig: AD).
This commit is contained in:
@@ -96,6 +96,46 @@ public class SandboxScriptHost
|
||||
/// <c>Log</c> and <c>Instance.Log</c> are the same object here too.
|
||||
/// </summary>
|
||||
public SandboxLog Log => Instance.Log;
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox mirror of the site runtime's <c>Files</c> global (design 2026-09-28), so a script
|
||||
/// that reads a file BINDS in the Test Run editor. The central sandbox has no access to a
|
||||
/// site node's filesystem, so every call throws the same
|
||||
/// <see cref="ScriptFileAccessException"/> (<see cref="ScriptFileAccessFailure.Disabled"/>) a
|
||||
/// site without configured roots throws — a script's own error handling runs as it would there.
|
||||
/// </summary>
|
||||
public SandboxFilesAccessor Files => Instance.Files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox mirror of the SiteRuntime <c>FilesAccessor</c>: binds identically, refuses at run time.
|
||||
/// </summary>
|
||||
public class SandboxFilesAccessor
|
||||
{
|
||||
private const string Message =
|
||||
"Files.* reads the SITE node's filesystem (under ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots), " +
|
||||
"which the central Test Run sandbox cannot reach — deploy to a site to exercise file-reading scripts.";
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ReadTextAsync</c>; always refused in the sandbox.</summary>
|
||||
/// <param name="path">The file path.</param>
|
||||
/// <param name="cancellationToken">Unused in the sandbox.</param>
|
||||
/// <returns>Never returns.</returns>
|
||||
public Task<string> ReadTextAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ReadLinesAsync</c>; always refused in the sandbox.</summary>
|
||||
/// <param name="path">The file path.</param>
|
||||
/// <param name="cancellationToken">Unused in the sandbox.</param>
|
||||
/// <returns>Never returns.</returns>
|
||||
public Task<IReadOnlyList<string>> ReadLinesAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ExistsAsync</c>; always refused in the sandbox.</summary>
|
||||
/// <param name="path">The file path.</param>
|
||||
/// <param name="cancellationToken">Unused in the sandbox.</param>
|
||||
/// <returns>Never returns.</returns>
|
||||
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -263,6 +303,9 @@ public class SandboxInstanceContext
|
||||
/// </summary>
|
||||
public SandboxLog Log { get; } = new();
|
||||
|
||||
/// <summary>Sandbox mirror of <c>ScriptRuntimeContext.Files</c> (design 2026-09-28); always refuses.</summary>
|
||||
public SandboxFilesAccessor Files { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SandboxInstanceContext.
|
||||
/// </summary>
|
||||
|
||||
@@ -71,7 +71,9 @@ public class ScriptAnalysisService
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Text",
|
||||
"System.Threading.Tasks");
|
||||
"System.Threading.Tasks",
|
||||
// Mirrors the site runtime's imports (design 2026-09-28).
|
||||
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts");
|
||||
|
||||
/// <summary>
|
||||
/// Options for a sandbox run — <see cref="DefaultOptions"/> plus the sandbox host assembly
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Why a script's <c>Files.*</c> call was refused or failed (design 2026-09-28).
|
||||
/// </summary>
|
||||
public enum ScriptFileAccessFailure
|
||||
{
|
||||
/// <summary>No allowed roots are configured on this node (the default), or the surface has no file access at all (Test Run sandbox).</summary>
|
||||
Disabled,
|
||||
|
||||
/// <summary>The path is empty, relative, a device/alternate-stream path, or otherwise malformed.</summary>
|
||||
InvalidPath,
|
||||
|
||||
/// <summary>The canonical path is not under any configured allowed root.</summary>
|
||||
OutsideAllowedRoots,
|
||||
|
||||
/// <summary>The file, or a directory between the allowed root and the file, is a symbolic link / junction / reparse point.</summary>
|
||||
LinkNotAllowed,
|
||||
|
||||
/// <summary>The file does not exist (or the path names a directory).</summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>The file is larger than the configured size cap.</summary>
|
||||
TooLarge,
|
||||
|
||||
/// <summary>The operating system refused or failed the read (permissions, sharing violation, I/O error).</summary>
|
||||
IoError,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single exception type a script's <c>Files.*</c> calls throw. Scripts cannot name
|
||||
/// <c>System.IO</c> exception types (the trust policy forbids the namespace), so every refusal
|
||||
/// and every I/O failure is surfaced as this Commons type, which a script can catch by name and
|
||||
/// branch on via <see cref="Failure"/>.
|
||||
/// </summary>
|
||||
public sealed class ScriptFileAccessException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance.</summary>
|
||||
/// <param name="failure">The failure category.</param>
|
||||
/// <param name="message">A message naming the path and the reason.</param>
|
||||
/// <param name="innerException">The underlying exception, if any.</param>
|
||||
public ScriptFileAccessException(ScriptFileAccessFailure failure, string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Failure = failure;
|
||||
}
|
||||
|
||||
/// <summary>Why the call was refused or failed.</summary>
|
||||
public ScriptFileAccessFailure Failure { get; }
|
||||
}
|
||||
@@ -70,6 +70,9 @@ public sealed class ScriptCompileSurface
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Log</c>.</summary>
|
||||
public CompileLog Log => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Files</c>.</summary>
|
||||
public CompileFiles Files => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext</c> (the <c>Instance</c> global).</summary>
|
||||
public sealed class CompileInstance
|
||||
{
|
||||
@@ -110,6 +113,37 @@ public sealed class ScriptCompileSurface
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.Log</c>.</summary>
|
||||
public CompileLog Log => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.Files</c>.</summary>
|
||||
public CompileFiles Files => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile-only mirror of the SiteRuntime <c>FilesAccessor</c> (design 2026-09-28) — the
|
||||
/// <c>Files</c> global's read-only, allow-listed file access.
|
||||
/// </summary>
|
||||
public sealed class CompileFiles
|
||||
{
|
||||
/// <summary>Mirrors <c>FilesAccessor.ReadTextAsync</c>.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's text.</returns>
|
||||
public Task<string> ReadTextAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ReadLinesAsync</c>.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's lines.</returns>
|
||||
public Task<IReadOnlyList<string>> ReadLinesAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ExistsAsync</c>.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the check.</param>
|
||||
/// <returns>Whether the file exists.</returns>
|
||||
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -342,5 +342,8 @@ public static class ScriptTrustPolicy
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks",
|
||||
// Script-facing Commons types (ScriptAlarm, ScriptFileAccessException, …) — mirrors the
|
||||
// site runtime's imports so the deploy gate binds the same short names (design 2026-09-28).
|
||||
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Files</c> script global (design 2026-09-28): READ-ONLY access to files under the site
|
||||
/// node's configured <c>ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots</c> — e.g. a
|
||||
/// recipe file whose path a MES/Delmia call handed to the machine. Off by default. Every
|
||||
/// refusal or failure throws <see cref="ScriptFileAccessException"/>; reads are bounded by the
|
||||
/// script's own execution deadline as well as the caller's token.
|
||||
/// <code>
|
||||
/// foreach (var line in await Files.ReadLinesAsync(path)) { … }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public sealed class FilesAccessor
|
||||
{
|
||||
private readonly IScriptFileReader? _reader;
|
||||
private readonly CancellationToken _scriptToken;
|
||||
|
||||
/// <summary>Initializes a new instance.</summary>
|
||||
/// <param name="reader">The host reader; null when the node has none wired (then every call throws Disabled).</param>
|
||||
/// <param name="scriptToken">The running script's execution-timeout token.</param>
|
||||
public FilesAccessor(IScriptFileReader? reader, CancellationToken scriptToken)
|
||||
{
|
||||
_reader = reader;
|
||||
_scriptToken = scriptToken;
|
||||
}
|
||||
|
||||
/// <summary>Reads a whole text file (BOM-detected, UTF-8 by default).</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's text.</returns>
|
||||
public async Task<string> ReadTextAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_scriptToken, cancellationToken);
|
||||
return await Reader.ReadTextAsync(path, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a text file as lines (<c>\r\n</c>, <c>\n</c> or <c>\r</c>); a trailing line break does
|
||||
/// not produce a final empty line.
|
||||
/// </summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's lines.</returns>
|
||||
public async Task<IReadOnlyList<string>> ReadLinesAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var text = await ReadTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return SplitLines(text);
|
||||
}
|
||||
|
||||
/// <summary>True when an existing file is at the path; false when nothing is there. Still refuses paths outside the allowed roots.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the check.</param>
|
||||
/// <returns>Whether the file exists.</returns>
|
||||
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_scriptToken, cancellationToken);
|
||||
return await Reader.ExistsAsync(path, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IScriptFileReader Reader => _reader
|
||||
?? throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled,
|
||||
"Script file access is not available on this node.");
|
||||
|
||||
internal static IReadOnlyList<string> SplitLines(string text)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
return Array.Empty<string>();
|
||||
var lines = text.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).ToList();
|
||||
if (lines.Count > 0 && lines[^1].Length == 0)
|
||||
lines.RemoveAt(lines.Count - 1);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,10 @@ public class ScriptCompilationService
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
"System.Threading.Tasks",
|
||||
// Script-facing Commons types (ScriptAlarm, ScriptFileAccessException, …) — lets a
|
||||
// script `catch (ScriptFileAccessException)` by its short name (design 2026-09-28).
|
||||
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts");
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
||||
@@ -347,6 +350,14 @@ public class ScriptGlobals
|
||||
/// <summary>Leveled per-run logging (design 2026-09-09). Usage: <c>Log.Info("…")</c>, <c>Log.Warn("…", ex)</c>. Always reaches the node log; becomes Flow Debugger trace rows only for a traced run.</summary>
|
||||
public ScriptLogger Log => Instance.Log;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only file access under the node's configured allowed roots (design 2026-09-28).
|
||||
/// Usage: <c>var lines = await Files.ReadLinesAsync(path);</c>. Off by default — every call
|
||||
/// throws <c>ScriptFileAccessException(Disabled)</c> until
|
||||
/// <c>ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots</c> is configured.
|
||||
/// </summary>
|
||||
public FilesAccessor Files => Instance.Files;
|
||||
|
||||
/// <summary>
|
||||
/// Parent composition (null when this script is on a root-level template).
|
||||
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// <c>ScadaBridge:SiteRuntime:ScriptFileAccess</c> — the read-only file access a site script gets
|
||||
/// through the <c>Files</c> global (design 2026-09-28). The script trust policy still forbids
|
||||
/// <c>System.IO</c> outright; this is the one sanctioned, host-mediated exception, and it is
|
||||
/// <b>off by default</b>: with no <see cref="AllowedRoots"/> every <c>Files.*</c> call throws
|
||||
/// <c>ScriptFileAccessException(Disabled)</c>.
|
||||
/// </summary>
|
||||
public sealed class ScriptFileAccessOptions
|
||||
{
|
||||
/// <summary>Largest value accepted for <see cref="MaxFileBytes"/> (64 MiB).</summary>
|
||||
public const long MaxFileBytesCeiling = 64L * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute directories (local or UNC) a script may read beneath. A path is allowed only when
|
||||
/// its canonical form (after <c>..</c> resolution) is strictly inside one of these roots, and
|
||||
/// no file or directory below the root is a symbolic link / junction. Empty (the default)
|
||||
/// disables script file access on this node. The node's service account still needs NTFS/share
|
||||
/// read permission — the allow-list narrows access, it never widens it.
|
||||
/// </summary>
|
||||
public List<string> AllowedRoots { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-read size cap in bytes (default 1 MiB, at most <see cref="MaxFileBytesCeiling"/>). A
|
||||
/// larger file throws <c>ScriptFileAccessException(TooLarge)</c> instead of being loaded into
|
||||
/// the script's memory.
|
||||
/// </summary>
|
||||
public long MaxFileBytes { get; set; } = 1024 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Host-side, read-only file access behind the script <c>Files</c> global (design 2026-09-28).
|
||||
/// Scripts never touch <c>System.IO</c> themselves; every path they pass is validated here
|
||||
/// against <see cref="ScriptFileAccessOptions.AllowedRoots"/>.
|
||||
/// </summary>
|
||||
public interface IScriptFileReader
|
||||
{
|
||||
/// <summary>Reads the whole file as text (BOM-detected, UTF-8 by default).</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's text.</returns>
|
||||
Task<string> ReadTextAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the path names an existing file under an allowed root; false when it does not exist.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the check.</param>
|
||||
/// <returns>Whether the file exists.</returns>
|
||||
Task<bool> ExistsAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IScriptFileReader"/>. The guard, in order: access enabled (at least one
|
||||
/// root) → path well-formed and fully qualified (no device <c>\\?\</c>/<c>\\.\</c> paths, no
|
||||
/// alternate data streams) → canonical path strictly inside an allowed root (directory-boundary
|
||||
/// match, so root <c>C:\recipes</c> does not admit <c>C:\recipes2\x</c>) → 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
|
||||
/// <see cref="ScriptFileAccessOptions.MaxFileBytes"/>, enforced again while reading in case the
|
||||
/// file grows. Every refusal and I/O failure is a <see cref="ScriptFileAccessException"/>.
|
||||
/// </summary>
|
||||
public sealed class ScriptFileReader : IScriptFileReader
|
||||
{
|
||||
private readonly IOptionsMonitor<SiteRuntimeOptions> _options;
|
||||
private readonly ILogger<ScriptFileReader> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance.</summary>
|
||||
/// <param name="options">Site runtime options (read per call, so a config reload applies).</param>
|
||||
/// <param name="logger">Logger for refusals and reads.</param>
|
||||
public ScriptFileReader(IOptionsMonitor<SiteRuntimeOptions> options, ILogger<ScriptFileReader> logger)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private static StringComparison PathComparison =>
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> 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).");
|
||||
}
|
||||
|
||||
/// <summary>Canonical root with exactly one trailing separator, for a directory-boundary prefix match.</summary>
|
||||
private static string NormalizeRoot(string configured)
|
||||
{
|
||||
var root = Path.GetFullPath(configured.Trim());
|
||||
return Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -263,6 +263,7 @@ internal static class ScriptRunLauncher
|
||||
IAuditWriter? auditWriter = null;
|
||||
var siteId = string.Empty;
|
||||
string? sourceNode = null;
|
||||
IScriptFileReader? fileReader = null;
|
||||
// ISiteEventLogger is a singleton; resolve from the root provider so
|
||||
// it is available to the catch blocks regardless of scope state.
|
||||
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
|
||||
@@ -346,6 +347,7 @@ internal static class ScriptRunLauncher
|
||||
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
|
||||
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
|
||||
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
|
||||
fileReader = serviceScope.ServiceProvider.GetService<IScriptFileReader>();
|
||||
}
|
||||
|
||||
context = new ScriptRuntimeContext(
|
||||
@@ -407,7 +409,9 @@ internal static class ScriptRunLauncher
|
||||
// effectiveOptions (the IOptionsMonitor snapshot taken at the top of this run),
|
||||
// so an operator's live edit applies from the NEXT run, like the event-policy
|
||||
// flags above.
|
||||
maxLogLinesPerRun: effectiveOptions.MaxLogLinesPerRun);
|
||||
maxLogLinesPerRun: effectiveOptions.MaxLogLinesPerRun,
|
||||
// Script file access (design 2026-09-28): the Files global's host reader.
|
||||
fileReader: fileReader);
|
||||
|
||||
var globals = new ScriptGlobals
|
||||
{
|
||||
@@ -737,7 +741,9 @@ internal static class ScriptRunLauncher
|
||||
// script's per-script execution-timeout token so a
|
||||
// Attributes.WaitAsync inside an on-trigger script is bounded
|
||||
// by the same script deadline.
|
||||
scriptTimeoutToken: cts.Token);
|
||||
scriptTimeoutToken: cts.Token,
|
||||
// Script file access (design 2026-09-28): the Files global's host reader.
|
||||
fileReader: services?.GetService<IScriptFileReader>());
|
||||
|
||||
var globals = new ScriptGlobals
|
||||
{
|
||||
|
||||
@@ -335,7 +335,8 @@ public class ScriptRuntimeContext
|
||||
bool isTraced = false,
|
||||
FlowTraceCollector? traceCollector = null,
|
||||
int maxLogLinesPerRun = 200,
|
||||
ScriptLogger? sharedLog = null)
|
||||
ScriptLogger? sharedLog = null,
|
||||
IScriptFileReader? fileReader = null)
|
||||
{
|
||||
_instanceActor = instanceActor;
|
||||
_sharedScriptLibrary = sharedScriptLibrary;
|
||||
@@ -368,6 +369,8 @@ public class ScriptRuntimeContext
|
||||
// WaitForAttribute (spec §4.3): default(CancellationToken) == None when
|
||||
// not threaded in — the WaitAsync Ask is then bounded only by its own timeout.
|
||||
_scriptTimeoutToken = scriptTimeoutToken;
|
||||
// Script file access (design 2026-09-28): null when the host wired no reader — Files.* then throws Disabled.
|
||||
_fileReader = fileReader;
|
||||
_isTraced = isTraced;
|
||||
_traceCollector = traceCollector;
|
||||
_traceSink = traceCollector is null ? null : Collect;
|
||||
@@ -503,7 +506,9 @@ public class ScriptRuntimeContext
|
||||
// Step logs (design §5): the inline shared script is its own execution node for step
|
||||
// rows, but it SHARES this run's Log — one line cap and one anchor for the whole
|
||||
// inline chain, so its lines carry this run's ExecutionId.
|
||||
sharedLog: Log);
|
||||
sharedLog: Log,
|
||||
// Script file access (design 2026-09-28): the inline shared script reads through the same host reader.
|
||||
fileReader: _fileReader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1248,6 +1253,15 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
public AlarmsAccessor Alarms => _alarms ??= new(this);
|
||||
|
||||
private readonly IScriptFileReader? _fileReader;
|
||||
private FilesAccessor? _files;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to files under the node's configured allowed roots (design 2026-09-28).
|
||||
/// Bounded by this run's execution deadline. Off by default.
|
||||
/// </summary>
|
||||
public FilesAccessor Files => _files ??= new(_fileReader, _scriptTimeoutToken);
|
||||
|
||||
/// <summary>
|
||||
/// Backing read for <see cref="AlarmsAccessor.CurrentAsync"/>: Asks THIS instance's
|
||||
/// Instance Actor for its retained alarm rows and projects them to
|
||||
|
||||
@@ -73,6 +73,11 @@ public static class ServiceCollectionExtensions
|
||||
// construction, which is what runs SiteLocalDbSetup.OnReady.
|
||||
services.AddSingleton<Commons.Interfaces.IOperationTrackingStore, Tracking.OperationTrackingStore>();
|
||||
|
||||
// Script file access (design 2026-09-28): the host reader behind the Files global.
|
||||
// Always registered — with no ScriptFileAccess:AllowedRoots configured it refuses every
|
||||
// call (Disabled), so an unconfigured site behaves exactly as before.
|
||||
services.AddSingleton<Scripts.IScriptFileReader, Scripts.ScriptFileReader>();
|
||||
|
||||
// Site-local repository implementations backed by SQLite.
|
||||
// WP2.6a: ExternalSystemDefinitionCache is a singleton shared by every scoped
|
||||
// SiteExternalSystemRepository instance (one per script-execution DI scope), so
|
||||
|
||||
@@ -257,4 +257,10 @@ public class SiteRuntimeOptions
|
||||
/// Must be greater than 0. Default: 200.</para>
|
||||
/// </summary>
|
||||
public int MaxLogLinesPerRun { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only script file access through the <c>Files</c> global (design 2026-09-28) — the
|
||||
/// allow-listed roots and per-read size cap. Off by default (no roots).
|
||||
/// </summary>
|
||||
public Scripts.ScriptFileAccessOptions ScriptFileAccess { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -119,5 +119,17 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase<SiteRunti
|
||||
$"(was {options.MaxLogLinesPerRun}); it is the per-run cap on Flow Debugger ScriptLog " +
|
||||
"rows a script's Log.* calls may produce (design 2026-09-09 D7), and a run that may " +
|
||||
"emit no lines at all cannot report its own truncation.");
|
||||
|
||||
var files = options.ScriptFileAccess ?? new Scripts.ScriptFileAccessOptions();
|
||||
builder.RequireThat(files.MaxFileBytes > 0 && files.MaxFileBytes <= Scripts.ScriptFileAccessOptions.MaxFileBytesCeiling,
|
||||
$"ScadaBridge:SiteRuntime:ScriptFileAccess:MaxFileBytes must be between 1 and " +
|
||||
$"{Scripts.ScriptFileAccessOptions.MaxFileBytesCeiling} (was {files.MaxFileBytes}); it caps one Files.* read.");
|
||||
for (var i = 0; i < files.AllowedRoots.Count; i++)
|
||||
{
|
||||
var root = files.AllowedRoots[i];
|
||||
builder.RequireThat(!string.IsNullOrWhiteSpace(root) && System.IO.Path.IsPathFullyQualified(root),
|
||||
$"ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots:{i} must be an absolute directory path " +
|
||||
$"(was '{root}'); a relative root would resolve against the service's working directory.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user