using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using Microsoft.SqlServer.TransactSql.ScriptDom; namespace D365MetadataBridge.Services { public sealed class AxDbTableReference { public string Schema { get; set; } = "dbo"; public string Table { get; set; } = ""; } /// /// Conservative AST validation: exactly one SELECT. This is the FIRST guard, not the only one — /// on a dev VM the MCP account is normally sysadmin, so SQL permissions restrict nothing, and /// AxDbSqlService runs every statement in a transaction it always rolls back (the second guard). /// public static class AxDbSqlPolicy { private static readonly HashSet Functions = new HashSet(StringComparer.OrdinalIgnoreCase) { "COUNT", "COUNT_BIG", "SUM", "AVG", "MIN", "MAX", "ABS", "ROUND", "CEILING", "FLOOR", "LEN", "DATALENGTH", "LOWER", "UPPER", "LTRIM", "RTRIM", "TRIM", "LEFT", "RIGHT", "SUBSTRING", "REPLACE", "CONCAT", "CONCAT_WS", "CHARINDEX", "PATINDEX", "ISNULL", "NULLIF", "COALESCE", "GETDATE", "GETUTCDATE", "SYSDATETIME", "SYSUTCDATETIME", "DATEADD", "DATEDIFF", "DATEDIFF_BIG", "DATEPART", "DATENAME", "YEAR", "MONTH", "DAY", "EOMONTH", "ROW_NUMBER", "RANK", "DENSE_RANK", "LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", "STRING_AGG", "STDEV", "STDEVP", "VAR", "VARP", "COLLATIONPROPERTY", "DATABASEPROPERTYEX", "DB_NAME" }; public static IReadOnlyList Validate(string sql) { if (string.IsNullOrWhiteSpace(sql) || sql.Length > 65536) throw new ArgumentException("SQL must contain 1..65536 characters."); var parser = new TSql160Parser(true); var script = (TSqlScript)parser.Parse(new StringReader(sql), out var errors); if (errors.Count != 0) throw new ArgumentException("Invalid SQL: " + errors[0].Message); if (script.Batches.Count != 1 || script.Batches[0].Statements.Count != 1) throw new ArgumentException("Exactly one SQL statement is required per item; batches are not allowed."); var statement = script.Batches[0].Statements[0]; if (!(statement is SelectStatement)) throw new ArgumentException("Only SELECT is allowed."); var nodes = Walk(statement).ToList(); // Exact spelling is conservative across both case-sensitive and case-insensitive database collations. // A case-folded match could hide a distinct physical table on a case-sensitive database. var cteList = ((SelectStatement)statement).WithCtesAndXmlNamespaces?.CommonTableExpressions?.ToList() ?? new List(); var ctes = new HashSet(cteList.Select(c => c.ExpressionName.Value), StringComparer.Ordinal); // A CTE name is in scope only for the CTEs AFTER it (and its own body, for // recursion) and for the main query. Inside an earlier CTE body the same name // resolves to the real object: `WITH a AS (SELECT * FROM syslogins), // syslogins AS (SELECT 1 x) SELECT * FROM a` reads the server's logins. So a // reference inside CTE i sees only CTEs 0..i; anything else goes to the // catalog check like any other table. var cteScope = new Dictionary>(ReferenceComparer.Instance); for (var i = 0; i < cteList.Count; i++) { var visible = new HashSet(cteList.Take(i + 1).Select(c => c.ExpressionName.Value), StringComparer.Ordinal); foreach (var reference in Walk(cteList[i].QueryExpression).OfType()) if (!cteScope.ContainsKey(reference)) cteScope[reference] = visible; } var tables = new List(); foreach (var node in nodes) { var kind = node.GetType().Name; if (node is TSqlStatement nested && nested != statement) throw new ArgumentException("Nested SQL statements are not supported."); if (node is SelectStatement select && select.Into != null) throw new ArgumentException("SELECT INTO is not allowed."); if (kind == "OutputClause" || kind == "OutputIntoClause" || kind == "ExecuteInsertSource" || kind == "NextValueForExpression" || kind == "SelectSetVariable" || kind.EndsWith("ForClause", StringComparison.Ordinal) || node is TableHint || node is OptimizerHint || node is UserDataTypeReference) throw new ArgumentException("Unsupported SQL construct: " + kind + "."); if (node is FunctionCall function && (function.CallTarget != null || !Functions.Contains(function.FunctionName.Value))) throw new ArgumentException("Only supported built-in SQL functions are allowed."); if (kind == "UserDefinedTypeCallTarget" || kind == "MultiPartIdentifierCallTarget") throw new ArgumentException("User-defined methods are not allowed."); if (node is TableReference && !(node is NamedTableReference || node is QueryDerivedTable || node is QualifiedJoin || node is UnqualifiedJoin || node is JoinParenthesisTableReference || node is InlineDerivedTable)) throw new ArgumentException("Unsupported table source: " + kind + "."); if (node is NamedTableReference table) { var name = table.SchemaObject; if (name.Identifiers.Count > 2 || name.Identifiers.Count == 0 || name.BaseIdentifier.Value.StartsWith("#", StringComparison.Ordinal)) throw new ArgumentException("Only local database tables and CTEs are allowed."); var inScope = cteScope.TryGetValue(table, out var visibleCtes) ? visibleCtes : ctes; if (name.Identifiers.Count == 1 && inScope.Contains(name.BaseIdentifier.Value)) continue; // Empty schema means SQL's normal default-schema resolution, not an assumed dbo object. tables.Add(new AxDbTableReference { Schema = name.SchemaIdentifier?.Value ?? "", Table = name.BaseIdentifier.Value }); } } return tables; } private sealed class ReferenceComparer : IEqualityComparer { public static readonly ReferenceComparer Instance = new ReferenceComparer(); public bool Equals(NamedTableReference x, NamedTableReference y) => ReferenceEquals(x, y); public int GetHashCode(NamedTableReference obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); } // Reflection walks only AST children, not tokens/parents. This also sees constructs nested in expressions/CTEs. private static IEnumerable Walk(TSqlFragment root) { yield return root; foreach (var property in root.GetType().GetProperties()) { if (property.GetIndexParameters().Length != 0 || property.Name == "ScriptTokenStream") continue; if (typeof(TSqlFragment).IsAssignableFrom(property.PropertyType)) { if (property.GetValue(root) is TSqlFragment child) foreach (var node in Walk(child)) yield return node; } else if (property.PropertyType.IsGenericType && typeof(IEnumerable).IsAssignableFrom(property.PropertyType) && typeof(TSqlFragment).IsAssignableFrom(property.PropertyType.GetGenericArguments()[0])) { if (property.GetValue(root) is IEnumerable children) foreach (TSqlFragment child in children) foreach (var node in Walk(child)) yield return node; } } } } }