436 lines
15 KiB
C#
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 <table></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>]");
|
|
}
|
|
}
|