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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user