b3021e7c5f
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).
76 lines
3.5 KiB
C#
76 lines
3.5 KiB
C#
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;
|
|
}
|
|
}
|