diff --git a/CLAUDE.md b/CLAUDE.md index e62d9542..98e3d1cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,7 +231,7 @@ spec for each is `docs/requirements/Component-.md`, and `README.md` carrie ### Akka.NET Conventions - 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.) -- 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. ## Tool Usage diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f316bde7..270b947e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -238,6 +238,7 @@ services: - "9023:8083" # gRPC streaming volumes: - ./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 # 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 @@ -274,6 +275,7 @@ services: - "9024:8083" # gRPC streaming volumes: - ./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 # 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 diff --git a/docker/recipes/SBTEST-R100.txt b/docker/recipes/SBTEST-R100.txt new file mode 100644 index 00000000..335ddfbc --- /dev/null +++ b/docker/recipes/SBTEST-R100.txt @@ -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 diff --git a/docker/recipes/SBTEST-R200.txt b/docker/recipes/SBTEST-R200.txt new file mode 100644 index 00000000..d78422bf --- /dev/null +++ b/docker/recipes/SBTEST-R200.txt @@ -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" diff --git a/docker/recipes/SBTEST-R900-invalid.txt b/docker/recipes/SBTEST-R900-invalid.txt new file mode 100644 index 00000000..43fcc8b4 --- /dev/null +++ b/docker/recipes/SBTEST-R900-invalid.txt @@ -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 diff --git a/docker/seed-mes-test-machine.sh b/docker/seed-mes-test-machine.sh index 46491e85..0929fafa 100755 --- a/docker/seed-mes-test-machine.sh +++ b/docker/seed-mes-test-machine.sh @@ -24,11 +24,17 @@ set -euo pipefail # handshakes are ported to per-attribute SetAsync + WaitAsync. # * Galaxy simulators SimLeft/RightMoveIn, SimLeft/RightMoveOut, SimDelmiaRecipe (ValueChange on the # 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. # - Inbound methods SimpleAlarmStatusRequest (updated in place) and AlarmStatusRequest (legacy # /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. # +# 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 # (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'. @@ -128,7 +134,7 @@ done 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)" -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)") 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" @@ -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 ensure_attr "$DR_ID" "$s" --data-type String --description "Recipe-download payload / outcome text" 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..." 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 } read -r -d '' SIM_RECIPE_CODE <<'CS' || true -// Plays the Galaxy DelmiaReceiver: accepts unless SimRecipeReject is set, DownloadPath is blank or ends in -// ".bad", or the work order is blank. SimNoResponse = never answer. +// Plays the Galaxy DelmiaReceiver. On the rising edge of RecipeDownloadFlag it READS the downloaded recipe +// 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 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"]; if (!IsTrue(await recv.Attributes.GetAsync("RecipeDownloadFlag"))) 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 forced = IsTrue(await Attributes.GetAsync("SimRecipeReject")); -var ok = !forced && path.Length > 0 && wo.Length > 0 && !path.EndsWith(".bad", System.StringComparison.OrdinalIgnoreCase); +// RECIPE_SCHEMA — recipe tag name -> target type (keep in step with the TestDelmiaReceiver attributes). +var schema = new System.Collections.Generic.Dictionary(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(); +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? 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("RecipeProcessResultText", ok ? "Simulated download accepted for " + wo - : forced ? "Simulated reject (SimRecipeReject)" : "Simulated reject: bad path or missing work order"); +await recv.Attributes.SetAsync("RecipeProcessResultText", text); await recv.Attributes.SetAsync("RecipeDownloadFlag", false); await recv.Attributes.SetAsync("RecipeProcessedFlag", true); return null; diff --git a/docker/site-a-node-a/appsettings.Site.json b/docker/site-a-node-a/appsettings.Site.json index ffe2acee..8cd166d4 100644 --- a/docker/site-a-node-a/appsettings.Site.json +++ b/docker/site-a-node-a/appsettings.Site.json @@ -9,6 +9,14 @@ "GrpcPort": 8083, "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": { "SeedNodes": [ "akka.tcp://scadabridge@scadabridge-site-a-a:8082", diff --git a/docker/site-a-node-b/appsettings.Site.json b/docker/site-a-node-b/appsettings.Site.json index 92c3bcd6..8e796ef5 100644 --- a/docker/site-a-node-b/appsettings.Site.json +++ b/docker/site-a-node-b/appsettings.Site.json @@ -9,6 +9,14 @@ "GrpcPort": 8083, "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": { "SeedNodes": [ "akka.tcp://scadabridge@scadabridge-site-a-b:8082", diff --git a/docker/verify-mes-test-machine.sh b/docker/verify-mes-test-machine.sh index 34c3278a..2a0b27e0 100755 --- a/docker/verify-mes-test-machine.sh +++ b/docker/verify-mes-test-machine.sh @@ -7,7 +7,13 @@ set -euo pipefail # 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). # -# 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. @@ -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_URL="${SCADABRIDGE_URL:-http://localhost:9000}" 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' 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 names(r): return sorted(a["Name"] for a in r.get("Alarms") or []) -print("A. DelmiaRecipeDownload (routes by MachineCode)") -r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.xml", "WorkOrderNumber": "WO-1001", "PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"}) -check("A1 accepted", r.get("Result") is True and "accepted" in r.get("ResultText", ""), r) -r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.bad", "WorkOrderNumber": "WO-1001"}) -check("A2 .bad path rejected", r.get("Result") is False and "reject" in r.get("ResultText", "").lower(), r) +def group_A(): + print("A. DelmiaRecipeDownload -> recipe file read + parsed + loaded (Files global)") + ROOT, OUTSIDE = os.environ["RECIPE_ROOT"], os.environ["RECIPE_OUTSIDE"] + SEP = "\\" if "\\" in ROOT else "/" + 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 = movein("990001_A") -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) + r = download(recipe("SBTEST-R100.txt")) + 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) + t = recv_tags() + check("A2 R100 values loaded with their types", + t.get("RecipeName") == "Aluminide Coat R100" and t.get("RecipeRevision") == "7" and t.get("ReactorTempSP") == "1050.5" + and t.get("PotTempSP") == "980" and t.get("PurgeEnabled", "").lower() == "true" and t.get("RecipeValueCount") == "12" + and t.get("OperatorNote") == "Load parts cold, verify fixture ID", t) + r = download(recipe("SBTEST-R200.txt")) + t = recv_tags() + check("A3 R200 re-download replaces every value (quoted text unwrapped)", r.get("Result") is True + and t.get("CoatingType") == "Chromide" and t.get("ChlorineFlowSP") == "11.75" and t.get("PurgeEnabled", "").lower() == "false" + and t.get("OperatorNote") == "Quoted text, commas kept", (r, t)) + r = download(recipe("SBTEST-R900-invalid.txt")) + txt = r.get("ResultText", "") + 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 + and "line 6: PotTempSP expects Int32" in txt, r) + check("A5 rejected download loaded nothing (still R200)", recv_tags().get("RecipeName") == "Chromide Coat R200") + r = download(recipe("SBTEST-NOPE.txt")) + 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") -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) -r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"}) -check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r)) -a = (r.get("Alarms") or [{}])[0] -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) -r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"}) -check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r)) -r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}}) -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 []} -check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags) -r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}}) -check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r)) -r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}}) -check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r)) -r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}}) -check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r)) -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)) -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) -r = call("AlarmStatusRequest", {}) -check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r) -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) +def group_B(): + print("B. MesMoveIn / MesMoveOut (SAPID -> BTDB -> SBTEST01)") + r = movein("990001_A") + 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) -print("D. Reset alarm sources") -for inst in ("SBTEST01", "SBTEST02"): - write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0) -r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}}) -check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(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) + r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"}) + check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r)) + a = (r.get("Alarms") or [{}])[0] + 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) + r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"}) + check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r)) + r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}}) + 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 []} + check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags) + r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}}) + check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r)) + r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}}) + check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r)) + r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}}) + check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r)) + 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)) + 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) + r = call("AlarmStatusRequest", {}) + check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r) + 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) + +def group_D(): + print("D. Reset alarm sources") + for inst in ("SBTEST01", "SBTEST02"): + write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0) + r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}}) + 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}") sys.exit(1 if failed else 0) diff --git a/docs/plans/2026-09-28-script-file-access-design.md b/docs/plans/2026-09-28-script-file-access-design.md new file mode 100644 index 00000000..35c6f06c --- /dev/null +++ b/docs/plans/2026-09-28-script-file-access-design.md @@ -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/`. diff --git a/docs/requirements/Component-ScriptAnalysis.md b/docs/requirements/Component-ScriptAnalysis.md index 17463dd6..df16d238 100644 --- a/docs/requirements/Component-ScriptAnalysis.md +++ b/docs/requirements/Component-ScriptAnalysis.md @@ -32,7 +32,7 @@ The following namespace/type prefixes are forbidden in all scripts: | 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.Threading` | Raw thread manipulation — forbidden, with the exceptions below | | `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. - 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. -- 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;`). ##### `AnalysisReferences` vs `DefaultReferences` diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md index e30b9a62..b907f037 100644 --- a/docs/requirements/Component-SiteRuntime.md +++ b/docs/requirements/Component-SiteRuntime.md @@ -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`. - 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` (`\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`) - `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.*`. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs index 1b36df80..56be1e37 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs @@ -96,6 +96,46 @@ public class SandboxScriptHost /// Log and Instance.Log are the same object here too. /// public SandboxLog Log => Instance.Log; + + /// + /// Sandbox mirror of the site runtime's Files 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 + /// () a + /// site without configured roots throws — a script's own error handling runs as it would there. + /// + public SandboxFilesAccessor Files => Instance.Files; +} + +/// +/// Sandbox mirror of the SiteRuntime FilesAccessor: binds identically, refuses at run time. +/// +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."; + + /// Mirrors FilesAccessor.ReadTextAsync; always refused in the sandbox. + /// The file path. + /// Unused in the sandbox. + /// Never returns. + public Task ReadTextAsync(string path, CancellationToken cancellationToken = default) + => throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message); + + /// Mirrors FilesAccessor.ReadLinesAsync; always refused in the sandbox. + /// The file path. + /// Unused in the sandbox. + /// Never returns. + public Task> ReadLinesAsync(string path, CancellationToken cancellationToken = default) + => throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message); + + /// Mirrors FilesAccessor.ExistsAsync; always refused in the sandbox. + /// The file path. + /// Unused in the sandbox. + /// Never returns. + public Task ExistsAsync(string path, CancellationToken cancellationToken = default) + => throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message); } /// @@ -263,6 +303,9 @@ public class SandboxInstanceContext /// public SandboxLog Log { get; } = new(); + /// Sandbox mirror of ScriptRuntimeContext.Files (design 2026-09-28); always refuses. + public SandboxFilesAccessor Files { get; } = new(); + /// /// Initializes a new instance of the SandboxInstanceContext. /// diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index 4732cb1e..2d67f613 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -71,7 +71,9 @@ public class ScriptAnalysisService "System.Collections.Generic", "System.Linq", "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"); /// /// Options for a sandbox run — plus the sandbox host assembly diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptFileAccessException.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptFileAccessException.cs new file mode 100644 index 00000000..e569683a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Scripts/ScriptFileAccessException.cs @@ -0,0 +1,50 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; + +/// +/// Why a script's Files.* call was refused or failed (design 2026-09-28). +/// +public enum ScriptFileAccessFailure +{ + /// No allowed roots are configured on this node (the default), or the surface has no file access at all (Test Run sandbox). + Disabled, + + /// The path is empty, relative, a device/alternate-stream path, or otherwise malformed. + InvalidPath, + + /// The canonical path is not under any configured allowed root. + OutsideAllowedRoots, + + /// The file, or a directory between the allowed root and the file, is a symbolic link / junction / reparse point. + LinkNotAllowed, + + /// The file does not exist (or the path names a directory). + NotFound, + + /// The file is larger than the configured size cap. + TooLarge, + + /// The operating system refused or failed the read (permissions, sharing violation, I/O error). + IoError, +} + +/// +/// The single exception type a script's Files.* calls throw. Scripts cannot name +/// System.IO 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 . +/// +public sealed class ScriptFileAccessException : Exception +{ + /// Initializes a new instance. + /// The failure category. + /// A message naming the path and the reason. + /// The underlying exception, if any. + public ScriptFileAccessException(ScriptFileAccessFailure failure, string message, Exception? innerException = null) + : base(message, innerException) + { + Failure = failure; + } + + /// Why the call was refused or failed. + public ScriptFileAccessFailure Failure { get; } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs index 60e11667..f547d529 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs @@ -70,6 +70,9 @@ public sealed class ScriptCompileSurface /// Mirrors ScriptGlobals.Log. public CompileLog Log => throw new NotSupportedException(CompileOnly); + /// Mirrors ScriptGlobals.Files. + public CompileFiles Files => throw new NotSupportedException(CompileOnly); + /// Compile-only mirror of ScriptRuntimeContext (the Instance global). public sealed class CompileInstance { @@ -110,6 +113,37 @@ public sealed class ScriptCompileSurface /// Mirrors ScriptRuntimeContext.Log. public CompileLog Log => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.Files. + public CompileFiles Files => throw new NotSupportedException(CompileOnly); + } + + /// + /// Compile-only mirror of the SiteRuntime FilesAccessor (design 2026-09-28) — the + /// Files global's read-only, allow-listed file access. + /// + public sealed class CompileFiles + { + /// Mirrors FilesAccessor.ReadTextAsync. + /// Absolute path under an allowed root. + /// Cancels the read. + /// The file's text. + public Task ReadTextAsync(string path, CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + + /// Mirrors FilesAccessor.ReadLinesAsync. + /// Absolute path under an allowed root. + /// Cancels the read. + /// The file's lines. + public Task> ReadLinesAsync(string path, CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + + /// Mirrors FilesAccessor.ExistsAsync. + /// Absolute path under an allowed root. + /// Cancels the check. + /// Whether the file exists. + public Task ExistsAsync(string path, CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs index 27f27e42..7a130404 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs @@ -342,5 +342,8 @@ public static class ScriptTrustPolicy "System.Collections.Generic", "System.Linq", "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", ]; } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FilesAccessor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FilesAccessor.cs new file mode 100644 index 00000000..2ffd8522 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/FilesAccessor.cs @@ -0,0 +1,75 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +/// +/// The Files script global (design 2026-09-28): READ-ONLY access to files under the site +/// node's configured ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots — e.g. a +/// recipe file whose path a MES/Delmia call handed to the machine. Off by default. Every +/// refusal or failure throws ; reads are bounded by the +/// script's own execution deadline as well as the caller's token. +/// +/// foreach (var line in await Files.ReadLinesAsync(path)) { … } +/// +/// +public sealed class FilesAccessor +{ + private readonly IScriptFileReader? _reader; + private readonly CancellationToken _scriptToken; + + /// Initializes a new instance. + /// The host reader; null when the node has none wired (then every call throws Disabled). + /// The running script's execution-timeout token. + public FilesAccessor(IScriptFileReader? reader, CancellationToken scriptToken) + { + _reader = reader; + _scriptToken = scriptToken; + } + + /// Reads a whole text file (BOM-detected, UTF-8 by default). + /// Absolute path under an allowed root. + /// Cancels the read. + /// The file's text. + public async Task ReadTextAsync(string path, CancellationToken cancellationToken = default) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(_scriptToken, cancellationToken); + return await Reader.ReadTextAsync(path, linked.Token).ConfigureAwait(false); + } + + /// + /// Reads a text file as lines (\r\n, \n or \r); a trailing line break does + /// not produce a final empty line. + /// + /// Absolute path under an allowed root. + /// Cancels the read. + /// The file's lines. + public async Task> ReadLinesAsync(string path, CancellationToken cancellationToken = default) + { + var text = await ReadTextAsync(path, cancellationToken).ConfigureAwait(false); + return SplitLines(text); + } + + /// True when an existing file is at the path; false when nothing is there. Still refuses paths outside the allowed roots. + /// Absolute path under an allowed root. + /// Cancels the check. + /// Whether the file exists. + public async Task 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 SplitLines(string text) + { + if (text.Length == 0) + return Array.Empty(); + 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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs index fdc13e20..85994a36 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -134,7 +134,10 @@ public class ScriptCompilationService "System", "System.Collections.Generic", "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"); /// /// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext @@ -347,6 +350,14 @@ public class ScriptGlobals /// Leveled per-run logging (design 2026-09-09). Usage: Log.Info("…"), Log.Warn("…", ex). Always reaches the node log; becomes Flow Debugger trace rows only for a traced run. public ScriptLogger Log => Instance.Log; + /// + /// Read-only file access under the node's configured allowed roots (design 2026-09-28). + /// Usage: var lines = await Files.ReadLinesAsync(path);. Off by default — every call + /// throws ScriptFileAccessException(Disabled) until + /// ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots is configured. + /// + public FilesAccessor Files => Instance.Files; + /// /// Parent composition (null when this script is on a root-level template). /// Parent.Attributes["SpeedRPM"] reaches the parent's attribute; diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptFileAccessOptions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptFileAccessOptions.cs new file mode 100644 index 00000000..aacc0dde --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptFileAccessOptions.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +/// +/// ScadaBridge:SiteRuntime:ScriptFileAccess — the read-only file access a site script gets +/// through the Files global (design 2026-09-28). The script trust policy still forbids +/// System.IO outright; this is the one sanctioned, host-mediated exception, and it is +/// off by default: with no every Files.* call throws +/// ScriptFileAccessException(Disabled). +/// +public sealed class ScriptFileAccessOptions +{ + /// Largest value accepted for (64 MiB). + public const long MaxFileBytesCeiling = 64L * 1024 * 1024; + + /// + /// Absolute directories (local or UNC) a script may read beneath. A path is allowed only when + /// its canonical form (after .. 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. + /// + public List AllowedRoots { get; set; } = new(); + + /// + /// Per-read size cap in bytes (default 1 MiB, at most ). A + /// larger file throws ScriptFileAccessException(TooLarge) instead of being loaded into + /// the script's memory. + /// + public long MaxFileBytes { get; set; } = 1024 * 1024; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptFileReader.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptFileReader.cs new file mode 100644 index 00000000..e49dc6ef --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptFileReader.cs @@ -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; + +/// +/// Host-side, read-only file access behind the script Files global (design 2026-09-28). +/// Scripts never touch System.IO themselves; every path they pass is validated here +/// against . +/// +public interface IScriptFileReader +{ + /// Reads the whole file as text (BOM-detected, UTF-8 by default). + /// Absolute path under an allowed root. + /// Cancels the read. + /// The file's text. + Task ReadTextAsync(string path, CancellationToken cancellationToken); + + /// True when the path names an existing file under an allowed root; false when it does not exist. + /// Absolute path under an allowed root. + /// Cancels the check. + /// Whether the file exists. + Task ExistsAsync(string path, CancellationToken cancellationToken); +} + +/// +/// Default . The guard, in order: access enabled (at least one +/// root) → path well-formed and fully qualified (no device \\?\/\\.\ paths, no +/// alternate data streams) → canonical path strictly inside an allowed root (directory-boundary +/// match, so root C:\recipes does not admit C:\recipes2\x) → 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 +/// , enforced again while reading in case the +/// file grows. Every refusal and I/O failure is a . +/// +public sealed class ScriptFileReader : IScriptFileReader +{ + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + + /// Initializes a new instance. + /// Site runtime options (read per call, so a config reload applies). + /// Logger for refusals and reads. + public ScriptFileReader(IOptionsMonitor options, ILogger logger) + { + _options = options; + _logger = logger; + } + + private static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + /// + public async Task 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); + } + } + + /// + public Task 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)."); + } + + /// Canonical root with exactly one trailing separator, for a directory-boundary prefix match. + private static string NormalizeRoot(string configured) + { + var root = Path.GetFullPath(configured.Trim()); + return Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar; + } + + /// + /// 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. + /// + 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); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs index 00cfe7f4..53e6e18d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs @@ -263,6 +263,7 @@ internal static class ScriptRunLauncher IAuditWriter? auditWriter = null; var siteId = string.Empty; string? sourceNode = null; + IScriptFileReader? fileReader = null; // ISiteEventLogger is a singleton; resolve from the root provider so // it is available to the catch blocks regardless of scope state. var siteEventLogger = serviceProvider?.GetService(); @@ -346,6 +347,7 @@ internal static class ScriptRunLauncher operationTrackingStore = serviceScope.ServiceProvider.GetService(); cachedForwarder = serviceScope.ServiceProvider.GetService(); sourceNode = serviceScope.ServiceProvider.GetService()?.NodeName; + fileReader = serviceScope.ServiceProvider.GetService(); } context = new ScriptRuntimeContext( @@ -407,7 +409,9 @@ internal static class ScriptRunLauncher // 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 // 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 { @@ -737,7 +741,9 @@ internal static class ScriptRunLauncher // script's per-script execution-timeout token so a // Attributes.WaitAsync inside an on-trigger script is bounded // 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()); var globals = new ScriptGlobals { diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 8934040c..50c379da 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -335,7 +335,8 @@ public class ScriptRuntimeContext bool isTraced = false, FlowTraceCollector? traceCollector = null, int maxLogLinesPerRun = 200, - ScriptLogger? sharedLog = null) + ScriptLogger? sharedLog = null, + IScriptFileReader? fileReader = null) { _instanceActor = instanceActor; _sharedScriptLibrary = sharedScriptLibrary; @@ -368,6 +369,8 @@ public class ScriptRuntimeContext // WaitForAttribute (spec §4.3): default(CancellationToken) == None when // not threaded in — the WaitAsync Ask is then bounded only by its own timeout. _scriptTimeoutToken = scriptTimeoutToken; + // Script file access (design 2026-09-28): null when the host wired no reader — Files.* then throws Disabled. + _fileReader = fileReader; _isTraced = isTraced; _traceCollector = traceCollector; _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 // 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. - sharedLog: Log); + sharedLog: Log, + // Script file access (design 2026-09-28): the inline shared script reads through the same host reader. + fileReader: _fileReader); } /// @@ -1248,6 +1253,15 @@ public class ScriptRuntimeContext /// public AlarmsAccessor Alarms => _alarms ??= new(this); + private readonly IScriptFileReader? _fileReader; + private FilesAccessor? _files; + + /// + /// 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. + /// + public FilesAccessor Files => _files ??= new(_fileReader, _scriptTimeoutToken); + /// /// Backing read for : Asks THIS instance's /// Instance Actor for its retained alarm rows and projects them to diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs index 033e228c..49c8141f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs @@ -73,6 +73,11 @@ public static class ServiceCollectionExtensions // construction, which is what runs SiteLocalDbSetup.OnReady. services.AddSingleton(); + // 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(); + // Site-local repository implementations backed by SQLite. // WP2.6a: ExternalSystemDefinitionCache is a singleton shared by every scoped // SiteExternalSystemRepository instance (one per script-execution DI scope), so diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs index 5b1dd7c8..2321308d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs @@ -257,4 +257,10 @@ public class SiteRuntimeOptions /// Must be greater than 0. Default: 200. /// public int MaxLogLinesPerRun { get; set; } = 200; + + /// + /// Read-only script file access through the Files global (design 2026-09-28) — the + /// allow-listed roots and per-read size cap. Off by default (no roots). + /// + public Scripts.ScriptFileAccessOptions ScriptFileAccess { get; set; } = new(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs index 98c916aa..09a0a292 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs @@ -119,5 +119,17 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase 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."); + } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index b70cbafa..7d3eb8e3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -819,6 +819,8 @@ public class ScriptAnalysisServiceTests " AckDT = a.AckTime,\n" + " AckComment = a.OperatorComment })\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 f = r.Where(a => a.IsMesReportable).ToList();\n" + "return new { WasSuccessful = true, Alarms = rows, Flagged = f.Count };"; diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs index b3ad0b33..ad8d986e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs @@ -58,12 +58,37 @@ public class RoslynScriptCompilerTests var viaInstance = await Instance.Alarms.CurrentAsync(); var mesReportable = await Alarms.MesReportableAsync(); 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)); 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] public void Compile_Empty_ForTheMesAlarmStatusScriptShape() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs index 1d1272f4..e1b8b5c5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs @@ -110,6 +110,9 @@ public class CompileSurfaceParityTests // future read method (e.g. an ActiveAsync convenience) cannot ship on the // runtime accessor while the design-time gate stays blind to it. 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) }, }; /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptFileReaderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptFileReaderTests.cs new file mode 100644 index 00000000..783f8034 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptFileReaderTests.cs @@ -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; + +/// +/// Script file access (design 2026-09-28): the host-side guard behind the Files global — +/// off by default, allow-listed roots with a directory-boundary match, no traversal, no links, +/// a size cap — plus the script-facing accessor and a real compile-and-run through +/// (so the trust validator is proven to accept it). +/// +public sealed class ScriptFileReaderTests : IDisposable +{ + private readonly string _base; + private readonly string _root; + + public ScriptFileReaderTests() + { + _base = Path.Combine(Path.GetTempPath(), "sb-files-" + Guid.NewGuid().ToString("N")); + _root = Path.Combine(_base, "recipes"); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(_base, recursive: true); } catch { /* best effort */ } + } + + private static IOptionsMonitor Monitor(ScriptFileAccessOptions files) => + new StaticMonitor(new SiteRuntimeOptions { ScriptFileAccess = files }); + + private ScriptFileReader Reader(long maxBytes = 1024 * 1024, params string[] roots) => + new(Monitor(new ScriptFileAccessOptions + { + AllowedRoots = roots.Length == 0 ? [_root] : roots.ToList(), + MaxFileBytes = maxBytes, + }), NullLogger.Instance); + + private string Write(string relative, string content) + { + var path = Path.Combine(_root, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + private static async Task FailureOf(Func act) => + (await Assert.ThrowsAsync(act)).Failure; + + [Fact] + public async Task NoRoots_IsDisabled_ByDefault() + { + var reader = new ScriptFileReader(Monitor(new ScriptFileAccessOptions()), NullLogger.Instance); + var path = Write("a.txt", "x"); + + Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => reader.ReadTextAsync(path, default))); + Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => reader.ExistsAsync(path, default))); + } + + [Fact] + public async Task ReadsText_UnderRoot_AndDetectsBom() + { + var path = Path.Combine(_root, "bom.txt"); + File.WriteAllText(path, "ReactorTempSP,1050\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + + var text = await Reader().ReadTextAsync(path, default); + + Assert.Equal("ReactorTempSP,1050\n", text); + } + + [Fact] + public async Task ReadsFromNestedDirectory() + { + var path = Write(Path.Combine("wo", "1001", "r.txt"), "a,1"); + Assert.Equal("a,1", await Reader().ReadTextAsync(path, default)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("relative/r.txt")] + public async Task RejectsEmptyOrRelative(string path) + { + Assert.Equal(ScriptFileAccessFailure.InvalidPath, await FailureOf(() => Reader().ReadTextAsync(path, default))); + } + + [Fact] + public async Task RejectsPathOutsideRoot() + { + var outside = Path.Combine(_base, "secret.txt"); + File.WriteAllText(outside, "nope"); + + Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots, + await FailureOf(() => Reader().ReadTextAsync(outside, default))); + } + + [Fact] + public async Task RejectsDotDotTraversalOutOfRoot() + { + File.WriteAllText(Path.Combine(_base, "secret.txt"), "nope"); + var sneaky = _root + Path.DirectorySeparatorChar + ".." + Path.DirectorySeparatorChar + "secret.txt"; + + Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots, + await FailureOf(() => Reader().ReadTextAsync(sneaky, default))); + } + + [Fact] + public async Task SiblingDirectorySharingThePrefix_IsNotInsideTheRoot() + { + // Root ".../recipes" must not admit ".../recipes2/x" — a directory-boundary match, not a string prefix. + var sibling = Path.Combine(_base, "recipes2"); + Directory.CreateDirectory(sibling); + var path = Path.Combine(sibling, "x.txt"); + File.WriteAllText(path, "nope"); + + Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots, + await FailureOf(() => Reader().ReadTextAsync(path, default))); + } + + [Fact] + public async Task TheRootItself_IsNotAFile() + { + Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots, + await FailureOf(() => Reader().ReadTextAsync(_root, default))); + } + + [Fact] + public async Task SymlinkedFileInsideRoot_IsRefused() + { + var outside = Path.Combine(_base, "secret.txt"); + File.WriteAllText(outside, "nope"); + var link = Path.Combine(_root, "link.txt"); + File.CreateSymbolicLink(link, outside); + + Assert.Equal(ScriptFileAccessFailure.LinkNotAllowed, + await FailureOf(() => Reader().ReadTextAsync(link, default))); + } + + [Fact] + public async Task SymlinkedDirectoryInsideRoot_IsRefused() + { + var outsideDir = Path.Combine(_base, "elsewhere"); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "nope"); + Directory.CreateSymbolicLink(Path.Combine(_root, "linked"), outsideDir); + + Assert.Equal(ScriptFileAccessFailure.LinkNotAllowed, + await FailureOf(() => Reader().ReadTextAsync(Path.Combine(_root, "linked", "secret.txt"), default))); + } + + [Fact] + public async Task MissingFile_IsNotFound_AndExistsIsFalse() + { + var path = Path.Combine(_root, "missing.txt"); + + Assert.Equal(ScriptFileAccessFailure.NotFound, await FailureOf(() => Reader().ReadTextAsync(path, default))); + Assert.False(await Reader().ExistsAsync(path, default)); + } + + [Fact] + public async Task Exists_TrueForFile_ButStillEnforcesRoots() + { + var path = Write("r.txt", "x"); + Assert.True(await Reader().ExistsAsync(path, default)); + Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots, + await FailureOf(() => Reader().ExistsAsync(Path.Combine(_base, "whatever.txt"), default))); + } + + [Fact] + public async Task FileOverTheCap_IsTooLarge() + { + var path = Write("big.txt", new string('x', 2048)); + + Assert.Equal(ScriptFileAccessFailure.TooLarge, await FailureOf(() => Reader(maxBytes: 1024).ReadTextAsync(path, default))); + Assert.Equal(2048, (await Reader(maxBytes: 2048).ReadTextAsync(path, default)).Length); + } + + [Fact] + public async Task SecondRoot_IsHonoured() + { + var other = Path.Combine(_base, "other"); + Directory.CreateDirectory(other); + var path = Path.Combine(other, "o.txt"); + File.WriteAllText(path, "ok"); + + Assert.Equal("ok", await Reader(1024 * 1024, _root, other).ReadTextAsync(path, default)); + } + + [Fact] + public async Task RootConfiguredWithTrailingSeparator_Works() + { + var path = Write("t.txt", "ok"); + Assert.Equal("ok", await Reader(1024 * 1024, _root + Path.DirectorySeparatorChar).ReadTextAsync(path, default)); + } + + [Fact] + public void SplitLines_HandlesMixedEndings_AndDropsTrailingEmptyLine() + { + Assert.Equal(["a", "b", "c", "", "d"], FilesAccessor.SplitLines("a\r\nb\nc\r\rd\n")); + Assert.Empty(FilesAccessor.SplitLines("")); + Assert.Equal([""], FilesAccessor.SplitLines("\n")); + } + + [Fact] + public async Task Accessor_WithoutReader_ThrowsDisabled() + { + var files = new FilesAccessor(reader: null, CancellationToken.None); + Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => files.ReadTextAsync("C:/x.txt"))); + } + + [Fact] + public async Task Accessor_HonoursTheScriptDeadline() + { + var path = Write("r.txt", "x"); + using var expired = new CancellationTokenSource(); + await expired.CancelAsync(); + var files = new FilesAccessor(Reader(), expired.Token); + + await Assert.ThrowsAnyAsync(() => files.ReadTextAsync(path)); + } + + [Fact] + public async Task RealScript_CompilesThroughTheTrustGate_AndParsesARecipe() + { + // End-to-end at the script boundary: the runtime compiler (trust validator included) + // accepts a script that uses Files + catches ScriptFileAccessException by name, and the + // compiled script reads + parses a recipe through the real ScriptRuntimeContext wiring. + var path = Write("recipe.txt", "# comment\nReactorTempSP,1050\nCoatingType,Aluminide\n\nTargetRuntime,480\n"); + const string code = """ + var path = (string)Parameters["Path"]; + try { + var values = new System.Collections.Generic.Dictionary(); + foreach (var line in await Files.ReadLinesAsync(path)) { + if (line.Trim().Length == 0 || line.TrimStart().StartsWith("#")) continue; + var i = line.IndexOf(','); + values[line.Substring(0, i).Trim()] = line.Substring(i + 1).Trim(); + } + var exists = await Instance.Files.ExistsAsync(path); + return values.Count + "|" + values["CoatingType"] + "|" + exists; + } catch (ScriptFileAccessException ex) when (ex.Failure == ScriptFileAccessFailure.NotFound) { + return "missing"; + } + """; + var compiled = new ScriptCompilationService(NullLogger.Instance).Compile("RecipeParse", code); + Assert.True(compiled.IsSuccess, string.Join("; ", compiled.Errors)); + + var ctx = new ScriptRuntimeContext( + ActorRefs.Nobody, sharedScriptLibrary: null!, currentCallDepth: 0, maxCallDepth: 10, + askTimeout: TimeSpan.FromSeconds(5), instanceName: "SBTEST01", + logger: NullLogger.Instance, fileReader: Reader()); + var run = async (string p) => (await compiled.CompiledScript!.RunAsync(new ScriptGlobals + { + Instance = ctx, + Parameters = new ScriptParameters(new Dictionary { ["Path"] = p }), + })).ReturnValue; + + Assert.Equal("3|Aluminide|True", await run(path)); + Assert.Equal("missing", await run(Path.Combine(_root, "nope.txt"))); + } + + [Fact] + public void RawSystemIo_IsStillRejectedByTheTrustGate() + { + // The Files global is the ONLY sanctioned door — System.IO stays forbidden. + var compiled = new ScriptCompilationService(NullLogger.Instance) + .Compile("Raw", "return System.IO.File.ReadAllText(\"C:/x.txt\");"); + Assert.False(compiled.IsSuccess); + } + + private sealed class StaticMonitor(SiteRuntimeOptions value) : IOptionsMonitor + { + public SiteRuntimeOptions CurrentValue => value; + public SiteRuntimeOptions Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs index 6ad2c26a..55f87b17 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs @@ -201,4 +201,46 @@ public class SiteRuntimeOptionsValidatorTests Assert.True(result.Failed); 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); + } }