Files
scadalink-design/src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs

126 lines
4.0 KiB
C#

using ScadaLink.Commons.Types;
namespace ScadaLink.TemplateEngine.Validation;
/// <summary>
/// Validates script code by attempting to compile it using Roslyn.
/// In production, this would compile C# scripts against a stub ScriptApi assembly
/// that provides the allowed API surface (attribute read/write, CallScript, CallShared, etc.)
/// and enforces the forbidden API list (System.IO, Process, Threading, Reflection, raw network).
///
/// For now, this implementation performs basic syntax validation.
/// </summary>
public class ScriptCompiler
{
/// <summary>
/// Forbidden namespace patterns — scripts (and trigger expressions, via
/// <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
/// than duplicated.
/// </summary>
internal static readonly string[] ForbiddenPatterns =
[
"System.IO.",
"System.Diagnostics.Process",
"System.Threading.",
"System.Reflection.",
"System.Net.Sockets.",
"System.Net.Http.",
];
/// <summary>
/// Attempts to compile a script and returns success or a compilation error.
/// </summary>
/// <param name="code">The C# script code.</param>
/// <param name="scriptName">The canonical name of the script (for error messages).</param>
/// <returns>Success if the script compiles, or Failure with the error message.</returns>
public Result<bool> TryCompile(string code, string scriptName)
{
if (string.IsNullOrWhiteSpace(code))
return Result<bool>.Failure($"Script '{scriptName}' has empty code.");
// Check for forbidden APIs
foreach (var pattern in ForbiddenPatterns)
{
if (code.Contains(pattern, StringComparison.Ordinal))
{
return Result<bool>.Failure(
$"Script '{scriptName}' uses forbidden API: '{pattern.TrimEnd('.')}'. " +
"Scripts cannot use System.IO, Process, Threading, Reflection, or raw network APIs.");
}
}
// Basic brace matching validation
var braceDepth = 0;
var inString = false;
var inLineComment = false;
var inBlockComment = false;
for (int i = 0; i < code.Length; i++)
{
var c = code[i];
var next = i + 1 < code.Length ? code[i + 1] : '\0';
if (inLineComment)
{
if (c == '\n') inLineComment = false;
continue;
}
if (inBlockComment)
{
if (c == '*' && next == '/')
{
inBlockComment = false;
i++;
}
continue;
}
if (c == '/' && next == '/')
{
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);
}
}