513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
330 lines
15 KiB
C#
330 lines
15 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;
|
|
|
|
/// <summary>Initializes the tests with a temporary root directory.</summary>
|
|
public ScriptFileReaderTests()
|
|
{
|
|
_base = Path.Combine(Path.GetTempPath(), "sb-files-" + Guid.NewGuid().ToString("N"));
|
|
_root = Path.Combine(_base, "recipes");
|
|
Directory.CreateDirectory(_root);
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
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;
|
|
|
|
/// <summary>Verifies that file access is disabled by default when no roots are configured.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that text under a root is read and a byte order mark is detected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a file in a nested directory under the root can be read.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that an empty or relative path is rejected.</summary>
|
|
/// <param name="path">The empty or relative path to read.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("relative/r.txt")]
|
|
public async Task RejectsEmptyOrRelative(string path)
|
|
{
|
|
Assert.Equal(ScriptFileAccessFailure.InvalidPath, await FailureOf(() => Reader().ReadTextAsync(path, default)));
|
|
}
|
|
|
|
/// <summary>Verifies that a path outside the configured root is rejected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that a dot-dot traversal out of the root is rejected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that a sibling directory sharing the root's name prefix is not treated as inside the root.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that the root directory itself is not treated as a file.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task TheRootItself_IsNotAFile()
|
|
{
|
|
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
|
|
await FailureOf(() => Reader().ReadTextAsync(_root, default)));
|
|
}
|
|
|
|
/// <summary>Verifies that a symlinked file inside the root is refused.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that a symlinked directory inside the root is refused.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that a missing file reports not found and that the existence check returns false.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that the existence check returns true for a file but still enforces the roots.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that a file over the size cap is reported as too large.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a file under a second configured root can be read.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that a root configured with a trailing separator works.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that line splitting handles mixed line endings and drops a trailing empty line.</summary>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Verifies that the script accessor throws a disabled error when no reader is configured.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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")));
|
|
}
|
|
|
|
/// <summary>Verifies that the script accessor honours the script deadline.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that a real script compiles through the trust gate and parses a recipe file.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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")));
|
|
}
|
|
|
|
/// <summary>Verifies that raw System.IO usage is still rejected by the trust gate.</summary>
|
|
[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>
|
|
{
|
|
/// <summary>Gets the fixed options instance.</summary>
|
|
public SiteRuntimeOptions CurrentValue => value;
|
|
/// <summary>Returns the fixed options instance regardless of name.</summary>
|
|
/// <param name="name">The options name, ignored.</param>
|
|
/// <returns>The fixed options instance.</returns>
|
|
public SiteRuntimeOptions Get(string? name) => value;
|
|
/// <summary>Ignores the listener because the options never change.</summary>
|
|
/// <param name="listener">The change listener, ignored.</param>
|
|
/// <returns>Always <see langword="null"/>.</returns>
|
|
public IDisposable? OnChange(Action<SiteRuntimeOptions, string?> listener) => null;
|
|
}
|
|
}
|