Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptFileReaderTests.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

282 lines
11 KiB
C#

using System.Text;
using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Script file access (design 2026-09-28): the host-side guard behind the <c>Files</c> global —
/// off by default, allow-listed roots with a directory-boundary match, no traversal, no links,
/// a size cap — plus the script-facing accessor and a real compile-and-run through
/// <see cref="ScriptCompilationService"/> (so the trust validator is proven to accept it).
/// </summary>
public sealed class ScriptFileReaderTests : IDisposable
{
private readonly string _base;
private readonly string _root;
public ScriptFileReaderTests()
{
_base = Path.Combine(Path.GetTempPath(), "sb-files-" + Guid.NewGuid().ToString("N"));
_root = Path.Combine(_base, "recipes");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
try { Directory.Delete(_base, recursive: true); } catch { /* best effort */ }
}
private static IOptionsMonitor<SiteRuntimeOptions> Monitor(ScriptFileAccessOptions files) =>
new StaticMonitor(new SiteRuntimeOptions { ScriptFileAccess = files });
private ScriptFileReader Reader(long maxBytes = 1024 * 1024, params string[] roots) =>
new(Monitor(new ScriptFileAccessOptions
{
AllowedRoots = roots.Length == 0 ? [_root] : roots.ToList(),
MaxFileBytes = maxBytes,
}), NullLogger<ScriptFileReader>.Instance);
private string Write(string relative, string content)
{
var path = Path.Combine(_root, relative);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, content);
return path;
}
private static async Task<ScriptFileAccessFailure> FailureOf(Func<Task> act) =>
(await Assert.ThrowsAsync<ScriptFileAccessException>(act)).Failure;
[Fact]
public async Task NoRoots_IsDisabled_ByDefault()
{
var reader = new ScriptFileReader(Monitor(new ScriptFileAccessOptions()), NullLogger<ScriptFileReader>.Instance);
var path = Write("a.txt", "x");
Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => reader.ReadTextAsync(path, default)));
Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => reader.ExistsAsync(path, default)));
}
[Fact]
public async Task ReadsText_UnderRoot_AndDetectsBom()
{
var path = Path.Combine(_root, "bom.txt");
File.WriteAllText(path, "ReactorTempSP,1050\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
var text = await Reader().ReadTextAsync(path, default);
Assert.Equal("ReactorTempSP,1050\n", text);
}
[Fact]
public async Task ReadsFromNestedDirectory()
{
var path = Write(Path.Combine("wo", "1001", "r.txt"), "a,1");
Assert.Equal("a,1", await Reader().ReadTextAsync(path, default));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("relative/r.txt")]
public async Task RejectsEmptyOrRelative(string path)
{
Assert.Equal(ScriptFileAccessFailure.InvalidPath, await FailureOf(() => Reader().ReadTextAsync(path, default)));
}
[Fact]
public async Task RejectsPathOutsideRoot()
{
var outside = Path.Combine(_base, "secret.txt");
File.WriteAllText(outside, "nope");
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
await FailureOf(() => Reader().ReadTextAsync(outside, default)));
}
[Fact]
public async Task RejectsDotDotTraversalOutOfRoot()
{
File.WriteAllText(Path.Combine(_base, "secret.txt"), "nope");
var sneaky = _root + Path.DirectorySeparatorChar + ".." + Path.DirectorySeparatorChar + "secret.txt";
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
await FailureOf(() => Reader().ReadTextAsync(sneaky, default)));
}
[Fact]
public async Task SiblingDirectorySharingThePrefix_IsNotInsideTheRoot()
{
// Root ".../recipes" must not admit ".../recipes2/x" — a directory-boundary match, not a string prefix.
var sibling = Path.Combine(_base, "recipes2");
Directory.CreateDirectory(sibling);
var path = Path.Combine(sibling, "x.txt");
File.WriteAllText(path, "nope");
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
await FailureOf(() => Reader().ReadTextAsync(path, default)));
}
[Fact]
public async Task TheRootItself_IsNotAFile()
{
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
await FailureOf(() => Reader().ReadTextAsync(_root, default)));
}
[Fact]
public async Task SymlinkedFileInsideRoot_IsRefused()
{
var outside = Path.Combine(_base, "secret.txt");
File.WriteAllText(outside, "nope");
var link = Path.Combine(_root, "link.txt");
File.CreateSymbolicLink(link, outside);
Assert.Equal(ScriptFileAccessFailure.LinkNotAllowed,
await FailureOf(() => Reader().ReadTextAsync(link, default)));
}
[Fact]
public async Task SymlinkedDirectoryInsideRoot_IsRefused()
{
var outsideDir = Path.Combine(_base, "elsewhere");
Directory.CreateDirectory(outsideDir);
File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "nope");
Directory.CreateSymbolicLink(Path.Combine(_root, "linked"), outsideDir);
Assert.Equal(ScriptFileAccessFailure.LinkNotAllowed,
await FailureOf(() => Reader().ReadTextAsync(Path.Combine(_root, "linked", "secret.txt"), default)));
}
[Fact]
public async Task MissingFile_IsNotFound_AndExistsIsFalse()
{
var path = Path.Combine(_root, "missing.txt");
Assert.Equal(ScriptFileAccessFailure.NotFound, await FailureOf(() => Reader().ReadTextAsync(path, default)));
Assert.False(await Reader().ExistsAsync(path, default));
}
[Fact]
public async Task Exists_TrueForFile_ButStillEnforcesRoots()
{
var path = Write("r.txt", "x");
Assert.True(await Reader().ExistsAsync(path, default));
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
await FailureOf(() => Reader().ExistsAsync(Path.Combine(_base, "whatever.txt"), default)));
}
[Fact]
public async Task FileOverTheCap_IsTooLarge()
{
var path = Write("big.txt", new string('x', 2048));
Assert.Equal(ScriptFileAccessFailure.TooLarge, await FailureOf(() => Reader(maxBytes: 1024).ReadTextAsync(path, default)));
Assert.Equal(2048, (await Reader(maxBytes: 2048).ReadTextAsync(path, default)).Length);
}
[Fact]
public async Task SecondRoot_IsHonoured()
{
var other = Path.Combine(_base, "other");
Directory.CreateDirectory(other);
var path = Path.Combine(other, "o.txt");
File.WriteAllText(path, "ok");
Assert.Equal("ok", await Reader(1024 * 1024, _root, other).ReadTextAsync(path, default));
}
[Fact]
public async Task RootConfiguredWithTrailingSeparator_Works()
{
var path = Write("t.txt", "ok");
Assert.Equal("ok", await Reader(1024 * 1024, _root + Path.DirectorySeparatorChar).ReadTextAsync(path, default));
}
[Fact]
public void SplitLines_HandlesMixedEndings_AndDropsTrailingEmptyLine()
{
Assert.Equal(["a", "b", "c", "", "d"], FilesAccessor.SplitLines("a\r\nb\nc\r\rd\n"));
Assert.Empty(FilesAccessor.SplitLines(""));
Assert.Equal([""], FilesAccessor.SplitLines("\n"));
}
[Fact]
public async Task Accessor_WithoutReader_ThrowsDisabled()
{
var files = new FilesAccessor(reader: null, CancellationToken.None);
Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => files.ReadTextAsync("C:/x.txt")));
}
[Fact]
public async Task Accessor_HonoursTheScriptDeadline()
{
var path = Write("r.txt", "x");
using var expired = new CancellationTokenSource();
await expired.CancelAsync();
var files = new FilesAccessor(Reader(), expired.Token);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => files.ReadTextAsync(path));
}
[Fact]
public async Task RealScript_CompilesThroughTheTrustGate_AndParsesARecipe()
{
// End-to-end at the script boundary: the runtime compiler (trust validator included)
// accepts a script that uses Files + catches ScriptFileAccessException by name, and the
// compiled script reads + parses a recipe through the real ScriptRuntimeContext wiring.
var path = Write("recipe.txt", "# comment\nReactorTempSP,1050\nCoatingType,Aluminide\n\nTargetRuntime,480\n");
const string code = """
var path = (string)Parameters["Path"];
try {
var values = new System.Collections.Generic.Dictionary<string, string>();
foreach (var line in await Files.ReadLinesAsync(path)) {
if (line.Trim().Length == 0 || line.TrimStart().StartsWith("#")) continue;
var i = line.IndexOf(',');
values[line.Substring(0, i).Trim()] = line.Substring(i + 1).Trim();
}
var exists = await Instance.Files.ExistsAsync(path);
return values.Count + "|" + values["CoatingType"] + "|" + exists;
} catch (ScriptFileAccessException ex) when (ex.Failure == ScriptFileAccessFailure.NotFound) {
return "missing";
}
""";
var compiled = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance).Compile("RecipeParse", code);
Assert.True(compiled.IsSuccess, string.Join("; ", compiled.Errors));
var ctx = new ScriptRuntimeContext(
ActorRefs.Nobody, sharedScriptLibrary: null!, currentCallDepth: 0, maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5), instanceName: "SBTEST01",
logger: NullLogger<ScriptRuntimeContext>.Instance, fileReader: Reader());
var run = async (string p) => (await compiled.CompiledScript!.RunAsync(new ScriptGlobals
{
Instance = ctx,
Parameters = new ScriptParameters(new Dictionary<string, object?> { ["Path"] = p }),
})).ReturnValue;
Assert.Equal("3|Aluminide|True", await run(path));
Assert.Equal("missing", await run(Path.Combine(_root, "nope.txt")));
}
[Fact]
public void RawSystemIo_IsStillRejectedByTheTrustGate()
{
// The Files global is the ONLY sanctioned door — System.IO stays forbidden.
var compiled = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance)
.Compile("Raw", "return System.IO.File.ReadAllText(\"C:/x.txt\");");
Assert.False(compiled.IsSuccess);
}
private sealed class StaticMonitor(SiteRuntimeOptions value) : IOptionsMonitor<SiteRuntimeOptions>
{
public SiteRuntimeOptions CurrentValue => value;
public SiteRuntimeOptions Get(string? name) => value;
public IDisposable? OnChange(Action<SiteRuntimeOptions, string?> listener) => null;
}
}