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; /// /// Script file access (design 2026-09-28): the host-side guard behind the Files 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 /// (so the trust validator is proven to accept it). /// 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 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.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 FailureOf(Func act) => (await Assert.ThrowsAsync(act)).Failure; [Fact] public async Task NoRoots_IsDisabled_ByDefault() { var reader = new ScriptFileReader(Monitor(new ScriptFileAccessOptions()), NullLogger.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(() => 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(); 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.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.Instance, fileReader: Reader()); var run = async (string p) => (await compiled.CompiledScript!.RunAsync(new ScriptGlobals { Instance = ctx, Parameters = new ScriptParameters(new Dictionary { ["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.Instance) .Compile("Raw", "return System.IO.File.ReadAllText(\"C:/x.txt\");"); Assert.False(compiled.IsSuccess); } private sealed class StaticMonitor(SiteRuntimeOptions value) : IOptionsMonitor { public SiteRuntimeOptions CurrentValue => value; public SiteRuntimeOptions Get(string? name) => value; public IDisposable? OnChange(Action listener) => null; } }