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;
}