Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FilesAccessor.cs
T
Joseph Doherty b3021e7c5f 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).
2026-09-28 10:36:42 -04:00

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;
}
}