Files
2026-10-23 18:46:50 +02:00

436 lines
15 KiB
C#

using System.Data.Common;
using System.Text.Json;
using System.Text.RegularExpressions;
using Omika.Data.Configuration;
using Omika.Data.HfSql.Connection;
namespace HfSqlOleDbAudit;
/// <summary>
/// Mode <c>connection-test</c> : valide les trois connexions HFSQL de Production
/// (OLE DB <c>PCSOFT.HFSQL</c>) en LECTURE SEULE, sans démarrer Omika.Api ni
/// Omika.Worker.
///
/// <para>
/// Parité Production exacte : la chaîne de connexion OLE DB est construite par
/// <see cref="HfSqlConnectionOptionsFactory"/> (Create / CreateForLicense /
/// CreateForService), <see cref="HfSqlOleDbConnectionStringBuilder"/> et
/// <see cref="HfSqlOleDbConnectionFactory"/> — les mêmes classes que
/// <c>BusinessOleDbConnection</c>, <c>LicenseOleDbConnection</c> et
/// <c>ServiceOleDbConnection</c>. Les mots de passe de fichiers
/// (ProtectionStd / ProtectionPlus) sont injectés dans les Extended Properties
/// par le mécanisme Production (réflexion sur <c>HfSqlTableAttribute</c>),
/// jamais dérivés, jamais calculés (aucun MD5, aucun wildcard improvisé).
/// </para>
///
/// <para>
/// Sécurité : lecture seule stricte (aucune commande d'écriture de données ni de
/// schéma), aucune écriture de fichier, aucun secret en argument CLI, aucun
/// secret affiché (seuls des indicateurs YES/NO et un code d'erreur numérique
/// sont émis). Aucune chaîne de connexion complète n'est jamais affichée.
/// </para>
/// </summary>
public static class ConnectionTest
{
public const string CommandName = "connection-test";
private static readonly Regex HfSqlErrorCodeRegex = new(
@"Code\s+erreur\s*:\s*(\d+)|Error\s+code\s*:\s*(\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>Sections de configuration supportées (strictement).</summary>
public enum Section
{
Hfsql,
LicenseConnection,
ServiceConnection
}
/// <summary>Arguments CLI validés du mode connection-test.</summary>
public sealed record Arguments(string ConfigPath, Section SectionValue, string? Table);
/// <summary>
/// Configuration de connexion résolue depuis appsettings.Production.json,
/// avec la vue OLE DB Production (<see cref="HfSqlConnectionOptions"/>).
/// Ne contient jamais les valeurs secrètes en clair dans sa représentation.
/// </summary>
public sealed record Config(
Section SectionValue,
string Server,
int Port,
string DatabaseName,
string Login,
bool DatabasePasswordPresent,
bool ProtectionStdPresent,
bool ProtectionPlusPresent,
HfSqlConnectionOptions ConnectionOptions);
// ================================================================
// ENTRY
// ================================================================
public static int Run(string[] args) => Run(args, Console.Out);
public static int Run(string[] args, TextWriter output)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(output);
if (!TryParseArguments(args, out Arguments? arguments, out string? argError) || arguments is null)
{
output.WriteLine("ERROR=" + argError);
PrintUsage(output);
return 2;
}
if (!TryLoadConfig(arguments.ConfigPath, arguments.SectionValue, out Config? config, out string? configError) || config is null)
{
output.WriteLine("ERROR=" + configError);
return 2;
}
foreach (string line in BuildSummaryLines(config))
{
output.WriteLine(line);
}
var factory = new HfSqlOleDbConnectionFactory();
// La connexion est créée puis TOUJOURS disposée (y compris si l'ouverture
// échoue) : le provider OLE DB natif PCSOFT.HFSQL doit être libéré
// proprement, sinon le processus peut se terminer anormalement à la
// finalisation de l'objet non disposé.
DbConnection connection;
try
{
connection = factory.Create(config.ConnectionOptions);
}
catch (Exception ex)
{
output.WriteLine("CONNECTION_OPEN=FAIL");
output.WriteLine("ERROR_TYPE=" + SanitizeErrorType(ex));
output.WriteLine("HFSQL_ERROR_CODE=" + (ExtractHfSqlErrorCode(ex) ?? "NONE"));
return 1;
}
using (connection)
{
try
{
connection.Open();
}
catch (Exception ex)
{
// Jamais de message brut : uniquement le type et le code numérique.
output.WriteLine("CONNECTION_OPEN=FAIL");
output.WriteLine("ERROR_TYPE=" + SanitizeErrorType(ex));
output.WriteLine("HFSQL_ERROR_CODE=" + (ExtractHfSqlErrorCode(ex) ?? "NONE"));
return 1;
}
output.WriteLine("CONNECTION_OPEN=PASS");
if (arguments.Table is not null)
{
bool ok = TrySelect(connection, arguments.Table, out int fieldCount, out bool hasRows, out string? code);
output.WriteLine("TABLE=" + arguments.Table);
output.WriteLine("SELECT=" + (ok ? "PASS" : "FAIL"));
output.WriteLine("FIELD_COUNT=" + (ok ? fieldCount.ToString() : "0"));
output.WriteLine("HAS_ROWS=" + (ok && hasRows ? "YES" : "NO"));
output.WriteLine("HFSQL_ERROR_CODE=" + (code ?? "NONE"));
if (!ok)
{
return 1;
}
}
}
return 0;
}
// ================================================================
// ARGUMENT PARSING
// ================================================================
public static bool TryParseArguments(IReadOnlyList<string> args, out Arguments? arguments, out string? error)
{
arguments = null;
error = null;
// args[0] = "connection-test", args[1] = <configPath>, args[2] = <section>, args[3]? = <table>.
if (args.Count < 3)
{
error = "INVALID_ARGUMENTS";
return false;
}
if (!TryParseSection(args[2], out Section section))
{
error = "UNSUPPORTED_SECTION";
return false;
}
string? table = null;
if (args.Count >= 4)
{
table = args[3];
if (!IsValidTableIdentifier(table))
{
error = "INVALID_TABLE_IDENTIFIER";
return false;
}
}
arguments = new Arguments(args[1], section, table);
return true;
}
public static bool TryParseSection(string section, out Section parsed)
{
if (section.Equals("Hfsql", StringComparison.OrdinalIgnoreCase))
{
parsed = Section.Hfsql;
return true;
}
if (section.Equals("LicenseConnection", StringComparison.OrdinalIgnoreCase))
{
parsed = Section.LicenseConnection;
return true;
}
if (section.Equals("ServiceConnection", StringComparison.OrdinalIgnoreCase))
{
parsed = Section.ServiceConnection;
return true;
}
parsed = default;
return false;
}
// ================================================================
// CONFIG LOADING (lecture seule)
// ================================================================
public static bool TryLoadConfig(string configPath, Section section, out Config? config, out string? error)
{
config = null;
error = null;
string json;
try
{
json = File.ReadAllText(configPath);
}
catch (Exception)
{
error = "CONFIG_FILE_NOT_FOUND";
return false;
}
JsonDocument document;
try
{
document = JsonDocument.Parse(json);
}
catch (JsonException)
{
error = "CONFIG_PARSE_FAILED";
return false;
}
using (document)
{
string sectionName = SectionName(section);
if (!document.RootElement.TryGetProperty(sectionName, out JsonElement sectionElement)
|| sectionElement.ValueKind != JsonValueKind.Object)
{
error = "SECTION_NOT_FOUND";
return false;
}
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
HfSqlConnectionOptions connectionOptions;
string server;
int port;
string databaseName;
string login;
try
{
switch (section)
{
case Section.Hfsql:
{
var options = sectionElement.Deserialize<HfsqlOptions>(jsonOptions) ?? new HfsqlOptions();
connectionOptions = HfSqlConnectionOptionsFactory.Create(options, options.FilePasswords);
server = options.Server ?? string.Empty;
port = options.Port;
databaseName = options.DatabaseName ?? string.Empty;
login = options.Login ?? string.Empty;
break;
}
case Section.LicenseConnection:
{
var options = sectionElement.Deserialize<LicenseConnectionOptions>(jsonOptions) ?? new LicenseConnectionOptions();
connectionOptions = HfSqlConnectionOptionsFactory.CreateForLicense(options);
server = options.Server ?? string.Empty;
port = options.Port;
databaseName = options.DatabaseName ?? string.Empty;
login = options.Login ?? string.Empty;
break;
}
case Section.ServiceConnection:
{
var options = sectionElement.Deserialize<ServiceConnectionOptions>(jsonOptions) ?? new ServiceConnectionOptions();
connectionOptions = HfSqlConnectionOptionsFactory.CreateForService(options);
server = options.Server ?? string.Empty;
port = options.Port;
databaseName = options.DatabaseName ?? string.Empty;
login = options.Login ?? string.Empty;
break;
}
default:
error = "UNSUPPORTED_SECTION";
return false;
}
}
catch (Exception)
{
error = "CONFIG_PARSE_FAILED";
return false;
}
config = new Config(
section,
server,
port,
databaseName,
login,
!string.IsNullOrWhiteSpace(connectionOptions.Credential),
!string.IsNullOrWhiteSpace(connectionOptions.FilePasswords.ProtectionStd),
!string.IsNullOrWhiteSpace(connectionOptions.FilePasswords.ProtectionPlus),
connectionOptions);
return true;
}
}
private static string SectionName(Section section) => section switch
{
Section.Hfsql => HfsqlOptions.SectionName,
Section.LicenseConnection => LicenseConnectionOptions.SectionName,
Section.ServiceConnection => ServiceConnectionOptions.SectionName,
_ => throw new ArgumentOutOfRangeException(nameof(section))
};
// ================================================================
// SUMMARY (jamais de secret)
// ================================================================
public static IReadOnlyList<string> BuildSummaryLines(Config config)
{
ArgumentNullException.ThrowIfNull(config);
return new[]
{
"Section=" + SectionName(config.SectionValue),
"Server=" + config.Server,
"Port=" + config.Port.ToString(),
"DatabaseName=" + config.DatabaseName,
"Login=" + config.Login,
"Provider=" + HfSqlConnectionOptions.OleDbProvider,
"DatabasePasswordPresent=" + (config.DatabasePasswordPresent ? "YES" : "NO"),
"ProtectionStdPresent=" + (config.ProtectionStdPresent ? "YES" : "NO"),
"ProtectionPlusPresent=" + (config.ProtectionPlusPresent ? "YES" : "NO")
};
}
// ================================================================
// SELECT (lecture seule)
// ================================================================
public static bool IsValidTableIdentifier(string name)
{
if (string.IsNullOrEmpty(name))
{
return false;
}
foreach (char c in name)
{
bool ok = (c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '_';
if (!ok)
{
return false;
}
}
return true;
}
/// <summary>Unique requête autorisée : <c>SELECT TOP 1 * FROM &lt;table&gt;</c>.</summary>
public static string BuildSelectSql(string table) => $"SELECT TOP 1 * FROM {table}";
private static bool TrySelect(
DbConnection connection,
string table,
out int fieldCount,
out bool hasRows,
out string? hfSqlErrorCode)
{
fieldCount = 0;
hasRows = false;
hfSqlErrorCode = null;
if (!IsValidTableIdentifier(table))
{
return false;
}
try
{
using var command = connection.CreateCommand();
command.CommandText = BuildSelectSql(table);
using var reader = command.ExecuteReader();
fieldCount = reader.FieldCount;
hasRows = reader.Read();
return true;
}
catch (Exception ex)
{
hfSqlErrorCode = ExtractHfSqlErrorCode(ex);
return false;
}
}
// ================================================================
// SANITIZATION (aucun message brut, aucun secret)
// ================================================================
public static string SanitizeErrorType(Exception ex) => ex.GetType().Name;
public static string? ExtractHfSqlErrorCode(Exception ex)
{
var match = HfSqlErrorCodeRegex.Match(ex.Message);
if (!match.Success)
{
return null;
}
return !string.IsNullOrEmpty(match.Groups[1].Value)
? match.Groups[1].Value
: match.Groups[2].Value;
}
private static void PrintUsage(TextWriter output)
{
output.WriteLine("Usage: HfSqlOleDbAudit connection-test <configPath> <Hfsql|LicenseConnection|ServiceConnection> [<table>]");
}
}