namespace Campaign_Tracker.Server.LegacyData; /// /// Guards the anti-corruption layer boundary against write SQL being executed /// on legacy Access tables (AC #2). /// /// Any concrete implementation that executes raw SQL must call /// before sending the command, so the layer boundary /// remains enforceable even when raw ADO.NET is used. /// public static class ReadOnlyCommandGuard { private static readonly string[] WriteKeywords = [ "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE", "EXEC", "EXECUTE", "MERGE", "REPLACE", ]; /// /// Validates that is a SELECT-only statement. /// Throws if any write keyword is detected. /// public static void Validate(string sql) { if (string.IsNullOrWhiteSpace(sql)) { throw new LegacyWriteAttemptException("Empty SQL is not permitted on legacy tables."); } var normalised = sql.Trim(); var commandText = StripNonExecutableText(normalised); if (!commandText.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)) { throw new LegacyWriteAttemptException( $"Only SELECT statements are permitted on legacy tables. Received: {Truncate(normalised)}"); } if (ContainsAdditionalStatement(commandText)) { throw new LegacyWriteAttemptException("Multiple SQL statements are not permitted on legacy tables."); } foreach (var keyword in WriteKeywords) { if (ContainsWordBoundary(commandText, keyword)) { throw new LegacyWriteAttemptException( $"Write keyword '{keyword}' detected in legacy table query. Statement blocked."); } } } private static bool ContainsWordBoundary(string sql, string keyword) { var index = -1; while ((index = sql.IndexOf(keyword, index + 1, StringComparison.OrdinalIgnoreCase)) >= 0) { var before = index == 0 || !IsIdentifierCharacter(sql[index - 1]); var after = index + keyword.Length >= sql.Length || !IsIdentifierCharacter(sql[index + keyword.Length]); if (before && after) { return true; } } return false; } private static bool ContainsAdditionalStatement(string sql) { var semicolonIndex = sql.IndexOf(';'); return semicolonIndex >= 0 && sql[(semicolonIndex + 1)..].Any(character => !char.IsWhiteSpace(character)); } private static string StripNonExecutableText(string sql) { var result = new char[sql.Length]; var index = 0; while (index < sql.Length) { if (sql[index] == '\'' || sql[index] == '"' || sql[index] == '[') { var close = sql[index] == '[' ? ']' : sql[index]; result[index++] = ' '; while (index < sql.Length) { var current = sql[index]; result[index++] = ' '; if (current == close) { break; } } continue; } if (sql[index] == '-' && index + 1 < sql.Length && sql[index + 1] == '-') { result[index++] = ' '; result[index++] = ' '; while (index < sql.Length && sql[index] != '\r' && sql[index] != '\n') { result[index++] = ' '; } continue; } if (sql[index] == '/' && index + 1 < sql.Length && sql[index + 1] == '*') { result[index++] = ' '; result[index++] = ' '; while (index < sql.Length) { var current = sql[index]; result[index++] = ' '; if (current == '*' && index < sql.Length && sql[index] == '/') { result[index++] = ' '; break; } } continue; } result[index] = sql[index]; index++; } return new string(result); } private static bool IsIdentifierCharacter(char character) => char.IsLetterOrDigit(character) || character == '_'; private static string Truncate(string sql) => sql.Length > 60 ? sql[..60] + "..." : sql; }