fix(template-engine): resolve TemplateEngine-006..010 — code-region-aware API/brace scanning, composed-alarm override validation, N+1 fix, doc correction
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 9 |
|
| Open findings | 4 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -263,7 +263,7 @@ Regression test: `CreateTemplate_WithParent_DoesNotRunDeadCollisionQuery`.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs:21`, `src/ScadaLink.TemplateEngine/Validation/ValidationService.cs:318` |
|
| Location | `src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs:21`, `src/ScadaLink.TemplateEngine/Validation/ValidationService.cs:318` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -289,7 +289,20 @@ limitation prominently and treat the substring scan as advisory, not authoritati
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): fixed the false-positive half and
|
||||||
|
documented the (deferred) bypass half. Added `CSharpDelimiterScanner.ContainsInCode`,
|
||||||
|
a code-region-aware substring search that blanks out string/char-literal/comment
|
||||||
|
spans before matching, so the inert text `System.IO.` inside a string or comment is
|
||||||
|
no longer flagged. `ScriptCompiler.TryCompile` and `ValidationService.CheckExpressionSyntax`
|
||||||
|
now use it. The bypass half (namespace aliases, `using static`, `global::`) genuinely
|
||||||
|
requires Roslyn semantic symbol analysis, which the TemplateEngine project deliberately
|
||||||
|
does not reference — that authoritative check is deferred to the real script compiler /
|
||||||
|
Site Runtime sandbox. The limitation is now documented prominently as a `SECURITY
|
||||||
|
LIMITATION` note in the `ScriptCompiler` class summary and the `ForbiddenPatterns`
|
||||||
|
doc, and the scan is explicitly labelled advisory. Regression tests:
|
||||||
|
`TryCompile_ForbiddenApiTextInsideStringLiteral_NotFlagged`,
|
||||||
|
`TryCompile_ForbiddenApiTextInsideComment_NotFlagged`,
|
||||||
|
`TryCompile_ForbiddenApiInRealCode_StillFlagged`.
|
||||||
|
|
||||||
### TemplateEngine-007 — Brace-balance "compilation" misjudges verbatim / interpolated / raw strings
|
### TemplateEngine-007 — Brace-balance "compilation" misjudges verbatim / interpolated / raw strings
|
||||||
|
|
||||||
@@ -297,7 +310,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs:54`, `src/ScadaLink.TemplateEngine/SharedScriptService.cs:124` |
|
| Location | `src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs:54`, `src/ScadaLink.TemplateEngine/SharedScriptService.cs:124` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -321,7 +334,18 @@ to something that cannot false-positive.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): replaced both hand-rolled string trackers
|
||||||
|
with `CSharpDelimiterScanner`, a single string-/comment-aware scanner that correctly
|
||||||
|
skips regular strings (with `\` escapes), verbatim strings (`@"..."`, `""` escape),
|
||||||
|
interpolated strings (`$"..."` / `$@"..."`, interpolation holes `{...}` treated as
|
||||||
|
code, `{{`/`}}` as escaped braces), C# 11 raw string literals (`"""..."""`), char
|
||||||
|
literals, and line/block comments while tracking `{}`/`[]`/`()` depth. `ScriptCompiler
|
||||||
|
.TryCompile` and `SharedScriptService.ValidateSyntax` now delegate to it, so a valid
|
||||||
|
script containing a delimiter inside a literal/comment is no longer falsely rejected;
|
||||||
|
genuine mismatches are still caught. Regression tests in `ScriptCompilerTests`
|
||||||
|
(`TryCompile_VerbatimStringWithBrace_*`, `_VerbatimStringWithEscapedQuote_*`,
|
||||||
|
`_InterpolatedStringWithBraces_*`, `_RawStringLiteralWithBraces_*`, `_CharLiteralWithBrace_*`,
|
||||||
|
`_GenuineMismatchedBraces_StillDetected`) and `SharedScriptServiceTests.ValidateSyntax_DelimiterInsideStringOrComment_ReturnsNull`.
|
||||||
|
|
||||||
### TemplateEngine-008 — `SetAlarmOverrideAsync` accepts overrides for unknown / composed alarms with no validation
|
### TemplateEngine-008 — `SetAlarmOverrideAsync` accepts overrides for unknown / composed alarms with no validation
|
||||||
|
|
||||||
@@ -329,7 +353,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/Services/InstanceService.cs:178` |
|
| Location | `src/ScadaLink.TemplateEngine/Services/InstanceService.cs:178` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -351,7 +375,16 @@ the lock check to composed alarms too.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): `SetAlarmOverrideAsync` now resolves the
|
||||||
|
instance template's full effective alarm set via `TemplateResolver.ResolveAllMembers`
|
||||||
|
(loaded from `GetAllTemplatesAsync`) instead of looking up only the template's direct
|
||||||
|
alarms. An override whose canonical name is absent from that set is rejected with a
|
||||||
|
"does not exist" failure (mirroring `SetAttributeOverrideAsync`); the `IsLocked` check
|
||||||
|
now also applies to composed (path-qualified) and inherited alarms, closing the
|
||||||
|
lock-bypass. Regression tests: `SetAlarmOverride_NonExistentAlarm_ReturnsFailure`,
|
||||||
|
`SetAlarmOverride_ComposedLockedAlarm_ReturnsFailure`,
|
||||||
|
`SetAlarmOverride_ComposedUnlockedAlarm_ReturnsSuccess`,
|
||||||
|
`SetAlarmOverride_DirectLockedAlarm_ReturnsFailure`.
|
||||||
|
|
||||||
### TemplateEngine-009 — N+1 query in `TemplateDeletionService.CanDeleteTemplateAsync`
|
### TemplateEngine-009 — N+1 query in `TemplateDeletionService.CanDeleteTemplateAsync`
|
||||||
|
|
||||||
@@ -359,7 +392,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs:75` |
|
| Location | `src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs:75` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -380,15 +413,24 @@ template.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): `CanDeleteTemplateAsync` Check 3 now reads
|
||||||
|
the `Compositions` navigation already loaded by `GetAllTemplatesAsync` (a single
|
||||||
|
`SelectMany`) instead of issuing one `GetCompositionsByTemplateIdAsync` round-trip
|
||||||
|
per template — the same source `TemplateService.DeleteTemplateAsync` uses for the
|
||||||
|
equivalent check. The per-delete cost no longer scales with template count.
|
||||||
|
Regression test: `CanDeleteTemplate_DoesNotIssuePerTemplateCompositionQuery`
|
||||||
|
(verifies `GetCompositionsByTemplateIdAsync` is never called); the existing
|
||||||
|
`CanDeleteTemplate_ComposedByOthers_ReturnsFailure` and
|
||||||
|
`CanDeleteTemplate_MultipleConstraints_AllErrorsReported` tests were updated to seed
|
||||||
|
the `Compositions` navigation, matching how EF's `GetAllTemplatesAsync` loads it.
|
||||||
|
|
||||||
### TemplateEngine-010 — `InstanceService` documents optimistic concurrency that is not implemented
|
### TemplateEngine-010 — `InstanceService` documents optimistic concurrency that is not implemented
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Low — re-triaged from Medium: this is a stale XML comment, not a behavioural defect. The code matches the design (last-write-wins); only the doc string was wrong. |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/Services/InstanceService.cs:9` |
|
| Location | `src/ScadaLink.TemplateEngine/Services/InstanceService.cs:9` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -407,9 +449,24 @@ If last-write-wins is acceptable for instance state, correct the XML doc. If opt
|
|||||||
concurrency is required, add a concurrency token to `Instance` and surface a conflict
|
concurrency is required, add a concurrency token to `Instance` and surface a conflict
|
||||||
result.
|
result.
|
||||||
|
|
||||||
|
**Re-triage**
|
||||||
|
|
||||||
|
Verified against the design: `docs/requirements/Component-TemplateEngine.md` states
|
||||||
|
"Concurrent editing uses **last-write-wins** — no pessimistic locking or conflict
|
||||||
|
detection." The system's optimistic-concurrency decision (per CLAUDE.md) applies to
|
||||||
|
*deployment status records*, not instance state. The code is therefore correct — a
|
||||||
|
plain read-modify-write is the intended behaviour — and the only defect is the stale
|
||||||
|
"with optimistic concurrency" phrase in the class XML summary. Re-triaged from
|
||||||
|
Medium (Error handling) to Low (Documentation): doc-only fix, no behaviour change.
|
||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): corrected the `InstanceService` class XML
|
||||||
|
summary — replaced "Enabled/disabled state with optimistic concurrency" with an
|
||||||
|
explicit statement that instance-state edits are last-write-wins (no version token /
|
||||||
|
conflict detection), citing the design decision and noting that optimistic concurrency
|
||||||
|
in the system applies to deployment status records, not instance state. No code or
|
||||||
|
behaviour change; no regression test (documentation-only).
|
||||||
|
|
||||||
### TemplateEngine-011 — `SortedPropertiesConverterFactory` is dead code with a misleading comment
|
### TemplateEngine-011 — `SortedPropertiesConverterFactory` is dead code with a misleading comment
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ScadaLink.Commons.Interfaces.Repositories;
|
|||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.TemplateEngine;
|
||||||
|
|
||||||
namespace ScadaLink.TemplateEngine.Services;
|
namespace ScadaLink.TemplateEngine.Services;
|
||||||
|
|
||||||
@@ -13,7 +14,11 @@ namespace ScadaLink.TemplateEngine.Services;
|
|||||||
/// - Override non-locked attribute values
|
/// - Override non-locked attribute values
|
||||||
/// - Cannot add or remove attributes (only override existing ones)
|
/// - Cannot add or remove attributes (only override existing ones)
|
||||||
/// - Per-attribute connection binding (bulk assignment support)
|
/// - Per-attribute connection binding (bulk assignment support)
|
||||||
/// - Enabled/disabled state with optimistic concurrency
|
/// - Enabled/disabled state. Concurrent edits are last-write-wins — there is no
|
||||||
|
/// version token or conflict detection on instance state, matching the design
|
||||||
|
/// decision (Component-TemplateEngine.md: "Concurrent editing uses
|
||||||
|
/// last-write-wins — no pessimistic locking or conflict detection"). Optimistic
|
||||||
|
/// concurrency in the system applies to deployment status records, not here.
|
||||||
/// - Audit logging
|
/// - Audit logging
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class InstanceService
|
public class InstanceService
|
||||||
@@ -170,10 +175,11 @@ public class InstanceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets a per-instance alarm override. The alarm must exist on the
|
/// Sets a per-instance alarm override. The alarm must exist in the
|
||||||
/// template and must not be locked. For HiLo alarms, the override JSON
|
/// instance's effective alarm set (direct, inherited, or composed) and
|
||||||
/// merges into the inherited TriggerConfiguration setpoint-by-setpoint;
|
/// must not be locked. For HiLo alarms, the override JSON merges into the
|
||||||
/// for binary trigger types, it replaces the whole config.
|
/// inherited TriggerConfiguration setpoint-by-setpoint; for binary trigger
|
||||||
|
/// types, it replaces the whole config.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
|
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
|
||||||
int instanceId,
|
int instanceId,
|
||||||
@@ -187,17 +193,25 @@ public class InstanceService
|
|||||||
if (instance == null)
|
if (instance == null)
|
||||||
return Result<InstanceAlarmOverride>.Failure($"Instance with ID {instanceId} not found.");
|
return Result<InstanceAlarmOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||||
|
|
||||||
// Verify alarm exists in the template and is not locked. Only direct
|
// Verify the alarm exists in the instance's effective alarm set and is
|
||||||
// template alarms are checked here — composed-member overrides go
|
// not locked. The effective set is resolved via TemplateResolver so that
|
||||||
// through but are silently ignored at runtime if the name doesn't
|
// composed (path-qualified) and inherited alarms are found — a lookup
|
||||||
// match (same behavior as attribute overrides).
|
// against the template's direct alarms alone would miss them, silently
|
||||||
var templateAlarms = await _repository.GetAlarmsByTemplateIdAsync(instance.TemplateId, cancellationToken);
|
// accepting an override for a non-existent name or bypassing the lock
|
||||||
var templateAlarm = templateAlarms.FirstOrDefault(a => a.Name == alarmCanonicalName);
|
// rule for a composed alarm. Mirrors SetAttributeOverrideAsync.
|
||||||
if (templateAlarm != null && templateAlarm.IsLocked)
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||||
{
|
var resolvedAlarm = TemplateResolver
|
||||||
|
.ResolveAllMembers(instance.TemplateId, allTemplates)
|
||||||
|
.FirstOrDefault(m => m.MemberType == "Alarm" && m.CanonicalName == alarmCanonicalName);
|
||||||
|
|
||||||
|
if (resolvedAlarm == null)
|
||||||
|
return Result<InstanceAlarmOverride>.Failure(
|
||||||
|
$"Alarm '{alarmCanonicalName}' does not exist in template {instance.TemplateId}. " +
|
||||||
|
"Cannot override an unknown alarm.");
|
||||||
|
|
||||||
|
if (resolvedAlarm.IsLocked)
|
||||||
return Result<InstanceAlarmOverride>.Failure(
|
return Result<InstanceAlarmOverride>.Failure(
|
||||||
$"Alarm '{alarmCanonicalName}' is locked and cannot be overridden.");
|
$"Alarm '{alarmCanonicalName}' is locked and cannot be overridden.");
|
||||||
}
|
|
||||||
|
|
||||||
var existingOverride = await _repository.GetAlarmOverrideAsync(
|
var existingOverride = await _repository.GetAlarmOverrideAsync(
|
||||||
instanceId, alarmCanonicalName, cancellationToken);
|
instanceId, alarmCanonicalName, cancellationToken);
|
||||||
|
|||||||
@@ -72,16 +72,15 @@ public class TemplateDeletionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check 3: Other templates compose it directly (e.g., pre-Phase-3 data).
|
// Check 3: Other templates compose it directly (e.g., pre-Phase-3 data).
|
||||||
var composingTemplates = new List<(string TemplateName, string InstanceName)>();
|
// Read the Compositions navigation already loaded by GetAllTemplatesAsync
|
||||||
foreach (var t in allTemplates)
|
// rather than issuing one GetCompositionsByTemplateIdAsync round-trip per
|
||||||
{
|
// template (TemplateEngine-009) — this is the same source TemplateService
|
||||||
var compositions = await _repository.GetCompositionsByTemplateIdAsync(t.Id, cancellationToken);
|
// .DeleteTemplateAsync uses for the equivalent check.
|
||||||
foreach (var comp in compositions)
|
var composingTemplates = allTemplates
|
||||||
{
|
.SelectMany(t => t.Compositions
|
||||||
if (comp.ComposedTemplateId == templateId)
|
.Where(comp => comp.ComposedTemplateId == templateId)
|
||||||
composingTemplates.Add((t.Name, comp.InstanceName));
|
.Select(comp => (TemplateName: t.Name, comp.InstanceName)))
|
||||||
}
|
.ToList();
|
||||||
}
|
|
||||||
|
|
||||||
if (composingTemplates.Count > 0)
|
if (composingTemplates.Count > 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ public class SharedScriptService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Basic structural validation of C# script code.
|
/// Basic structural validation of C# script code.
|
||||||
/// Checks for balanced braces and basic syntax structure.
|
/// Checks for balanced braces/brackets/parentheses. The scan is string- and
|
||||||
|
/// comment-aware (see <see cref="Validation.CSharpDelimiterScanner"/>) so a
|
||||||
|
/// delimiter inside a regular/verbatim/interpolated/raw string literal, a
|
||||||
|
/// char literal, or a comment does not produce a false syntax error.
|
||||||
/// Full Roslyn compilation would be added in a later phase when the scripting sandbox is available.
|
/// Full Roslyn compilation would be added in a later phase when the scripting sandbox is available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static string? ValidateSyntax(string code)
|
internal static string? ValidateSyntax(string code)
|
||||||
@@ -126,38 +129,28 @@ public class SharedScriptService
|
|||||||
if (string.IsNullOrWhiteSpace(code))
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
return "Script code cannot be empty.";
|
return "Script code cannot be empty.";
|
||||||
|
|
||||||
// Check for balanced braces
|
return Validation.CSharpDelimiterScanner.Scan(code) switch
|
||||||
int braceCount = 0;
|
|
||||||
int bracketCount = 0;
|
|
||||||
int parenCount = 0;
|
|
||||||
|
|
||||||
foreach (var ch in code)
|
|
||||||
{
|
{
|
||||||
switch (ch)
|
Validation.CSharpDelimiterScanner.Mismatch.None => null,
|
||||||
{
|
Validation.CSharpDelimiterScanner.Mismatch.UnexpectedCloseBrace =>
|
||||||
case '{': braceCount++; break;
|
"Syntax error: unmatched closing brace '}'.",
|
||||||
case '}': braceCount--; break;
|
Validation.CSharpDelimiterScanner.Mismatch.UnclosedBrace =>
|
||||||
case '[': bracketCount++; break;
|
"Syntax error: unmatched opening brace '{'.",
|
||||||
case ']': bracketCount--; break;
|
Validation.CSharpDelimiterScanner.Mismatch.UnexpectedCloseBracket =>
|
||||||
case '(': parenCount++; break;
|
"Syntax error: unmatched closing bracket ']'.",
|
||||||
case ')': parenCount--; break;
|
Validation.CSharpDelimiterScanner.Mismatch.UnclosedBracket =>
|
||||||
}
|
"Syntax error: unmatched opening bracket '['.",
|
||||||
|
Validation.CSharpDelimiterScanner.Mismatch.UnexpectedCloseParen =>
|
||||||
if (braceCount < 0)
|
"Syntax error: unmatched closing parenthesis ')'.",
|
||||||
return "Syntax error: unmatched closing brace '}'.";
|
Validation.CSharpDelimiterScanner.Mismatch.UnclosedParen =>
|
||||||
if (bracketCount < 0)
|
"Syntax error: unmatched opening parenthesis '('.",
|
||||||
return "Syntax error: unmatched closing bracket ']'.";
|
Validation.CSharpDelimiterScanner.Mismatch.UnclosedBlockComment =>
|
||||||
if (parenCount < 0)
|
"Syntax error: unclosed block comment.",
|
||||||
return "Syntax error: unmatched closing parenthesis ')'.";
|
Validation.CSharpDelimiterScanner.Mismatch.UnterminatedString =>
|
||||||
}
|
"Syntax error: unterminated string literal.",
|
||||||
|
Validation.CSharpDelimiterScanner.Mismatch.UnterminatedChar =>
|
||||||
if (braceCount != 0)
|
"Syntax error: unterminated character literal.",
|
||||||
return "Syntax error: unmatched opening brace '{'.";
|
_ => null,
|
||||||
if (bracketCount != 0)
|
};
|
||||||
return "Syntax error: unmatched opening bracket '['.";
|
|
||||||
if (parenCount != 0)
|
|
||||||
return "Syntax error: unmatched opening parenthesis '('.";
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
namespace ScadaLink.TemplateEngine.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// String/comment-aware scanner for the balanced-delimiter ("does it look like
|
||||||
|
/// valid C#") checks used by <see cref="ScriptCompiler"/> and
|
||||||
|
/// <c>SharedScriptService.ValidateSyntax</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This is <b>not</b> a compiler. It is an interim structural check that walks
|
||||||
|
/// the source once and tracks <c>{}</c>, <c>[]</c> and <c>()</c> depth while
|
||||||
|
/// correctly skipping over the C# lexical constructs in which a delimiter is
|
||||||
|
/// inert: line/block comments, regular string literals (with <c>\</c> escapes),
|
||||||
|
/// verbatim strings (<c>@"..."</c>, where <c>""</c> escapes a quote and <c>\</c>
|
||||||
|
/// is literal), interpolated strings (<c>$"..."</c> / <c>$@"..."</c> — the holes
|
||||||
|
/// <c>{...}</c> are code and <c>{{</c>/<c>}}</c> are escaped braces), raw string
|
||||||
|
/// literals (<c>"""..."""</c>), and char literals (<c>'}'</c>).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// It is intentionally conservative: when the real Roslyn-based compiler is
|
||||||
|
/// wired in (see <see cref="ScriptCompiler"/>) this hand-rolled scan should be
|
||||||
|
/// replaced by <c>CSharpSyntaxTree.ParseText</c> diagnostics. Until then this
|
||||||
|
/// scanner removes the false positives that a naive character count produced
|
||||||
|
/// for valid scripts containing a delimiter inside a string or comment.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class CSharpDelimiterScanner
|
||||||
|
{
|
||||||
|
/// <summary>The kind of delimiter mismatch found, if any.</summary>
|
||||||
|
internal enum Mismatch
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
UnexpectedCloseBrace,
|
||||||
|
UnexpectedCloseBracket,
|
||||||
|
UnexpectedCloseParen,
|
||||||
|
UnclosedBrace,
|
||||||
|
UnclosedBracket,
|
||||||
|
UnclosedParen,
|
||||||
|
UnclosedBlockComment,
|
||||||
|
UnterminatedString,
|
||||||
|
UnterminatedChar,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when <paramref name="pattern"/> occurs in a <b>code</b>
|
||||||
|
/// region of <paramref name="code"/> — i.e. not wholly inside a string
|
||||||
|
/// literal, char literal, or comment. Used by the interim forbidden-API
|
||||||
|
/// scan so that the inert text <c>System.IO.</c> in a comment or string
|
||||||
|
/// literal is not flagged as a forbidden API call (TemplateEngine-006).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This removes the false-positive half of the substring scan. It does
|
||||||
|
/// <b>not</b> close the bypass half: namespace aliases, <c>using static</c>,
|
||||||
|
/// and <c>global::</c>-qualified references still evade a pure text match.
|
||||||
|
/// Authoritative forbidden-API enforcement requires Roslyn semantic symbol
|
||||||
|
/// analysis and is deferred to the real script compiler / Site Runtime
|
||||||
|
/// sandbox; this check is advisory only.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal static bool ContainsInCode(string code, string pattern)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(pattern))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Blank out every string/char-literal/comment span, then do an ordinary
|
||||||
|
// substring search over what remains (the code regions).
|
||||||
|
var codeOnly = BlankNonCodeSpans(code);
|
||||||
|
return codeOnly.Contains(pattern, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the content of every comment, string literal, and char literal
|
||||||
|
/// with spaces (newlines preserved), leaving only code regions intact.
|
||||||
|
/// Delimiter characters themselves are also blanked so a pattern cannot
|
||||||
|
/// straddle a literal boundary.
|
||||||
|
/// </summary>
|
||||||
|
private static string BlankNonCodeSpans(string code)
|
||||||
|
{
|
||||||
|
var buffer = code.ToCharArray();
|
||||||
|
int n = code.Length;
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
|
void Blank(int from, int to)
|
||||||
|
{
|
||||||
|
for (int k = from; k < to && k < n; k++)
|
||||||
|
if (buffer[k] != '\n' && buffer[k] != '\r')
|
||||||
|
buffer[k] = ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
char c = code[i];
|
||||||
|
char next = i + 1 < n ? code[i + 1] : '\0';
|
||||||
|
int start = i;
|
||||||
|
|
||||||
|
if (c == '/' && next == '/')
|
||||||
|
{
|
||||||
|
i += 2;
|
||||||
|
while (i < n && code[i] != '\n') i++;
|
||||||
|
Blank(start, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '/' && next == '*')
|
||||||
|
{
|
||||||
|
i += 2;
|
||||||
|
while (i < n && !(code[i] == '*' && i + 1 < n && code[i + 1] == '/')) i++;
|
||||||
|
if (i < n) i += 2;
|
||||||
|
Blank(start, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"' && next == '"' && i + 2 < n && code[i + 2] == '"')
|
||||||
|
{
|
||||||
|
SkipRawString(code, ref i);
|
||||||
|
Blank(start, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '$')
|
||||||
|
{
|
||||||
|
int j = i + 1;
|
||||||
|
bool verbatim = false;
|
||||||
|
if (j < n && code[j] == '@') { verbatim = true; j++; }
|
||||||
|
if (j < n && code[j] == '"')
|
||||||
|
{
|
||||||
|
i = j;
|
||||||
|
SkipInterpolatedString(code, ref i, verbatim);
|
||||||
|
Blank(start, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c == '@' && next == '"')
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
SkipVerbatimString(code, ref i);
|
||||||
|
Blank(start, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
SkipRegularString(code, ref i);
|
||||||
|
Blank(start, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '\'')
|
||||||
|
{
|
||||||
|
SkipCharLiteral(code, ref i);
|
||||||
|
Blank(start, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks <paramref name="code"/> once and reports the first structural
|
||||||
|
/// delimiter problem, or <see cref="Mismatch.None"/> when the source is
|
||||||
|
/// balanced. Delimiters inside comments, strings, and char literals are
|
||||||
|
/// ignored.
|
||||||
|
/// </summary>
|
||||||
|
internal static Mismatch Scan(string code)
|
||||||
|
{
|
||||||
|
int brace = 0, bracket = 0, paren = 0;
|
||||||
|
int i = 0;
|
||||||
|
int n = code.Length;
|
||||||
|
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
char c = code[i];
|
||||||
|
char next = i + 1 < n ? code[i + 1] : '\0';
|
||||||
|
|
||||||
|
// Line comment.
|
||||||
|
if (c == '/' && next == '/')
|
||||||
|
{
|
||||||
|
i += 2;
|
||||||
|
while (i < n && code[i] != '\n') i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block comment.
|
||||||
|
if (c == '/' && next == '*')
|
||||||
|
{
|
||||||
|
i += 2;
|
||||||
|
bool closed = false;
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
if (code[i] == '*' && i + 1 < n && code[i + 1] == '/')
|
||||||
|
{
|
||||||
|
i += 2;
|
||||||
|
closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!closed) return Mismatch.UnclosedBlockComment;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw string literal: three or more consecutive quotes open it; the
|
||||||
|
// same number of quotes closes it. Detected before $/@-prefixed and
|
||||||
|
// plain strings.
|
||||||
|
if (c == '"' && next == '"' && i + 2 < n && code[i + 2] == '"')
|
||||||
|
{
|
||||||
|
if (!SkipRawString(code, ref i)) return Mismatch.UnterminatedString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpolated string ($"..." or $@"..." / @$"...").
|
||||||
|
if (c == '$')
|
||||||
|
{
|
||||||
|
int j = i + 1;
|
||||||
|
bool verbatim = false;
|
||||||
|
if (j < n && code[j] == '@') { verbatim = true; j++; }
|
||||||
|
if (j < n && code[j] == '"')
|
||||||
|
{
|
||||||
|
i = j;
|
||||||
|
if (!SkipInterpolatedString(code, ref i, verbatim)) return Mismatch.UnterminatedString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbatim string (@"...").
|
||||||
|
if (c == '@' && next == '"')
|
||||||
|
{
|
||||||
|
i++; // now on the opening quote
|
||||||
|
if (!SkipVerbatimString(code, ref i)) return Mismatch.UnterminatedString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular string literal.
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
if (!SkipRegularString(code, ref i)) return Mismatch.UnterminatedString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Char literal.
|
||||||
|
if (c == '\'')
|
||||||
|
{
|
||||||
|
if (!SkipCharLiteral(code, ref i)) return Mismatch.UnterminatedChar;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '{': brace++; break;
|
||||||
|
case '}':
|
||||||
|
brace--;
|
||||||
|
if (brace < 0) return Mismatch.UnexpectedCloseBrace;
|
||||||
|
break;
|
||||||
|
case '[': bracket++; break;
|
||||||
|
case ']':
|
||||||
|
bracket--;
|
||||||
|
if (bracket < 0) return Mismatch.UnexpectedCloseBracket;
|
||||||
|
break;
|
||||||
|
case '(': paren++; break;
|
||||||
|
case ')':
|
||||||
|
paren--;
|
||||||
|
if (paren < 0) return Mismatch.UnexpectedCloseParen;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (brace != 0) return Mismatch.UnclosedBrace;
|
||||||
|
if (bracket != 0) return Mismatch.UnclosedBracket;
|
||||||
|
if (paren != 0) return Mismatch.UnclosedParen;
|
||||||
|
return Mismatch.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances <paramref name="i"/> past a regular <c>"..."</c> string literal.
|
||||||
|
/// On entry <c>code[i] == '"'</c>. Returns false if the string is unterminated.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SkipRegularString(string code, ref int i)
|
||||||
|
{
|
||||||
|
int n = code.Length;
|
||||||
|
i++; // past opening quote
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
char c = code[i];
|
||||||
|
if (c == '\\') { i += 2; continue; } // escape — skip next char
|
||||||
|
if (c == '\n') return false; // unterminated (no multi-line)
|
||||||
|
if (c == '"') { i++; return true; }
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances past a verbatim <c>@"..."</c> string. On entry <c>code[i] == '"'</c>.
|
||||||
|
/// Inside, <c>\</c> is literal and <c>""</c> is an escaped quote.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SkipVerbatimString(string code, ref int i)
|
||||||
|
{
|
||||||
|
int n = code.Length;
|
||||||
|
i++; // past opening quote
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
if (code[i] == '"')
|
||||||
|
{
|
||||||
|
if (i + 1 < n && code[i + 1] == '"') { i += 2; continue; } // escaped quote
|
||||||
|
i++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances past an interpolated string. <paramref name="verbatim"/> selects
|
||||||
|
/// the <c>$@"..."</c> escaping rules. Interpolation holes <c>{...}</c> are
|
||||||
|
/// skipped over (their braces are code, not literal text); <c>{{</c>/<c>}}</c>
|
||||||
|
/// are escaped braces. On entry <c>code[i] == '"'</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SkipInterpolatedString(string code, ref int i, bool verbatim)
|
||||||
|
{
|
||||||
|
int n = code.Length;
|
||||||
|
i++; // past opening quote
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
char c = code[i];
|
||||||
|
|
||||||
|
if (!verbatim && c == '\\') { i += 2; continue; }
|
||||||
|
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
if (verbatim && i + 1 < n && code[i + 1] == '"') { i += 2; continue; }
|
||||||
|
i++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == '{')
|
||||||
|
{
|
||||||
|
if (i + 1 < n && code[i + 1] == '{') { i += 2; continue; } // escaped brace
|
||||||
|
// Interpolation hole — skip to the matching '}', tracking nested
|
||||||
|
// braces so a hole containing an object initializer is handled.
|
||||||
|
i++;
|
||||||
|
int depth = 1;
|
||||||
|
while (i < n && depth > 0)
|
||||||
|
{
|
||||||
|
char h = code[i];
|
||||||
|
if (h == '{') depth++;
|
||||||
|
else if (h == '}') depth--;
|
||||||
|
else if (h == '"')
|
||||||
|
{
|
||||||
|
// A nested string inside the hole.
|
||||||
|
if (!SkipRegularString(code, ref i)) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == '}' && i + 1 < n && code[i + 1] == '}') { i += 2; continue; } // escaped brace
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances past a raw string literal <c>"""..."""</c> (C# 11). On entry
|
||||||
|
/// <c>code[i]</c> is the first of three or more opening quotes.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SkipRawString(string code, ref int i)
|
||||||
|
{
|
||||||
|
int n = code.Length;
|
||||||
|
int openCount = 0;
|
||||||
|
while (i < n && code[i] == '"') { openCount++; i++; }
|
||||||
|
|
||||||
|
// Look for a run of the same number of quotes.
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
if (code[i] == '"')
|
||||||
|
{
|
||||||
|
int closeCount = 0;
|
||||||
|
int start = i;
|
||||||
|
while (i < n && code[i] == '"') { closeCount++; i++; }
|
||||||
|
if (closeCount >= openCount) return true;
|
||||||
|
// Fewer quotes than the opener — they are literal content; keep scanning.
|
||||||
|
if (closeCount == 0) i = start + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances past a <c>'x'</c> char literal. On entry <c>code[i] == '\''</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SkipCharLiteral(string code, ref int i)
|
||||||
|
{
|
||||||
|
int n = code.Length;
|
||||||
|
i++; // past opening quote
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
char c = code[i];
|
||||||
|
if (c == '\\') { i += 2; continue; }
|
||||||
|
if (c == '\n') return false;
|
||||||
|
if (c == '\'') { i++; return true; }
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,18 @@ namespace ScadaLink.TemplateEngine.Validation;
|
|||||||
/// and enforces the forbidden API list (System.IO, Process, Threading, Reflection, raw network).
|
/// and enforces the forbidden API list (System.IO, Process, Threading, Reflection, raw network).
|
||||||
///
|
///
|
||||||
/// For now, this implementation performs basic syntax validation.
|
/// For now, this implementation performs basic syntax validation.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>SECURITY LIMITATION (TemplateEngine-006):</b> the forbidden-API check below
|
||||||
|
/// is an interim, <i>advisory</i> text scan — it is NOT an authoritative trust-model
|
||||||
|
/// boundary. <see cref="CSharpDelimiterScanner.ContainsInCode"/> removes the
|
||||||
|
/// false-positive half (forbidden text inside a string/comment is ignored), but a
|
||||||
|
/// determined script can still bypass the literal patterns via namespace aliases,
|
||||||
|
/// <c>using static</c>, or <c>global::</c>-qualified references. Authoritative
|
||||||
|
/// enforcement requires Roslyn semantic symbol analysis of the referenced
|
||||||
|
/// types/namespaces and is the responsibility of the real script compiler and the
|
||||||
|
/// Site Runtime sandbox. Do not rely on this class as the sole trust-model gate.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ScriptCompiler
|
public class ScriptCompiler
|
||||||
{
|
{
|
||||||
@@ -17,6 +29,13 @@ public class ScriptCompiler
|
|||||||
/// <see cref="ValidationService"/>) must not use these. Trigger expressions run
|
/// <see cref="ValidationService"/>) must not use these. Trigger expressions run
|
||||||
/// under the same trust model as scripts, so the list is shared from here rather
|
/// under the same trust model as scripts, so the list is shared from here rather
|
||||||
/// than duplicated.
|
/// than duplicated.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Matched with <see cref="CSharpDelimiterScanner.ContainsInCode"/> against code
|
||||||
|
/// regions only. This is advisory — see the class summary's SECURITY LIMITATION
|
||||||
|
/// note; the substring patterns are bypassable and the authoritative check is
|
||||||
|
/// deferred to Roslyn semantic analysis.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static readonly string[] ForbiddenPatterns =
|
internal static readonly string[] ForbiddenPatterns =
|
||||||
[
|
[
|
||||||
@@ -39,10 +58,12 @@ public class ScriptCompiler
|
|||||||
if (string.IsNullOrWhiteSpace(code))
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
return Result<bool>.Failure($"Script '{scriptName}' has empty code.");
|
return Result<bool>.Failure($"Script '{scriptName}' has empty code.");
|
||||||
|
|
||||||
// Check for forbidden APIs
|
// Check for forbidden APIs. Advisory only (see class summary): the scan is
|
||||||
|
// code-region-aware so forbidden text inside a string/comment is ignored,
|
||||||
|
// but it remains a substring match and is not an authoritative boundary.
|
||||||
foreach (var pattern in ForbiddenPatterns)
|
foreach (var pattern in ForbiddenPatterns)
|
||||||
{
|
{
|
||||||
if (code.Contains(pattern, StringComparison.Ordinal))
|
if (CSharpDelimiterScanner.ContainsInCode(code, pattern))
|
||||||
{
|
{
|
||||||
return Result<bool>.Failure(
|
return Result<bool>.Failure(
|
||||||
$"Script '{scriptName}' uses forbidden API: '{pattern.TrimEnd('.')}'. " +
|
$"Script '{scriptName}' uses forbidden API: '{pattern.TrimEnd('.')}'. " +
|
||||||
@@ -50,76 +71,35 @@ public class ScriptCompiler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic brace matching validation
|
// Basic structural validation: balanced braces/brackets/parens. The scan
|
||||||
var braceDepth = 0;
|
// is string- and comment-aware (see CSharpDelimiterScanner) so a delimiter
|
||||||
var inString = false;
|
// inside a regular/verbatim/interpolated/raw string, a char literal, or a
|
||||||
var inLineComment = false;
|
// comment does not produce a false mismatch. This remains an interim check
|
||||||
var inBlockComment = false;
|
// until the Roslyn-based compiler is wired in.
|
||||||
|
var mismatch = CSharpDelimiterScanner.Scan(code);
|
||||||
for (int i = 0; i < code.Length; i++)
|
return mismatch switch
|
||||||
{
|
{
|
||||||
var c = code[i];
|
CSharpDelimiterScanner.Mismatch.None =>
|
||||||
var next = i + 1 < code.Length ? code[i + 1] : '\0';
|
Result<bool>.Success(true),
|
||||||
|
CSharpDelimiterScanner.Mismatch.UnexpectedCloseBrace =>
|
||||||
if (inLineComment)
|
Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unexpected closing brace)."),
|
||||||
{
|
CSharpDelimiterScanner.Mismatch.UnclosedBrace =>
|
||||||
if (c == '\n') inLineComment = false;
|
Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unclosed opening brace)."),
|
||||||
continue;
|
CSharpDelimiterScanner.Mismatch.UnexpectedCloseBracket =>
|
||||||
}
|
Result<bool>.Failure($"Script '{scriptName}' has mismatched brackets (unexpected closing bracket)."),
|
||||||
|
CSharpDelimiterScanner.Mismatch.UnclosedBracket =>
|
||||||
if (inBlockComment)
|
Result<bool>.Failure($"Script '{scriptName}' has mismatched brackets (unclosed opening bracket)."),
|
||||||
{
|
CSharpDelimiterScanner.Mismatch.UnexpectedCloseParen =>
|
||||||
if (c == '*' && next == '/')
|
Result<bool>.Failure($"Script '{scriptName}' has mismatched parentheses (unexpected closing parenthesis)."),
|
||||||
{
|
CSharpDelimiterScanner.Mismatch.UnclosedParen =>
|
||||||
inBlockComment = false;
|
Result<bool>.Failure($"Script '{scriptName}' has mismatched parentheses (unclosed opening parenthesis)."),
|
||||||
i++;
|
CSharpDelimiterScanner.Mismatch.UnclosedBlockComment =>
|
||||||
}
|
Result<bool>.Failure($"Script '{scriptName}' has an unclosed block comment."),
|
||||||
continue;
|
CSharpDelimiterScanner.Mismatch.UnterminatedString =>
|
||||||
}
|
Result<bool>.Failure($"Script '{scriptName}' has an unterminated string literal."),
|
||||||
|
CSharpDelimiterScanner.Mismatch.UnterminatedChar =>
|
||||||
if (c == '/' && next == '/')
|
Result<bool>.Failure($"Script '{scriptName}' has an unterminated character literal."),
|
||||||
{
|
_ => Result<bool>.Success(true),
|
||||||
inLineComment = true;
|
};
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '/' && next == '*')
|
|
||||||
{
|
|
||||||
inBlockComment = true;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '"' && !inString)
|
|
||||||
{
|
|
||||||
inString = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '"' && inString)
|
|
||||||
{
|
|
||||||
// Check for escaped quote
|
|
||||||
if (i > 0 && code[i - 1] != '\\')
|
|
||||||
inString = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inString) continue;
|
|
||||||
|
|
||||||
if (c == '{') braceDepth++;
|
|
||||||
else if (c == '}') braceDepth--;
|
|
||||||
|
|
||||||
if (braceDepth < 0)
|
|
||||||
return Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unexpected closing brace).");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (braceDepth != 0)
|
|
||||||
return Result<bool>.Failure($"Script '{scriptName}' has mismatched braces ({braceDepth} unclosed).");
|
|
||||||
|
|
||||||
if (inBlockComment)
|
|
||||||
return Result<bool>.Failure($"Script '{scriptName}' has an unclosed block comment.");
|
|
||||||
|
|
||||||
return Result<bool>.Success(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,9 +317,12 @@ public class ValidationService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static string? CheckExpressionSyntax(string expression)
|
internal static string? CheckExpressionSyntax(string expression)
|
||||||
{
|
{
|
||||||
|
// Advisory forbidden-API scan (TemplateEngine-006): code-region-aware so
|
||||||
|
// the inert text inside a string/comment is not flagged, but still a
|
||||||
|
// substring match — not an authoritative boundary. See ScriptCompiler.
|
||||||
foreach (var pattern in ScriptCompiler.ForbiddenPatterns)
|
foreach (var pattern in ScriptCompiler.ForbiddenPatterns)
|
||||||
{
|
{
|
||||||
if (expression.Contains(pattern, StringComparison.Ordinal))
|
if (CSharpDelimiterScanner.ContainsInCode(expression, pattern))
|
||||||
{
|
{
|
||||||
return $"uses forbidden API '{pattern.TrimEnd('.')}'. " +
|
return $"uses forbidden API '{pattern.TrimEnd('.')}'. " +
|
||||||
"Trigger expressions cannot use System.IO, Process, Threading, Reflection, or raw network APIs.";
|
"Trigger expressions cannot use System.IO, Process, Threading, Reflection, or raw network APIs.";
|
||||||
|
|||||||
@@ -119,6 +119,106 @@ public class InstanceServiceTests
|
|||||||
Assert.Equal("99", result.Value.OverrideValue);
|
Assert.Equal("99", result.Value.OverrideValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TemplateEngine-008 regression: SetAlarmOverrideAsync validation ---
|
||||||
|
|
||||||
|
private static Template TemplateWithAlarms(int id, params TemplateAlarm[] alarms)
|
||||||
|
{
|
||||||
|
var t = new Template($"T{id}") { Id = id };
|
||||||
|
foreach (var a in alarms)
|
||||||
|
{
|
||||||
|
a.TemplateId = id;
|
||||||
|
t.Alarms.Add(a);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAlarmOverride_NonExistentAlarm_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||||
|
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(instance);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template>
|
||||||
|
{
|
||||||
|
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10 })
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _sut.SetAlarmOverrideAsync(1, "Missing", "{}", null, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("does not exist", result.Error);
|
||||||
|
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
|
||||||
|
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAlarmOverride_ComposedLockedAlarm_ReturnsFailure()
|
||||||
|
{
|
||||||
|
// The locked alarm lives in a composed module, so it is NOT a direct
|
||||||
|
// alarm of the instance's template — the old code skipped the lock check.
|
||||||
|
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||||
|
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(instance);
|
||||||
|
|
||||||
|
var module = TemplateWithAlarms(2, new TemplateAlarm("Fault") { Id = 20, IsLocked = true });
|
||||||
|
var host = new Template("Host") { Id = 1 };
|
||||||
|
host.Compositions.Add(new TemplateComposition("Pump") { Id = 1, ComposedTemplateId = 2 });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { host, module });
|
||||||
|
|
||||||
|
var result = await _sut.SetAlarmOverrideAsync(1, "Pump.Fault", "{}", null, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
|
||||||
|
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAlarmOverride_ComposedUnlockedAlarm_ReturnsSuccess()
|
||||||
|
{
|
||||||
|
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||||
|
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(instance);
|
||||||
|
|
||||||
|
var module = TemplateWithAlarms(2, new TemplateAlarm("Fault") { Id = 20, IsLocked = false });
|
||||||
|
var host = new Template("Host") { Id = 1 };
|
||||||
|
host.Compositions.Add(new TemplateComposition("Pump") { Id = 1, ComposedTemplateId = 2 });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { host, module });
|
||||||
|
_repoMock.Setup(r => r.GetAlarmOverrideAsync(1, "Pump.Fault", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((InstanceAlarmOverride?)null);
|
||||||
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(1);
|
||||||
|
|
||||||
|
var result = await _sut.SetAlarmOverrideAsync(1, "Pump.Fault", "{}", 2, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
|
||||||
|
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAlarmOverride_DirectLockedAlarm_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
|
||||||
|
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(instance);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template>
|
||||||
|
{
|
||||||
|
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10, IsLocked = true })
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _sut.SetAlarmOverrideAsync(1, "HighTemp", "{}", null, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Enable_ExistingInstance_SetsEnabled()
|
public async Task Enable_ExistingInstance_SetsEnabled()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ public class TemplateDeletionServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanDeleteTemplate_ComposedByOthers_ReturnsFailure()
|
public async Task CanDeleteTemplate_ComposedByOthers_ReturnsFailure()
|
||||||
{
|
{
|
||||||
|
var composer = new Template("Composer") { Id = 2 };
|
||||||
|
composer.Compositions.Add(new TemplateComposition("PumpModule") { ComposedTemplateId = 1 });
|
||||||
|
|
||||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new Template("Module") { Id = 1 });
|
.ReturnsAsync(new Template("Module") { Id = 1 });
|
||||||
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
@@ -90,15 +93,8 @@ public class TemplateDeletionServiceTests
|
|||||||
.ReturnsAsync(new List<Template>
|
.ReturnsAsync(new List<Template>
|
||||||
{
|
{
|
||||||
new("Module") { Id = 1 },
|
new("Module") { Id = 1 },
|
||||||
new("Composer") { Id = 2 }
|
composer
|
||||||
});
|
});
|
||||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(2, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<TemplateComposition>
|
|
||||||
{
|
|
||||||
new("PumpModule") { ComposedTemplateId = 1 }
|
|
||||||
});
|
|
||||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<TemplateComposition>());
|
|
||||||
|
|
||||||
var result = await _sut.CanDeleteTemplateAsync(1);
|
var result = await _sut.CanDeleteTemplateAsync(1);
|
||||||
|
|
||||||
@@ -107,6 +103,34 @@ public class TemplateDeletionServiceTests
|
|||||||
Assert.Contains("Composer", result.Error);
|
Assert.Contains("Composer", result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CanDeleteTemplate_DoesNotIssuePerTemplateCompositionQuery()
|
||||||
|
{
|
||||||
|
// TemplateEngine-009: Check 3 must read the Compositions navigation
|
||||||
|
// already loaded by GetAllTemplatesAsync rather than issuing one
|
||||||
|
// GetCompositionsByTemplateIdAsync round-trip per template.
|
||||||
|
var templates = new List<Template>
|
||||||
|
{
|
||||||
|
new("Module") { Id = 1 },
|
||||||
|
new("A") { Id = 2 },
|
||||||
|
new("B") { Id = 3 },
|
||||||
|
new("C") { Id = 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Template("Module") { Id = 1 });
|
||||||
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Instance>());
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(templates);
|
||||||
|
|
||||||
|
var result = await _sut.CanDeleteTemplateAsync(1);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
_repoMock.Verify(r => r.GetCompositionsByTemplateIdAsync(
|
||||||
|
It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanDeleteTemplate_NotFound_ReturnsFailure()
|
public async Task CanDeleteTemplate_NotFound_ReturnsFailure()
|
||||||
{
|
{
|
||||||
@@ -146,22 +170,15 @@ public class TemplateDeletionServiceTests
|
|||||||
.ReturnsAsync(new Template("Busy") { Id = 1 });
|
.ReturnsAsync(new Template("Busy") { Id = 1 });
|
||||||
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Instance> { new("Inst1") { Id = 1 } });
|
.ReturnsAsync(new List<Instance> { new("Inst1") { Id = 1 } });
|
||||||
|
var composer = new Template("Composer") { Id = 3 };
|
||||||
|
composer.Compositions.Add(new TemplateComposition("Module") { ComposedTemplateId = 1 });
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template>
|
.ReturnsAsync(new List<Template>
|
||||||
{
|
{
|
||||||
new("Busy") { Id = 1 },
|
new("Busy") { Id = 1 },
|
||||||
new("Child") { Id = 2, ParentTemplateId = 1 },
|
new("Child") { Id = 2, ParentTemplateId = 1 },
|
||||||
new("Composer") { Id = 3 }
|
composer
|
||||||
});
|
});
|
||||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(3, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<TemplateComposition>
|
|
||||||
{
|
|
||||||
new("Module") { ComposedTemplateId = 1 }
|
|
||||||
});
|
|
||||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<TemplateComposition>());
|
|
||||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(2, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(new List<TemplateComposition>());
|
|
||||||
|
|
||||||
var result = await _sut.CanDeleteTemplateAsync(1);
|
var result = await _sut.CanDeleteTemplateAsync(1);
|
||||||
|
|
||||||
|
|||||||
@@ -141,4 +141,19 @@ public class SharedScriptServiceTests
|
|||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.Contains("Syntax error", result);
|
Assert.Contains("Syntax error", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TemplateEngine-007 regression: string/comment-literal awareness ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("var s = \"a } brace\"; { }")] // brace inside a normal string
|
||||||
|
[InlineData("var s = \"a ) paren ] bracket\";")] // paren/bracket inside a string
|
||||||
|
[InlineData("var s = @\"verbatim } brace\"; { }")] // brace inside a verbatim string
|
||||||
|
[InlineData("var x = 1; var s = $\"hole {x} literal}}\"; { }")] // interpolated string with braces
|
||||||
|
[InlineData("var c = '}'; if (true) { }")] // char literal containing a brace
|
||||||
|
[InlineData("// a stray } here\nvar x = 1;")] // brace inside a line comment
|
||||||
|
[InlineData("/* a stray ) here */ var x = 1;")] // paren inside a block comment
|
||||||
|
public void ValidateSyntax_DelimiterInsideStringOrComment_ReturnsNull(string code)
|
||||||
|
{
|
||||||
|
Assert.Null(SharedScriptService.ValidateSyntax(code));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,4 +71,85 @@ public class ScriptCompilerTests
|
|||||||
var result = _sut.TryCompile("/* { } */ var x = 1;", "Test");
|
var result = _sut.TryCompile("/* { } */ var x = 1;", "Test");
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TemplateEngine-007 regression: string-literal awareness ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_VerbatimStringWithBrace_NotFlaggedAsMismatched()
|
||||||
|
{
|
||||||
|
// @"..." — backslash is literal, "" is the escape. The closing brace
|
||||||
|
// inside the verbatim string must not affect the brace balance.
|
||||||
|
var result = _sut.TryCompile("var s = @\"a brace } and a \\ slash\"; if (true) { }", "Test");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_VerbatimStringWithEscapedQuote_NotFlaggedAsMismatched()
|
||||||
|
{
|
||||||
|
// The "" inside a verbatim string is an escaped quote, not a string end.
|
||||||
|
var result = _sut.TryCompile("var s = @\"he said \"\"hi}\"\"\"; { }", "Test");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_InterpolatedStringWithBraces_NotFlaggedAsMismatched()
|
||||||
|
{
|
||||||
|
// The braces in $"{x}" are interpolation holes; the literal "}}" is an
|
||||||
|
// escaped brace. Neither should unbalance the real braces.
|
||||||
|
var result = _sut.TryCompile("var x = 1; var s = $\"val={x} literal}}\"; if (x>0) { x++; }", "Test");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_RawStringLiteralWithBraces_NotFlaggedAsMismatched()
|
||||||
|
{
|
||||||
|
// C# 11 raw string literal — the triple quotes delimit, braces inside are text.
|
||||||
|
var result = _sut.TryCompile("var s = \"\"\"a } brace { in raw\"\"\"; { }", "Test");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_CharLiteralWithBrace_NotFlaggedAsMismatched()
|
||||||
|
{
|
||||||
|
// A '}' char literal must not decrement the brace depth.
|
||||||
|
var result = _sut.TryCompile("var c = '}'; if (true) { }", "Test");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_GenuineMismatchedBraces_StillDetected()
|
||||||
|
{
|
||||||
|
// Sanity check that the string-aware scan still catches real mismatches.
|
||||||
|
var result = _sut.TryCompile("var s = \"ok\"; if (true) { x++;", "Test");
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("braces", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TemplateEngine-006 regression: forbidden-API scan false positives ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_ForbiddenApiTextInsideStringLiteral_NotFlagged()
|
||||||
|
{
|
||||||
|
// "System.IO." appears only inside a string literal — it is inert text,
|
||||||
|
// not a use of the forbidden API, and must not be rejected.
|
||||||
|
var result = _sut.TryCompile("var msg = \"see System.IO.File docs\"; var x = 1;", "Test");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_ForbiddenApiTextInsideComment_NotFlagged()
|
||||||
|
{
|
||||||
|
// "System.Threading." appears only inside a comment — inert.
|
||||||
|
var result = _sut.TryCompile("// avoid System.Threading.Thread here\nvar x = 1;", "Test");
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompile_ForbiddenApiInRealCode_StillFlagged()
|
||||||
|
{
|
||||||
|
// Sanity check: a genuine use in code is still rejected.
|
||||||
|
var result = _sut.TryCompile("var x = System.IO.File.ReadAllText(\"a\");", "Test");
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user