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:
Joseph Doherty
2026-09-28 10:36:42 -04:00
parent 0af0672597
commit b3021e7c5f
31 changed files with 1157 additions and 77 deletions
+1 -1
View File
@@ -231,7 +231,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
### Akka.NET Conventions ### Akka.NET Conventions
- Tell for hot-path internal communication; Ask reserved for system boundaries. - Tell for hot-path internal communication; Ask reserved for system boundaries.
- Cross-cluster communication is gRPC (per-site PSK-authenticated): site→central `CentralControlService`, central→site `SiteCommandService`, plus the `SiteStreamService` data stream. ClusterClient/ClusterClientReceptionist were removed in the migration's Phase 4 — service discovery is by dialling configured endpoints, not the receptionist. (Akka.Cluster.Tools remains for ClusterSingleton.) - Cross-cluster communication is gRPC (per-site PSK-authenticated): site→central `CentralControlService`, central→site `SiteCommandService`, plus the `SiteStreamService` data stream. ClusterClient/ClusterClientReceptionist were removed in the migration's Phase 4 — service discovery is by dialling configured endpoints, not the receptionist. (Akka.Cluster.Tools remains for ClusterSingleton.)
- Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory. Every Roslyn script-compile surface must also attach the shared `CachingScriptMetadataResolver.Instance` (ScriptAnalysis) via `ScriptOptions.WithMetadataResolver` — the four today are Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and ScriptAnalysis `RoslynScriptCompiler`; a new surface that omits it silently reintroduces a per-compile native metadata leak (see Component-ScriptAnalysis.md). - Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). **One host-mediated exception (2026-09-28):** site scripts get a read-only `Files` global (`ReadTextAsync`/`ReadLinesAsync`/`ExistsAsync`) that reads only beneath `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots` — **off by default** (empty ⇒ every call throws `ScriptFileAccessException(Disabled)`), canonical-path directory-boundary match, no symlinks/junctions below a root, `MaxFileBytes` cap (1 MiB). The script still never names a `System.IO` type; errors are the Commons `ScriptFileAccessException`, and `Commons.Types.Scripts` is now a default script import on every surface. Not on inbound (central) scripts; the Test Run sandbox binds it but refuses. Design: `docs/plans/2026-09-28-script-file-access-design.md`. The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory. Every Roslyn script-compile surface must also attach the shared `CachingScriptMetadataResolver.Instance` (ScriptAnalysis) via `ScriptOptions.WithMetadataResolver` — the four today are Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and ScriptAnalysis `RoslynScriptCompiler`; a new surface that omits it silently reintroduces a per-compile native metadata leak (see Component-ScriptAnalysis.md).
- Application-level correlation IDs on all request/response messages. - Application-level correlation IDs on all request/response messages.
## Tool Usage ## Tool Usage
+2
View File
@@ -238,6 +238,7 @@ services:
- "9023:8083" # gRPC streaming - "9023:8083" # gRPC streaming
volumes: volumes:
- ./site-a-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro - ./site-a-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
- ./recipes:/app/recipes:ro # script file access (Files global) — sample Delmia recipes
- site-a-node-a-data:/app/data - site-a-node-a-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app). # Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is # Secrets 0.6.2 refuses a store path under the content root; this mount is
@@ -274,6 +275,7 @@ services:
- "9024:8083" # gRPC streaming - "9024:8083" # gRPC streaming
volumes: volumes:
- ./site-a-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro - ./site-a-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
- ./recipes:/app/recipes:ro # script file access (Files global) — sample Delmia recipes
- site-a-node-b-data:/app/data - site-a-node-b-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app). # Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is # Secrets 0.6.2 refuses a store path under the content root; this mount is
+16
View File
@@ -0,0 +1,16 @@
# Delmia recipe download - sample for the SBTEST test machines (docker/seed-mes-test-machine.sh)
# One value per line: RecipeValueName,RecipeValue
# The value may be an int, a double (invariant culture, '.' decimal), a bool (true/false) or text.
# Blank lines and lines starting with '#' are ignored. Everything after the FIRST comma is the value.
RecipeName,Aluminide Coat R100
RecipeRevision,7
CoatingType,Aluminide
ReactorTempSP,1050.5
PotTempSP,980
ArgonFlowSP,40.25
HydrogenFlowSP,25
ChlorineFlowSP,12.5
VacuumSP,0.5
TargetRuntime,480
PurgeEnabled,true
OperatorNote,Load parts cold, verify fixture ID
+13
View File
@@ -0,0 +1,13 @@
# Second valid recipe - different values/types so a re-download visibly changes every tag
RecipeName,Chromide Coat R200
RecipeRevision,12
CoatingType,Chromide
ReactorTempSP,1082
PotTempSP,1001
ArgonFlowSP,38
HydrogenFlowSP,27
ChlorineFlowSP,11.75
VacuumSP,0.45
TargetRuntime,540
PurgeEnabled,false
OperatorNote,"Quoted text, commas kept"
+6
View File
@@ -0,0 +1,6 @@
# Deliberately INVALID - the receiver must reject it and load nothing:
# line 4: no comma; line 5: unknown RecipeValueName; line 6: PotTempSP must be an int
RecipeName,Broken Recipe
ReactorTempSP 1050
MysterySetting,42
PotTempSP,980.5
+80 -8
View File
@@ -24,11 +24,17 @@ set -euo pipefail
# handshakes are ported to per-attribute SetAsync + WaitAsync. # handshakes are ported to per-attribute SetAsync + WaitAsync.
# * Galaxy simulators SimLeft/RightMoveIn, SimLeft/RightMoveOut, SimDelmiaRecipe (ValueChange on the # * Galaxy simulators SimLeft/RightMoveIn, SimLeft/RightMoveOut, SimDelmiaRecipe (ValueChange on the
# trigger flags) — they play the machine side and complete each handshake. # trigger flags) — they play the machine side and complete each handshake.
# SimDelmiaRecipe READS + PARSES the downloaded recipe file via the Files global
# and loads it into the TestDelmiaReceiver recipe tags (see docker/recipes/).
# - Instances SBTEST01 and SBTEST02 (SBTEST02 overrides SensorFault to MES-reportable), deployed. # - Instances SBTEST01 and SBTEST02 (SBTEST02 overrides SensorFault to MES-reportable), deployed.
# - Inbound methods SimpleAlarmStatusRequest (updated in place) and AlarmStatusRequest (legacy # - Inbound methods SimpleAlarmStatusRequest (updated in place) and AlarmStatusRequest (legacy
# /mes/simplealarmstatus + /mes/alarmstatus shapes). Both resolve the machine through BTDB.dbo.Machine, # /mes/simplealarmstatus + /mes/alarmstatus shapes). Both resolve the machine through BTDB.dbo.Machine,
# so they need the "BTDB" database connection — present on vd03, absent on the docker rig. # so they need the "BTDB" database connection — present on vd03, absent on the docker rig.
# #
# Recipe files: the site needs ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots covering the folder the
# DownloadPath points into (rig: docker/recipes bind-mounted at /app/recipes on site-a; vd03:
# E:\ApiInstall\ScadaBridge\recipes). Without it every download is rejected "recipe file not readable (Disabled)".
#
# The MES methods resolve SAPID -> BTDB.dbo.Machine.Code, so each instance needs a BTDB row # The MES methods resolve SAPID -> BTDB.dbo.Machine.Code, so each instance needs a BTDB row
# (Code = instance name). On vd03 those were inserted 2026-09-28: SBTEST01 = SAPID 990001 (MachineID 203), # (Code = instance name). On vd03 those were inserted 2026-09-28: SBTEST01 = SAPID 990001 (MachineID 203),
# SBTEST02 = SAPID 990002 (MachineID 204), LastUpdateBy 'scadabridge-test-seed'. # SBTEST02 = SAPID 990002 (MachineID 204), LastUpdateBy 'scadabridge-test-seed'.
@@ -128,7 +134,7 @@ done
ensure_attr "$RX_ID" MoveInJobSequenceNumber --data-type String --description "MoveIn payload" ensure_attr "$RX_ID" MoveInJobSequenceNumber --data-type String --description "MoveIn payload"
ensure_attr "$RX_ID" ScanStateCmd --data-type Boolean --value false --description "Unused by the simulator (prod parity)" ensure_attr "$RX_ID" ScanStateCmd --data-type Boolean --value false --description "Unused by the simulator (prod parity)"
echo "2/7 Template TestDelmiaReceiver (10 static attributes)..." echo "2/7 Template TestDelmiaReceiver (10 handshake + 14 recipe static attributes)..."
DR_ID=$(ensure_template TestDelmiaReceiver "In-memory stand-in for the Galaxy DelmiaReceiver object (prod attribute names, static)") DR_ID=$(ensure_template TestDelmiaReceiver "In-memory stand-in for the Galaxy DelmiaReceiver object (prod attribute names, static)")
ensure_attr "$DR_ID" ReadyFlag --data-type Boolean --value true --description "Precondition; true = receiver accepts downloads" ensure_attr "$DR_ID" ReadyFlag --data-type Boolean --value true --description "Precondition; true = receiver accepts downloads"
ensure_attr "$DR_ID" RecipeDownloadFlag --data-type Boolean --value false --description "Trigger: set true after the payload is written" ensure_attr "$DR_ID" RecipeDownloadFlag --data-type Boolean --value false --description "Trigger: set true after the payload is written"
@@ -137,6 +143,22 @@ ensure_attr "$DR_ID" RecipeProcessResult --data-type Boolean --value false --des
for s in DownloadPath WorkOrderNumber PartNumber JobStepNumber Username RecipeProcessResultText; do for s in DownloadPath WorkOrderNumber PartNumber JobStepNumber Username RecipeProcessResultText; do
ensure_attr "$DR_ID" "$s" --data-type String --description "Recipe-download payload / outcome text" ensure_attr "$DR_ID" "$s" --data-type String --description "Recipe-download payload / outcome text"
done done
# Recipe tags the simulator loads from the downloaded file (the Galaxy object's recipe schema stand-in).
# The type here is what each file value is converted to — keep it in step with RECIPE_SCHEMA in SimDelmiaRecipe.
ensure_attr "$DR_ID" RecipeName --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" RecipeRevision --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" CoatingType --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ReactorTempSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" PotTempSP --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ArgonFlowSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" HydrogenFlowSP --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ChlorineFlowSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" VacuumSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" TargetRuntime --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" PurgeEnabled --data-type Boolean --value false --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" OperatorNote --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" RecipeValueCount --data-type Int32 --value 0 --description "How many values the last accepted download loaded"
ensure_attr "$DR_ID" RecipeLoadedFile --data-type String --description "Path of the last accepted recipe file"
echo "3/7 Template TestMesMachine (standalone base) — compositions, knobs, alarm tags, alarms..." echo "3/7 Template TestMesMachine (standalone base) — compositions, knobs, alarm tags, alarms..."
M_ID=$(ensure_template TestMesMachine "In-memory MES/Delmia/alarm-API test machine: Left/Right MES receivers + Delmia receiver, simulated Galaxy side (docker/seed-mes-test-machine.sh)") M_ID=$(ensure_template TestMesMachine "In-memory MES/Delmia/alarm-API test machine: Left/Right MES receivers + Delmia receiver, simulated Galaxy side (docker/seed-mes-test-machine.sh)")
@@ -379,19 +401,69 @@ return null;
CS CS
} }
read -r -d '' SIM_RECIPE_CODE <<'CS' || true read -r -d '' SIM_RECIPE_CODE <<'CS' || true
// Plays the Galaxy DelmiaReceiver: accepts unless SimRecipeReject is set, DownloadPath is blank or ends in // Plays the Galaxy DelmiaReceiver. On the rising edge of RecipeDownloadFlag it READS the downloaded recipe
// ".bad", or the work order is blank. SimNoResponse = never answer. // file through the Files global (script file access, design 2026-09-28 — only beneath the site's configured
// ScriptFileAccess:AllowedRoots), parses "RecipeValueName,RecipeValue" lines, converts each value to its
// recipe tag's type and loads them. All-or-nothing: any bad line rejects the download and loads nothing.
// Blank lines and '#' comments are ignored; everything after the FIRST comma is the value; a value wrapped
// in double quotes is unwrapped. SimRecipeReject / SimNoResponse knobs still apply.
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); }; System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
var recv = Children["DelmiaReceiver"]; var recv = Children["DelmiaReceiver"];
if (!IsTrue(await recv.Attributes.GetAsync("RecipeDownloadFlag"))) return null; if (!IsTrue(await recv.Attributes.GetAsync("RecipeDownloadFlag"))) return null;
if (IsTrue(await Attributes.GetAsync("SimNoResponse"))) return null; if (IsTrue(await Attributes.GetAsync("SimNoResponse"))) return null;
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString() ?? ""; var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString()?.Trim() ?? "";
var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? ""; var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? "";
var forced = IsTrue(await Attributes.GetAsync("SimRecipeReject")); // RECIPE_SCHEMA — recipe tag name -> target type (keep in step with the TestDelmiaReceiver attributes).
var ok = !forced && path.Length > 0 && wo.Length > 0 && !path.EndsWith(".bad", System.StringComparison.OrdinalIgnoreCase); var schema = new System.Collections.Generic.Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) {
["RecipeName"] = "String", ["RecipeRevision"] = "Int32", ["CoatingType"] = "String",
["ReactorTempSP"] = "Double", ["PotTempSP"] = "Int32", ["ArgonFlowSP"] = "Double",
["HydrogenFlowSP"] = "Int32", ["ChlorineFlowSP"] = "Double", ["VacuumSP"] = "Double",
["TargetRuntime"] = "Int32", ["PurgeEnabled"] = "Boolean", ["OperatorNote"] = "String" };
var inv = System.Globalization.CultureInfo.InvariantCulture;
var errors = new System.Collections.Generic.List<string>();
var values = new System.Collections.Generic.List<(string Name, object Value)>();
if (IsTrue(await Attributes.GetAsync("SimRecipeReject"))) errors.Add("simulated reject (SimRecipeReject)");
else if (wo.Length == 0) errors.Add("missing work order number");
else if (path.Length == 0) errors.Add("missing download path");
else {
IReadOnlyList<string>? lines = null;
try { lines = await Files.ReadLinesAsync(path); }
catch (ScriptFileAccessException ex) { errors.Add($"recipe file not readable ({ex.Failure}): {ex.Message}"); }
for (var i = 0; lines != null && i < lines.Count; i++) {
var line = lines[i].Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
var comma = line.IndexOf(',');
if (comma <= 0) { errors.Add($"line {i + 1}: expected RecipeValueName,RecipeValue"); continue; }
var name = line.Substring(0, comma).Trim();
var raw = line.Substring(comma + 1).Trim();
if (raw.Length >= 2 && raw[0] == '"' && raw[raw.Length - 1] == '"') raw = raw.Substring(1, raw.Length - 2);
var key = schema.Keys.FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));
if (key == null) { errors.Add($"line {i + 1}: unknown recipe value '{name}'"); continue; }
if (values.Any(v => v.Name == key)) { errors.Add($"line {i + 1}: '{key}' appears more than once"); continue; }
object? typed = null;
switch (schema[key]) {
case "Int32": if (int.TryParse(raw, System.Globalization.NumberStyles.Integer, inv, out var n)) typed = n; break;
case "Double": if (double.TryParse(raw, System.Globalization.NumberStyles.Float, inv, out var d)) typed = d; break;
case "Boolean": if (bool.TryParse(raw, out var bv)) typed = bv; break;
default: typed = raw; break;
}
if (typed == null) { errors.Add($"line {i + 1}: {key} expects {schema[key]}, got '{raw}'"); continue; }
values.Add((key, typed));
}
if (lines != null && errors.Count == 0 && values.Count == 0) errors.Add("recipe file contains no values");
}
var ok = errors.Count == 0;
if (ok) {
foreach (var (name, value) in values) await recv.Attributes.SetAsync(name, value);
await recv.Attributes.SetAsync("RecipeValueCount", values.Count);
await recv.Attributes.SetAsync("RecipeLoadedFile", path);
}
var file = path.Substring(path.LastIndexOfAny(new[] { '\\', '/' }) + 1);
var text = ok ? $"Loaded {values.Count} recipe values from {file} for {wo}"
: "Recipe rejected: " + string.Join("; ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} more)" : "");
if (!ok) Log.Warn(text);
await recv.Attributes.SetAsync("RecipeProcessResult", ok); await recv.Attributes.SetAsync("RecipeProcessResult", ok);
await recv.Attributes.SetAsync("RecipeProcessResultText", ok ? "Simulated download accepted for " + wo await recv.Attributes.SetAsync("RecipeProcessResultText", text);
: forced ? "Simulated reject (SimRecipeReject)" : "Simulated reject: bad path or missing work order");
await recv.Attributes.SetAsync("RecipeDownloadFlag", false); await recv.Attributes.SetAsync("RecipeDownloadFlag", false);
await recv.Attributes.SetAsync("RecipeProcessedFlag", true); await recv.Attributes.SetAsync("RecipeProcessedFlag", true);
return null; return null;
@@ -9,6 +9,14 @@
"GrpcPort": 8083, "GrpcPort": 8083,
"MetricsPort": 8084 "MetricsPort": 8084
}, },
// Script file access (design 2026-09-28): the Files global reads only beneath these
// roots. ./recipes is bind-mounted read-only at /app/recipes on site-a (both nodes, so
// a failover keeps the same view); site-b/site-c stay unconfigured = Files disabled.
"SiteRuntime": {
"ScriptFileAccess": {
"AllowedRoots": [ "/app/recipes" ]
}
},
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
"akka.tcp://scadabridge@scadabridge-site-a-a:8082", "akka.tcp://scadabridge@scadabridge-site-a-a:8082",
@@ -9,6 +9,14 @@
"GrpcPort": 8083, "GrpcPort": 8083,
"MetricsPort": 8084 "MetricsPort": 8084
}, },
// Script file access (design 2026-09-28): the Files global reads only beneath these
// roots. ./recipes is bind-mounted read-only at /app/recipes on site-a (both nodes, so
// a failover keeps the same view); site-b/site-c stay unconfigured = Files disabled.
"SiteRuntime": {
"ScriptFileAccess": {
"AllowedRoots": [ "/app/recipes" ]
}
},
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
"akka.tcp://scadabridge@scadabridge-site-a-b:8082", "akka.tcp://scadabridge@scadabridge-site-a-b:8082",
+104 -57
View File
@@ -7,7 +7,13 @@ set -euo pipefail
# SBTEST01 / SBTEST02 test machines, and trips/clears the computed alarms by writing their static source # SBTEST01 / SBTEST02 test machines, and trips/clears the computed alarms by writing their static source
# attributes. Needs the BTDB rows SBTEST01=990001 / SBTEST02=990002 (vd03 only). # attributes. Needs the BTDB rows SBTEST01=990001 / SBTEST02=990002 (vd03 only).
# #
# SCADABRIDGE_URL=http://wonder-app-vd03.zmr.zimmer.com:8085 bash docker/verify-mes-test-machine.sh # SCADABRIDGE_URL=http://wonder-app-vd03.zmr.zimmer.com:8085 \
# RECIPE_ROOT='E:\ApiInstall\ScadaBridge\recipes' RECIPE_OUTSIDE='E:\ApiInstall\ScadaBridge\app\appsettings.Site.json' \
# bash docker/verify-mes-test-machine.sh
#
# Group A reads the docker/recipes/ sample files through the Files global: RECIPE_ROOT is where they live
# ON THE SITE NODE (default /app/recipes, the rig's site-a mount) and must be inside the site's
# ScriptFileAccess:AllowedRoots; RECIPE_OUTSIDE is an existing file OUTSIDE the roots (proves the allow-list).
# #
# Leaves both machines as it found them: alarm sources reset to their defaults, no batch moved in. # Leaves both machines as it found them: alarm sources reset to their defaults, no batch moved in.
@@ -16,6 +22,8 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
export SB_CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge" export SB_CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
export SB_URL="${SCADABRIDGE_URL:-http://localhost:9000}" export SB_URL="${SCADABRIDGE_URL:-http://localhost:9000}"
export SB_USER="${SCADABRIDGE_USERNAME:-multi-role}" SB_PASS="${SCADABRIDGE_PASSWORD:-password}" export SB_USER="${SCADABRIDGE_USERNAME:-multi-role}" SB_PASS="${SCADABRIDGE_PASSWORD:-password}"
export VERIFY_GROUPS="${VERIFY_GROUPS:-ABCD}" # the rig has no BTDB: VERIFY_GROUPS=AD there
export RECIPE_ROOT="${RECIPE_ROOT:-/app/recipes}" RECIPE_OUTSIDE="${RECIPE_OUTSIDE:-/app/appsettings.Site.json}"
python3 - <<'PY' python3 - <<'PY'
import json, os, subprocess, sys import json, os, subprocess, sys
@@ -57,68 +65,107 @@ def movein(sap, wo=WO): return call("MesMoveIn", {"SAPID": sap, "ContainerNumb
def moveout(sap, wo=WO): return call("MesMoveOut", {"SAPID": sap, "ContainerNumber": "CN-1", "NumberWorkOrders": len(wo), "WorkOrders": wo, "OperatorName": "sb-test"}) def moveout(sap, wo=WO): return call("MesMoveOut", {"SAPID": sap, "ContainerNumber": "CN-1", "NumberWorkOrders": len(wo), "WorkOrders": wo, "OperatorName": "sb-test"})
def names(r): return sorted(a["Name"] for a in r.get("Alarms") or []) def names(r): return sorted(a["Name"] for a in r.get("Alarms") or [])
print("A. DelmiaRecipeDownload (routes by MachineCode)") def group_A():
r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.xml", "WorkOrderNumber": "WO-1001", "PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"}) print("A. DelmiaRecipeDownload -> recipe file read + parsed + loaded (Files global)")
check("A1 accepted", r.get("Result") is True and "accepted" in r.get("ResultText", ""), r) ROOT, OUTSIDE = os.environ["RECIPE_ROOT"], os.environ["RECIPE_OUTSIDE"]
r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.bad", "WorkOrderNumber": "WO-1001"}) SEP = "\\" if "\\" in ROOT else "/"
check("A2 .bad path rejected", r.get("Result") is False and "reject" in r.get("ResultText", "").lower(), r) def recipe(name): return ROOT.rstrip("/\\") + SEP + name
def download(path, wo="WO-1001"):
return call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": path, "WorkOrderNumber": wo,
"PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"})
def recv_tags():
snap = cli("debug", "snapshot", "--id", str(instances["SBTEST01"]))
return {a["attributeName"].split(".", 1)[1]: a["value"] for a in snap["attributeValues"]
if a["attributeName"].startswith("DelmiaReceiver.")}
print("B. MesMoveIn / MesMoveOut (SAPID -> BTDB -> SBTEST01)") r = download(recipe("SBTEST-R100.txt"))
r = movein("990001_A") check("A1 R100 accepted, 12 values", r.get("Result") is True and r.get("ResultText") == "Loaded 12 recipe values from SBTEST-R100.txt for WO-1001", r)
batch = r.get("BatchID", 0) t = recv_tags()
check("B1 MoveIn left succeeds with a batch id", r.get("WasSuccessful") is True and batch >= 5000, r) check("A2 R100 values loaded with their types",
r = moveout("990001_A") t.get("RecipeName") == "Aluminide Coat R100" and t.get("RecipeRevision") == "7" and t.get("ReactorTempSP") == "1050.5"
check("B2 MoveOut left returns the same batch", r.get("WasSuccessful") is True and r.get("BatchID") == batch, r) and t.get("PotTempSP") == "980" and t.get("PurgeEnabled", "").lower() == "true" and t.get("RecipeValueCount") == "12"
check("B3 MoveOut Run returns 16 cycle rows", len(r.get("Data") or []) == 16 and r["Data"][0].get("ValueName") == "TargetRuntime", r.get("Data")) and t.get("OperatorNote") == "Load parts cold, verify fixture ID", t)
r = moveout("990001_A") r = download(recipe("SBTEST-R200.txt"))
check("B4 second MoveOut rejected (nothing moved in)", r.get("WasSuccessful") is False and "No batch" in r.get("ErrorText", ""), r) t = recv_tags()
r = movein("990001_A", [{"WorkOrderNumber": "WO-FAIL-1", "PartNumber": "PN-2001"}]) check("A3 R200 re-download replaces every value (quoted text unwrapped)", r.get("Result") is True
check("B5 FAIL work order rejected", r.get("WasSuccessful") is False and "FAIL" in r.get("ErrorText", ""), r) and t.get("CoatingType") == "Chromide" and t.get("ChlorineFlowSP") == "11.75" and t.get("PurgeEnabled", "").lower() == "false"
r = movein("990001") and t.get("OperatorNote") == "Quoted text, commas kept", (r, t))
check("B6 missing side suffix", r.get("WasSuccessful") is False and "suffix" in r.get("ErrorText", ""), r) r = download(recipe("SBTEST-R900-invalid.txt"))
r = movein("990001_B_LT"); lt = r.get("BatchID", 0) txt = r.get("ResultText", "")
check("B7 leak-test MoveIn right", r.get("WasSuccessful") is True and lt > 0, r) check("A4 invalid recipe rejected with line numbers", r.get("Result") is False and "line 4" in txt and "line 5: unknown recipe value 'MysterySetting'" in txt
r = moveout("990001_B_LT") and "line 6: PotTempSP expects Int32" in txt, r)
check("B8 leak-test MoveOut returns 6 LT rows", r.get("WasSuccessful") is True and len(r.get("Data") or []) == 6 and r["Data"][0]["ValueName"] == "LTType", r) check("A5 rejected download loaded nothing (still R200)", recv_tags().get("RecipeName") == "Chromide Coat R200")
r = movein("999999_A") r = download(recipe("SBTEST-NOPE.txt"))
check("B9 unknown SAPID", r.get("WasSuccessful") is False and "No machine found" in r.get("ErrorText", ""), r) check("A6 missing file -> NotFound", r.get("Result") is False and "(NotFound)" in r.get("ResultText", ""), r)
r = download(OUTSIDE)
check("A7 file outside AllowedRoots -> refused", r.get("Result") is False and "(OutsideAllowedRoots)" in r.get("ResultText", ""), r)
r = download(recipe("SBTEST-R100.txt"), wo="")
check("A8 blank work order rejected", r.get("Result") is False and "work order" in r.get("ResultText", ""), r)
print("C. Alarm APIs") def group_B():
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"}) print("B. MesMoveIn / MesMoveOut (SAPID -> BTDB -> SBTEST01)")
check("C1 no alarms active -> success, empty", r.get("WasSuccessful") is True and names(r) == [], r) r = movein("990001_A")
for inst in ("SBTEST01", "SBTEST02"): batch = r.get("BatchID", 0)
check("B1 MoveIn left succeeds with a batch id", r.get("WasSuccessful") is True and batch >= 5000, r)
r = moveout("990001_A")
check("B2 MoveOut left returns the same batch", r.get("WasSuccessful") is True and r.get("BatchID") == batch, r)
check("B3 MoveOut Run returns 16 cycle rows", len(r.get("Data") or []) == 16 and r["Data"][0].get("ValueName") == "TargetRuntime", r.get("Data"))
r = moveout("990001_A")
check("B4 second MoveOut rejected (nothing moved in)", r.get("WasSuccessful") is False and "No batch" in r.get("ErrorText", ""), r)
r = movein("990001_A", [{"WorkOrderNumber": "WO-FAIL-1", "PartNumber": "PN-2001"}])
check("B5 FAIL work order rejected", r.get("WasSuccessful") is False and "FAIL" in r.get("ErrorText", ""), r)
r = movein("990001")
check("B6 missing side suffix", r.get("WasSuccessful") is False and "suffix" in r.get("ErrorText", ""), r)
r = movein("990001_B_LT"); lt = r.get("BatchID", 0)
check("B7 leak-test MoveIn right", r.get("WasSuccessful") is True and lt > 0, r)
r = moveout("990001_B_LT")
check("B8 leak-test MoveOut returns 6 LT rows", r.get("WasSuccessful") is True and len(r.get("Data") or []) == 6 and r["Data"][0]["ValueName"] == "LTType", r)
r = movein("999999_A")
check("B9 unknown SAPID", r.get("WasSuccessful") is False and "No machine found" in r.get("ErrorText", ""), r)
def group_C():
print("C. Alarm APIs")
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
check("C1 no alarms active -> success, empty", r.get("WasSuccessful") is True and names(r) == [], r)
for inst in ("SBTEST01", "SBTEST02"):
write(inst, "VacuumPressure", 12); write(inst, "DoorOpen", "true"); write(inst, "SensorStatus", -1) write(inst, "VacuumPressure", 12); write(inst, "DoorOpen", "true"); write(inst, "SensorStatus", -1)
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"}) r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r)) check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r))
a = (r.get("Alarms") or [{}])[0] a = (r.get("Alarms") or [{}])[0]
check("C3 row shape", a.get("HierarchicalName") == "SBTEST01.HighVacuumFault" and a.get("IsFlaggedForMES") is True check("C3 row shape", a.get("HierarchicalName") == "SBTEST01.HighVacuumFault" and a.get("IsFlaggedForMES") is True
and a.get("Severity") == 800 and a.get("StatusCode") == "Triggered.Acked" and a.get("TriggeredDT"), a) and a.get("Severity") == 800 and a.get("StatusCode") == "Triggered.Acked" and a.get("TriggeredDT"), a)
r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"}) r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"})
check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r)) check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}}) r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}})
check("C5 full, no filter = all 3 active", names(r) == ["DoorOpenAlarm", "HighVacuumFault", "SensorFault"], names(r)) check("C5 full, no filter = all 3 active", names(r) == ["DoorOpenAlarm", "HighVacuumFault", "SensorFault"], names(r))
flags = {x["Name"]: x["IsFlaggedForMES"] for x in r.get("Alarms") or []} flags = {x["Name"]: x["IsFlaggedForMES"] for x in r.get("Alarms") or []}
check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags) check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags)
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}}) r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}})
check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r)) check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}}) r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}})
check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r)) check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}}) r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}})
check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r)) check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}, "AlarmFilter": {"IncludeAcked": False}}) r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}, "AlarmFilter": {"IncludeAcked": False}})
check("C10 IncludeAcked=false drops computed (always-acked) alarms", r.get("WasSuccessful") is True and names(r) == [], names(r)) check("C10 IncludeAcked=false drops computed (always-acked) alarms", r.get("WasSuccessful") is True and names(r) == [], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "123"}}) r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "123"}})
check("C11 unknown SAPID error text", r.get("WasSuccessful") is False and "Failed to find machine with SAPID '123'" in (r.get("ErrorText") or ""), r) check("C11 unknown SAPID error text", r.get("WasSuccessful") is False and "Failed to find machine with SAPID '123'" in (r.get("ErrorText") or ""), r)
r = call("AlarmStatusRequest", {}) r = call("AlarmStatusRequest", {})
check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r) check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r)
r = call("SimpleAlarmStatusRequest", {"SAPID": "103559"}) r = call("SimpleAlarmStatusRequest", {"SAPID": "103559"})
check("C13 machine without AlarmStatus script -> not supported", r.get("WasSuccessful") is False and "not supported" in (r.get("ErrorText") or ""), r) check("C13 machine without AlarmStatus script -> not supported", r.get("WasSuccessful") is False and "not supported" in (r.get("ErrorText") or ""), r)
print("D. Reset alarm sources") def group_D():
for inst in ("SBTEST01", "SBTEST02"): print("D. Reset alarm sources")
for inst in ("SBTEST01", "SBTEST02"):
write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0) write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0)
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}}) r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}})
check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(r)) check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(r))
GROUPS = os.environ["VERIFY_GROUPS"].upper()
for g in "ABCD":
if g in GROUPS:
globals()["group_" + g]()
print(f"\n{'PASS' if failed == 0 else 'FAIL'} {passed}/{passed + failed}") print(f"\n{'PASS' if failed == 0 else 'FAIL'} {passed}/{passed + failed}")
sys.exit(1 if failed else 0) sys.exit(1 if failed else 0)
@@ -0,0 +1,55 @@
# Script file access — the `Files` global (2026-09-28)
## Problem
A Delmia recipe download hands the machine a `DownloadPath`; in production the Galaxy `DelmiaReceiver`
object's own script opens that text file, parses it and loads the values into its tags. ScadaBridge scripts
cannot do the same thing: the trust policy forbids `System.IO` outright, and the deploy gate refuses any
script that names a `System.IO` type. So an in-memory test machine (and any future ScadaBridge-side
receiver) could exercise the handshake but never the file parse.
## Options considered
1. **Leave parsing in the Galaxy.** No change; test machines never parse.
2. **A small file-reader HTTP service reached through `ExternalSystem.Call`.** No trust-model change, but a
new service to deploy and secure on every site.
3. **A host-mediated, allow-listed platform API.** *Chosen* (user decision). A deliberate, narrow exception
to the trust boundary: the script never touches `System.IO`; the host reads on its behalf, only beneath
directories an administrator lists.
Rejected outright: reading through SQL Server (`OPENROWSET(BULK …)` via `Database.Connection`) — needs
elevated SQL rights, reads the SQL host's filesystem, and sidesteps the trust policy.
## Decisions
- **Surface.** `Files.ReadTextAsync(path)`, `Files.ReadLinesAsync(path)`, `Files.ExistsAsync(path)` on the
site `ScriptGlobals` (+ `Instance.Files`), each taking an optional `CancellationToken`. Read-only — no
write, list, delete or directory enumeration; add one only with its own use case.
- **Off by default.** `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots` (absolute local/UNC dirs,
default empty) and `MaxFileBytes` (default 1 MiB, 1…64 MiB). No roots ⇒ every call throws `Disabled`, so
upgrading a site changes nothing until an operator opts in. Validated at boot.
- **Guard (host side, `ScriptFileReader`).** Fully-qualified path; no device paths or alternate data
streams; canonicalize, then a directory-boundary prefix match against each root; no reparse point on the
file or any directory between root and file; size cap enforced before and during the read.
- **One exception type in Commons** — `ScriptFileAccessException` + `ScriptFileAccessFailure` — because
scripts cannot name `System.IO` exceptions. `Commons.Types.Scripts` joins the default script imports on
every surface so it binds by short name.
- **Site scripts only.** Inbound API scripts run on central and have no `Files`. The Test Run sandbox
mirrors the member (so scripts bind) and throws `Disabled` at run time. `ScriptCompileSurface.CompileFiles`
mirrors it for the deploy gate; parity is pinned by `CompileSurfaceParityTests`.
- **Not audited.** File reads are not one of the audit channels; refusals log a node Warning.
## Accepted residuals
- Time-of-check/time-of-use between the link check and the open: a writer inside an allowed root could swap
a file for a link in between. Bounded by the allow-list (only administrator-chosen directories) and by the
API being read-only.
- The node's service account permissions still apply; on a site pair both nodes need the same roots and the
same access (a script can run on either node after failover).
## First consumer
`docker/seed-mes-test-machine.sh`: the `SimDelmiaRecipe` simulator on `TestMesMachine` reads the downloaded
recipe file (`RecipeValueName,RecipeValue` per line), types each value (int → double → bool → string) and
loads it into the matching `TestDelmiaReceiver` recipe attributes, rejecting unknown names and malformed
lines. Sample files: `docker/recipes/`.
@@ -32,7 +32,7 @@ The following namespace/type prefixes are forbidden in all scripts:
| Scope | Rationale | | Scope | Rationale |
|-------|-----------| |-------|-----------|
| `System.IO` | File system access — forbidden entirely | | `System.IO` | File system access — forbidden entirely. The only file access a script has is the host-mediated, allow-listed, read-only `Files` global on site scripts (2026-09-28, `Component-SiteRuntime.md` → *File Access*) — the script never touches a `System.IO` type, so this scope is unchanged |
| `System.Diagnostics.Process` | Process spawning — forbidden; `Stopwatch`, `Debug`, `Activity`, and other `System.Diagnostics` types are **allowed** | | `System.Diagnostics.Process` | Process spawning — forbidden; `Stopwatch`, `Debug`, `Activity`, and other `System.Diagnostics` types are **allowed** |
| `System.Threading` | Raw thread manipulation — forbidden, with the exceptions below | | `System.Threading` | Raw thread manipulation — forbidden, with the exceptions below |
| `System.Reflection` | Reflection — forbidden entirely | | `System.Reflection` | Reflection — forbidden entirely |
@@ -92,7 +92,7 @@ The identifiers `dynamic` and `Activator` are forbidden at any scope, as they pr
- For each identifier in the syntax tree, resolves the underlying symbol to its fully qualified containing namespace and type name. - For each identifier in the syntax tree, resolves the underlying symbol to its fully qualified containing namespace and type name.
- Flags any symbol whose containing namespace or type matches a forbidden scope in `ScriptTrustPolicy.ForbiddenScopes`, taking `AllowedExceptions` into account. - Flags any symbol whose containing namespace or type matches a forbidden scope in `ScriptTrustPolicy.ForbiddenScopes`, taking `AllowedExceptions` into account.
- Correctly handles aliases (`using X = System.IO.File`), `using static`, and `global::` prefixes — the resolved symbol is checked, not the spelling. - Correctly handles aliases (`using X = System.IO.File`), `using static`, and `global::` prefixes — the resolved symbol is checked, not the spelling.
- The validation compilation applies the **same default namespace imports** (`ScriptTrustPolicy.DefaultImports` — `using System;`, `System.Collections.Generic`, `System.Linq`, `System.Threading.Tasks`) that `RoslynScriptCompiler` compiles the real script with. Without them the semantic pass under-resolves **bare** identifiers the running script sees fully bound — e.g. `Environment.Exit(0)` or `GC.Collect()`, whose `Environment`/`GC` resolve to the forbidden `System.Environment`/`System.GC` types only when `using System;` is in scope. Matching the runtime import set closes that blind spot; it can only make more identifiers resolve to their true (forbidden) namespace, never produce a false allow. - The validation compilation applies the **same default namespace imports** (`ScriptTrustPolicy.DefaultImports` — `using System;`, `System.Collections.Generic`, `System.Linq`, `System.Threading.Tasks`, and since 2026-09-28 `ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts`, so script-facing Commons types such as `ScriptFileAccessException` bind by short name) that `RoslynScriptCompiler` compiles the real script with. Without them the semantic pass under-resolves **bare** identifiers the running script sees fully bound — e.g. `Environment.Exit(0)` or `GC.Collect()`, whose `Environment`/`GC` resolve to the forbidden `System.Environment`/`System.GC` types only when `using System;` is in scope. Matching the runtime import set closes that blind spot; it can only make more identifiers resolve to their true (forbidden) namespace, never produce a false allow.
- Because the full reference set is loaded, this pass also catches a forbidden type accessed inside an otherwise-allowed namespace (e.g., bare `Process` after `using System.Diagnostics;`). - Because the full reference set is loaded, this pass also catches a forbidden type accessed inside an otherwise-allowed namespace (e.g., bare `Process` after `using System.Diagnostics;`).
##### `AnalysisReferences` vs `DefaultReferences` ##### `AnalysisReferences` vs `DefaultReferences`
@@ -520,6 +520,17 @@ Available to all script runs and alarm on-trigger runs:
- The type lives in Commons so the runtime accessor and the Script Analysis compile-only surface (`ScriptCompileSurface.CompileAlarmsAccessor`) project to the **same** type — a script binding at the design-time gate binds identically at the site. Parity is pinned by `CompileSurfaceParityTests`. - The type lives in Commons so the runtime accessor and the Script Analysis compile-only surface (`ScriptCompileSurface.CompileAlarmsAccessor`) project to the **same** type — a script binding at the design-time gate binds identically at the site. Parity is pinned by `CompileSurfaceParityTests`.
- Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §5.2. - Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §5.2.
### File Access (`Files`, read-only, allow-listed — 2026-09-28)
- `Files.ReadTextAsync(path)` → `string`, `Files.ReadLinesAsync(path)` → `IReadOnlyList<string>` (`\r\n`/`\n`/`\r`; a trailing line break adds no empty last line), `Files.ExistsAsync(path)` → `bool`. Also reachable as `Instance.Files`. The use case that motivated it: a recipe/NC file whose path a Delmia/MES call hands to the machine, which a script parses and loads into attributes.
- **The one sanctioned exception to the `System.IO` ban, and it is host-mediated.** Scripts still cannot name any `System.IO` type — the trust policy is unchanged. The runtime `FilesAccessor` delegates to the site-singleton `ScriptFileReader` (`SiteRuntime/Scripts/`), which does every check on the host side.
- **Off by default.** `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots` (list of absolute local/UNC directories, default empty) enables it; with no roots every call throws `ScriptFileAccessException(Disabled)`, so an unconfigured site behaves exactly as before. `ScriptFileAccess:MaxFileBytes` (default 1 MiB, validated 1…64 MiB) caps one read. Both validated at boot (`SiteRuntimeOptionsValidator` — a relative root is rejected). Read per call through `IOptionsMonitor`.
- **Guard, in order:** roots configured → path non-empty, fully qualified, not a device path (`\\?\`, `\\.\`), no NTFS alternate data stream → canonical path (`Path.GetFullPath`, so `..` is resolved first) **strictly inside** a root with a directory-boundary match (root `C:\recipes` does not admit `C:\recipes2\x`, and the root itself is not a file) → no symbolic link / junction / reparse point on the file **or on any directory between the root and the file** (the root itself may be a link — an administrator chose it) → size under the cap, re-checked while reading in case the file grows. Files are opened `FileShare.ReadWrite | Delete` so a file still being written by its producer can be read; text is BOM-detected, UTF-8 by default.
- **Errors are one Commons type.** Every refusal and I/O failure is `ScriptFileAccessException` (`Commons.Types.Scripts`) with a `Failure` of `Disabled`, `InvalidPath`, `OutsideAllowedRoots`, `LinkNotAllowed`, `NotFound`, `TooLarge` or `IoError` — scripts cannot name `System.IO` exception types, so this is what they `catch`. `Commons.Types.Scripts` is now one of the **default script imports** on every surface (runtime, deploy gate, Test Run editor), so the short name binds.
- **Bounded by the run.** Reads are cancelled by the script's own execution deadline as well as any token the script passes. Refusals (outside roots, links) log a Warning on the node; successful reads log at Debug. There is no audit row — file reads are not an audited channel (the `AuditLog` scope is outbound API/DB, notifications and inbound API).
- **The node's service account still needs read permission** on the path; the allow-list only ever narrows access. A time-of-check/time-of-use window exists between the link check and the open (a writer inside an allowed root could swap a file for a link in between) — acceptable because only the directories an administrator lists are reachable at all, and read-only.
- **Site scripts only.** Inbound API method scripts (central) have no `Files` global. The Central UI Test Run sandbox mirrors the member so scripts bind, but every call throws `ScriptFileAccessException(Disabled)` — central cannot reach a site's filesystem — so a script's own error handling runs as it would on an unconfigured site. The design-time `ScriptCompileSurface.CompileFiles` mirror is pinned by `CompileSurfaceParityTests`.
- Design record: `docs/plans/2026-09-28-script-file-access-design.md`.
### Logging (`Log`) ### Logging (`Log`)
- `Log.Debug(message, exception?)` / `Log.Info` / `Log.Warn` / `Log.Error` — leveled, per-run logging available to **every** script, alongside the `Alarms` accessor above (design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` D4/D5). Synchronous, `void`, and it **never throws into the script**: a failing sink is swallowed, because a debug observer must not fail a production run. - `Log.Debug(message, exception?)` / `Log.Info` / `Log.Warn` / `Log.Error` — leveled, per-run logging available to **every** script, alongside the `Alarms` accessor above (design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` D4/D5). Synchronous, `void`, and it **never throws into the script**: a failing sink is swallowed, because a debug observer must not fail a production run.
- **Every line always reaches the node's `ILogger`** (structured `Instance` / `Script` / `ExecutionId` / message), traced or not — an untraced run is an ordinary logging surface and pays nothing else. A `ScriptTrace` / `ScriptLog` **audit row** is emitted **only when the run is traced** (a Flow Debugger Live run, or a real call to a method with `TraceAllCalls`), so a timer or value-change script logging at high rate costs no audit volume. There is no site-event-log sink for `Log.*`. - **Every line always reaches the node's `ILogger`** (structured `Instance` / `Script` / `ExecutionId` / message), traced or not — an untraced run is an ordinary logging surface and pays nothing else. A `ScriptTrace` / `ScriptLog` **audit row** is emitted **only when the run is traced** (a Flow Debugger Live run, or a real call to a method with `TraceAllCalls`), so a timer or value-change script logging at high rate costs no audit volume. There is no site-event-log sink for `Log.*`.
@@ -96,6 +96,46 @@ public class SandboxScriptHost
/// <c>Log</c> and <c>Instance.Log</c> are the same object here too. /// <c>Log</c> and <c>Instance.Log</c> are the same object here too.
/// </summary> /// </summary>
public SandboxLog Log => Instance.Log; public SandboxLog Log => Instance.Log;
/// <summary>
/// Sandbox mirror of the site runtime's <c>Files</c> global (design 2026-09-28), so a script
/// that reads a file BINDS in the Test Run editor. The central sandbox has no access to a
/// site node's filesystem, so every call throws the same
/// <see cref="ScriptFileAccessException"/> (<see cref="ScriptFileAccessFailure.Disabled"/>) a
/// site without configured roots throws — a script's own error handling runs as it would there.
/// </summary>
public SandboxFilesAccessor Files => Instance.Files;
}
/// <summary>
/// Sandbox mirror of the SiteRuntime <c>FilesAccessor</c>: binds identically, refuses at run time.
/// </summary>
public class SandboxFilesAccessor
{
private const string Message =
"Files.* reads the SITE node's filesystem (under ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots), " +
"which the central Test Run sandbox cannot reach — deploy to a site to exercise file-reading scripts.";
/// <summary>Mirrors <c>FilesAccessor.ReadTextAsync</c>; always refused in the sandbox.</summary>
/// <param name="path">The file path.</param>
/// <param name="cancellationToken">Unused in the sandbox.</param>
/// <returns>Never returns.</returns>
public Task<string> ReadTextAsync(string path, CancellationToken cancellationToken = default)
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
/// <summary>Mirrors <c>FilesAccessor.ReadLinesAsync</c>; always refused in the sandbox.</summary>
/// <param name="path">The file path.</param>
/// <param name="cancellationToken">Unused in the sandbox.</param>
/// <returns>Never returns.</returns>
public Task<IReadOnlyList<string>> ReadLinesAsync(string path, CancellationToken cancellationToken = default)
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
/// <summary>Mirrors <c>FilesAccessor.ExistsAsync</c>; always refused in the sandbox.</summary>
/// <param name="path">The file path.</param>
/// <param name="cancellationToken">Unused in the sandbox.</param>
/// <returns>Never returns.</returns>
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
} }
/// <summary> /// <summary>
@@ -263,6 +303,9 @@ public class SandboxInstanceContext
/// </summary> /// </summary>
public SandboxLog Log { get; } = new(); public SandboxLog Log { get; } = new();
/// <summary>Sandbox mirror of <c>ScriptRuntimeContext.Files</c> (design 2026-09-28); always refuses.</summary>
public SandboxFilesAccessor Files { get; } = new();
/// <summary> /// <summary>
/// Initializes a new instance of the SandboxInstanceContext. /// Initializes a new instance of the SandboxInstanceContext.
/// </summary> /// </summary>
@@ -71,7 +71,9 @@ public class ScriptAnalysisService
"System.Collections.Generic", "System.Collections.Generic",
"System.Linq", "System.Linq",
"System.Text", "System.Text",
"System.Threading.Tasks"); "System.Threading.Tasks",
// Mirrors the site runtime's imports (design 2026-09-28).
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts");
/// <summary> /// <summary>
/// Options for a sandbox run — <see cref="DefaultOptions"/> plus the sandbox host assembly /// Options for a sandbox run — <see cref="DefaultOptions"/> plus the sandbox host assembly
@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
/// <summary>
/// Why a script's <c>Files.*</c> call was refused or failed (design 2026-09-28).
/// </summary>
public enum ScriptFileAccessFailure
{
/// <summary>No allowed roots are configured on this node (the default), or the surface has no file access at all (Test Run sandbox).</summary>
Disabled,
/// <summary>The path is empty, relative, a device/alternate-stream path, or otherwise malformed.</summary>
InvalidPath,
/// <summary>The canonical path is not under any configured allowed root.</summary>
OutsideAllowedRoots,
/// <summary>The file, or a directory between the allowed root and the file, is a symbolic link / junction / reparse point.</summary>
LinkNotAllowed,
/// <summary>The file does not exist (or the path names a directory).</summary>
NotFound,
/// <summary>The file is larger than the configured size cap.</summary>
TooLarge,
/// <summary>The operating system refused or failed the read (permissions, sharing violation, I/O error).</summary>
IoError,
}
/// <summary>
/// The single exception type a script's <c>Files.*</c> calls throw. Scripts cannot name
/// <c>System.IO</c> exception types (the trust policy forbids the namespace), so every refusal
/// and every I/O failure is surfaced as this Commons type, which a script can catch by name and
/// branch on via <see cref="Failure"/>.
/// </summary>
public sealed class ScriptFileAccessException : Exception
{
/// <summary>Initializes a new instance.</summary>
/// <param name="failure">The failure category.</param>
/// <param name="message">A message naming the path and the reason.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public ScriptFileAccessException(ScriptFileAccessFailure failure, string message, Exception? innerException = null)
: base(message, innerException)
{
Failure = failure;
}
/// <summary>Why the call was refused or failed.</summary>
public ScriptFileAccessFailure Failure { get; }
}
@@ -70,6 +70,9 @@ public sealed class ScriptCompileSurface
/// <summary>Mirrors <c>ScriptGlobals.Log</c>.</summary> /// <summary>Mirrors <c>ScriptGlobals.Log</c>.</summary>
public CompileLog Log => throw new NotSupportedException(CompileOnly); public CompileLog Log => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptGlobals.Files</c>.</summary>
public CompileFiles Files => throw new NotSupportedException(CompileOnly);
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext</c> (the <c>Instance</c> global).</summary> /// <summary>Compile-only mirror of <c>ScriptRuntimeContext</c> (the <c>Instance</c> global).</summary>
public sealed class CompileInstance public sealed class CompileInstance
{ {
@@ -110,6 +113,37 @@ public sealed class ScriptCompileSurface
/// <summary>Mirrors <c>ScriptRuntimeContext.Log</c>.</summary> /// <summary>Mirrors <c>ScriptRuntimeContext.Log</c>.</summary>
public CompileLog Log => throw new NotSupportedException(CompileOnly); public CompileLog Log => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptRuntimeContext.Files</c>.</summary>
public CompileFiles Files => throw new NotSupportedException(CompileOnly);
}
/// <summary>
/// Compile-only mirror of the SiteRuntime <c>FilesAccessor</c> (design 2026-09-28) — the
/// <c>Files</c> global's read-only, allow-listed file access.
/// </summary>
public sealed class CompileFiles
{
/// <summary>Mirrors <c>FilesAccessor.ReadTextAsync</c>.</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 Task<string> ReadTextAsync(string path, CancellationToken cancellationToken = default)
=> throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>FilesAccessor.ReadLinesAsync</c>.</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 Task<IReadOnlyList<string>> ReadLinesAsync(string path, CancellationToken cancellationToken = default)
=> throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>FilesAccessor.ExistsAsync</c>.</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 Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
=> throw new NotSupportedException(CompileOnly);
} }
/// <summary> /// <summary>
@@ -342,5 +342,8 @@ public static class ScriptTrustPolicy
"System.Collections.Generic", "System.Collections.Generic",
"System.Linq", "System.Linq",
"System.Threading.Tasks", "System.Threading.Tasks",
// Script-facing Commons types (ScriptAlarm, ScriptFileAccessException, …) — mirrors the
// site runtime's imports so the deploy gate binds the same short names (design 2026-09-28).
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts",
]; ];
} }
@@ -0,0 +1,75 @@
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;
}
}
@@ -134,7 +134,10 @@ public class ScriptCompilationService
"System", "System",
"System.Collections.Generic", "System.Collections.Generic",
"System.Linq", "System.Linq",
"System.Threading.Tasks"); "System.Threading.Tasks",
// Script-facing Commons types (ScriptAlarm, ScriptFileAccessException, …) — lets a
// script `catch (ScriptFileAccessException)` by its short name (design 2026-09-28).
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts");
/// <summary> /// <summary>
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext /// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
@@ -347,6 +350,14 @@ public class ScriptGlobals
/// <summary>Leveled per-run logging (design 2026-09-09). Usage: <c>Log.Info("…")</c>, <c>Log.Warn("…", ex)</c>. Always reaches the node log; becomes Flow Debugger trace rows only for a traced run.</summary> /// <summary>Leveled per-run logging (design 2026-09-09). Usage: <c>Log.Info("…")</c>, <c>Log.Warn("…", ex)</c>. Always reaches the node log; becomes Flow Debugger trace rows only for a traced run.</summary>
public ScriptLogger Log => Instance.Log; public ScriptLogger Log => Instance.Log;
/// <summary>
/// Read-only file access under the node's configured allowed roots (design 2026-09-28).
/// Usage: <c>var lines = await Files.ReadLinesAsync(path);</c>. Off by default — every call
/// throws <c>ScriptFileAccessException(Disabled)</c> until
/// <c>ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots</c> is configured.
/// </summary>
public FilesAccessor Files => Instance.Files;
/// <summary> /// <summary>
/// Parent composition (null when this script is on a root-level template). /// Parent composition (null when this script is on a root-level template).
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute; /// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// <c>ScadaBridge:SiteRuntime:ScriptFileAccess</c> — the read-only file access a site script gets
/// through the <c>Files</c> global (design 2026-09-28). The script trust policy still forbids
/// <c>System.IO</c> outright; this is the one sanctioned, host-mediated exception, and it is
/// <b>off by default</b>: with no <see cref="AllowedRoots"/> every <c>Files.*</c> call throws
/// <c>ScriptFileAccessException(Disabled)</c>.
/// </summary>
public sealed class ScriptFileAccessOptions
{
/// <summary>Largest value accepted for <see cref="MaxFileBytes"/> (64 MiB).</summary>
public const long MaxFileBytesCeiling = 64L * 1024 * 1024;
/// <summary>
/// Absolute directories (local or UNC) a script may read beneath. A path is allowed only when
/// its canonical form (after <c>..</c> resolution) is strictly inside one of these roots, and
/// no file or directory below the root is a symbolic link / junction. Empty (the default)
/// disables script file access on this node. The node's service account still needs NTFS/share
/// read permission — the allow-list narrows access, it never widens it.
/// </summary>
public List<string> AllowedRoots { get; set; } = new();
/// <summary>
/// Per-read size cap in bytes (default 1 MiB, at most <see cref="MaxFileBytesCeiling"/>). A
/// larger file throws <c>ScriptFileAccessException(TooLarge)</c> instead of being loaded into
/// the script's memory.
/// </summary>
public long MaxFileBytes { get; set; } = 1024 * 1024;
}
@@ -0,0 +1,198 @@
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Host-side, read-only file access behind the script <c>Files</c> global (design 2026-09-28).
/// Scripts never touch <c>System.IO</c> themselves; every path they pass is validated here
/// against <see cref="ScriptFileAccessOptions.AllowedRoots"/>.
/// </summary>
public interface IScriptFileReader
{
/// <summary>Reads the whole file as text (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>
Task<string> ReadTextAsync(string path, CancellationToken cancellationToken);
/// <summary>True when the path names an existing file under an allowed root; false when it does not exist.</summary>
/// <param name="path">Absolute path under an allowed root.</param>
/// <param name="cancellationToken">Cancels the check.</param>
/// <returns>Whether the file exists.</returns>
Task<bool> ExistsAsync(string path, CancellationToken cancellationToken);
}
/// <summary>
/// Default <see cref="IScriptFileReader"/>. The guard, in order: access enabled (at least one
/// root) → path well-formed and fully qualified (no device <c>\\?\</c>/<c>\\.\</c> paths, no
/// alternate data streams) → canonical path strictly inside an allowed root (directory-boundary
/// match, so root <c>C:\recipes</c> does not admit <c>C:\recipes2\x</c>) → no symbolic link,
/// junction or other reparse point on the file or any directory between the root and the file
/// (so a link planted inside a root cannot point outside it) → size under
/// <see cref="ScriptFileAccessOptions.MaxFileBytes"/>, enforced again while reading in case the
/// file grows. Every refusal and I/O failure is a <see cref="ScriptFileAccessException"/>.
/// </summary>
public sealed class ScriptFileReader : IScriptFileReader
{
private readonly IOptionsMonitor<SiteRuntimeOptions> _options;
private readonly ILogger<ScriptFileReader> _logger;
/// <summary>Initializes a new instance.</summary>
/// <param name="options">Site runtime options (read per call, so a config reload applies).</param>
/// <param name="logger">Logger for refusals and reads.</param>
public ScriptFileReader(IOptionsMonitor<SiteRuntimeOptions> options, ILogger<ScriptFileReader> logger)
{
_options = options;
_logger = logger;
}
private static StringComparison PathComparison =>
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
/// <inheritdoc />
public async Task<string> ReadTextAsync(string path, CancellationToken cancellationToken)
{
var (full, root, settings) = Resolve(path);
var info = new FileInfo(full);
if (!info.Exists)
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.");
EnsureNoLinks(info, root);
if (info.Length > settings.MaxFileBytes)
throw Fail(ScriptFileAccessFailure.TooLarge,
$"File '{full}' is {info.Length} bytes; the limit is {settings.MaxFileBytes} (ScriptFileAccess:MaxFileBytes).");
try
{
using var buffer = new MemoryStream();
await using (var stream = new FileStream(full, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete, bufferSize: 16 * 1024, useAsync: true))
{
var chunk = new byte[16 * 1024];
int n;
while ((n = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0)
{
if (buffer.Length + n > settings.MaxFileBytes)
throw Fail(ScriptFileAccessFailure.TooLarge,
$"File '{full}' grew past the {settings.MaxFileBytes}-byte limit while it was being read.");
buffer.Write(chunk, 0, n);
}
}
buffer.Position = 0;
using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Script file read {Path} ({Bytes} bytes)", full, buffer.Length);
return text;
}
catch (ScriptFileAccessException) { throw; }
catch (OperationCanceledException) { throw; }
catch (FileNotFoundException ex)
{
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.", ex);
}
catch (DirectoryNotFoundException ex)
{
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.", ex);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
throw Fail(ScriptFileAccessFailure.IoError, $"Could not read '{full}': {ex.Message}", ex);
}
}
/// <inheritdoc />
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var (full, root, _) = Resolve(path);
var info = new FileInfo(full);
if (!info.Exists)
return Task.FromResult(false);
EnsureNoLinks(info, root);
return Task.FromResult(true);
}
private (string Full, string Root, ScriptFileAccessOptions Settings) Resolve(string path)
{
var settings = _options.CurrentValue.ScriptFileAccess ?? new ScriptFileAccessOptions();
var roots = settings.AllowedRoots.Where(r => !string.IsNullOrWhiteSpace(r)).ToList();
if (roots.Count == 0)
throw Fail(ScriptFileAccessFailure.Disabled,
"Script file access is disabled on this node (ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots is empty).");
if (string.IsNullOrWhiteSpace(path) || path.IndexOf('\0') >= 0)
throw Fail(ScriptFileAccessFailure.InvalidPath, "A file path is required.");
path = path.Trim();
if (path.StartsWith(@"\\?\", StringComparison.Ordinal) || path.StartsWith(@"\\.\", StringComparison.Ordinal)
|| path.StartsWith("//?/", StringComparison.Ordinal) || path.StartsWith("//./", StringComparison.Ordinal))
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Device paths are not allowed: '{path}'.");
if (!Path.IsPathFullyQualified(path))
throw Fail(ScriptFileAccessFailure.InvalidPath, $"The path must be absolute: '{path}'.");
string full;
try
{
full = Path.GetFullPath(path);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Invalid path '{path}': {ex.Message}", ex);
}
// NTFS alternate data streams ("file.txt:hidden") — only the drive-letter colon is legal.
if (OperatingSystem.IsWindows() && full.IndexOf(':', 2) >= 0)
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Alternate data streams are not allowed: '{path}'.");
foreach (var configured in roots)
{
var root = NormalizeRoot(configured);
if (full.Length > root.Length && full.StartsWith(root, PathComparison))
return (full, root, settings);
}
_logger.LogWarning("Script file access refused: '{Path}' is outside the allowed roots ({Roots})",
full, string.Join(", ", roots));
throw Fail(ScriptFileAccessFailure.OutsideAllowedRoots,
$"'{full}' is outside the allowed roots (ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots).");
}
/// <summary>Canonical root with exactly one trailing separator, for a directory-boundary prefix match.</summary>
private static string NormalizeRoot(string configured)
{
var root = Path.GetFullPath(configured.Trim());
return Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar;
}
/// <summary>
/// Refuses a reparse point (symlink / junction / mount) on the file or on any directory strictly
/// below the root: a link inside an allowed root could otherwise point anywhere. The root itself
/// may be a link — an administrator configured it.
/// </summary>
private void EnsureNoLinks(FileInfo file, string root)
{
if (IsLink(file))
throw LinkRefused(file.FullName);
for (var dir = file.Directory; dir != null; dir = dir.Parent)
{
var withSep = Path.EndsInDirectorySeparator(dir.FullName) ? dir.FullName : dir.FullName + Path.DirectorySeparatorChar;
if (withSep.Length <= root.Length)
break;
if (IsLink(dir))
throw LinkRefused(dir.FullName);
}
}
private static bool IsLink(FileSystemInfo info) =>
info.LinkTarget != null || (info.Exists && info.Attributes.HasFlag(FileAttributes.ReparsePoint));
private ScriptFileAccessException LinkRefused(string at)
{
_logger.LogWarning("Script file access refused: '{Path}' is a symbolic link / reparse point", at);
return Fail(ScriptFileAccessFailure.LinkNotAllowed,
$"'{at}' is a symbolic link, junction or reparse point; links below an allowed root are not followed.");
}
private static ScriptFileAccessException Fail(ScriptFileAccessFailure failure, string message, Exception? inner = null)
=> new(failure, message, inner);
}
@@ -263,6 +263,7 @@ internal static class ScriptRunLauncher
IAuditWriter? auditWriter = null; IAuditWriter? auditWriter = null;
var siteId = string.Empty; var siteId = string.Empty;
string? sourceNode = null; string? sourceNode = null;
IScriptFileReader? fileReader = null;
// ISiteEventLogger is a singleton; resolve from the root provider so // ISiteEventLogger is a singleton; resolve from the root provider so
// it is available to the catch blocks regardless of scope state. // it is available to the catch blocks regardless of scope state.
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>(); var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
@@ -346,6 +347,7 @@ internal static class ScriptRunLauncher
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>(); operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>(); cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName; sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
fileReader = serviceScope.ServiceProvider.GetService<IScriptFileReader>();
} }
context = new ScriptRuntimeContext( context = new ScriptRuntimeContext(
@@ -407,7 +409,9 @@ internal static class ScriptRunLauncher
// effectiveOptions (the IOptionsMonitor snapshot taken at the top of this run), // effectiveOptions (the IOptionsMonitor snapshot taken at the top of this run),
// so an operator's live edit applies from the NEXT run, like the event-policy // so an operator's live edit applies from the NEXT run, like the event-policy
// flags above. // flags above.
maxLogLinesPerRun: effectiveOptions.MaxLogLinesPerRun); maxLogLinesPerRun: effectiveOptions.MaxLogLinesPerRun,
// Script file access (design 2026-09-28): the Files global's host reader.
fileReader: fileReader);
var globals = new ScriptGlobals var globals = new ScriptGlobals
{ {
@@ -737,7 +741,9 @@ internal static class ScriptRunLauncher
// script's per-script execution-timeout token so a // script's per-script execution-timeout token so a
// Attributes.WaitAsync inside an on-trigger script is bounded // Attributes.WaitAsync inside an on-trigger script is bounded
// by the same script deadline. // by the same script deadline.
scriptTimeoutToken: cts.Token); scriptTimeoutToken: cts.Token,
// Script file access (design 2026-09-28): the Files global's host reader.
fileReader: services?.GetService<IScriptFileReader>());
var globals = new ScriptGlobals var globals = new ScriptGlobals
{ {
@@ -335,7 +335,8 @@ public class ScriptRuntimeContext
bool isTraced = false, bool isTraced = false,
FlowTraceCollector? traceCollector = null, FlowTraceCollector? traceCollector = null,
int maxLogLinesPerRun = 200, int maxLogLinesPerRun = 200,
ScriptLogger? sharedLog = null) ScriptLogger? sharedLog = null,
IScriptFileReader? fileReader = null)
{ {
_instanceActor = instanceActor; _instanceActor = instanceActor;
_sharedScriptLibrary = sharedScriptLibrary; _sharedScriptLibrary = sharedScriptLibrary;
@@ -368,6 +369,8 @@ public class ScriptRuntimeContext
// WaitForAttribute (spec §4.3): default(CancellationToken) == None when // WaitForAttribute (spec §4.3): default(CancellationToken) == None when
// not threaded in — the WaitAsync Ask is then bounded only by its own timeout. // not threaded in — the WaitAsync Ask is then bounded only by its own timeout.
_scriptTimeoutToken = scriptTimeoutToken; _scriptTimeoutToken = scriptTimeoutToken;
// Script file access (design 2026-09-28): null when the host wired no reader — Files.* then throws Disabled.
_fileReader = fileReader;
_isTraced = isTraced; _isTraced = isTraced;
_traceCollector = traceCollector; _traceCollector = traceCollector;
_traceSink = traceCollector is null ? null : Collect; _traceSink = traceCollector is null ? null : Collect;
@@ -503,7 +506,9 @@ public class ScriptRuntimeContext
// Step logs (design §5): the inline shared script is its own execution node for step // Step logs (design §5): the inline shared script is its own execution node for step
// rows, but it SHARES this run's Log — one line cap and one anchor for the whole // rows, but it SHARES this run's Log — one line cap and one anchor for the whole
// inline chain, so its lines carry this run's ExecutionId. // inline chain, so its lines carry this run's ExecutionId.
sharedLog: Log); sharedLog: Log,
// Script file access (design 2026-09-28): the inline shared script reads through the same host reader.
fileReader: _fileReader);
} }
/// <summary> /// <summary>
@@ -1248,6 +1253,15 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
public AlarmsAccessor Alarms => _alarms ??= new(this); public AlarmsAccessor Alarms => _alarms ??= new(this);
private readonly IScriptFileReader? _fileReader;
private FilesAccessor? _files;
/// <summary>
/// Read-only access to files under the node's configured allowed roots (design 2026-09-28).
/// Bounded by this run's execution deadline. Off by default.
/// </summary>
public FilesAccessor Files => _files ??= new(_fileReader, _scriptTimeoutToken);
/// <summary> /// <summary>
/// Backing read for <see cref="AlarmsAccessor.CurrentAsync"/>: Asks THIS instance's /// Backing read for <see cref="AlarmsAccessor.CurrentAsync"/>: Asks THIS instance's
/// Instance Actor for its retained alarm rows and projects them to /// Instance Actor for its retained alarm rows and projects them to
@@ -73,6 +73,11 @@ public static class ServiceCollectionExtensions
// construction, which is what runs SiteLocalDbSetup.OnReady. // construction, which is what runs SiteLocalDbSetup.OnReady.
services.AddSingleton<Commons.Interfaces.IOperationTrackingStore, Tracking.OperationTrackingStore>(); services.AddSingleton<Commons.Interfaces.IOperationTrackingStore, Tracking.OperationTrackingStore>();
// Script file access (design 2026-09-28): the host reader behind the Files global.
// Always registered — with no ScriptFileAccess:AllowedRoots configured it refuses every
// call (Disabled), so an unconfigured site behaves exactly as before.
services.AddSingleton<Scripts.IScriptFileReader, Scripts.ScriptFileReader>();
// Site-local repository implementations backed by SQLite. // Site-local repository implementations backed by SQLite.
// WP2.6a: ExternalSystemDefinitionCache is a singleton shared by every scoped // WP2.6a: ExternalSystemDefinitionCache is a singleton shared by every scoped
// SiteExternalSystemRepository instance (one per script-execution DI scope), so // SiteExternalSystemRepository instance (one per script-execution DI scope), so
@@ -257,4 +257,10 @@ public class SiteRuntimeOptions
/// Must be greater than 0. Default: 200.</para> /// Must be greater than 0. Default: 200.</para>
/// </summary> /// </summary>
public int MaxLogLinesPerRun { get; set; } = 200; public int MaxLogLinesPerRun { get; set; } = 200;
/// <summary>
/// Read-only script file access through the <c>Files</c> global (design 2026-09-28) — the
/// allow-listed roots and per-read size cap. Off by default (no roots).
/// </summary>
public Scripts.ScriptFileAccessOptions ScriptFileAccess { get; set; } = new();
} }
@@ -119,5 +119,17 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase<SiteRunti
$"(was {options.MaxLogLinesPerRun}); it is the per-run cap on Flow Debugger ScriptLog " + $"(was {options.MaxLogLinesPerRun}); it is the per-run cap on Flow Debugger ScriptLog " +
"rows a script's Log.* calls may produce (design 2026-09-09 D7), and a run that may " + "rows a script's Log.* calls may produce (design 2026-09-09 D7), and a run that may " +
"emit no lines at all cannot report its own truncation."); "emit no lines at all cannot report its own truncation.");
var files = options.ScriptFileAccess ?? new Scripts.ScriptFileAccessOptions();
builder.RequireThat(files.MaxFileBytes > 0 && files.MaxFileBytes <= Scripts.ScriptFileAccessOptions.MaxFileBytesCeiling,
$"ScadaBridge:SiteRuntime:ScriptFileAccess:MaxFileBytes must be between 1 and " +
$"{Scripts.ScriptFileAccessOptions.MaxFileBytesCeiling} (was {files.MaxFileBytes}); it caps one Files.* read.");
for (var i = 0; i < files.AllowedRoots.Count; i++)
{
var root = files.AllowedRoots[i];
builder.RequireThat(!string.IsNullOrWhiteSpace(root) && System.IO.Path.IsPathFullyQualified(root),
$"ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots:{i} must be an absolute directory path " +
$"(was '{root}'); a relative root would resolve against the service's working directory.");
}
} }
} }
@@ -819,6 +819,8 @@ public class ScriptAnalysisServiceTests
" AckDT = a.AckTime,\n" + " AckDT = a.AckTime,\n" +
" AckComment = a.OperatorComment })\n" + " AckComment = a.OperatorComment })\n" +
" .ToList();\n" + " .ToList();\n" +
"var recipe = await Files.ReadLinesAsync(@\"C:\\recipes\\r.txt\");\n" +
"var recipeThere = await Instance.Files.ExistsAsync(@\"C:\\recipes\\r.txt\");\n" +
"var r = await Alarms.MesReportableAsync();\n" + "var r = await Alarms.MesReportableAsync();\n" +
"var f = r.Where(a => a.IsMesReportable).ToList();\n" + "var f = r.Where(a => a.IsMesReportable).ToList();\n" +
"return new { WasSuccessful = true, Alarms = rows, Flagged = f.Count };"; "return new { WasSuccessful = true, Alarms = rows, Flagged = f.Count };";
@@ -58,12 +58,37 @@ public class RoslynScriptCompilerTests
var viaInstance = await Instance.Alarms.CurrentAsync(); var viaInstance = await Instance.Alarms.CurrentAsync();
var mesReportable = await Alarms.MesReportableAsync(); var mesReportable = await Alarms.MesReportableAsync();
var flaggedAlarms = mesReportable.Where(a => a.IsMesReportable).ToList(); var flaggedAlarms = mesReportable.Where(a => a.IsMesReportable).ToList();
var recipeText = await Files.ReadTextAsync(@"C:\recipes\r.txt");
var recipeLines = await Instance.Files.ReadLinesAsync(@"C:\recipes\r.txt");
var recipeThere = await Files.ExistsAsync(@"C:\recipes\r.txt");
"""; """;
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)); var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
Assert.Empty(diagnostics); Assert.Empty(diagnostics);
} }
[Fact]
public void Compile_Empty_ForARecipeFileParsingScript()
{
// Script file access (design 2026-09-28): the Files global + catching the Commons
// ScriptFileAccessException by name must bind at design time — scripts cannot name the
// System.IO exception types.
const string code = """
var path = (string)Parameters["DownloadPath"];
try {
foreach (var line in await Files.ReadLinesAsync(path, CancellationToken)) {
var parts = line.Split(',', 2);
if (parts.Length == 2 && double.TryParse(parts[1], out var d)) { }
}
return true;
} catch (ScriptFileAccessException ex) when (ex.Failure == ScriptFileAccessFailure.NotFound) {
return false;
}
""";
Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
}
[Fact] [Fact]
public void Compile_Empty_ForTheMesAlarmStatusScriptShape() public void Compile_Empty_ForTheMesAlarmStatusScriptShape()
{ {
@@ -110,6 +110,9 @@ public class CompileSurfaceParityTests
// future read method (e.g. an ActiveAsync convenience) cannot ship on the // future read method (e.g. an ActiveAsync convenience) cannot ship on the
// runtime accessor while the design-time gate stays blind to it. // runtime accessor while the design-time gate stays blind to it.
new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor) }, new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor) },
// Script file access (design 2026-09-28): a new Files.* read method cannot ship on the
// runtime accessor without its design-time mirror.
new object[] { typeof(FilesAccessor), typeof(ScriptCompileSurface.CompileFiles) },
}; };
/// <summary> /// <summary>
@@ -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;
}
}
@@ -201,4 +201,46 @@ public class SiteRuntimeOptionsValidatorTests
Assert.True(result.Failed); Assert.True(result.Failed);
Assert.Contains("TagSubscribeInFlightCeilingMs", result.FailureMessage); Assert.Contains("TagSubscribeInFlightCeilingMs", result.FailureMessage);
} }
[Fact]
public void ScriptFileAccess_DefaultsToDisabled_AndIsValid()
{
var options = new SiteRuntimeOptions();
Assert.Empty(options.ScriptFileAccess.AllowedRoots);
Assert.True(Validate(options).Succeeded);
}
[Fact]
public void ScriptFileAccess_RelativeRoot_IsRejected()
{
var options = new SiteRuntimeOptions();
options.ScriptFileAccess.AllowedRoots.Add("recipes");
var result = Validate(options);
Assert.True(result.Failed);
Assert.Contains("ScriptFileAccess:AllowedRoots:0", result.FailureMessage);
}
[Fact]
public void ScriptFileAccess_AbsoluteRoot_IsAccepted()
{
var options = new SiteRuntimeOptions();
options.ScriptFileAccess.AllowedRoots.Add(Path.GetTempPath());
Assert.True(Validate(options).Succeeded);
}
[Theory]
[InlineData(0L)]
[InlineData(64L * 1024 * 1024 + 1)]
public void ScriptFileAccess_MaxFileBytesOutOfRange_IsRejected(long max)
{
var options = new SiteRuntimeOptions();
options.ScriptFileAccess.MaxFileBytes = max;
var result = Validate(options);
Assert.True(result.Failed);
Assert.Contains("ScriptFileAccess:MaxFileBytes", result.FailureMessage);
}
} }