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