|
- namespace Campaign_Tracker.Server.LegacyData;
-
- /// <summary>
- /// 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
- /// <see cref="Validate"/> before sending the command, so the layer boundary
- /// remains enforceable even when raw ADO.NET is used.
- /// </summary>
- public static class ReadOnlyCommandGuard
- {
- private static readonly string[] WriteKeywords =
- [
- "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER",
- "TRUNCATE", "EXEC", "EXECUTE", "MERGE", "REPLACE",
- ];
-
- /// <summary>
- /// Validates that <paramref name="sql"/> is a SELECT-only statement.
- /// Throws <see cref="LegacyWriteAttemptException"/> if any write keyword is detected.
- /// </summary>
- 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;
- }
|