Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
</Project>
+124
View File
@@ -0,0 +1,124 @@
using System.Reflection;
using System.Text;
using Omika.Data.HfSql.Metadata;
using Omika.Data.HfSql.Protection;
namespace HfSqlModelAudit;
/// <summary>
/// Audite les modèles physiques HFSQL (Batik / Omika) déclarés dans l'assembly
/// Omika.Data. Lit par réflexion l'attribut <see cref="HfSqlTableAttribute"/> de
/// chaque type des namespaces <c>Omika.Data.HfSql.Models.Batik</c> et
/// <c>Omika.Data.HfSql.Models.Omika</c>.
///
/// Génère deux rapports :
/// docs/diagnostics/hfsql-model-audit.csv
/// docs/diagnostics/hfsql-model-audit.md
///
/// Aucune connexion n'est ouverte et aucun secret n'est manipulé.
/// </summary>
public static class Program
{
private const string BatikNamespace = "Omika.Data.HfSql.Models.Batik";
private const string OmikaNamespace = "Omika.Data.HfSql.Models.Omika";
private sealed record ModelInfo(
string Schema,
string LogicalName,
string DiskName,
string Groups,
string SchemaVersions);
public static int Main(string[] args)
{
var outputDir = args.Length > 0 ? args[0] : @"D:\Avalonia\Omika\docs\diagnostics";
Directory.CreateDirectory(outputDir);
var assembly = typeof(HfsqlFilePasswordDeriver).Assembly;
var models = LoadModels(assembly);
var batik = models.Where(m => m.Schema == "Batik").OrderBy(m => m.LogicalName).ToList();
var omika = models.Where(m => m.Schema == "Omika").OrderBy(m => m.LogicalName).ToList();
WriteCsv(Path.Combine(outputDir, "hfsql-model-audit.csv"), models);
WriteMarkdown(Path.Combine(outputDir, "hfsql-model-audit.md"), batik, omika);
Console.WriteLine($"Batik : {batik.Count} tables");
Console.WriteLine($"Omika : {omika.Count} tables");
Console.WriteLine($"Total : {models.Count} tables");
Console.WriteLine("Rapports générés dans " + outputDir);
return 0;
}
private static List<ModelInfo> LoadModels(Assembly assembly)
{
var result = new List<ModelInfo>();
foreach (var type in assembly.GetTypes())
{
string schema;
if (type.Namespace == BatikNamespace)
schema = "Batik";
else if (type.Namespace == OmikaNamespace)
schema = "Omika";
else
continue;
var attr = type.GetCustomAttribute<HfSqlTableAttribute>();
if (attr is null)
continue;
result.Add(new ModelInfo(
schema,
attr.TableName,
attr.DiskName ?? "",
string.Join(",", attr.Groups),
string.Join(",", attr.SchemaVersions)));
}
return result;
}
private static void WriteCsv(string path, List<ModelInfo> models)
{
var sb = new StringBuilder();
sb.AppendLine("Schema,Table,DiskName,Groups,SchemaVersions");
foreach (var m in models.OrderBy(m => m.Schema).ThenBy(m => m.LogicalName))
{
sb.AppendLine($"{m.Schema},{m.LogicalName},{m.DiskName},{m.Groups},{m.SchemaVersions}");
}
File.WriteAllText(path, sb.ToString());
}
private static void WriteMarkdown(string path, List<ModelInfo> batik, List<ModelInfo> omika)
{
var sb = new StringBuilder();
sb.AppendLine("# Audit des modèles physiques HFSQL");
sb.AppendLine();
sb.AppendLine("Généré par `tools/HfSqlModelAudit` à partir de l'assembly `Omika.Data`.");
sb.AppendLine();
sb.AppendLine("## Batik");
sb.AppendLine();
sb.AppendLine($"**{batik.Count} tables**");
sb.AppendLine();
sb.AppendLine("| Table | DiskName | Groups |");
sb.AppendLine("|---|---|---|");
foreach (var m in batik)
{
sb.AppendLine($"| {m.LogicalName} | {m.DiskName} | {m.Groups} |");
}
sb.AppendLine();
sb.AppendLine("## Omika");
sb.AppendLine();
sb.AppendLine($"**{omika.Count} tables**");
sb.AppendLine();
sb.AppendLine("| Table | DiskName | Groups |");
sb.AppendLine("|---|---|---|");
foreach (var m in omika)
{
sb.AppendLine($"| {m.LogicalName} | {m.DiskName} | {m.Groups} |");
}
sb.AppendLine();
File.WriteAllText(path, sb.ToString());
}
}
+435
View File
@@ -0,0 +1,435 @@
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>]");
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Le sous-dossier tests/ héberge un projet de tests xUnit séparé : il ne
doit pas être compilé ni publié par l'exécutable de diagnostic. -->
<DefaultItemExcludes>$(DefaultItemExcludes);tests\**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+784
View File
@@ -0,0 +1,784 @@
using System.Data.OleDb;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Omika.Data.HfSql.Protection;
namespace HfSqlOleDbAudit;
internal static class Program
{
private static int Main(string[] args)
{
// Mode "connection-test" (lecture seule, OLE DB PCSOFT.HFSQL). Dispatch
// ajouté sans modifier le comportement historique ci-dessous : tout autre
// premier argument emprunte exactement le chemin d'origine.
if (args.Length > 0
&& StringComparer.OrdinalIgnoreCase.Equals(args[0], ConnectionTest.CommandName))
{
return ConnectionTest.Run(args);
}
if (args.Length < 2)
{
Console.WriteLine("Usage: HfSqlOleDbAudit <configPath> <Batik|Omika>");
return 1;
}
string configPath = args[0];
string schema = args[1];
// ================================================================
// CONFIG LOADING
// ================================================================
TestConfig? config;
try
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
config = JsonSerializer.Deserialize<TestConfig>(File.ReadAllText(configPath), options);
}
catch
{
config = null;
}
if (config is null)
{
Console.WriteLine("Config JSON: INVALID");
return 1;
}
Console.WriteLine("Config JSON: VALID");
if (string.IsNullOrWhiteSpace(config.ProtectionPlusSecret))
{
Console.WriteLine("ProtectionPlusSecret: MISSING");
return 1;
}
Console.WriteLine("ProtectionPlusSecret: AVAILABLE");
// ================================================================
// DATABASE CHOICE
// ================================================================
DatabaseConfig db = schema.Equals("Batik", StringComparison.OrdinalIgnoreCase)
? config.Batik
: config.Omika;
string connectionDatabase = db.Database;
string passwordDatabaseName = db.OriginalDatabaseName ?? db.Database;
Console.WriteLine("PasswordDatabaseNameSource: " +
(db.OriginalDatabaseName is null ? "CURRENT" : "ORIGINAL"));
// ================================================================
// EXPLICIT LOCAL CONFIG — file passwords come from the local config,
// NOT derived. No derivation, no wildcard.
// ================================================================
string protectionStd = config.FilePasswords.ProtectionStd;
string protectionPlus = config.FilePasswords.ProtectionPlus;
Console.WriteLine("PasswordSource: EXPLICIT_LOCAL_CONFIG");
Console.WriteLine("PasswordDerivationUsed: NO");
Console.WriteLine("ProtectionPlusSecretUsedForConnection: NO");
// ================================================================
// LOAD TABLES
// ================================================================
List<TableInfo> tables = LoadTables(schema);
var results = new Dictionary<string, TableResult>(StringComparer.OrdinalIgnoreCase);
foreach (var t in tables)
{
results[t.LogicalName] = new TableResult { Table = t };
}
// ================================================================
// PHASE 1 — DIRECT
// ================================================================
using (var cn = new OleDbConnection(BuildConnectionString(config, db, null)))
{
cn.Open();
foreach (var t in tables)
{
var r = results[t.LogicalName];
QueryResult qr = TrySelect(cn, t.LogicalName);
if (qr.Success)
{
r.DirectAccess = true;
r.FinalAccess = FinalAccess.Direct;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.DirectAccess = false;
r.FinalAccess = FinalAccess.Unknown;
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
// ================================================================
// PHASE 2 — FAILURES-ONLY EXPLICIT MAP
// ================================================================
string strategy = "FAILURES_ONLY_EXPLICIT_MAP";
List<string>? workingEntries = null;
var failedProtected = tables
.Where(t => !results[t.LogicalName].DirectAccess
&& (t.ExpectedGroup == ExpectedGroup.ProtectionStd
|| t.ExpectedGroup == ExpectedGroup.ProtectionPlus))
.ToList();
if (failedProtected.Count > 0)
{
var entries = new List<string>();
foreach (var t in failedProtected)
{
if (t.ExpectedGroup == ExpectedGroup.ProtectionStd)
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionStd));
else
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionPlus));
}
string ext = string.Join(";", entries);
using (var cn = new OleDbConnection(BuildConnectionString(config, db, ext)))
{
cn.Open();
foreach (var t in failedProtected)
{
var r = results[t.LogicalName];
QueryResult qr = TrySelect(cn, t.LogicalName);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = t.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
workingEntries = entries;
}
// ================================================================
// CANARY CHECK — if ParametreMetier passes, STD is verified.
// ================================================================
bool stdVerified = false;
bool plusVerified = false;
if (results.TryGetValue("ParametreMetier", out var pmFinal)
&& !pmFinal.DirectAccess && pmFinal.ProtectedAccess)
{
stdVerified = true;
}
if ((results.TryGetValue("ParametreMetierEntete", out var pmeFinal)
&& !pmeFinal.DirectAccess && pmeFinal.ProtectedAccess)
|| (results.TryGetValue("ParametreSysteme", out var psFinal)
&& !psFinal.DirectAccess && psFinal.ProtectedAccess))
{
plusVerified = true;
}
// ================================================================
// STRATEGY 2 — FULL EXPLICIT MAP (if ParametreMetier still failed)
// ================================================================
bool pmStillFailed = results.TryGetValue("ParametreMetier", out var pmCheck)
&& pmCheck.FinalAccess != FinalAccess.Direct
&& pmCheck.FinalAccess != FinalAccess.ProtectionStd;
if (pmStillFailed)
{
strategy = "FULL_EXPLICIT_MAP";
var entries = new List<string>();
foreach (var t in tables)
{
if (t.ExpectedGroup == ExpectedGroup.ProtectionStd)
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionStd));
else if (t.ExpectedGroup == ExpectedGroup.ProtectionPlus)
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionPlus));
}
string ext = string.Join(";", entries);
using (var cn = new OleDbConnection(BuildConnectionString(config, db, ext)))
{
cn.Open();
foreach (var name in new[] { "Client", "ParametreMetier", "ParametreMetierEntete", "ParametreSysteme" })
{
if (results.TryGetValue(name, out var r) && !r.DirectAccess)
{
QueryResult qr = TrySelect(cn, name);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = r.Table.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
foreach (var t in tables.Where(t => !results[t.LogicalName].DirectAccess
&& results[t.LogicalName].FinalAccess == FinalAccess.Unknown))
{
var r = results[t.LogicalName];
QueryResult qr = TrySelect(cn, t.LogicalName);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = t.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
workingEntries = entries;
// Re-evaluate canaries after strategy 2
if (results.TryGetValue("ParametreMetier", out var pm2)
&& !pm2.DirectAccess && pm2.ProtectedAccess)
{
stdVerified = true;
}
if ((results.TryGetValue("ParametreMetierEntete", out var pme2)
&& !pme2.DirectAccess && pme2.ProtectedAccess)
|| (results.TryGetValue("ParametreSysteme", out var ps2)
&& !ps2.DirectAccess && ps2.ProtectedAccess))
{
plusVerified = true;
}
}
// ================================================================
// STRATEGY 3 — WILDCARD_PLUS_OVERRIDES (if ParametreMetier still failed)
// ================================================================
bool pmStillFailed2 = results.TryGetValue("ParametreMetier", out var pmCheck2)
&& pmCheck2.FinalAccess != FinalAccess.Direct
&& pmCheck2.FinalAccess != FinalAccess.ProtectionStd;
if (pmStillFailed2)
{
strategy = "WILDCARD_PLUS_OVERRIDES";
var entries = new List<string>();
// Wildcard first: applies ProtectionStd to all files.
entries.Add(BuildFilePasswordEntry("*", protectionStd));
// Then explicit overrides for each ProtectionPlus file.
foreach (var t in tables.Where(t => t.ExpectedGroup == ExpectedGroup.ProtectionPlus))
{
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionPlus));
}
string ext = string.Join(";", entries);
using (var cn = new OleDbConnection(BuildConnectionString(config, db, ext)))
{
cn.Open();
foreach (var name in new[] { "Client", "ParametreMetier", "ParametreMetierEntete", "ParametreSysteme" })
{
if (results.TryGetValue(name, out var r) && !r.DirectAccess)
{
QueryResult qr = TrySelect(cn, name);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = r.Table.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
foreach (var t in tables.Where(t => !results[t.LogicalName].DirectAccess
&& results[t.LogicalName].FinalAccess == FinalAccess.Unknown))
{
var r = results[t.LogicalName];
QueryResult qr = TrySelect(cn, t.LogicalName);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = t.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
workingEntries = entries;
// Re-evaluate canaries after strategy 3
if (results.TryGetValue("ParametreMetier", out var pm3)
&& !pm3.DirectAccess && pm3.ProtectedAccess)
{
stdVerified = true;
}
if ((results.TryGetValue("ParametreMetierEntete", out var pme3)
&& !pme3.DirectAccess && pme3.ProtectedAccess)
|| (results.TryGetValue("ParametreSysteme", out var ps3)
&& !ps3.DirectAccess && ps3.ProtectedAccess))
{
plusVerified = true;
}
}
// ================================================================
// NEGATIVE CONTROL
// ================================================================
bool negativeControlRun = false;
bool negativeControlPass = false;
var protectedPassed = tables.FirstOrDefault(t => results[t.LogicalName].ProtectedAccess);
if (protectedPassed is not null && workingEntries is not null)
{
negativeControlRun = true;
string fakePassword = "00000000000000000000000000000000";
string chosenName = protectedPassed.LogicalName;
var fakeEntries = new List<string>(workingEntries);
string escapedChosen = EscapeExtendedPropertyValue(chosenName);
fakeEntries.RemoveAll(e => e.StartsWith("Password=" + escapedChosen + ":"));
fakeEntries.Add(BuildFilePasswordEntry(chosenName, fakePassword));
string fakeExt = string.Join(";", fakeEntries);
using (var cn = new OleDbConnection(BuildConnectionString(config, db, fakeExt)))
{
cn.Open();
QueryResult qr = TrySelect(cn, chosenName);
negativeControlPass = !qr.Success;
}
}
// ================================================================
// FINALIZE — mark any table still unresolved as Failed
// ================================================================
foreach (var t in tables)
{
var r = results[t.LogicalName];
if (r.FinalAccess == FinalAccess.Unknown)
{
r.FinalAccess = FinalAccess.Failed;
}
}
// ================================================================
// CONSOLE OUTPUT
// ================================================================
Console.WriteLine();
Console.WriteLine($"=== {schema} ===");
Console.WriteLine();
foreach (var t in tables)
{
var r = results[t.LogicalName];
Console.WriteLine("LogicalFile: " + t.LogicalName);
Console.WriteLine("ExpectedGroup: " + t.ExpectedGroup);
Console.WriteLine("DirectAccess: " + r.DirectAccess);
Console.WriteLine("ProtectedAccess: " + r.ProtectedAccess);
Console.WriteLine("FinalAccess: " + GetFinalAccessLabel(r.FinalAccess));
Console.WriteLine("FieldCount: " + r.FieldCount);
Console.WriteLine("HasRows: " + r.HasRows);
Console.WriteLine("HfSqlErrorCode: " + (r.HfSqlErrorCode ?? "NONE"));
Console.WriteLine();
}
int directCount = tables.Count(t => results[t.LogicalName].DirectAccess);
int stdCount = tables.Count(t => results[t.LogicalName].FinalAccess == FinalAccess.ProtectionStd);
int plusCount = tables.Count(t => results[t.LogicalName].FinalAccess == FinalAccess.ProtectionPlus);
int failedCount = tables.Count(t => results[t.LogicalName].FinalAccess == FinalAccess.Failed);
Console.WriteLine("=== SUMMARY ===");
Console.WriteLine($"Tables: {tables.Count}");
Console.WriteLine($"Direct: {directCount}");
Console.WriteLine($"ProtectionStd: {stdCount}");
Console.WriteLine($"ProtectionPlus: {plusCount}");
Console.WriteLine($"Failed: {failedCount}");
Console.WriteLine();
Console.WriteLine($"ParametreMetier: {GetCanaryLabel(results, "ParametreMetier")}");
Console.WriteLine($"ParametreMetierEntete: {GetCanaryLabel(results, "ParametreMetierEntete")}");
Console.WriteLine($"ParametreSysteme: {GetCanaryLabel(results, "ParametreSysteme")}");
Console.WriteLine();
Console.WriteLine($"Strategy: {strategy}");
Console.WriteLine($"ProtectionStd OLE DB: {(stdVerified ? "VERIFIED_FROM_DATABASE" : "UNRESOLVED")}");
Console.WriteLine($"ProtectionPlus OLE DB: {(plusVerified ? "VERIFIED_FROM_DATABASE" : "UNRESOLVED")}");
Console.WriteLine($"NegativeControl: {(negativeControlRun ? (negativeControlPass ? "PASS" : "FAIL") : "NOT_RUN")}");
// ================================================================
// CSV REPORT
// ================================================================
string csvPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "docs", "diagnostics", "hfsql-oledb-open-all.csv");
csvPath = Path.GetFullPath(csvPath);
Directory.CreateDirectory(Path.GetDirectoryName(csvPath)!);
bool csvExists = File.Exists(csvPath);
var csvLines = new List<string>();
if (!csvExists)
{
csvLines.Add("Schema,Table,DiskName,ExpectedGroup,DirectAccess,ProtectedAccess,FinalAccess,FieldCount,HasRows,HfSqlErrorCode");
}
foreach (var t in tables)
{
var r = results[t.LogicalName];
csvLines.Add(string.Join(",",
Csv(schema), Csv(t.LogicalName), Csv(t.DiskName), Csv(t.ExpectedGroup.ToString()),
Csv(r.DirectAccess.ToString()), Csv(r.ProtectedAccess.ToString()),
Csv(GetFinalAccessLabel(r.FinalAccess)),
Csv(r.FieldCount.ToString()), Csv(r.HasRows.ToString()),
Csv(r.HfSqlErrorCode ?? "")));
}
File.AppendAllLines(csvPath, csvLines);
Console.WriteLine($"CSV: CREATED ({csvPath})");
// ================================================================
// MARKDOWN REPORT
// ================================================================
string mdPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "docs", "diagnostics", "hfsql-oledb-open-all.md");
mdPath = Path.GetFullPath(mdPath);
Directory.CreateDirectory(Path.GetDirectoryName(mdPath)!);
var md = new System.Text.StringBuilder();
bool mdExists = File.Exists(mdPath);
if (!mdExists)
{
md.AppendLine("# HFSQL OLE DB Open All — Diagnostic");
md.AppendLine();
}
md.AppendLine($"## {schema}");
md.AppendLine();
md.AppendLine("### Résumé");
md.AppendLine();
md.AppendLine($"- Tables: {tables.Count}");
md.AppendLine($"- Direct: {directCount}");
md.AppendLine($"- ProtectionStd: {stdCount}");
md.AppendLine($"- ProtectionPlus: {plusCount}");
md.AppendLine($"- Failed: {failedCount}");
md.AppendLine();
md.AppendLine($"- ParametreMetier: {GetCanaryLabel(results, "ParametreMetier")}");
md.AppendLine($"- ParametreMetierEntete: {GetCanaryLabel(results, "ParametreMetierEntete")}");
md.AppendLine($"- ParametreSysteme: {GetCanaryLabel(results, "ParametreSysteme")}");
md.AppendLine();
md.AppendLine($"- Strategy: {strategy}");
md.AppendLine($"- ProtectionStd OLE DB: {(stdVerified ? "VERIFIED_FROM_DATABASE" : "UNRESOLVED")}");
md.AppendLine($"- ProtectionPlus OLE DB: {(plusVerified ? "VERIFIED_FROM_DATABASE" : "UNRESOLVED")}");
md.AppendLine($"- NegativeControl: {(negativeControlRun ? (negativeControlPass ? "PASS" : "FAIL") : "NOT_RUN")}");
md.AppendLine();
md.AppendLine("### Canaris");
md.AppendLine();
md.AppendLine($"- ParametreMetier: {GetCanaryLabel(results, "ParametreMetier")}");
md.AppendLine($"- ParametreMetierEntete: {GetCanaryLabel(results, "ParametreMetierEntete")}");
md.AppendLine($"- ParametreSysteme: {GetCanaryLabel(results, "ParametreSysteme")}");
md.AppendLine();
md.AppendLine("### Échecs");
md.AppendLine();
foreach (var t in tables.Where(t => results[t.LogicalName].FinalAccess == FinalAccess.Failed))
{
var r = results[t.LogicalName];
md.AppendLine($"- {t.LogicalName} (Expected={t.ExpectedGroup}, HfSql={r.HfSqlErrorCode ?? "NONE"})");
}
md.AppendLine();
File.AppendAllText(mdPath, md.ToString());
Console.WriteLine($"MD: CREATED ({mdPath})");
return 0;
}
// ================================================================
// HELPERS
// ================================================================
private static string GetCanaryLabel(Dictionary<string, TableResult> results, string name)
{
if (!results.TryGetValue(name, out var r)) return "FAILED";
return r.FinalAccess switch
{
FinalAccess.Direct => "DIRECT",
FinalAccess.ProtectionStd => "PROTECTION_STD",
FinalAccess.ProtectionPlus => "PROTECTION_PLUS",
FinalAccess.Failed => "FAILED",
_ => "FAILED"
};
}
private static string GetFinalAccessLabel(FinalAccess access)
{
return access switch
{
FinalAccess.Direct => "DIRECT",
FinalAccess.ProtectionStd => "PROTECTION_STD",
FinalAccess.ProtectionPlus => "PROTECTION_PLUS",
FinalAccess.Failed => "FAILED",
_ => "UNKNOWN"
};
}
private static string Csv(string? value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return "\"" + value.Replace("\"", "\"\"") + "\"";
return value;
}
// ================================================================
// MODEL LOADING (reflection)
// ================================================================
private static List<TableInfo> LoadTables(string schema)
{
var assembly = typeof(HfsqlFilePasswordDeriver).Assembly;
string ns = schema.Equals("Batik", StringComparison.OrdinalIgnoreCase)
? "Omika.Data.HfSql.Models.Batik"
: "Omika.Data.HfSql.Models.Omika";
var tables = new List<TableInfo>();
foreach (var type in assembly.GetTypes())
{
if (type.Namespace != ns) continue;
var attr = type.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "HfSqlTableAttribute");
if (attr is null) continue;
string tableName = GetAttrProperty(attr, "TableName") ?? type.Name;
string diskName = GetAttrProperty(attr, "DiskName") ?? tableName + ".fic";
string[] groups = GetAttrGroups(attr);
bool hasStd = groups.Any(g => g.Equals("ProtectionStd", StringComparison.OrdinalIgnoreCase));
bool hasPlus = groups.Any(g => g.Equals("ProtectionPlus", StringComparison.OrdinalIgnoreCase));
ExpectedGroup expected;
if (hasStd && hasPlus) expected = ExpectedGroup.Conflict;
else if (hasPlus) expected = ExpectedGroup.ProtectionPlus;
else if (hasStd) expected = ExpectedGroup.ProtectionStd;
else expected = ExpectedGroup.None;
tables.Add(new TableInfo
{
LogicalName = tableName,
DiskName = diskName,
ExpectedGroup = expected
});
}
return tables.OrderBy(t => t.LogicalName).ToList();
}
private static string? GetAttrProperty(object attr, string propName)
{
var prop = attr.GetType().GetProperty(propName);
if (prop is null) return null;
var val = prop.GetValue(attr);
return val?.ToString();
}
private static string[] GetAttrGroups(object attr)
{
var prop = attr.GetType().GetProperty("Groups");
if (prop is null) return [];
var val = prop.GetValue(attr);
if (val is string[] arr) return arr;
if (val is IEnumerable<string> en) return en.ToArray();
return [];
}
// ================================================================
// CONNECTION STRING / EXTENDED PROPERTIES
// ================================================================
private static string EscapeExtendedPropertyValue(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("=", "\\=")
.Replace(";", "\\;")
.Replace(":", "\\:");
}
private static string BuildFilePasswordEntry(string logicalFileName, string password)
{
return "Password=" +
EscapeExtendedPropertyValue(logicalFileName) +
":" +
EscapeExtendedPropertyValue(password);
}
private static string BuildConnectionString(
TestConfig root,
DatabaseConfig database,
string? extendedProperties)
{
var builder = new OleDbConnectionStringBuilder();
builder.Provider = "PCSOFT.HFSQL";
builder.DataSource = root.Server + ":" + root.Port;
builder["Initial Catalog"] = database.Database;
builder["User ID"] = database.User;
builder["Password"] = database.Credential;
if (!string.IsNullOrWhiteSpace(extendedProperties))
{
builder["Extended Properties"] = extendedProperties;
}
return builder.ConnectionString;
}
// ================================================================
// QUERY
// ================================================================
private static QueryResult TrySelect(OleDbConnection connection, string logicalName)
{
if (!IsValidIdentifier(logicalName))
{
return new QueryResult(false, 0, false, null);
}
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SELECT TOP 1 * FROM {logicalName}";
using var reader = cmd.ExecuteReader();
int fieldCount = reader.FieldCount;
bool hasRows = reader.Read();
return new QueryResult(true, fieldCount, hasRows, null);
}
catch (OleDbException ex)
{
return new QueryResult(false, 0, false, ExtractHfSqlErrorCode(ex));
}
}
private static bool IsValidIdentifier(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;
}
private static string? ExtractHfSqlErrorCode(OleDbException ex)
{
string msg = ex.Message;
var m = Regex.Match(msg, @"Code\s+erreur\s*:\s*(\d+)");
if (!m.Success)
m = Regex.Match(msg, @"Error\s+code\s*:\s*(\d+)");
return m.Success ? m.Groups[1].Value : null;
}
// ================================================================
// STRUCTURES
// ================================================================
private sealed record QueryResult(
bool Success,
int FieldCount,
bool HasRows,
string? HfSqlErrorCode);
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string ProtectionStd { get; set; } = "";
public string ProtectionPlus { get; set; } = "";
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private enum ExpectedGroup
{
None,
ProtectionStd,
ProtectionPlus,
Conflict
}
private enum FinalAccess
{
Unknown,
Direct,
ProtectionStd,
ProtectionPlus,
Failed
}
private sealed class TableInfo
{
public string LogicalName { get; init; } = "";
public string DiskName { get; init; } = "";
public ExpectedGroup ExpectedGroup { get; init; }
}
private sealed class TableResult
{
public required TableInfo Table { get; init; }
public bool DirectAccess { get; set; }
public bool ProtectedAccess { get; set; }
public FinalAccess FinalAccess { get; set; }
public int FieldCount { get; set; }
public bool HasRows { get; set; }
public string? HfSqlErrorCode { get; set; }
}
}
@@ -0,0 +1,463 @@
using HfSqlOleDbAudit;
namespace HfSqlOleDbAudit.Tests;
/// <summary>
/// Tests unitaires du mode <c>connection-test</c> (sans serveur HFSQL réel).
/// Couvre : parsing des 3 sections, rejet de section inconnue, non-affichage des
/// secrets, absence de chaîne de connexion complète, validateur d'identifiant,
/// requête SELECT lecture seule, absence d'écriture de fichier, codes de sortie,
/// et préservation du mode historique.
/// </summary>
public sealed class ConnectionTestTests
{
// ================================================================
// 1-3. Parsing des sections
// ================================================================
[Fact]
public void LoadConfig_Hfsql_ParsesAllFields()
{
const string json = """
{
"Hfsql": {
"Server": "SRV-OMIKA",
"Port": 4900,
"DatabaseName": "Omika",
"Login": "compte_tech",
"Password": "p4ss",
"FilePasswords": { "ProtectionStd": "std", "ProtectionPlus": "plus" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.Hfsql, out var config, out _));
Assert.NotNull(config);
Assert.Equal("SRV-OMIKA", config.Server);
Assert.Equal(4900, config.Port);
Assert.Equal("Omika", config.DatabaseName);
Assert.Equal("compte_tech", config.Login);
Assert.True(config.DatabasePasswordPresent);
Assert.True(config.ProtectionStdPresent);
Assert.True(config.ProtectionPlusPresent);
// Parité Production : la vue OLE DB porte les mêmes valeurs.
Assert.Equal("SRV-OMIKA", config.ConnectionOptions.Server);
Assert.Equal(4900, config.ConnectionOptions.Port);
Assert.Equal("Omika", config.ConnectionOptions.Database);
Assert.Equal("compte_tech", config.ConnectionOptions.User);
});
}
[Fact]
public void LoadConfig_LicenseConnection_ParsesAllFields()
{
const string json = """
{
"LicenseConnection": {
"Server": "SRV-OMIKA",
"Port": 4900,
"DatabaseName": "Licence",
"Login": "licence_user",
"Password": "licpass",
"FilePasswords": { "ProtectionStd": "std", "ProtectionPlus": "plus" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.LicenseConnection, out var config, out _));
Assert.NotNull(config);
Assert.Equal("SRV-OMIKA", config.Server);
Assert.Equal(4900, config.Port);
Assert.Equal("Licence", config.DatabaseName);
Assert.Equal("licence_user", config.Login);
Assert.True(config.DatabasePasswordPresent);
Assert.True(config.ProtectionStdPresent);
Assert.True(config.ProtectionPlusPresent);
});
}
[Fact]
public void LoadConfig_ServiceConnection_ParsesAllFields_WithOptionalPassword()
{
const string json = """
{
"ServiceConnection": {
"Server": "SRV-OMIKA",
"Port": 4900,
"DatabaseName": "Service",
"Login": "service_user",
"Password": "",
"FilePasswords": { "ProtectionStd": "", "ProtectionPlus": "" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.ServiceConnection, out var config, out _));
Assert.NotNull(config);
Assert.Equal("SRV-OMIKA", config.Server);
Assert.Equal(4900, config.Port);
Assert.Equal("Service", config.DatabaseName);
Assert.Equal("service_user", config.Login);
// Mot de passe Service optionnel : absent ici.
Assert.False(config.DatabasePasswordPresent);
Assert.False(config.ProtectionStdPresent);
Assert.False(config.ProtectionPlusPresent);
});
}
// ================================================================
// 4. Section inconnue rejetée
// ================================================================
[Theory]
[InlineData("Batik")]
[InlineData("SyncConnection")]
[InlineData("")]
[InlineData("hfsqlx")]
public void TryParseSection_UnknownSection_ReturnsFalse(string section)
{
Assert.False(ConnectionTest.TryParseSection(section, out _));
}
[Theory]
[InlineData("Hfsql")]
[InlineData("hfsql")]
[InlineData("LicenseConnection")]
[InlineData("ServiceConnection")]
public void TryParseSection_SupportedSection_ReturnsTrue(string section)
{
Assert.True(ConnectionTest.TryParseSection(section, out _));
}
[Fact]
public void Run_UnknownSection_ReturnsExit2()
{
using var sw = new StringWriter();
int code = ConnectionTest.Run(new[] { "connection-test", "C:\\whatever.json", "Bogus" }, sw);
Assert.Equal(2, code);
Assert.Contains("UNSUPPORTED_SECTION", sw.ToString());
}
// ================================================================
// 5-8. Aucun secret / aucune chaîne de connexion affichés
// ================================================================
[Fact]
public void BuildSummaryLines_NeverPrintsSecrets()
{
const string password = "SUP3R-S3CR3T-DBP4SS";
const string std = "STD-SECRET";
const string plus = "PLUS-SECRET";
string json = $$"""
{
"Hfsql": {
"Server": "SRV-OMIKA", "Port": 4900, "DatabaseName": "Omika", "Login": "user",
"Password": "{{password}}",
"FilePasswords": { "ProtectionStd": "{{std}}", "ProtectionPlus": "{{plus}}" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.Hfsql, out var config, out _));
string all = string.Join('\n', ConnectionTest.BuildSummaryLines(config!));
Assert.DoesNotContain(password, all);
Assert.DoesNotContain(std, all);
Assert.DoesNotContain(plus, all);
});
}
[Fact]
public void BuildSummaryLines_NeverPrintsFullConnectionString()
{
const string json = """
{
"Hfsql": {
"Server": "SRV-OMIKA", "Port": 4900, "DatabaseName": "Omika", "Login": "user",
"Password": "secret-db",
"FilePasswords": { "ProtectionStd": "secret-std", "ProtectionPlus": "secret-plus" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.Hfsql, out var config, out _));
var lines = ConnectionTest.BuildSummaryLines(config!);
foreach (string line in lines)
{
Assert.DoesNotContain(";", line); // séparateur de chaîne de connexion
Assert.DoesNotContain("Password=", line);
Assert.DoesNotContain("Data Source", line);
Assert.DoesNotContain("Initial Catalog", line);
Assert.DoesNotContain("Extended Properties", line);
Assert.DoesNotContain("User ID", line);
}
});
}
[Fact]
public void BuildSummaryLines_ShowsPresenceFlagsOnly()
{
const string json = """
{
"Hfsql": {
"Server": "SRV-OMIKA", "Port": 4900, "DatabaseName": "Omika", "Login": "user",
"Password": "x",
"FilePasswords": { "ProtectionStd": "y", "ProtectionPlus": "z" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.Hfsql, out var config, out _));
var lines = ConnectionTest.BuildSummaryLines(config!);
Assert.Contains("DatabasePasswordPresent=YES", lines);
Assert.Contains("ProtectionStdPresent=YES", lines);
Assert.Contains("ProtectionPlusPresent=YES", lines);
Assert.Contains("Provider=PCSOFT.HFSQL", lines);
Assert.Contains("Section=Hfsql", lines);
});
}
// ================================================================
// 9. Identifiant de table
// ================================================================
[Theory]
[InlineData("ParametreMetier")]
[InlineData("ParametreMetierEntete")]
[InlineData("ParametreSysteme")]
[InlineData("TOSRV")]
[InlineData("table_1")]
[InlineData("A1_b2")]
public void IsValidTableIdentifier_Valid(string name)
{
Assert.True(ConnectionTest.IsValidTableIdentifier(name));
}
[Theory]
[InlineData("")]
[InlineData("ParametreMetier; DROP TABLE")]
[InlineData("foo bar")]
[InlineData("foo-bar")]
[InlineData("foo.bar")]
[InlineData("foo/bar")]
[InlineData("foo\\bar")]
public void IsValidTableIdentifier_Invalid(string name)
{
Assert.False(ConnectionTest.IsValidTableIdentifier(name));
}
[Fact]
public void Run_InvalidTable_ReturnsExit2()
{
using var sw = new StringWriter();
int code = ConnectionTest.Run(new[] { "connection-test", "C:\\whatever.json", "Hfsql", "Bad;Table" }, sw);
Assert.Equal(2, code);
Assert.Contains("INVALID_TABLE_IDENTIFIER", sw.ToString());
}
// ================================================================
// 10. Aucune commande SQL d'écriture
// ================================================================
[Fact]
public void BuildSelectSql_IsReadOnlySelect()
{
Assert.Equal("SELECT TOP 1 * FROM ParametreMetier", ConnectionTest.BuildSelectSql("ParametreMetier"));
}
[Fact]
public void ConnectionTestSource_HasNoWriteSql()
{
string source = File.ReadAllText(RepoFile("tools", "HfSqlOleDbAudit", "ConnectionTest.cs"));
string[] forbidden =
{
"ExecuteNonQuery",
"INSERT",
"UPDATE",
"DELETE",
"MERGE",
"TRUNCATE",
"CREATE TABLE",
"DROP TABLE",
"ALTER TABLE",
"CREATE DATABASE",
"DROP DATABASE"
};
foreach (string token in forbidden)
{
Assert.DoesNotContain(token, source, StringComparison.OrdinalIgnoreCase);
}
}
// ================================================================
// 11. Aucune écriture de fichier dans connection-test
// ================================================================
[Fact]
public void ConnectionTestSource_HasNoFileWrite()
{
string source = File.ReadAllText(RepoFile("tools", "HfSqlOleDbAudit", "ConnectionTest.cs"));
string[] forbidden =
{
"File.Write",
"File.Append",
"File.Copy",
"File.Move",
"File.Create",
"File.Delete",
"File.OpenWrite",
"WriteAllText",
"WriteAllLines",
"WriteAllBytes",
"AppendAllText",
"AppendAllLines",
"Directory.CreateDirectory",
"FileStream",
"StreamWriter"
};
foreach (string token in forbidden)
{
Assert.DoesNotContain(token, source, StringComparison.OrdinalIgnoreCase);
}
}
// ================================================================
// 12-13. Codes de sortie
// ================================================================
[Fact]
public void Run_MissingConfigFile_ReturnsExit2()
{
using var sw = new StringWriter();
string missing = Path.Combine(Path.GetTempPath(), "hfsql_audit_missing_" + Guid.NewGuid().ToString("N") + ".json");
int code = ConnectionTest.Run(new[] { "connection-test", missing, "Hfsql" }, sw);
Assert.Equal(2, code);
Assert.Contains("CONFIG_FILE_NOT_FOUND", sw.ToString());
}
[Fact]
public void Run_MissingSection_ReturnsExit2()
{
const string json = """{ "Unrelated": { "x": 1 } }""";
WithTempJson(json, path =>
{
using var sw = new StringWriter();
int code = ConnectionTest.Run(new[] { "connection-test", path, "Hfsql" }, sw);
Assert.Equal(2, code);
Assert.Contains("SECTION_NOT_FOUND", sw.ToString());
});
}
[Fact]
public void Run_MissingArguments_ReturnsExit2()
{
using var sw = new StringWriter();
Assert.Equal(2, ConnectionTest.Run(new[] { "connection-test" }, sw));
Assert.Equal(2, ConnectionTest.Run(new[] { "connection-test", "C:\\x.json" }, sw));
}
[Fact]
public void Run_InvalidConfigPath_DoesNotEchoExceptionMessage()
{
using var sw = new StringWriter();
int code = ConnectionTest.Run(new[] { "connection-test", "\u0000bad\0path.json", "Hfsql" }, sw);
Assert.Equal(2, code);
// Aucun message d'exception brut (peut contenir des fragments sensibles).
Assert.DoesNotContain("at System.", sw.ToString());
}
// ================================================================
// Sanitization / erreurs
// ================================================================
[Fact]
public void ExtractHfSqlErrorCode_ReadsNumericCodeOnly()
{
Assert.Equal("70001", ConnectionTest.ExtractHfSqlErrorCode(new Exception("Échec : Code erreur : 70001 dans l'accès")));
Assert.Equal("42", ConnectionTest.ExtractHfSqlErrorCode(new Exception("Error code: 42")));
Assert.Null(ConnectionTest.ExtractHfSqlErrorCode(new Exception("aucun code ici")));
}
[Fact]
public void SanitizeErrorType_ReturnsTypeNameOnly()
{
Assert.Equal("InvalidOperationException", ConnectionTest.SanitizeErrorType(new InvalidOperationException("secret-message")));
}
// ================================================================
// 14. Mode historique inchangé
// ================================================================
[Fact]
public void HistoricalAuditMode_IsUnchanged()
{
string source = File.ReadAllText(RepoFile("tools", "HfSqlOleDbAudit", "Program.cs"));
// Marqueurs du mode historique toujours présents.
Assert.Contains("=== SUMMARY ===", source);
Assert.Contains("CSV: CREATED", source);
Assert.Contains("MD: CREATED", source);
Assert.Contains("LoadTables", source);
Assert.Contains("Usage: HfSqlOleDbAudit <configPath> <Batik|Omika>", source);
// Le dispatch connection-test est le seul ajout en tête de Main.
Assert.Contains(ConnectionTest.CommandName, source);
}
// ================================================================
// Helpers
// ================================================================
private static void WithTempJson(string json, Action<string> action)
{
string path = Path.Combine(Path.GetTempPath(), "hfsql_audit_ct_" + Guid.NewGuid().ToString("N") + ".json");
try
{
File.WriteAllText(path, json);
action(path);
}
finally
{
File.Delete(path);
}
}
private static string RepoFile(params string[] parts)
{
string relative = Path.Combine(parts);
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
string candidate = Path.Combine(dir.FullName, relative);
if (File.Exists(candidate))
{
return candidate;
}
dir = dir.Parent;
}
throw new InvalidOperationException("Fichier introuvable depuis la racine du dépôt : " + relative);
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<!--
Tests du mode "connection-test" de HfSqlOleDbAudit SANS serveur HFSQL réel :
parsing de configuration, rejet de section inconnue, non-affichage des secrets,
validateur d'identifiant de table, requête SELECT lecture seule, absence
d'écriture de fichier, codes de sortie et préservation du mode historique.
-->
<ItemGroup>
<ProjectReference Include="..\..\HfSqlOleDbAudit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.ClientSnapshotProbe</AssemblyName>
<RootNamespace>Omika.ClientSnapshotProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+132
View File
@@ -0,0 +1,132 @@
// Probe LECTURE SEULE ciblé : chargement Clients du snapshot mobile.
//
// Reproduit EXACTEMENT le chemin du snapshot Clients corrigé :
// MobileSnapshotService.LoadClientsAsync
// -> ClientService.SearchDetailsAsync
// -> BatikClientRepository.SearchDetailsAsync (bulk, pas de N+1)
//
// Le probe compte le nombre de requêtes HFSQL exécutées pour prouver que le
// chargement est bulk (nombre borné, indépendant du nombre de clients) et
// qu'aucun GetById par client n'est émis.
//
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée.
using Microsoft.Extensions.Configuration;
using Omika.Core.Contracts.Clients;
using Omika.Core.Contracts.Common;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Interfaces;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Repositories.Batik;
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
var configPath = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"config", "hfsql-test.local.json"));
var config = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var hfsqlOptions = new HfsqlOptions();
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
Console.WriteLine($"=== Probe Client Snapshot (base {hfsqlOptions.DatabaseName}) ===");
Console.WriteLine();
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
var factory = new HfSqlOleDbConnectionFactory();
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
// Wrapper compteur de requêtes HFSQL (lecture seule, délègue tout).
var counting = new CountingHfsqlConnection(business);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await counting.OpenAsync();
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
// --- 3. Repository Batik + chemin bulk du snapshot Clients ---
IClientRepository repository = new BatikClientRepository(counting);
var criteria = new ClientSearchCriteria
{
Page = 1,
PageSize = 1000
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
PagedResult<Client> result = await repository.SearchDetailsAsync(criteria, cts.Token);
sw.Stop();
Console.WriteLine();
Console.WriteLine("--- Résultat ---");
Console.WriteLine($"clients chargés (page) = {result.Items.Count}");
Console.WriteLine($"total clients (SearchDetails) = {result.TotalCount}");
Console.WriteLine($"requêtes HFSQL exécutées = {counting.QueryCount}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
// Vérification anti-N+1 : le nombre de requêtes doit être borné et très
// inférieur au nombre de clients. Avec l'ancien pattern N+1, on aurait
// observé ~1 requête par client (18824 requêtes).
if (result.TotalCount > 0 && counting.QueryCount >= result.TotalCount)
{
Console.WriteLine("ATTENTION : le nombre de requêtes est >= au nombre de clients (pattern N+1 suspect).");
}
else
{
Console.WriteLine("OK : chargement bulk (requêtes << clients), aucun GetById par client.");
}
}
catch (Exception ex)
{
sw.Stop();
Console.WriteLine($"ERREUR: {ex.Message}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
}
Console.WriteLine();
Console.WriteLine("Probe Client Snapshot terminé (lecture seule).");
/// <summary>
/// Wrapper IHfsqlConnection qui délègue à la connexion réelle et compte les
/// requêtes de lecture (QueryAsync). Lecture seule : aucune écriture.
/// </summary>
internal sealed class CountingHfsqlConnection : IHfsqlConnection
{
private readonly IHfsqlConnection _inner;
public int QueryCount { get; private set; }
public CountingHfsqlConnection(IHfsqlConnection inner) => _inner = inner;
public HfsqlOptions Options => _inner.Options;
public HfsqlConnectionState State => _inner.State;
public Task OpenAsync(CancellationToken ct = default) => _inner.OpenAsync(ct);
public Task CloseAsync() => _inner.CloseAsync();
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> _inner.ExecuteScalarAsync(sql, parameters, ct);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> _inner.ExecuteNonQueryAsync(sql, parameters, ct);
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
{
QueryCount++;
return await _inner.QueryAsync(sql, parameters, ct);
}
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> _inner.GetColumnsAsync(tableName, ct);
public ValueTask DisposeAsync() => _inner.DisposeAsync();
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.CollaborateurBulkProbe</AssemblyName>
<RootNamespace>Omika.CollaborateurBulkProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
@@ -0,0 +1,132 @@
// Probe LECTURE SEULE ciblé : chargement Collaborateurs du snapshot mobile (bulk).
//
// Reproduit EXACTEMENT le chemin du snapshot Collaborateurs corrigé :
// MobileSnapshotService.LoadCollaborateursAsync
// -> ICollaborateurService.SearchDetailsAsync
// -> BatikCollaborateurRepository.SearchDetailsAsync (bulk, pas de N+1)
//
// Le probe compte le nombre de requêtes HFSQL exécutées pour prouver que le
// chargement est bulk (nombre borné, indépendant du nombre de collaborateurs) et
// qu'aucun GetById par collaborateur n'est émis.
//
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée.
using Microsoft.Extensions.Configuration;
using Omika.Core.Contracts.Common;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Organisation;
using Omika.Core.Interfaces;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Repositories.Batik;
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
var configPath = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"config", "hfsql-test.local.json"));
var config = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var hfsqlOptions = new HfsqlOptions();
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
Console.WriteLine($"=== Probe Collaborateurs Snapshot Bulk (base {hfsqlOptions.DatabaseName}) ===");
Console.WriteLine();
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
var factory = new HfSqlOleDbConnectionFactory();
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
// Wrapper compteur de requêtes HFSQL (lecture seule, délègue tout).
var counting = new CountingHfsqlConnection(business);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await counting.OpenAsync();
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
// --- 3. Repository Batik + chemin bulk du snapshot Collaborateurs ---
ICollaborateurRepository repository = new BatikCollaborateurRepository(counting);
var criteria = new CollaborateurSearchCriteria
{
Page = 1,
PageSize = 1000
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
PagedResult<Collaborateur> result = await repository.SearchDetailsAsync(criteria, cts.Token);
sw.Stop();
Console.WriteLine();
Console.WriteLine("--- Résultat ---");
Console.WriteLine($"collaborateurs chargés (page) = {result.Items.Count}");
Console.WriteLine($"total collaborateurs (SearchDetails) = {result.TotalCount}");
Console.WriteLine($"requêtes HFSQL exécutées = {counting.QueryCount}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
// Vérification anti-N+1 : le nombre de requêtes doit être borné et très
// inférieur au nombre de collaborateurs. Avec l'ancien pattern N+1, on aurait
// observé ~1 requête par collaborateur (timeout HTTP 30s).
if (result.TotalCount > 0 && counting.QueryCount >= result.TotalCount)
{
Console.WriteLine("ATTENTION : le nombre de requêtes est >= au nombre de collaborateurs (pattern N+1 suspect).");
}
else
{
Console.WriteLine("OK : chargement bulk (requêtes << collaborateurs), aucun GetById par collaborateur.");
}
}
catch (Exception ex)
{
sw.Stop();
Console.WriteLine($"ERREUR: {ex.Message}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
}
Console.WriteLine();
Console.WriteLine("Probe Collaborateurs Snapshot Bulk terminé (lecture seule).");
/// <summary>
/// Wrapper IHfsqlConnection qui délègue à la connexion réelle et compte les
/// requêtes de lecture (QueryAsync). Lecture seule : aucune écriture.
/// </summary>
internal sealed class CountingHfsqlConnection : IHfsqlConnection
{
private readonly IHfsqlConnection _inner;
public int QueryCount { get; private set; }
public CountingHfsqlConnection(IHfsqlConnection inner) => _inner = inner;
public HfsqlOptions Options => _inner.Options;
public HfsqlConnectionState State => _inner.State;
public Task OpenAsync(CancellationToken ct = default) => _inner.OpenAsync(ct);
public Task CloseAsync() => _inner.CloseAsync();
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> _inner.ExecuteScalarAsync(sql, parameters, ct);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> _inner.ExecuteNonQueryAsync(sql, parameters, ct);
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
{
QueryCount++;
return await _inner.QueryAsync(sql, parameters, ct);
}
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> _inner.GetColumnsAsync(tableName, ct);
public ValueTask DisposeAsync() => _inner.DisposeAsync();
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+65
View File
@@ -0,0 +1,65 @@
// Probe pour compter les Interventions E2E_TEST dans BATIK_TEST.
// Vérifie qu'une seule Intervention correspond à l'opération E2E.
using System.Data.OleDb;
const string server = "127.0.0.1";
const int port = 4900;
const string user = "susana";
const string password = "123";
string BuildConnStr(string database) =>
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
Console.WriteLine("=== Comptage Interventions E2E_TEST dans BATIK_TEST ===");
try
{
using var conn = new OleDbConnection(BuildConnStr("BATIK_TEST"));
conn.Open();
// Compter toutes les Interventions E2E_TEST.
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM Intervention WHERE IN_RefInterne LIKE 'E2E_TEST%'";
var count = cmd.ExecuteScalar();
Console.WriteLine($" Interventions E2E_TEST (IN_RefInterne LIKE 'E2E_TEST%'): {count}");
}
// Lister les Interventions E2E_TEST avec leurs détails.
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT IN_PKID, IN_RefInterne, IN_Titre, IN_Description FROM Intervention WHERE IN_RefInterne LIKE 'E2E_TEST%'";
using var reader = cmd.ExecuteReader();
Console.WriteLine("\n Détails des Interventions E2E_TEST:");
while (reader.Read())
{
Console.WriteLine($" IN_PKID={reader["IN_PKID"]}, Ref={reader["IN_RefInterne"]}, Titre={reader["IN_Titre"]}, Desc={reader["IN_Description"]}");
}
}
// Compter les MobLog pour l'opération Replay (operationId 0fce297f dans ML_Message JSON).
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM MobLog WHERE ML_Message LIKE '%0fce297f%'";
var count = cmd.ExecuteScalar();
Console.WriteLine($"\n MobLog pour operationId 0fce297f (Replay): {count}");
}
// Lister les MobLog pour l'opération Replay.
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT ML_PKID, ML_DateCreat, ML_Message, ML_Niveau FROM MobLog WHERE ML_Message LIKE '%0fce297f%'";
using var reader = cmd.ExecuteReader();
Console.WriteLine("\n Détails MobLog Replay:");
while (reader.Read())
{
Console.WriteLine($" ML_PKID={reader["ML_PKID"]}, Date={reader["ML_DateCreat"]}, Niveau={reader["ML_Niveau"]}, Msg={reader["ML_Message"]}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR: {ex.Message}");
}
Console.WriteLine("\nProbe comptage terminé.");
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.DiagnosticHashTool</AssemblyName>
<RootNamespace>Omika.DiagnosticHashTool</RootNamespace>
</PropertyGroup>
<!--
Outil hors-ligne de génération de hash PBKDF2-SHA256 pour le COMPTE BOOTSTRAP uniquement
(T23, §39). N'est PAS un endpoint Web anonyme. Utilisé sur une machine admin pour préparer
la valeur DiagnosticsAuth:Operators[].PasswordHash du premier déploiement.
-->
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Diagnostics.Core\Omika.Diagnostics.Core.csproj" />
</ItemGroup>
</Project>
+45
View File
@@ -0,0 +1,45 @@
using Omika.Diagnostics.Security;
// ============================================================
// Omika.DiagnosticHashTool — génération HORS LIGNE d'un hash
// PBKDF2-SHA256 pour le COMPTE BOOTSTRAP Diagnostics.Web (T23 §39).
//
// Usage :
// Omika.DiagnosticHashTool "<mot-de-passe-bootstrap>"
// Omika.DiagnosticHashTool --stdin (lit une ligne sur stdin)
//
// ⚠️ Réservé au bootstrap du PREMIER admin Production. Le mot de passe ne
// doit jamais être placé dans Git / le rapport / appsettings versionné.
// Seul le HASH est ensuite mis dans la configuration / le secret store.
// Préférez --stdin pour éviter de laisser le mot de passe dans
// l'historique du shell.
// ============================================================
string? password;
if (args.Length == 0)
{
Console.Error.WriteLine("Usage: Omika.DiagnosticHashTool \"<mot-de-passe>\" | --stdin");
return 2;
}
if (args[0] == "--stdin")
{
password = Console.ReadLine();
}
else
{
password = args[0];
}
if (string.IsNullOrWhiteSpace(password))
{
Console.Error.WriteLine("Mot de passe vide refusé.");
return 2;
}
string hash = DiagnosticPasswordHasher.Hash(password);
// Affichage du HASH uniquement (jamais le mot de passe).
Console.WriteLine(hash);
return 0;
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
</Project>
+108
View File
@@ -0,0 +1,108 @@
// Probe LECTURE SEULE de l'accès aux fichiers HFSQL nécessaires au CREATE
// Intervention E2E, via BusinessOleDbConnection + HfSqlFileAccessResolver.
// Aucune écriture. Aucun secret affiché.
using System.Text.Json;
using Omika.Data.Configuration;
using Omika.Data.HfSql.Connection;
const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
if (!File.Exists(ConfigPath))
{
Console.WriteLine("CONFIG ABSENTE : " + ConfigPath);
return;
}
var json = File.ReadAllText(ConfigPath);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// --- Charger la section Hfsql (BATIK_TEST) ---
if (!root.TryGetProperty("Hfsql", out var hfsql))
{
Console.WriteLine("SECTION Hfsql ABSENTE dans la config.");
return;
}
var options = new HfsqlOptions
{
ConnectionName = GetString(hfsql, "ConnectionName") ?? "Business",
Server = GetString(hfsql, "Server"),
Port = GetInt(hfsql, "Port") ?? 4900,
DatabaseName = GetString(hfsql, "DatabaseName"),
Login = GetString(hfsql, "Login"),
Password = GetString(hfsql, "Password"),
Driver = GetString(hfsql, "Driver") ?? "HFSQL Client/Server",
ConnectionTimeoutSeconds = GetInt(hfsql, "ConnectionTimeoutSeconds") ?? 30
};
// --- FilePasswords (ProtectionStd / ProtectionPlus) ---
if (hfsql.TryGetProperty("FilePasswords", out var fp))
{
options.FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = GetString(fp, "ProtectionStd"),
ProtectionPlus = GetString(fp, "ProtectionPlus")
};
}
bool protectionStdConfigured = !string.IsNullOrWhiteSpace(options.FilePasswords.ProtectionStd);
Console.WriteLine($"ProtectionStd configured : {(protectionStdConfigured ? "YES" : "NO")}");
Console.WriteLine($"Database : {options.DatabaseName}");
Console.WriteLine($"Server : {options.Server}:{options.Port}");
Console.WriteLine();
// --- Construire les options OLE DB ---
var oleDbOptions = HfSqlConnectionOptionsFactory.Create(options, options.FilePasswords);
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, oleDbOptions);
// --- Fichiers nécessaires au CREATE Intervention Batik ---
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Intervention", ExpectedGroup = "ProtectionStd" },
new() { LogicalFile = "ParametreMetier", ExpectedGroup = "ProtectionStd" },
new() { LogicalFile = "Intervention_Collaborateur", ExpectedGroup = "ProtectionStd" },
new() { LogicalFile = "Intervention_Personne", ExpectedGroup = "ProtectionStd" },
new() { LogicalFile = "EtatIntervention", ExpectedGroup = "ProtectionStd" }
};
try
{
var result = await resolver.ResolveAsync(files);
Console.WriteLine("=== Résolution d'accès (lecture seule) ===");
bool allOk = true;
foreach (var file in files)
{
var profile = result[file.LogicalFile];
string status = profile.ObservedAccess switch
{
HfSqlFileAccessMode.Direct => "DIRECT",
HfSqlFileAccessMode.ProtectionStd => "PROTECTION_STD",
HfSqlFileAccessMode.ProtectionPlus => "PROTECTION_PLUS",
HfSqlFileAccessMode.Failed => "FAILED",
_ => profile.ObservedAccess.ToString()
};
string error = profile.HfSqlErrorCode is null ? "" : $" (HFSQL code {profile.HfSqlErrorCode})";
Console.WriteLine($" {file.LogicalFile,-30} : {status}{error}");
if (profile.ObservedAccess == HfSqlFileAccessMode.Failed)
allOk = false;
}
Console.WriteLine();
Console.WriteLine(allOk
? "RESULTAT : TOUS LES FICHIERS ACCESSIBLES"
: "RESULTAT : AU MOINS UN FICHIER INACCESSIBLE");
}
catch (Exception ex)
{
Console.WriteLine($"ERREUR GLOBALE : {ex.Message}");
Console.WriteLine($" Type : {ex.GetType().FullName}");
}
static string? GetString(JsonElement el, string name)
=> el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
static int? GetInt(JsonElement el, string name)
=> el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : null;
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.FilePasswordProbe</AssemblyName>
<RootNamespace>Omika.FilePasswordProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+140
View File
@@ -0,0 +1,140 @@
// Probe LECTURE SEULE ciblé : reproduit EXACTEMENT le mécanisme de connexion de l'API
// (BusinessOleDbConnection -> HfSqlOleDbConnectionFactory -> BuildExtendedPropertiesFromFilePasswords)
// pour tester l'accès aux tables Client, Lieu, ParametreMetier sur BATIK_TEST.
//
// Aucune écriture. Aucun password affiché. Aucun hash/dérivation.
// Les seules valeurs utilisées sont celles de config/hfsql-test.local.json (Hfsql.FilePasswords).
using System.Data.Common;
using System.Reflection;
using Microsoft.Extensions.Configuration;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.HfSql.Metadata;
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
// Chemin absolu vers config/hfsql-test.local.json à la racine du dépôt.
// Le probe est lancé depuis la racine du dépôt (D:\Avalonia\Omika).
var configPath = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"config", "hfsql-test.local.json"));
var config = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var hfsqlOptions = new HfsqlOptions();
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
bool protectionStdConfigured = !string.IsNullOrWhiteSpace(hfsqlOptions.FilePasswords.ProtectionStd);
bool protectionPlusConfigured = !string.IsNullOrWhiteSpace(hfsqlOptions.FilePasswords.ProtectionPlus);
Console.WriteLine($"=== Probe FilePassword (base {hfsqlOptions.DatabaseName}) ===");
Console.WriteLine($"ProtectionStd configurée : {(protectionStdConfigured ? "OUI" : "NON")}");
Console.WriteLine($"ProtectionPlus configurée : {(protectionPlusConfigured ? "OUI" : "NON")}");
Console.WriteLine();
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
var factory = new HfSqlOleDbConnectionFactory();
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
// --- 3. Tables à tester : nom logique, groupe attendu (modèles physiques), colonne PK ---
var tables = new[]
{
new { Logical = "Client", Group = "ProtectionStd", Column = "CL_PKID" },
new { Logical = "Lieu", Group = "ProtectionStd", Column = "LI_PKID" },
new { Logical = "ParametreMetier", Group = "ProtectionStd", Column = "PA_PKID" },
};
// --- 4. Instrumenter la fabrique pour savoir quels fichiers reçoivent le mot de passe ---
// La fabrique construit les Extended Properties via BuildExtendedPropertiesFromFilePasswords,
// qui dérive désormais la liste des fichiers protégés depuis les modèles physiques
// (attributs HfSqlTable avec Groups). On reproduit cette logique pour déterminer si
// chaque table est couverte par le mot de passe.
var coveredByFactory = GetFactoryCoveredFiles();
Console.WriteLine("--- Couverture des mots de passe par la fabrique (mécanisme API) ---");
foreach (var t in tables)
{
bool covered = coveredByFactory.Contains(t.Logical, StringComparer.OrdinalIgnoreCase);
Console.WriteLine($" {t.Logical,-18} groupe={t.Group,-14} password_applied_par_fabrique={(covered ? "OUI" : "NON")}");
}
Console.WriteLine();
// --- 5. Ouvrir la connexion (même mécanisme que l'API) et tester chaque table ---
try
{
await business.OpenAsync();
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
Console.WriteLine();
foreach (var t in tables)
{
await ProbeTableAsync(business, t.Logical, t.Group, t.Column);
}
}
catch (Exception ex)
{
Console.WriteLine($"ERREUR ouverture connexion: {ex.Message}");
}
Console.WriteLine();
Console.WriteLine("Probe FilePassword terminé (lecture seule).");
// --- Helpers ---
static async Task ProbeTableAsync(
IHfsqlConnection connection,
string logical,
string group,
string column)
{
string sql = $"SELECT TOP 1 {column} FROM {logical}";
Console.WriteLine($"--- {logical} (groupe={group}) ---");
Console.WriteLine($" requête: {sql}");
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var rows = await connection.QueryAsync(sql, cancellationToken: cts.Token);
Console.WriteLine($" résultat: OK (rows={rows.Count})");
}
catch (Exception ex)
{
Console.WriteLine($" résultat: ERREUR");
Console.WriteLine($" message: {ex.Message}");
}
Console.WriteLine();
}
/// <summary>
/// Reproduit la logique de HfSqlOleDbConnectionFactory.BuildExtendedPropertiesFromFilePasswords
/// (dérivée des modèles physiques via les attributs HfSqlTable avec Groups) pour déterminer
/// quels fichiers logiques reçoivent un mot de passe via le mécanisme API.
/// </summary>
static HashSet<string> GetFactoryCoveredFiles()
{
var covered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var type in typeof(HfSqlOleDbConnectionFactory).Assembly.GetTypes())
{
var attribute = type.GetCustomAttribute<HfSqlTableAttribute>();
if (attribute is null)
continue;
foreach (var group in attribute.Groups)
{
if (group.Equals("ProtectionStd", StringComparison.OrdinalIgnoreCase) ||
group.Equals("ProtectionPlus", StringComparison.OrdinalIgnoreCase))
{
covered.Add(attribute.TableName);
}
}
}
return covered;
}
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Omika.Tests")]
@@ -0,0 +1,23 @@
using System.Text.RegularExpressions;
namespace Omika.HfSqlProbe;
internal static partial class ConnectionStringRedactor
{
[GeneratedRegex("PWD=[^;]*", RegexOptions.IgnoreCase)]
private static partial Regex PwdPattern();
[GeneratedRegex("Password=[^;]*", RegexOptions.IgnoreCase)]
private static partial Regex PasswordPattern();
[GeneratedRegex("UID=[^;]*", RegexOptions.IgnoreCase)]
private static partial Regex UidPattern();
public static string Redact(string message)
{
message = PwdPattern().Replace(message, "PWD=***");
message = PasswordPattern().Replace(message, "Password=***");
message = UidPattern().Replace(message, "UID=***");
return message;
}
}
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace Omika.HfSqlProbe;
/// <summary>
/// Root configuration for local HFSQL test databases.
/// Never serialize this object — it contains credentials.
/// </summary>
public sealed class HfSqlTestConfiguration
{
[JsonPropertyName("Server")]
public string Server { get; set; } = string.Empty;
[JsonPropertyName("Port")]
public int Port { get; set; }
[JsonPropertyName("Batik")]
public HfSqlTestDatabaseConfiguration Batik { get; set; } = new();
[JsonPropertyName("Omika")]
public HfSqlTestDatabaseConfiguration Omika { get; set; } = new();
public HfSqlTestDatabaseConfiguration Resolve(string databaseKey)
{
return databaseKey.ToLowerInvariant() switch
{
"batik" => Batik,
"omika" => Omika,
_ => throw new ArgumentException(
$"Unknown database key '{databaseKey}'. Expected 'batik' or 'omika'.")
};
}
}
@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Omika.HfSqlProbe;
/// <summary>
/// Represents the local test configuration for a single HFSQL database.
/// </summary>
public sealed class HfSqlTestDatabaseConfiguration
{
[JsonPropertyName("Database")]
public string Database { get; set; } = string.Empty;
[JsonPropertyName("User")]
public string User { get; set; } = string.Empty;
/// <summary>Server credential; never displayed.</summary>
[JsonPropertyName("Credential")]
public string Credential { get; set; } = string.Empty;
/// <summary>Original database name before cloning, if applicable.</summary>
[JsonPropertyName("OriginalDatabaseName")]
public string? OriginalDatabaseName { get; set; }
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,43 @@
namespace Omika.HfSqlProbe;
internal static class ProbeArgumentParser
{
public static ProbeOptions Parse(ReadOnlySpan<string> args)
{
var options = new ProbeOptions();
for (int i = 0; i < args.Length; i++)
{
string arg = args[i];
if (arg == "--connection-string" && i + 1 < args.Length)
options.ConnectionString = args[++i];
else if (arg == "--allow-write-probe")
options.AllowWriteProbe = true;
else if (arg == "--table" && i + 1 < args.Length)
options.Table = args[++i];
else if (arg == "--id-column" && i + 1 < args.Length)
options.IdColumn = args[++i];
else if (arg == "--marker-column" && i + 1 < args.Length)
options.MarkerColumn = args[++i];
else if (arg == "--schema" && i + 1 < args.Length)
options.Schema = args[++i];
else if (arg == "--database-name" && i + 1 < args.Length)
options.DatabaseName = args[++i];
else if (arg == "--use-client-table")
options.UseClientTable = true;
}
return options;
}
public static string NormalizeCommand(ReadOnlySpan<string> args)
{
if (args.Length == 0) return "";
return args[0].ToLowerInvariant();
}
public static bool IsWriteCommand(string command)
=> command is "transaction" or "returning" or "client-insert" or "client-graph";
public static bool IsValidCommand(string command)
=> command is "capabilities" or "audit" or "transaction" or "returning" or "omika-guid"
or "client-insert" or "client-graph" or "table-exists" or "protected-access" or "--help";
}
+13
View File
@@ -0,0 +1,13 @@
namespace Omika.HfSqlProbe;
internal sealed class ProbeOptions
{
public string? ConnectionString { get; set; }
public bool AllowWriteProbe { get; set; }
public string? Table { get; set; }
public string? IdColumn { get; set; }
public string? MarkerColumn { get; set; }
public string? Schema { get; set; }
public string? DatabaseName { get; set; }
public bool UseClientTable { get; set; }
}
+39
View File
@@ -0,0 +1,39 @@
namespace Omika.HfSqlProbe;
internal static class ProbeSqlBuilder
{
/// <summary>
/// Builds INSERT ... RETURNING &lt;idColumn&gt;.
/// Defensively validates all identifiers before interpolation.
/// </summary>
public static string BuildInsertReturning(string table, string idColumn, string markerColumn)
{
SqlIdentifierValidator.Validate(table);
SqlIdentifierValidator.Validate(idColumn);
SqlIdentifierValidator.Validate(markerColumn);
return $"INSERT INTO {table} ({markerColumn}) VALUES (?) RETURNING {idColumn}";
}
/// <summary>Count rows matching a marker value.</summary>
public static string BuildCountByMarker(string table, string markerColumn)
{
SqlIdentifierValidator.Validate(table);
SqlIdentifierValidator.Validate(markerColumn);
return $"SELECT COUNT(*) FROM {table} WHERE {markerColumn} = ?";
}
/// <summary>Insert a marker row (no RETURNING).</summary>
public static string BuildInsertMarker(string table, string markerColumn)
{
SqlIdentifierValidator.Validate(table);
SqlIdentifierValidator.Validate(markerColumn);
return $"INSERT INTO {table} ({markerColumn}) VALUES (?)";
}
/// <summary>Read GUID samples.</summary>
public static string BuildSelectGuids(int limit)
{
return "SELECT GUID FROM Client WHERE GUID IS NOT NULL AND GUID <> '' " +
$"LIMIT {limit}";
}
}
+704
View File
@@ -0,0 +1,704 @@
using System.Data;
using System.Data.Odbc;
namespace Omika.HfSqlProbe;
public static class Program
{
public static int Main(string[] args)
{
if (args.Length == 0) { PrintUsage(); return 2; }
string command = ProbeArgumentParser.NormalizeCommand(args);
if (!ProbeArgumentParser.IsValidCommand(command)) { Console.Error.WriteLine($"ERROR: unknown command '{command}'."); PrintUsage(); return 2; }
if (command == "--help") { PrintUsage(); return 0; }
var options = ProbeArgumentParser.Parse(args.AsSpan(1));
if (options.ConnectionString is null) { Console.Error.WriteLine("ERROR: --connection-string is required."); return 2; }
if (command == "capabilities") return RunCapabilities(options);
if (command == "audit") return RunAudit(options);
if (command == "table-exists") return RunTableExists(options);
if (command == "protected-access") return RunProtectedAccess(options);
if (ProbeArgumentParser.IsWriteCommand(command))
{
if (!options.AllowWriteProbe) { Console.Error.WriteLine("ERROR: This probe performs temporary writes."); Console.Error.WriteLine(" It must be run ONLY against a clone/test database."); Console.Error.WriteLine(" Add --allow-write-probe to confirm you understand this."); return 2; }
Console.WriteLine("===================================");
Console.WriteLine(" HFSQL ODBC WRITE PROBE");
Console.WriteLine("===================================");
Console.WriteLine();
Console.WriteLine("\u26a0 This probe performs TEMPORARY writes.");
Console.WriteLine(" It MUST run on a clone/test database ONLY.");
Console.WriteLine();
}
if (command == "transaction") return RunTransactionProbe(options);
if (command == "returning") return RunReturningProbe(options);
if (command == "client-insert") return RunClientInsert(options);
if (command == "client-graph") return RunClientGraph(options);
if (command == "omika-guid") return RunOmikaGuidProbe(options);
return 0;
}
static int RunCapabilities(ProbeOptions options)
{
Console.WriteLine("HFSQL ODBC CAPABILITY PROBE");
Console.WriteLine("===========================");
Console.WriteLine();
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
Console.WriteLine("Connection: OPEN");
Console.WriteLine($" Driver: {MaskConnectionProperty(connection.Driver)}");
Console.WriteLine($" DataSource: {MaskConnectionProperty(connection.DataSource)}");
Console.WriteLine($" ServerVersion: {connection.ServerVersion}");
Console.WriteLine($" Database property: {(string.IsNullOrEmpty(connection.Database) ? "(empty / not exposed by driver)" : MaskConnectionProperty(connection.Database))}");
Console.WriteLine();
Console.WriteLine("CONNECTION: PASS");
Console.WriteLine();
Console.WriteLine("── ODBC Metadata ──");
try
{
var tables = connection.GetSchema("Tables");
Console.WriteLine($" ODBC metadata: SUPPORTED");
Console.WriteLine($" Tables: {tables.Rows.Count}");
}
catch (Exception ex)
{
Console.WriteLine($" ODBC metadata: UNSUPPORTED ({ex.GetType().Name})");
}
Console.WriteLine();
Console.WriteLine("── Driver info ──");
using (var tx = connection.BeginTransaction()) { tx.Rollback(); Console.WriteLine(" BeginTransaction + Rollback: OK (API level)"); }
return 0;
}
catch (OdbcException ex) { Console.Error.WriteLine($"CONNECTION FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 3; }
catch (Exception ex) { Console.Error.WriteLine($"CONNECTION FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 3; }
}
static int RunTransactionProbe(ProbeOptions options)
{
if (options.Table is null) { Console.Error.WriteLine("ERROR: --table is required for transaction probe."); return 2; }
int code = ValidateIdentifiers(options.Table, options.IdColumn ?? "PKID", options.MarkerColumn ?? "Marker");
if (code != 0) return code;
string marker = $"PROBE_TX_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
string table = options.Table, markerCol = options.MarkerColumn ?? "Marker";
Console.WriteLine($"Table: {table}");
Console.WriteLine($"Marker: {marker}");
Console.WriteLine();
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
using var tx = connection.BeginTransaction();
Console.WriteLine("BEGIN TRANSACTION: OK");
using (var insertCmd = connection.CreateCommand()) { insertCmd.Transaction = tx; insertCmd.CommandText = ProbeSqlBuilder.BuildInsertMarker(table, markerCol); insertCmd.Parameters.Add(new OdbcParameter { Value = marker }); Console.WriteLine($"INSERT: OK ({insertCmd.ExecuteNonQuery()} row(s))"); }
using (var selCmd = connection.CreateCommand()) { selCmd.Transaction = tx; selCmd.CommandText = ProbeSqlBuilder.BuildCountByMarker(table, markerCol); selCmd.Parameters.Add(new OdbcParameter { Value = marker }); Console.WriteLine($"VISIBLE BEFORE ROLLBACK: {(Convert.ToInt32(selCmd.ExecuteScalar()) > 0 ? "YES" : "NO")}"); }
tx.Rollback();
Console.WriteLine("ROLLBACK: OK");
using (var selCmd2 = connection.CreateCommand()) { selCmd2.CommandText = ProbeSqlBuilder.BuildCountByMarker(table, markerCol); selCmd2.Parameters.Add(new OdbcParameter { Value = marker }); Console.WriteLine($"ABSENT AFTER ROLLBACK: {(Convert.ToInt32(selCmd2.ExecuteScalar()) == 0 ? "YES" : "NO")}"); }
return 0;
}
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
}
static int RunReturningProbe(ProbeOptions options)
{
if (options.Table is null) { Console.Error.WriteLine("ERROR: --table is required for returning probe."); return 2; }
int code = ValidateIdentifiers(options.Table, options.IdColumn ?? "PKID", options.MarkerColumn ?? "Marker");
if (code != 0) return code;
string marker = $"PROBE_RET_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
string table = options.Table, idCol = options.IdColumn ?? "PKID", markerCol = options.MarkerColumn ?? "Marker";
Console.WriteLine($"Table: {table}");
Console.WriteLine($"ID col: {idCol}");
Console.WriteLine($"Marker: {marker}");
Console.WriteLine();
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
Console.WriteLine("-- METHOD A: ExecuteScalar --");
try
{
using var txA = connection.BeginTransaction();
using var cmdA = connection.CreateCommand();
cmdA.Transaction = txA;
cmdA.CommandText = ProbeSqlBuilder.BuildInsertReturning(table, idCol, markerCol);
cmdA.Parameters.Add(new OdbcParameter { Value = marker });
object? scalar = cmdA.ExecuteScalar();
Console.WriteLine($" Accepted: YES");
Console.WriteLine($" CLR type: {scalar?.GetType().Name ?? "null"}");
Console.WriteLine($" Raw value: {scalar}");
if (scalar is long l) Console.WriteLine($" Int64: {l} ({(l > 0 ? "VALID" : "ZERO/INVALID")})");
else if (scalar is not null) { try { Console.WriteLine($" Convert.ToInt64: {Convert.ToInt64(scalar)}"); } catch { Console.WriteLine(" Convert.ToInt64: FAILED"); } }
txA.Rollback();
}
catch (OdbcException ex) { Console.WriteLine($" ERROR: {ConnectionStringRedactor.Redact(ex.Message)}"); }
Console.WriteLine();
Console.WriteLine("-- METHOD B: ExecuteReader --");
try
{
using var txB = connection.BeginTransaction();
using var cmdB = connection.CreateCommand();
cmdB.Transaction = txB;
cmdB.CommandText = ProbeSqlBuilder.BuildInsertReturning(table, idCol, markerCol);
cmdB.Parameters.Add(new OdbcParameter { Value = marker });
using var reader = cmdB.ExecuteReader();
Console.WriteLine($" HasRows: {reader.HasRows}");
Console.WriteLine($" FieldCount: {reader.FieldCount}");
if (reader.Read()) { for (int f = 0; f < reader.FieldCount; f++) Console.WriteLine($" [{f}] {reader.GetName(f)} ({reader.GetDataTypeName(f)}): {reader.GetValue(f)}"); }
reader.Close();
txB.Rollback();
}
catch (OdbcException ex) { Console.WriteLine($" ERROR: {ConnectionStringRedactor.Redact(ex.Message)}"); }
Console.WriteLine();
Console.WriteLine("-- VERIFY ROLLBACK --");
using (var verifyCmd = connection.CreateCommand()) { verifyCmd.CommandText = ProbeSqlBuilder.BuildCountByMarker(table, markerCol); verifyCmd.Parameters.Add(new OdbcParameter { Value = marker }); Console.WriteLine($" Rows with marker: {Convert.ToInt32(verifyCmd.ExecuteScalar())}"); }
return 0;
}
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
}
static string DeriveProtectionStdPassword(string databaseName)
{
byte[] hash = System.Security.Cryptography.MD5.HashData(
System.Text.Encoding.UTF8.GetBytes(databaseName));
return Convert.ToHexString(hash).ToLowerInvariant();
}
static int RunProtectedAccess(ProbeOptions options)
{
if (options.Table is null) { Console.Error.WriteLine("ERROR: --table is required."); return 2; }
string table = SqlIdentifierValidator.Validate(options.Table);
if (string.IsNullOrEmpty(options.DatabaseName)) { Console.Error.WriteLine("ERROR: --database-name is required."); return 2; }
string dbName = options.DatabaseName;
Console.WriteLine($"Table: {table}");
Console.WriteLine($"Database: {dbName}");
Console.WriteLine();
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
Console.WriteLine("Connection: OPEN");
// Step A: try normal access
Console.WriteLine("-- Normal access --");
bool directPassed = false;
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SELECT COUNT(*) FROM {table}";
int count = Convert.ToInt32(cmd.ExecuteScalar());
Console.WriteLine($" ACCESS = UNPROTECTED (rows: {count})");
return 0;
}
catch (OdbcException ex)
{
string msg = ex.Message;
Console.WriteLine($" ERROR: {ConnectionStringRedactor.Redact(msg)}");
Console.WriteLine($" SQLState: {ex.Errors[0]?.SQLState}, NativeError: {ex.Errors[0]?.NativeError}");
Console.WriteLine(" Classification: DIRECT ACCESS FAILED — attempting OPEN FILE fallback");
}
// Step B: password fallback — PC SOFT official ODBC syntax
Console.WriteLine("-- ProtectionStd fallback (OPEN FILE USING) --");
string derived = DeriveProtectionStdPassword(dbName);
// Validate derived password is 32-char hex before any use
if (!System.Text.RegularExpressions.Regex.IsMatch(derived, @"^[0-9a-f]{32}$"))
{
Console.Error.WriteLine(" FATAL: Derived password failed validation.");
return 1;
}
Console.WriteLine($" Password derived: YES (32 hex chars)");
Console.WriteLine($" Password value: [NEVER DISPLAYED]");
// Execute OPEN FILE USING on the same connection — Client/Server form
try
{
using var openCmd = connection.CreateCommand();
// Extract connection properties for Client/Server OPEN FILE
string csDb = connection.Database;
if (string.IsNullOrEmpty(csDb)) csDb = dbName;
string csServer = connection.DataSource;
// Build Client/Server explicit OPEN FILE (sanitized form for reporting)
string openCmdText = $"OPEN FILE {table} SERVER '{csServer}' DATABASE '{csDb}' USER '<supplied>' USING '<redacted>'";
Console.WriteLine($" {openCmdText}");
openCmd.CommandText = $"OPEN FILE {table} SERVER '{csServer}' DATABASE '{csDb}' USER 'susana' USING '{derived}'";
openCmd.ExecuteNonQuery();
Console.WriteLine(" OPEN FILE (C/S): ACCEPTED");
// Retry SELECT
using var retryCmd = connection.CreateCommand();
retryCmd.CommandText = $"SELECT COUNT(*) FROM {table}";
int retryCount = Convert.ToInt32(retryCmd.ExecuteScalar());
Console.WriteLine($" SELECT after OPEN: PASS (rows: {retryCount})");
Console.WriteLine($" ACCESS = PROTECTED — ProtectionStd VERIFIED");
return 0;
}
catch (OdbcException ex2)
{
Console.WriteLine($" OPEN FILE (C/S) error: {ConnectionStringRedactor.Redact(ex2.Message)}");
Console.WriteLine($" SQLState: {ex2.Errors[0]?.SQLState}, NativeError: {ex2.Errors[0]?.NativeError}");
Console.WriteLine(" Classification: OPEN FILE USING FAILED");
return 1;
}
}
catch (OdbcException ex)
{
Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}");
return 1;
}
}
static int RunTableExists(ProbeOptions options)
{
if (options.Table is null) { Console.Error.WriteLine("ERROR: --table is required."); return 2; }
string table = SqlIdentifierValidator.Validate(options.Table);
Console.WriteLine($"Checking: {table}");
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SELECT COUNT(*) FROM {table}";
int count = Convert.ToInt32(cmd.ExecuteScalar());
Console.WriteLine($" EXISTS: YES (rows: {count})");
// Also try GetSchema
try
{
var cols = connection.GetSchema("Columns", [null, null, table, null]);
Console.WriteLine($" GetSchema Columns: {cols.Rows.Count} columns");
foreach (System.Data.DataRow row in cols.Rows)
{
string col = row["COLUMN_NAME"]?.ToString() ?? "?";
string type = row["TYPE_NAME"]?.ToString() ?? "?";
int size = row["COLUMN_SIZE"] is int s ? s : 0;
bool nullable = row["IS_NULLABLE"]?.ToString() == "YES";
Console.WriteLine($" {col,-25} {type,-10} size={size,4} nullable={nullable}");
}
}
catch { Console.WriteLine(" GetSchema Columns: FAILED"); }
return 0;
}
catch (OdbcException ex) { Console.Error.WriteLine($" DOES NOT EXIST or error: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
}
static int RunAudit(ProbeOptions options)
{
Console.WriteLine("HFSQL ODBC PHYSICAL AUDIT");
Console.WriteLine("========================");
Console.WriteLine();
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
Console.WriteLine("Connected.");
Console.WriteLine();
// ── MetaDataCollections ──
Console.WriteLine("── ODBC MetaDataCollections ──");
try
{
var md = connection.GetSchema("MetaDataCollections");
foreach (System.Data.DataRow row in md.Rows)
Console.WriteLine($" {row["CollectionName"]}: {row["NumberOfRestrictions"]} restrictions");
}
catch (Exception ex) { Console.WriteLine($" ERROR: {ex.Message}"); }
Console.WriteLine();
// ── Tables ──
Console.WriteLine("── Tables ──");
var tables = connection.GetSchema("Tables");
var tableNames = new List<string>();
foreach (System.Data.DataRow row in tables.Rows)
{
string? name = row["TABLE_NAME"]?.ToString();
if (!string.IsNullOrEmpty(name)) tableNames.Add(name);
}
tableNames.Sort();
int i = 0;
foreach (var t in tableNames)
Console.WriteLine($" [{++i:D2}] {t}");
Console.WriteLine($" Total: {tableNames.Count}");
Console.WriteLine();
// ── Columns for key tables ──
string[] keyTables = ["Client", "Adresse", "ParametreMetier", "Contact",
"Site", "Client_Adresse", "ClientAdresse", "Client_Lieu", "ClientLieu",
"Lieu", "Personne", "Lieu_Personne", "LieuPersonne", "Compteur"];
foreach (var tbl in keyTables)
{
if (!tableNames.Contains(tbl, StringComparer.OrdinalIgnoreCase)) continue;
Console.WriteLine($"── Columns: {tbl} ──");
try
{
var cols = connection.GetSchema("Columns", [null, null, tbl, null]);
foreach (System.Data.DataRow row in cols.Rows)
{
string colName = row["COLUMN_NAME"]?.ToString() ?? "?";
string typeName = row["TYPE_NAME"]?.ToString() ?? "?";
int colSize = row["COLUMN_SIZE"] is int s ? s : 0;
bool nullable = row["IS_NULLABLE"]?.ToString() == "YES";
Console.WriteLine($" {colName,-30} {typeName,-15} size={colSize,5} nullable={nullable}");
}
}
catch (Exception ex) { Console.WriteLine($" ERROR: {ex.Message}"); }
Console.WriteLine();
}
return 0;
}
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
}
static int RunClientInsert(ProbeOptions options)
{
if (!options.UseClientTable)
{
Console.Error.WriteLine("ERROR: --use-client-table is required for client-insert.");
Console.Error.WriteLine(" This probe writes to the Client table on a CLONE/TEST DB ONLY.");
return 2;
}
bool isOmika = string.Equals(options.Schema, "omika", StringComparison.OrdinalIgnoreCase);
string marker = $"ODBC_PROBE_{DateTime.UtcNow:yyyyMMddHHmmss}";
string markerGuid = isOmika
? Guid.NewGuid().ToString("D").ToLowerInvariant()[..32]
: "";
Console.WriteLine($"Target: {(isOmika ? "OMIKA" : "BATIK")} Client");
Console.WriteLine($"Marker: {marker}");
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
using var tx = connection.BeginTransaction();
Console.WriteLine("BEGIN: OK");
string sql;
var parameters = new List<OdbcParameter>();
if (isOmika)
{
sql = @"INSERT INTO Client (
CL_NOM,
CL_CODECLIENT,
CL_INFOSUP,
CL_PAYS,
CL_CODETVA,
CL_SIREN,
CL_SIRET,
GUID
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?
) RETURNING CL_PKID";
parameters.Add(new OdbcParameter { Value = $"ODBC PROBE {marker}" });
parameters.Add(new OdbcParameter { Value = marker });
parameters.Add(new OdbcParameter { Value = "" });
parameters.Add(new OdbcParameter { Value = "FR" });
parameters.Add(new OdbcParameter { Value = "" });
parameters.Add(new OdbcParameter { Value = "" });
parameters.Add(new OdbcParameter { Value = "" });
parameters.Add(new OdbcParameter { Value = markerGuid });
Console.WriteLine($"GUID candidate: {markerGuid.Length} chars, pattern: {(markerGuid.Count(c => c == '-') == 4 ? "8-4-4-4-8" : "UNEXPECTED")}");
}
else
{
// Minimal BATIK Client INSERT — only columns documented as required
sql = @"INSERT INTO Client (
CL_NOM,
CL_CODECLIENT
) VALUES (
?, ?
) RETURNING CL_PKID";
parameters.Add(new OdbcParameter { Value = $"ODBC PROBE {marker}" });
parameters.Add(new OdbcParameter { Value = marker });
}
using var cmd = connection.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.Parameters.AddRange([.. parameters]);
Console.WriteLine("-- ExecuteScalar --");
object? scalar = cmd.ExecuteScalar();
Console.WriteLine($" Accepted: YES");
Console.WriteLine($" CLR type: {scalar?.GetType().Name ?? "null"}");
Console.WriteLine($" Raw value: {scalar}");
long pkid = 0;
if (scalar is not null)
{
try { pkid = Convert.ToInt64(scalar); Console.WriteLine($" Convert.ToInt64: {pkid} ({(pkid > 0 ? "VALID" : "ZERO/INVALID")})"); }
catch (Exception ex) { Console.WriteLine($" Convert.ToInt64: FAILED ({ex.Message})"); }
}
// Rollback
tx.Rollback();
Console.WriteLine("ROLLBACK: OK");
Console.WriteLine("-- Verify rollback --");
using var verifyCmd = connection.CreateCommand();
verifyCmd.CommandText = "SELECT COUNT(*) FROM Client WHERE CL_CODECLIENT = ?";
verifyCmd.Parameters.Add(new OdbcParameter { Value = marker });
int count = Convert.ToInt32(verifyCmd.ExecuteScalar());
Console.WriteLine($" Rows with marker: {count}");
Console.WriteLine(count == 0 ? " Rollback: VERIFIED" : " Rollback: UNEXPECTED");
Console.WriteLine();
Console.WriteLine(pkid > 0 && count == 0
? "VERDICT: CLIENT INSERT + RETURNING + ROLLBACK = VERIFIED"
: "VERDICT: UNEXPECTED — check output above.");
return (pkid > 0 && count == 0) ? 0 : 1;
}
catch (OdbcException ex)
{
Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}");
return 1;
}
}
static int RunClientGraph(ProbeOptions options)
{
if (!options.UseClientTable)
{
Console.Error.WriteLine("ERROR: --use-client-table is required for client-graph.");
Console.Error.WriteLine(" This probe writes to Client + related tables on a CLONE/TEST DB ONLY.");
return 2;
}
bool isOmika = string.Equals(options.Schema, "omika", StringComparison.OrdinalIgnoreCase);
string marker = $"GRAPH_PROBE_{DateTime.UtcNow:yyyyMMddHHmmss}";
Console.WriteLine($"Target: {(isOmika ? "OMIKA" : "BATIK")} Client + Adresse graph");
Console.WriteLine($"Marker: {marker}");
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
using var tx = connection.BeginTransaction();
Console.WriteLine("BEGIN: OK");
// === Step 1: INSERT Adresse ===
long adressePkId = 0;
{
using var cmdAdr = connection.CreateCommand();
cmdAdr.Transaction = tx;
cmdAdr.CommandText = "INSERT INTO Adresse (AD_LIGNE_1, AD_CODEPOSTAL, AD_VILLE) VALUES (?, ?, ?) RETURNING AD_PKID";
cmdAdr.Parameters.Add(new OdbcParameter { Value = $"Probe {marker}" });
cmdAdr.Parameters.Add(new OdbcParameter { Value = "99999" });
cmdAdr.Parameters.Add(new OdbcParameter { Value = "PROBE CITY" });
object? adrId = cmdAdr.ExecuteScalar();
adressePkId = Convert.ToInt64(adrId!);
Console.WriteLine($"INSERT Adresse: OK, AD_PKID={adressePkId}, CLR={adrId?.GetType().Name}");
}
if (adressePkId <= 0) { Console.Error.WriteLine("FAILED: Adresse PKID invalid."); return 1; }
// === Step 2: INSERT Client with FK to Adresse ===
long clientPkId = 0;
{
using var cmdCli = connection.CreateCommand();
cmdCli.Transaction = tx;
cmdCli.CommandText = "INSERT INTO Client (CL_NOM, CL_CODECLIENT, CL_IDADRESSEPRINCIPALE) VALUES (?, ?, ?) RETURNING CL_PKID";
cmdCli.Parameters.Add(new OdbcParameter { Value = $"ODBC GRAPH {marker}" });
cmdCli.Parameters.Add(new OdbcParameter { Value = marker });
cmdCli.Parameters.Add(new OdbcParameter { Value = adressePkId });
object? cliId = cmdCli.ExecuteScalar();
clientPkId = Convert.ToInt64(cliId!);
Console.WriteLine($"INSERT Client: OK, CL_PKID={clientPkId}, FK_ADRESSE={adressePkId}, CLR={cliId?.GetType().Name}");
}
if (clientPkId <= 0) { Console.Error.WriteLine("FAILED: Client PKID invalid."); return 1; }
Console.WriteLine($"FK chain verified: AD_PKID {adressePkId} -> CL_IDADRESSEPRINCIPALE {adressePkId}");
// === Rollback global ===
tx.Rollback();
Console.WriteLine("ROLLBACK: OK (global)");
// === Verify all absent ===
using (var cmdCheck = connection.CreateCommand())
{
cmdCheck.CommandText = "SELECT COUNT(*) FROM Client WHERE CL_CODECLIENT = ?";
cmdCheck.Parameters.Add(new OdbcParameter { Value = marker });
int c = Convert.ToInt32(cmdCheck.ExecuteScalar());
Console.WriteLine($"Client rows with marker: {c}");
}
using (var cmdCheckAdr = connection.CreateCommand())
{
cmdCheckAdr.CommandText = "SELECT COUNT(*) FROM Adresse WHERE AD_VILLE = 'PROBE CITY' AND AD_LIGNE_1 LIKE 'Probe GRAPH%'";
int a = Convert.ToInt32(cmdCheckAdr.ExecuteScalar());
Console.WriteLine($"Adresse rows with marker: {a}");
}
Console.WriteLine();
Console.WriteLine("VERDICT: MULTI-INSERT + FK CHAINING + GLOBAL ROLLBACK = VERIFIED");
return 0;
}
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
}
static int RunOmikaGuidProbe(ProbeOptions options)
{
Console.WriteLine("OMIKA GUID PROBE");
Console.WriteLine("================");
Console.WriteLine();
try
{
using var connection = new OdbcConnection(options.ConnectionString);
connection.Open();
Console.WriteLine("Connected. Querying Client table for GUID statistics...");
Console.WriteLine();
using var cmd = connection.CreateCommand(); cmd.CommandText = ProbeSqlBuilder.BuildSelectGuids(200);
int count = 0, empty = 0, allHex = 0, hasDash = 0, hasBrace = 0, hasNonPrint = 0;
int parseN = 0, parseD = 0, parseAny = 0;
int exactly4Dashes = 0, standardDashPos = 0, looksLikeTruncatedD = 0;
int allUpper = 0, allLower = 0, mixedCase = 0;
var nonHexSeen = new Dictionary<char, int>();
var lenDist = new Dictionary<int, int>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
count++;
string? raw = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrEmpty(raw)) { empty++; continue; }
string guid = raw.TrimEnd('\0', ' ');
int len = guid.Length;
lenDist.TryGetValue(len, out int lc); lenDist[len] = lc + 1;
bool isAllHex = guid.Where(c => c != '-').All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'));
if (isAllHex) allHex++;
if (guid.Contains('-')) hasDash++;
if (guid.Contains('{') || guid.Contains('}')) hasBrace++;
if (guid.Any(c => char.IsControl(c) && c != '\0')) hasNonPrint++;
foreach (char c in guid)
{
bool isHex = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f');
if (!isHex && c != '-')
{
nonHexSeen.TryGetValue(c, out int nh); nonHexSeen[c] = nh + 1;
}
}
if (Guid.TryParseExact(guid, "N", out _)) parseN++;
if (Guid.TryParseExact(guid, "D", out _)) parseD++;
if (Guid.TryParse(guid, out _)) parseAny++;
// Dash position analysis (0-based)
int dashCount = guid.Count(c => c == '-');
if (dashCount == 4) exactly4Dashes++;
bool hasStandardPositions = dashCount == 4
&& guid.Length == 32
&& guid[8] == '-' && guid[13] == '-' && guid[18] == '-' && guid[23] == '-';
if (hasStandardPositions) standardDashPos++;
if (hasStandardPositions && isAllHex)
{
looksLikeTruncatedD++;
}
// Casing
var hexOnly = guid.Replace("-", "");
if (hexOnly.Length > 0)
{
if (hexOnly.All(c => c >= 'A' && c <= 'F' || c >= '0' && c <= '9')) allUpper++;
else if (hexOnly.All(c => c >= 'a' && c <= 'f' || c >= '0' && c <= '9')) allLower++;
else mixedCase++;
}
}
Console.WriteLine($" Sample: {count}");
Console.WriteLine($" Empty/null: {empty}");
Console.WriteLine();
Console.WriteLine("── Length distribution ──");
foreach (var kv in lenDist.OrderBy(x => x.Key))
Console.WriteLine($" Len={kv.Key}: {kv.Value}");
Console.WriteLine();
Console.WriteLine("── Content analysis ──");
Console.WriteLine($" All-hex: {allHex}/{count}");
Console.WriteLine($" Contains '-': {hasDash}/{count}");
Console.WriteLine($" Contains brace: {hasBrace}/{count}");
Console.WriteLine($" Non-printable: {hasNonPrint}/{count}");
Console.WriteLine();
Console.WriteLine("── Dash position analysis ──");
Console.WriteLine($" Exactly 4 dashes: {exactly4Dashes}/{count}");
Console.WriteLine($" Standard positions: {standardDashPos}/{count}");
Console.WriteLine($" LooksLikeTruncatedD:{looksLikeTruncatedD}/{count}");
Console.WriteLine();
Console.WriteLine("── Casing (hex chars only) ──");
Console.WriteLine($" All uppercase: {allUpper}/{count}");
Console.WriteLine($" All lowercase: {allLower}/{count}");
Console.WriteLine($" Mixed case: {mixedCase}/{count}");
Console.WriteLine();
Console.WriteLine("── Guid.Parse ──");
Console.WriteLine($" ParseExact N: {parseN}/{count}");
Console.WriteLine($" ParseExact D: {parseD}/{count}");
Console.WriteLine($" ParseAny: {parseAny}/{count}");
if (nonHexSeen.Count > 0)
{
Console.WriteLine();
Console.WriteLine("── Non-hex chars (codepoint: count) ──");
foreach (var kv in nonHexSeen.OrderByDescending(x => x.Value).Take(20))
Console.WriteLine($" U+{(int)kv.Key:X4} ('{kv.Key}'): {kv.Value}");
}
Console.WriteLine();
Console.WriteLine("VERDICT: DETAILED ANALYSIS ABOVE.");
return 0;
}
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
}
static int ValidateIdentifiers(string table, string idColumn, string markerColumn)
{
try { SqlIdentifierValidator.Validate(table); SqlIdentifierValidator.Validate(idColumn); SqlIdentifierValidator.Validate(markerColumn); return 0; }
catch (ArgumentException ex) { Console.Error.WriteLine($"ERROR: {ex.Message}"); return 2; }
}
static string MaskConnectionProperty(string? value)
{
if (string.IsNullOrEmpty(value)) return "(empty)";
if (value.Contains("PWD=", StringComparison.OrdinalIgnoreCase) || value.Contains("Password=", StringComparison.OrdinalIgnoreCase) || value.Contains("UID=", StringComparison.OrdinalIgnoreCase)) return "(masked)";
return value;
}
static void PrintUsage()
{
Console.WriteLine("HFSQL ODBC CAPABILITY PROBE");
Console.WriteLine();
Console.WriteLine("COMMANDS:");
Console.WriteLine(" capabilities Display driver info + connection capabilities (READ-ONLY)");
Console.WriteLine(" audit List all tables and key columns metadata (READ-ONLY)");
Console.WriteLine(" transaction Test BEGIN/INSERT/ROLLBACK cycle (WRITE — needs --allow-write-probe)");
Console.WriteLine(" returning Test INSERT ... RETURNING via ODBC (WRITE — needs --allow-write-probe)");
Console.WriteLine(" omika-guid Read GUID statistics from Client table (READ-ONLY)");
Console.WriteLine();
Console.WriteLine("OPTIONS:");
Console.WriteLine(" --connection-string HFSQL ODBC connection string (DSN=...)");
Console.WriteLine(" --allow-write-probe Confirm working on clone/test DB (required for writes)");
Console.WriteLine(" --table Table name for write probes");
Console.WriteLine(" --id-column Auto-ID column name (default: PKID)");
Console.WriteLine(" --marker-column Column to write a marker value (default: Marker)");
Console.WriteLine(" --schema batik | omika (for client-specific probes)");
Console.WriteLine(" --use-client-table Write to Client table (clone DB only!)");
Console.WriteLine();
Console.WriteLine("\u26a0 NEVER run write probes against a production database.");
}
}
@@ -0,0 +1,21 @@
using System.Text.RegularExpressions;
namespace Omika.HfSqlProbe;
internal static partial class SqlIdentifierValidator
{
/// <summary>Accepts only letters, digits, and underscores.</summary>
[GeneratedRegex(@"\A[A-Za-z_][A-Za-z0-9_]*\z")]
private static partial Regex IdentifierPattern();
public static bool IsValid(string name) => IdentifierPattern().IsMatch(name);
/// <summary>Validates and returns the identifier, or throws on invalid input.</summary>
public static string Validate(string name)
{
if (!IsValid(name))
throw new ArgumentException(
$"Invalid SQL identifier: '{name}'. Only [A-Za-z0-9_] allowed.");
return name;
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Omika.IdempotenceProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+192
View File
@@ -0,0 +1,192 @@
// Probe de diagnostic IDEMPOTENCE — 100% LECTURE SEULE.
// Interroge MobReception, MobLog et les Interventions E2E_TEST dans BATIK_TEST
// pour reconstruire la chronologie des OperationId CREATE et Replay.
// Aucun INSERT / UPDATE / DELETE.
using System.Data.OleDb;
const string server = "127.0.0.1";
const int port = 4900;
const string user = "susana";
const string password = "123";
string BuildConnStr(string database) =>
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
const string createOpId = "5b915439-d9d9-44b5-9552-1f8f90db96c3";
const string replayOpId = "0fce297f-e4e4-45ad-9027-b4e6e23d5c8a";
Console.WriteLine("=== DIAGNOSTIC IDEMPOTENCE — LECTURE SEULE ===");
Console.WriteLine($"CREATE OperationId : {createOpId}");
Console.WriteLine($"Replay OperationId : {replayOpId}");
Console.WriteLine();
try
{
using var conn = new OleDbConnection(BuildConnStr("BATIK_TEST"));
conn.Open();
Console.WriteLine("Connexion BATIK_TEST: OPEN");
Console.WriteLine();
// ============================================================
// 1. Interventions E2E_TEST (toutes)
// ============================================================
Console.WriteLine("── 1. Interventions E2E_TEST (IN_RefInterne LIKE 'E2E_TEST%') ──");
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT IN_PKID, IN_RefInterne, IN_Titre, IN_Description FROM Intervention WHERE IN_RefInterne LIKE 'E2E_TEST%' ORDER BY IN_PKID";
using var reader = cmd.ExecuteReader();
int n = 0;
while (reader.Read())
{
n++;
Console.WriteLine($" [{n}] IN_PKID={reader["IN_PKID"]}, Ref={reader["IN_RefInterne"]}, Titre={reader["IN_Titre"]}, Desc={reader["IN_Description"]}");
}
Console.WriteLine($" Total E2E_TEST: {n}");
}
Console.WriteLine();
// ============================================================
// 2. MobLog pour CREATE OperationId
// ============================================================
Console.WriteLine($"── 2. MobLog pour CREATE OperationId {createOpId} ──");
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT ML_PKID, ML_DateCreat, ML_Message, ML_Niveau FROM MobLog WHERE ML_Message LIKE ? ORDER BY ML_PKID";
cmd.Parameters.Add(new OleDbParameter { Value = $"%{createOpId}%" });
using var reader = cmd.ExecuteReader();
int n = 0;
while (reader.Read())
{
n++;
Console.WriteLine($" [{n}] ML_PKID={reader["ML_PKID"]}, Date={reader["ML_DateCreat"]}, Niveau={reader["ML_Niveau"]}");
Console.WriteLine($" Msg={reader["ML_Message"]}");
}
Console.WriteLine($" Total MobLog CREATE: {n}");
}
Console.WriteLine();
// ============================================================
// 3. MobLog pour Replay OperationId
// ============================================================
Console.WriteLine($"── 3. MobLog pour Replay OperationId {replayOpId} ──");
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT ML_PKID, ML_DateCreat, ML_Message, ML_Niveau FROM MobLog WHERE ML_Message LIKE ? ORDER BY ML_PKID";
cmd.Parameters.Add(new OleDbParameter { Value = $"%{replayOpId}%" });
using var reader = cmd.ExecuteReader();
int n = 0;
while (reader.Read())
{
n++;
Console.WriteLine($" [{n}] ML_PKID={reader["ML_PKID"]}, Date={reader["ML_DateCreat"]}, Niveau={reader["ML_Niveau"]}");
Console.WriteLine($" Msg={reader["ML_Message"]}");
}
Console.WriteLine($" Total MobLog Replay: {n}");
}
Console.WriteLine();
// ============================================================
// 4. MobReception pour CREATE OperationId
// ============================================================
Console.WriteLine($"── 4. MobReception pour CREATE OperationId {createOpId} ──");
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT MR_PKID, MR_IDToSRV, MR_JSON, MR_CMD, MR_Fait, MR_DateHeureRecep FROM MobReception WHERE MR_JSON LIKE ? ORDER BY MR_PKID";
cmd.Parameters.Add(new OleDbParameter { Value = $"%{createOpId}%" });
using var reader = cmd.ExecuteReader();
int n = 0;
while (reader.Read())
{
n++;
Console.WriteLine($" [{n}] MR_PKID={reader["MR_PKID"]}, MR_IDToSRV={reader["MR_IDToSRV"]}, Fait={reader["MR_Fait"]}, Date={reader["MR_DateHeureRecep"]}");
Console.WriteLine($" JSON={reader["MR_JSON"]}");
}
Console.WriteLine($" Total MobReception CREATE: {n}");
}
Console.WriteLine();
// ============================================================
// 5. MobReception pour Replay OperationId
// ============================================================
Console.WriteLine($"── 5. MobReception pour Replay OperationId {replayOpId} ──");
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT MR_PKID, MR_IDToSRV, MR_JSON, MR_CMD, MR_Fait, MR_DateHeureRecep FROM MobReception WHERE MR_JSON LIKE ? ORDER BY MR_PKID";
cmd.Parameters.Add(new OleDbParameter { Value = $"%{replayOpId}%" });
using var reader = cmd.ExecuteReader();
int n = 0;
while (reader.Read())
{
n++;
Console.WriteLine($" [{n}] MR_PKID={reader["MR_PKID"]}, MR_IDToSRV={reader["MR_IDToSRV"]}, Fait={reader["MR_Fait"]}, Date={reader["MR_DateHeureRecep"]}");
Console.WriteLine($" JSON={reader["MR_JSON"]}");
}
Console.WriteLine($" Total MobReception Replay: {n}");
}
Console.WriteLine();
// ============================================================
// 6. Tous les MobLog contenant E2E_TEST (pour corréler les références)
// ============================================================
Console.WriteLine("── 6. MobLog contenant 'E2E_TEST' (corrélation références) ──");
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT ML_PKID, ML_DateCreat, ML_Message, ML_Niveau FROM MobLog WHERE ML_Message LIKE '%E2E_TEST%' ORDER BY ML_PKID";
using var reader = cmd.ExecuteReader();
int n = 0;
while (reader.Read())
{
n++;
Console.WriteLine($" [{n}] ML_PKID={reader["ML_PKID"]}, Date={reader["ML_DateCreat"]}, Niveau={reader["ML_Niveau"]}");
Console.WriteLine($" Msg={reader["ML_Message"]}");
}
Console.WriteLine($" Total MobLog E2E_TEST: {n}");
}
Console.WriteLine();
// ============================================================
// 7. Tous les MobReception contenant E2E_TEST
// ============================================================
Console.WriteLine("── 7. MobReception contenant 'E2E_TEST' ──");
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT MR_PKID, MR_IDToSRV, MR_JSON, MR_CMD, MR_Fait, MR_DateHeureRecep FROM MobReception WHERE MR_JSON LIKE '%E2E_TEST%' ORDER BY MR_PKID";
using var reader = cmd.ExecuteReader();
int n = 0;
while (reader.Read())
{
n++;
Console.WriteLine($" [{n}] MR_PKID={reader["MR_PKID"]}, MR_IDToSRV={reader["MR_IDToSRV"]}, Fait={reader["MR_Fait"]}, Date={reader["MR_DateHeureRecep"]}");
Console.WriteLine($" JSON={reader["MR_JSON"]}");
}
Console.WriteLine($" Total MobReception E2E_TEST: {n}");
}
Console.WriteLine();
// ============================================================
// 8. TOSRV (base Service) — vérifier s'il reste des opérations
// ============================================================
Console.WriteLine("── 8. TOSRV (base Service BATIK.01_BLS) ──");
using (var srvConn = new OleDbConnection(BuildConnStr("BATIK.01_BLS")))
{
srvConn.Open();
using var cmd = srvConn.CreateCommand();
cmd.CommandText = "SELECT IDTOSRV, TS_Date, TS_Mobile, TS_JSON, TS_CMD FROM TOSRV";
using var reader = cmd.ExecuteReader();
int n = 0;
while (reader.Read())
{
n++;
Console.WriteLine($" [{n}] IDTOSRV={reader["IDTOSRV"]}, Date={reader["TS_Date"]}, Mobile={reader["TS_Mobile"]}, Cmd={reader["TS_CMD"]}");
Console.WriteLine($" JSON={reader["TS_JSON"]}");
}
Console.WriteLine($" Total TOSRV: {n}");
}
Console.WriteLine();
Console.WriteLine("=== DIAGNOSTIC TERMINÉ (aucune écriture effectuée) ===");
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR: {ex.Message}");
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+69
View File
@@ -0,0 +1,69 @@
// Probe pour tester la récupération de l'identifiant auto-généré HFSQL
// après un INSERT réel dans une transaction.
// Écriture dans BATIK_TEST (base de test confirmée) avec référence unique E2E_PROBE_.
using System.Data.OleDb;
const string server = "127.0.0.1";
const int port = 4900;
const string user = "susana";
const string password = "123";
string BuildConnStr(string database) =>
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
string reference = $"E2E_PROBE_{DateTime.Now:HHmmss}";
Console.WriteLine($"=== Test INSERT + LAST_INSERT_ID() (base BATIK_TEST) ===");
Console.WriteLine($" Référence unique: {reference}");
try
{
using var conn = new OleDbConnection(BuildConnStr("BATIK_TEST"));
conn.Open();
using var tx = conn.BeginTransaction();
// INSERT dans Intervention (IN_PKID auto-généré).
using (var cmd = conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText = "INSERT INTO Intervention (IN_IDTypeIntervention, IN_Titre, IN_RefInterne, IN_Description, Archive, CreationDate, ModifDate) VALUES (?, ?, ?, ?, ?, ?, ?)";
cmd.Parameters.Add(new OleDbParameter { Value = 1 });
cmd.Parameters.Add(new OleDbParameter { Value = "Probe LAST_INSERT_ID" });
cmd.Parameters.Add(new OleDbParameter { Value = reference });
cmd.Parameters.Add(new OleDbParameter { Value = "Probe test" });
cmd.Parameters.Add(new OleDbParameter { Value = false });
cmd.Parameters.Add(new OleDbParameter { Value = DateTime.Now });
cmd.Parameters.Add(new OleDbParameter { Value = DateTime.Now });
int rows = cmd.ExecuteNonQuery();
Console.WriteLine($" INSERT exécuté, rows={rows}");
}
// Test LAST_INSERT_ID() dans la même transaction.
using (var cmd = conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText = "SELECT LAST_INSERT_ID()";
var value = cmd.ExecuteScalar();
Console.WriteLine($" LAST_INSERT_ID() -> {(value is null || value == DBNull.Value ? "NULL" : value)}");
}
// Test MAX(IN_PKID) pour comparaison.
using (var cmd = conn.CreateCommand())
{
cmd.Transaction = tx;
cmd.CommandText = "SELECT MAX(IN_PKID) FROM Intervention";
var value = cmd.ExecuteScalar();
Console.WriteLine($" MAX(IN_PKID) -> {(value is null || value == DBNull.Value ? "NULL" : value)}");
}
// ROLLBACK pour ne pas laisser de données.
tx.Rollback();
Console.WriteLine(" ROLLBACK effectué (aucune donnée laissée).");
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR: {ex.Message}");
}
Console.WriteLine();
Console.WriteLine("Probe INSERT + LAST_INSERT_ID() terminé.");
+121
View File
@@ -0,0 +1,121 @@
namespace Omika.LanguesProbe;
/// <summary>
/// Arguments d'une commande, séparés en positionnels et options nommées.
///
/// <para>
/// L'analyse est volontairement stricte : une option inconnue est <b>refusée</b>
/// au lieu d'être ignorée. Sur un poste de développement, une faute de frappe
/// silencieuse (<c>--dossiers</c>, <c>--sens</c> mal orthographié) ferait
/// travailler l'outil sur autre chose que ce que le développeur croit, et le
/// diagnostic serait faux sans que rien ne le signale.
/// </para>
///
/// <para>
/// Les valeurs ne sont jamais redécoupées : un chemin contenant des espaces
/// arrive en un seul argument, grâce aux guillemets du shell, et le reste.
/// </para>
/// </summary>
internal sealed class Arguments
{
private readonly List<string> _positionnels = [];
private readonly Dictionary<string, string> _options = new(StringComparer.OrdinalIgnoreCase);
private Arguments()
{
}
/// <summary>
/// Analyse les arguments d'une commande.
/// </summary>
/// <param name="args">Arguments suivant le nom de la commande.</param>
/// <param name="optionsAttendues">Options nommées acceptées, sans les tirets.</param>
/// <exception cref="AideDemandeeException"><c>--aide</c> ou <c>-h</c> est présent.</exception>
/// <exception cref="ErreurUsageException">Option inconnue, ou option sans valeur.</exception>
public static Arguments Analyser(string[] args, params string[] optionsAttendues)
{
var resultat = new Arguments();
var connues = new HashSet<string>(optionsAttendues, StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < args.Length; i++)
{
string argument = args[i];
if (argument is "--aide" or "-h")
{
// Reconnu à n'importe quelle position : « traduire --aide » doit expliquer la
// commande, pas réclamer --dossier.
throw new AideDemandeeException();
}
if (!argument.StartsWith("--", StringComparison.Ordinal))
{
resultat._positionnels.Add(SansGuillemets(argument));
continue;
}
string nom = argument[2..];
if (!connues.Contains(nom))
{
throw new ErreurUsageException($"option inconnue « {argument} »");
}
if (i + 1 >= args.Length)
{
throw new ErreurUsageException($"l'option « {argument} » attend une valeur");
}
// La valeur est consommée telle quelle, même si elle commence par « -- » : un texte à
// traduire peut légitimement commencer par un tiret.
resultat._options[nom] = SansGuillemets(args[++i]);
}
return resultat;
}
/// <summary>Argument positionnel, ou erreur d'usage s'il manque.</summary>
public string Positionnel(int index, string description)
=> index < _positionnels.Count
? _positionnels[index]
: throw new ErreurUsageException($"argument manquant : {description}");
/// <summary>Valeur d'une option, ou <see langword="null"/> si elle est absente.</summary>
public string? Option(string nom)
=> _options.TryGetValue(nom, out string? valeur) ? valeur : null;
/// <summary>Valeur d'une option, ou <paramref name="defaut"/> si elle est absente.</summary>
public string Option(string nom, string defaut) => Option(nom) ?? defaut;
/// <summary>Positionnels à partir d'un index (un texte peut arriver en plusieurs arguments).</summary>
public IReadOnlyList<string> Reste(int depuis)
{
if (depuis >= _positionnels.Count)
{
return [];
}
return _positionnels.GetRange(depuis, _positionnels.Count - depuis);
}
/// <summary>
/// Retire une paire de guillemets entourant une valeur : un copier-coller
/// depuis un document, ou un shell qui transmet les guillemets eux-mêmes,
/// produirait sinon un chemin de fichier introuvable.
/// </summary>
private static string SansGuillemets(string valeur)
=> valeur.Length >= 2 && valeur[0] == '"' && valeur[^1] == '"' ? valeur[1..^1] : valeur;
}
/// <summary>Argument manquant, option inconnue : l'appel est incorrect (code de sortie 1).</summary>
internal sealed class ErreurUsageException : Exception
{
public ErreurUsageException(string message)
: base(message)
{
}
}
/// <summary><c>--aide</c> demandé : ce n'est pas une erreur (code de sortie 0).</summary>
internal sealed class AideDemandeeException : Exception
{
}
@@ -0,0 +1,285 @@
using System.Text;
using Omika.App.Local.Services;
namespace Omika.LanguesProbe;
/// <summary>
/// Commandes « inspecter » et « encoder » : tout ce qui se vérifie sur le
/// <b>vocabulaire seul</b>, sans moteur d'inférence.
///
/// <para>
/// C'est la partie utilisable dès aujourd'hui, moteur éteint : un vocabulaire
/// illisible, un profil de normalisation approximé, un repli octet absent ou un
/// découpage non réversible s'observent ici, alors qu'ils ne se verraient en
/// production que sous forme de traductions dégradées.
/// </para>
/// </summary>
internal static class InspectionVocabulaire
{
/// <summary>
/// Phrases témoins de l'aller-retour : elles cumulent ce qui casse un
/// découpage réel — accents, ponctuation espagnole inversée, apostrophes,
/// chiffres et unités souvent hors vocabulaire, majuscules en début de
/// phrase (le marqueur d'espace y est présent).
/// </summary>
private static readonly string[] PhrasesTemoins =
[
"Bonjour, comment allez-vous ?",
"Le client a signé le devis hier après-midi.",
"Rendez-vous demain matin à 8 h 30.",
"L'intervention est terminée : 1 234,56 € de pièces.",
"¿Dónde está el cliente?",
"La factura se enviará mañana por la mañana.",
"Necesito hablar con el responsable de la obra.",
"El técnico llegó a las nueve y revisó la caldera."
];
/// <summary>Commande « inspecter » : identité et comportement du vocabulaire.</summary>
public static int Inspecter(string[] args)
{
Arguments arguments = Arguments.Analyser(args);
string chemin = arguments.Positionnel(0, "chemin du vocabulaire (.spm)");
SentencePieceTokenizer tokenizer;
try
{
tokenizer = SentencePieceTokenizer.Charger(chemin);
}
catch (SentencePieceException ex)
{
return EchecVocabulaire(ex);
}
Console.WriteLine("── Vocabulaire ──");
Console.WriteLine($" Fichier : {Program.NomFichier(chemin)}");
Console.WriteLine($" Pièces : {tokenizer.TailleVocabulaire}");
Console.WriteLine($" Jetons <unk>/<s>/</s>/<pad> : {TexteIdentifiant(tokenizer.IdTokenInconnu)} / {TexteIdentifiant(tokenizer.IdDebut)}"
+ $" / {TexteIdentifiant(tokenizer.IdFin)} / {TexteIdentifiant(tokenizer.IdRemplissage)}");
Console.WriteLine($" Normalisation : {tokenizer.ProfilNormalisation}");
// Le repli octet décide du sort des caractères hors vocabulaire : accents perdus ou
// préservés. C'est la seconde chose à vérifier après les jetons techniques.
Console.WriteLine(tokenizer.RepliOctetDisponible
? " Repli octet : disponible (un caractère hors vocabulaire est découpé en octets UTF-8)"
: " Repli octet : INDISPONIBLE (un caractère hors vocabulaire devient <unk>, donc perdu)");
// Le type de segmentation (UNIGRAM / BPE) vit dans le trainer_spec du .spm, que le
// tokeniseur lit sans le publier : aucune propriété ne l'expose. L'annoncer demanderait de
// le déduire du seul comportement, ce qui serait une supposition — on dit qu'il est
// indéterminable ici plutôt que de laisser croire à une mesure.
Console.WriteLine(" Segmentation : non déterminable ici (UNIGRAM/BPE non exposé par SentencePieceTokenizer)");
Console.WriteLine();
Console.WriteLine("── Avertissements du tokeniseur ──");
if (tokenizer.Avertissements.Count == 0)
{
Console.WriteLine(" aucun avertissement");
}
else
{
foreach (string avertissement in tokenizer.Avertissements)
{
Console.WriteLine($" - {avertissement}");
}
}
Console.WriteLine();
Console.WriteLine("── Aller-retour : texte → identifiants → texte ──");
Console.WriteLine(" DIFFERENT signale un découpage non réversible : à comparer avec un tokeniseur de référence.");
int ecarts = 0;
foreach (string phrase in PhrasesTemoins)
{
if (!AllerRetour(tokenizer, phrase))
{
ecarts++;
}
}
Console.WriteLine();
Console.WriteLine(ecarts == 0
? $"VERDICT : aller-retour IDENTIQUE sur les {PhrasesTemoins.Length} phrases témoins."
: $"VERDICT : {ecarts} phrase(s) sur {PhrasesTemoins.Length} ne reviennent pas identiques (premier écart indiqué).");
return CodesSortie.Succes;
}
/// <summary>Commande « encoder » : identifiants et pièces, dans l'ordre.</summary>
public static int Encoder(string[] args)
{
Arguments arguments = Arguments.Analyser(args, "texte");
string chemin = arguments.Positionnel(0, "chemin du vocabulaire (.spm)");
// Un texte non cité arrive en plusieurs arguments : les recoller avec des espaces évite de
// ne traduire que le premier mot.
string texte = arguments.Option("texte") ?? string.Join(' ', arguments.Reste(1));
if (string.IsNullOrEmpty(texte))
{
throw new ErreurUsageException("texte manquant : « encoder <vocab.spm> \"<texte>\" »");
}
SentencePieceTokenizer tokenizer;
try
{
tokenizer = SentencePieceTokenizer.Charger(chemin);
}
catch (SentencePieceException ex)
{
return EchecVocabulaire(ex);
}
IReadOnlyList<int> ids = tokenizer.EncodeAsync(texte).GetAwaiter().GetResult();
Console.WriteLine($"Vocabulaire : {Program.NomFichier(chemin)} ({tokenizer.TailleVocabulaire} pièces)");
Console.WriteLine($"Texte : {texte}");
Console.WriteLine($"Identifiants ({ids.Count}) : {string.Join(' ', ids)}");
Console.WriteLine();
Console.WriteLine(" # id pièce");
for (int i = 0; i < ids.Count; i++)
{
Console.WriteLine($" {i,-5} {ids[i],-8} {TextePiece(tokenizer, ids[i])}");
}
return CodesSortie.Succes;
}
/// <summary>Aller-retour d'une phrase témoin ; vrai si le texte revient identique.</summary>
private static bool AllerRetour(SentencePieceTokenizer tokenizer, string phrase)
{
// L'encodage est synchrone et sans E/S : l'attente directe est légitime dans une
// application console, qui n'a pas de contexte de synchronisation à préserver.
IReadOnlyList<int> ids = tokenizer.EncodeAsync(phrase).GetAwaiter().GetResult();
string decode = tokenizer.Decode(ids);
bool identique = string.Equals(decode, phrase, StringComparison.Ordinal);
Console.WriteLine();
Console.WriteLine($" « {phrase} »");
Console.WriteLine($" identifiants ({ids.Count}) : {AfficherIds(ids)}");
Console.WriteLine($" décodé : « {decode} »");
if (identique)
{
Console.WriteLine(" VERDICT : IDENTIQUE");
}
else
{
// Le premier caractère divergent désigne la pièce fautive : le développeur n'a pas à
// comparer les deux chaînes à l'œil.
int position = PremiereDivergence(phrase, decode);
Console.WriteLine(
$" VERDICT : DIFFERENT — premier écart au caractère {position} : "
+ $"« {CaractereLisible(phrase, position)} » attendu, « {CaractereLisible(decode, position)} » obtenu");
}
return identique;
}
/// <summary>
/// Texte d'une pièce, tel que le tokeniseur le restitue.
///
/// <para>
/// La table des pièces n'est pas publiée (elle est privée) : la seule lecture
/// possible passe par <see cref="SentencePieceTokenizer.Decode"/> sur un
/// identifiant isolé. Deux conséquences, rendues visibles à l'affichage :
/// </para>
/// <list type="bullet">
/// <item>le marqueur d'espace ▁ est retraduit en espace puis rogné en bord de
/// chaîne : il est retrouvé en décodant <b>deux fois</b> le même identifiant,
/// le second exemplaire révélant l'espace de séparation ;</item>
/// <item>une pièce d'octet isolée ne forme pas un caractère UTF-8 valide et se
/// décode en U+FFFD — c'est en soi l'indice d'un fragment d'octet. Un octet
/// ASCII, lui, s'affiche comme le caractère correspondant, qui <b>est</b> sa
/// valeur.</item>
/// </list>
/// </summary>
private static string TextePiece(SentencePieceTokenizer tokenizer, int id)
{
if (id == tokenizer.IdTokenInconnu)
{
return "<unk> — jeton technique";
}
if (id == tokenizer.IdDebut)
{
return "<s> — jeton technique";
}
if (id == tokenizer.IdFin)
{
return "</s> — jeton technique";
}
if (id == tokenizer.IdRemplissage)
{
return "<pad> — jeton technique";
}
string seul = tokenizer.Decode([id]);
if (seul.Length == 0)
{
return "(aucun texte : jeton de contrôle ou identifiant hors vocabulaire)";
}
if (seul.All(c => c == '\uFFFD'))
{
return "<octet> — fragment d'octet, non affichable isolément";
}
bool porteMarqueur = tokenizer.Decode([id, id]).Length == (2 * seul.Length) + 1;
return porteMarqueur ? $"▁{seul}" : seul;
}
/// <summary>Liste d'identifiants, tronquée quand elle dépasse ce qui reste lisible.</summary>
private static string AfficherIds(IReadOnlyList<int> ids)
{
const int maximum = 24;
var texte = new StringBuilder();
for (int i = 0; i < Math.Min(ids.Count, maximum); i++)
{
if (i > 0)
{
texte.Append(' ');
}
texte.Append(ids[i]);
}
if (ids.Count > maximum)
{
texte.Append($" … (+{ids.Count - maximum} identifiants)");
}
return texte.ToString();
}
private static int PremiereDivergence(string attendu, string obtenu)
{
int commun = Math.Min(attendu.Length, obtenu.Length);
for (int i = 0; i < commun; i++)
{
if (attendu[i] != obtenu[i])
{
return i;
}
}
return commun;
}
private static string CaractereLisible(string texte, int position)
=> position >= texte.Length ? "fin de chaîne" : texte[position].ToString();
/// <summary>Identifiant d'un jeton technique, « absent » pour -1 (pièce absente du vocabulaire).</summary>
private static string TexteIdentifiant(int id) => id >= 0 ? id.ToString() : "absent";
/// <summary>
/// Le message de <see cref="SentencePieceException"/> est déjà destiné à
/// l'utilisateur (français, sans chemin) : il est affiché tel quel.
/// </summary>
private static int EchecVocabulaire(SentencePieceException ex)
{
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
return CodesSortie.OperationEchouee;
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.App.Local\Omika.App.Local.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,213 @@
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.Core.Contracts.Mobile.Languages;
namespace Omika.LanguesProbe;
/// <summary>
/// Magasin de pack de langue adossé à un dossier du disque : c'est ce qui permet
/// d'exécuter le <b>vrai</b> pipeline (<c>OnnxTranslationService</c>) sur les
/// fichiers d'un développeur, hors téléphone.
///
/// <para>
/// Le dossier passé en ligne de commande est traité comme
/// « <c>langues/&lt;code&gt;/&lt;sens&gt;</c> » : tous les fichiers
/// <c>*.spm</c> et <c>*.onnx</c> qu'il contient sont déclarés pour le sens
/// demandé, avec leur taille réelle. Aucune empreinte SHA-256 n'est inventée :
/// un pack décrit depuis le disque n'a pas de référence serveur à laquelle se
/// comparer, et une fausse empreinte rendrait la vérification mensongère.
/// </para>
///
/// <para>
/// <b>Lecture seule.</b> Les méthodes d'écriture de l'interface refusent d'agir
/// plutôt que de faire semblant : un outil de diagnostic ne doit jamais
/// modifier, ni supprimer, ce qu'un développeur lui demande d'examiner.
/// </para>
/// </summary>
internal sealed class PackLangueDossier : ILanguagePackStore
{
private readonly string _dossier;
private readonly string _code;
private readonly string _sens;
public PackLangueDossier(string dossier, string code, string sens)
{
_dossier = dossier;
_code = code;
_sens = sens;
}
/// <summary>
/// Dossier partagé entre les deux sens d'une même langue
/// (« &lt;code&gt;\commun »), frère du dossier examiné. Un modèle familial
/// (<c>opus-mt-tc-big-itc-itc</c>) y range ses poids une seule fois, et chaque
/// sens ne porte que son <c>modele.json</c>. Absent pour un pack bilingue.
/// </summary>
private string DossierCommun
{
get
{
string? parent = Path.GetDirectoryName(_dossier);
return string.IsNullOrEmpty(parent)
? string.Empty
: Path.Combine(parent, LanguageModelDirections.Commun);
}
}
/// <summary>Répertoire racine des packs sur l'appareil — ici, le dossier examiné.</summary>
public string Racine => _dossier;
/// <summary>
/// Aucun manifeste serveur n'est disponible hors application : le retour
/// <see langword="null"/> est l'état « mémoire d'index absente », déjà géré
/// par les appelants.
/// </summary>
public Task<LanguageManifestResponse?> GetIndexAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<LanguageManifestResponse?>(null);
public Task EnregistrerIndexAsync(LanguageManifestResponse manifeste, CancellationToken cancellationToken = default)
=> throw LectureSeule();
public Task<PackLangueInstalle?> GetPackInstalleAsync(string code, CancellationToken cancellationToken = default)
=> Task.FromResult<PackLangueInstalle?>(DecrirePack());
public Task<IReadOnlyList<PackLangueInstalle>> ListerPacksInstallesAsync(CancellationToken cancellationToken = default)
{
var packs = new List<PackLangueInstalle>();
if (Directory.Exists(_dossier))
{
packs.Add(DecrirePack());
}
return Task.FromResult<IReadOnlyList<PackLangueInstalle>>(packs);
}
public Task EnregistrerPackAsync(PackLangueInstalle pack, CancellationToken cancellationToken = default)
=> throw LectureSeule();
public Task SupprimerPackAsync(string code, CancellationToken cancellationToken = default)
=> throw LectureSeule();
public string PreparerFichierPartiel(string code, string sens, string nomFichier)
=> throw LectureSeule();
/// <summary>
/// Décrit ce qui est <b>réellement présent</b> : c'est cette description qui
/// décide des fichiers que le service ouvrira, donc ce que l'outil mesure.
/// </summary>
public PackLangueInstalle DecrirePack()
{
var fichiers = new List<FichierModeleInstalle>();
if (Directory.Exists(_dossier))
{
// Tri par nom : deux exécutions successives doivent produire exactement le même
// inventaire, sans quoi une comparaison entre deux mesures n'aurait aucun sens.
string[] chemins = Directory.GetFiles(_dossier);
Array.Sort(chemins, StringComparer.Ordinal);
foreach (string chemin in chemins)
{
string nom = Path.GetFileName(chemin);
if (!nom.EndsWith(".spm", StringComparison.OrdinalIgnoreCase)
&& !nom.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
{
continue;
}
fichiers.Add(new FichierModeleInstalle
{
Sens = _sens,
NomFichier = nom,
TailleOctets = new FileInfo(chemin).Length
});
}
}
// Poids partagés d'un modèle familial : le service les résout APRÈS le
// dossier du sens (voir OnnxTranslationService.FichiersDuSens). Les
// omettre ferait déclarer « pack incomplet » un pack familial valide.
string commun = DossierCommun;
if (commun.Length > 0 && Directory.Exists(commun))
{
string[] cheminsCommuns = Directory.GetFiles(commun);
Array.Sort(cheminsCommuns, StringComparer.Ordinal);
foreach (string chemin in cheminsCommuns)
{
string nom = Path.GetFileName(chemin);
if (!nom.EndsWith(".spm", StringComparison.OrdinalIgnoreCase)
&& !nom.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
{
continue;
}
fichiers.Add(new FichierModeleInstalle
{
Sens = LanguageModelDirections.Commun,
NomFichier = nom,
TailleOctets = new FileInfo(chemin).Length
});
}
}
return new PackLangueInstalle
{
Code = _code,
// Le libellé vient du manifeste serveur, absent ici ; il n'est lu par aucun appel du
// pipeline de traduction.
Libelle = _code,
Version = string.Empty,
InstalleLeUtc = DateTimeOffset.UtcNow,
Fichiers = fichiers
};
}
public bool FichierPresent(string code, FichierModeleInstalle fichier)
=> File.Exists(CheminFichier(code, fichier.Sens, fichier.NomFichier));
/// <summary>
/// Vérifie la présence et la taille relevée à l'inventaire. L'empreinte
/// SHA-256 n'est pas recalculée : le pack étant décrit depuis le disque
/// lui-même, il n'existe aucune empreinte attendue à confronter.
/// </summary>
public Task<bool> VerifierFichierAsync(
string code, FichierModeleInstalle fichier, CancellationToken cancellationToken = default)
{
var info = new FileInfo(CheminFichier(code, fichier.Sens, fichier.NomFichier));
bool conforme = info.Exists && (fichier.TailleOctets <= 0 || info.Length == fichier.TailleOctets);
return Task.FromResult(conforme);
}
/// <summary>
/// Chemin réel d'un fichier du pack. Le nom est réduit à son nom simple,
/// comme dans <c>LanguagePaths</c> : un nom de fichier ne doit jamais
/// permettre de sortir du dossier examiné. Un fichier absent du dossier du
/// sens est cherché dans son frère <c>commun</c> (poids partagé d'un modèle
/// familial), en suivant exactement la priorité du service.
/// </summary>
public string CheminFichier(string code, string sens, string nomFichier)
{
string nom = Path.GetFileName(nomFichier);
string chemin = Path.Combine(_dossier, nom);
if (File.Exists(chemin))
{
return chemin;
}
string commun = DossierCommun;
if (commun.Length > 0)
{
string partage = Path.Combine(commun, nom);
if (File.Exists(partage))
{
return partage;
}
}
return chemin;
}
private static NotSupportedException LectureSeule()
=> new("Cet outil de diagnostic ne modifie jamais le dossier examiné.");
}
+225
View File
@@ -0,0 +1,225 @@
using System.Reflection;
using System.Text.RegularExpressions;
namespace Omika.LanguesProbe;
/// <summary>
/// Omika.LanguesProbe — vérifie un couple de traduction hors ligne (FR ⇄ ES)
/// sur le poste de développement, à partir des fichiers réels du pack.
///
/// <para>
/// <b>Pourquoi cet outil.</b> Tant que le symbole <c>OMIKA_ONNX_RUNTIME</c> n'est
/// pas défini, le moteur d'inférence n'est embarqué dans aucune tête de
/// livraison : contrôler un <c>vocab.spm</c> et un <c>*.onnx</c> réels
/// (identifiants des jetons techniques, normalisation, aller-retour du
/// découpage, traduction complète) supposait un déploiement sur téléphone. Ici
/// les fichiers sont lus directement sur le disque : c'est le véhicule
/// d'exécution qui permet de décider de l'activation du moteur à partir de
/// mesures, et non de suppositions.
/// </para>
///
/// <para>
/// <b>Aucun paquet NuGet supplémentaire</b> : l'outil n'utilise que du code déjà
/// présent dans <c>Omika.App.Local</c>, qui compile et s'exécute sans ONNX
/// Runtime.
/// </para>
///
/// <para>
/// <b>Codes de sortie</b> : 0 succès, 1 erreur d'usage (argument manquant,
/// option ou commande inconnue), 2 opération en échec (fichier absent,
/// vocabulaire refusé, traduction échouée).
/// </para>
/// </summary>
public static class Program
{
/// <summary>
/// Version lue dans les métadonnées d'assembly, elle-même issue de
/// <c>Directory.Build.props</c> : un littéral ici dériverait de la version de
/// la solution sans que personne ne s'en aperçoive.
/// </summary>
private static readonly string VersionOutil =
typeof(Program).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? typeof(Program).Assembly.GetName().Version?.ToString()
?? "version inconnue";
// Motif des chemins absolus (Windows « C:\a\b » comme Unix « /a/b »), réduits au seul nom de
// fichier dans les messages : un diagnostic ne doit pas divulguer l'arborescence de la machine.
private static readonly Regex CheminAbsolu = new(@"(?:[A-Za-z]:)?(?:[\\/][^\\/\s""']+){2,}", RegexOptions.Compiled);
public static int Main(string[] args)
{
try
{
return Executer(args);
}
catch (AideDemandeeException)
{
// « --aide » demandé au milieu d'une commande : ce n'est pas une erreur.
AfficherAide();
return CodesSortie.Succes;
}
catch (ErreurUsageException ex)
{
Console.Error.WriteLine($"ERREUR D'USAGE : {ex.Message}");
Console.Error.WriteLine("Utilisez « --aide » pour la liste des commandes.");
return CodesSortie.ErreurUsage;
}
catch (Exception ex)
{
// Aucune trace technique ne sort d'ici : le message d'une exception .NET peut contenir
// le chemin absolu d'un fichier. Le nom de fichier seul suffit à agir.
Console.Error.WriteLine($"ÉCHEC : {MessageSansChemin(ex.Message)}");
return CodesSortie.OperationEchouee;
}
}
/// <summary>
/// Nom de fichier seul, pour tout message destiné à l'écran. Retourne une
/// mention neutre plutôt que le chemin brut quand le chemin n'a pas de nom
/// exploitable (chemin racine, chemin terminé par un séparateur).
/// </summary>
internal static string NomFichier(string chemin)
{
string nom = Path.GetFileName(chemin.TrimEnd('\\', '/'));
return string.IsNullOrEmpty(nom) ? "(élément sans nom)" : nom;
}
/// <summary>
/// Remplace les chemins absolus d'un message par leur seul nom de fichier.
/// Les messages de ce dépôt évitent déjà les chemins, mais ceux du framework
/// (<c>IOException</c>, <c>FileNotFoundException</c>) n'en donnent aucune
/// garantie : le filtre est donc appliqué à la frontière, une seule fois.
/// </summary>
internal static string MessageSansChemin(string message)
=> CheminAbsolu.Replace(message, m => Path.GetFileName(m.Value));
/// <summary>
/// Rappel actionnable quand aucun moteur d'inférence n'est utilisable : sans
/// lui, l'échec serait incompréhensible alors que c'est l'état attendu de la
/// machine de développement.
/// </summary>
internal static void AfficherMoteurAbsent()
{
Console.Error.WriteLine();
Console.Error.WriteLine(" La traduction ne peut pas être calculée : le moteur d'inférence n'est pas");
Console.Error.WriteLine(" compilé dans Omika.App.Local (symbole OMIKA_ONNX_RUNTIME absent).");
Console.Error.WriteLine(" → Activer le moteur : docs\\VALIDATION_TRADUCTION_ONNX.md");
Console.Error.WriteLine(" (procédure et état actuels : docs\\TRADUCTION_HORS_LIGNE_MOBILE.md, §7)");
Console.Error.WriteLine(" → « inspecter » et « encoder » restent utilisables sans moteur.");
}
private static int Executer(string[] args)
{
PreparerConsole();
AfficherEnTete();
if (args.Length == 0)
{
// L'aide est la réponse utile, mais l'appel est incomplet : le code de sortie le dit,
// pour qu'un script distingue « commande absente » de « commande réussie ».
AfficherAide();
return CodesSortie.ErreurUsage;
}
string commande = args[0].Trim().ToLowerInvariant();
string[] reste = args[1..];
switch (commande)
{
case "--aide" or "-h" or "aide":
AfficherAide();
return CodesSortie.Succes;
case "inspecter":
return InspectionVocabulaire.Inspecter(reste);
case "encoder":
return InspectionVocabulaire.Encoder(reste);
case "traduire":
return TraductionEssai.Traduire(reste);
default:
Console.Error.WriteLine($"ERREUR D'USAGE : commande inconnue « {args[0]} ».");
AfficherAide();
return CodesSortie.ErreurUsage;
}
}
/// <summary>
/// Sortie en UTF-8 : les messages sont en français et l'affichage des pièces
/// utilise le marqueur d'espace ▁ du modèle. Sans cela, une console Windows
/// restée sur une page de codes héritée rendrait des caractères de
/// remplacement — un diagnostic illisible ne sert à rien. Un échec est
/// ignoré : une sortie redirigée n'a pas de console à reconfigurer.
/// </summary>
private static void PreparerConsole()
{
try
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
}
catch (Exception)
{
// Aucune console interactive : l'encodage demandé par l'appelant fait foi.
}
}
private static void AfficherEnTete()
=> Console.WriteLine(
$"Omika.LanguesProbe {VersionOutil} — validation d'un couple de traduction hors ligne (FR ⇄ ES), "
+ "sans déploiement." + Environment.NewLine);
private static void AfficherAide()
{
Console.WriteLine(
"""
COMMANDES
inspecter <vocab.spm>
Lit un vocabulaire SentencePiece réel et affiche : nombre de pièces,
identifiants des jetons <unk> / <s> / </s> / <pad> (« absent » vaut -1),
profil de normalisation, repli octet, avertissements du tokeniseur, puis un
aller-retour texte → identifiants → texte sur des phrases françaises et
espagnoles. Ne nécessite AUCUN moteur ONNX.
encoder <vocab.spm> <texte…>
Découpe un texte et affiche chaque identifiant avec la pièce qu'il porte, dans
l'ordre. Sert à comparer avec un tokeniseur de référence (sentencepiece,
transformers) pour localiser une divergence de découpage.
Le texte peut être cité entre guillemets ou donné en plusieurs arguments.
traduire --dossier <dossierDuPack> --texte "<texte>" [--sens <sens>] [--code <code>]
Décrit le pack RÉELLEMENT présent dans <dossierDuPack> (fichiers *.spm et
*.onnx), puis exécute le pipeline complet de OnnxTranslationService : résultat,
temps de chargement du vocabulaire, temps de chargement de chaque modèle et
durée de la traduction. Le dossier est traité comme « langues/es/<sens> » :
ses fichiers sont lus tels quels, rien n'y est écrit ni modifié.
--sens versFrancais (défaut) | depuisFrancais
--code code de langue du pack (défaut : es)
--texte texte à traduire (obligatoire)
--aide, -h
Affiche cette aide.
CODES DE SORTIE
0 succès
1 erreur d'usage (argument manquant, option ou commande inconnue)
2 opération en échec (fichier absent, vocabulaire refusé, traduction échouée)
SANS MOTEUR ONNX RUNTIME
Le moteur n'est compilé que sous le symbole OMIKA_ONNX_RUNTIME (voir
docs\VALIDATION_TRADUCTION_ONNX.md ; état actuel : docs\TRADUCTION_HORS_LIGNE_MOBILE.md,
§7). « inspecter » et « encoder » fonctionnent sans lui ; « traduire » explique alors
que la traduction est indisponible, sans jamais afficher de trace technique.
EXEMPLES
Omika.LanguesProbe inspecter "C:\packs\es\versFrancais\vocab.spm"
Omika.LanguesProbe encoder "C:\packs\es\versFrancais\vocab.spm" "Bonjour, comment allez-vous ?"
Omika.LanguesProbe traduire --dossier "C:\packs\es\versFrancais" --texte "La factura se enviará mañana."
""");
}
}
/// <summary>Codes de sortie du programme, énoncés aussi par <c>--aide</c>.</summary>
internal static class CodesSortie
{
internal const int Succes = 0;
internal const int ErreurUsage = 1;
internal const int OperationEchouee = 2;
}
+332
View File
@@ -0,0 +1,332 @@
using System.Diagnostics;
using System.Reflection;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Infrastructure;
using Omika.App.Local.Models;
using Omika.App.Local.Services;
using Omika.Core.Contracts.Mobile.Languages;
namespace Omika.LanguesProbe;
/// <summary>
/// Commande « traduire » : décrit le pack réellement présent sur le disque, puis
/// exécute le <b>vrai</b> pipeline de traduction
/// (<c>OnnxTranslationService</c>) hors d'un téléphone.
///
/// <para>
/// <b>Le moteur est retrouvé par réflexion, et c'est délibéré.</b>
/// <c>OnnxRuntimeInferenceSessionFactory</c> n'existe dans l'assembly
/// <c>Omika.App.Local</c> que si le symbole <c>OMIKA_ONNX_RUNTIME</c> y est
/// défini : une référence directe ne compilerait pas tant que le moteur n'est
/// pas embarqué, ce qui interdirait justement d'utiliser cet outil <b>avant</b>
/// de l'activer. La réflexion laisse donc l'outil compiler dans les deux états,
/// sans référencer <c>Microsoft.ML.OnnxRuntime</c>, et fait basculer cette
/// commande toute seule le jour où le moteur est activé.
/// </para>
/// </summary>
internal static class TraductionEssai
{
private const string NomTypeMoteur = "Omika.App.Local.Services.OnnxRuntimeInferenceSessionFactory";
public static int Traduire(string[] args)
{
Arguments arguments = Arguments.Analyser(args, "dossier", "sens", "texte", "code");
string dossier = arguments.Option("dossier")
?? throw new ErreurUsageException("option obligatoire manquante : --dossier <dossierDuPack>");
string texte = arguments.Option("texte")
?? throw new ErreurUsageException("option obligatoire manquante : --texte \"<texte>\"");
string code = arguments.Option("code", "es");
string sensDemande = arguments.Option("sens", LanguageModelDirections.VersFrancais);
if (!LanguagePaths.EstCodeValide(code))
{
throw new ErreurUsageException(
$"code de langue invalide « {code} » (minuscules ASCII et tirets, 2 à 10 caractères : es, pt-br)");
}
SensTraduction? sens = LireSens(sensDemande)
?? throw new ErreurUsageException(
$"sens inconnu « {sensDemande} » : attendu {LanguageModelDirections.VersFrancais} "
+ $"ou {LanguageModelDirections.DepuisFrancais}");
if (string.IsNullOrWhiteSpace(texte))
{
throw new ErreurUsageException("le texte à traduire est vide");
}
// Le dossier est traité comme « langues/<code>/<sens> » : c'est le contrat de la commande.
if (!Directory.Exists(dossier))
{
Console.Error.WriteLine($"ÉCHEC : le dossier « {Program.NomFichier(dossier)} » est introuvable.");
return CodesSortie.OperationEchouee;
}
string sensCode = sens.Value.VersCode();
var magasin = new PackLangueDossier(dossier, code, sensCode);
PackLangueInstalle pack = magasin.DecrirePack();
Console.WriteLine("── Pack présent sur le disque ──");
Console.WriteLine($" Dossier : {Program.NomFichier(dossier)} Langue : {code} Sens : {sensCode}");
if (pack.Fichiers.Count == 0)
{
Console.Error.WriteLine("ÉCHEC : ce dossier ne contient aucun fichier .spm ni .onnx.");
return CodesSortie.OperationEchouee;
}
foreach (FichierModeleInstalle fichier in pack.Fichiers)
{
Console.WriteLine($" {fichier.NomFichier,-22} {LangueDisponible.FormaterTaille(fichier.TailleOctets),10}");
}
// L'inventaire ne dit pas QUEL fichier le service retiendra : le rappeler évite de chercher
// longtemps pourquoi « encoder_v2.onnx » n'est pas celui qui a été chargé.
Console.WriteLine($" Priorité du service : {OnnxTranslationService.FichierVocabulaire}, "
+ $"{OnnxTranslationService.FichierEncodeur}, puis {OnnxTranslationService.FichierDecodeur} "
+ "(sinon le premier fichier du dossier)");
// Un jeton de cible mal déclaré produirait la mauvaise langue SANS erreur :
// l'afficher est la première chose à vérifier quand une traduction « sonne faux ».
AfficherMetadonnees(magasin, code, sensCode);
FichierModeleInstalle? vocabulaire = VocabulaireRetenu(pack);
if (vocabulaire is null)
{
Console.Error.WriteLine("ÉCHEC : aucun vocabulaire .spm dans ce dossier.");
return CodesSortie.OperationEchouee;
}
var chrono = Stopwatch.StartNew();
Console.WriteLine();
Console.WriteLine("── Vocabulaire ──");
SentencePieceTokenizer tokenizer;
try
{
// Chargé ici en plus du service (qui le recharge pour lui) : cela donne le temps réel du
// fichier et fait échouer la commande AVANT le chargement des modèles, qui coûte
// autrement plus cher qu'un diagnostic de vocabulaire.
tokenizer = SentencePieceTokenizer.Charger(magasin.CheminFichier(code, sensCode, vocabulaire.NomFichier));
}
catch (SentencePieceException ex)
{
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
return CodesSortie.OperationEchouee;
}
chrono.Stop();
Console.WriteLine($" {vocabulaire.NomFichier} chargé en {chrono.ElapsedMilliseconds} ms — "
+ $"{tokenizer.TailleVocabulaire} pièces, {tokenizer.Avertissements.Count} avertissement(s)");
Console.WriteLine($" Normalisation : {tokenizer.ProfilNormalisation}");
Console.WriteLine(" Détail (aller-retour, repli octet, pièces) : commandes « inspecter » et « encoder ».");
IOnnxInferenceSessionFactory fabrique = ResoudreFabrique();
Console.WriteLine();
Console.WriteLine("── Moteur d'inférence ──");
Console.WriteLine($" Moteur : {fabrique.NomMoteur}");
Console.WriteLine($" Disponible : {(fabrique.EstDisponible ? "oui" : "non")}");
if (!fabrique.EstDisponible)
{
Program.AfficherMoteurAbsent();
return CodesSortie.OperationEchouee;
}
// Le service est libéré en fin de commande (ses sessions ONNX avec lui) : un modèle de
// plusieurs dizaines de Mo ne doit pas survivre à un outil de diagnostic.
using var service = new OnnxTranslationService(magasin, fabrique, new OnnxTranslationOptions());
if (!service.EstInstalleeAsync(code, sens.Value).GetAwaiter().GetResult())
{
Console.Error.WriteLine("ÉCHEC : le service ne reconnaît pas ce dossier comme un pack exploitable");
Console.Error.WriteLine(" (vocabulaire ou modèle .onnx manquant pour ce sens).");
return CodesSortie.OperationEchouee;
}
Console.WriteLine();
Console.WriteLine("── Chargement des modèles (session jetable, libérée aussitôt) ──");
foreach (FichierModeleInstalle fichier in pack.Fichiers)
{
if (fichier.NomFichier.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
{
MesurerChargementModele(fabrique, magasin, code, sensCode, fichier);
}
}
// Écart le plus fréquent entre deux exports d'un même modèle : les noms d'entrées/sorties.
// Le service les lit dans OnnxTranslationOptions ; les afficher rend l'écart visible.
Console.WriteLine(" Noms attendus par OnnxTranslationOptions : input_ids, attention_mask,");
Console.WriteLine(" decoder_input_ids, encoder_hidden_states, encoder_attention_mask, logits.");
Console.WriteLine();
Console.WriteLine("── Traduction ──");
Console.WriteLine($" Sens : {sensCode} ({LibelleSens(sens.Value)})");
Console.WriteLine($" Source : {texte}");
chrono.Restart();
try
{
string traduction = service.TraduireAsync(texte, code, sens.Value).GetAwaiter().GetResult();
chrono.Stop();
Console.WriteLine($" Résultat : {traduction}");
Console.WriteLine($" Durée : {chrono.ElapsedMilliseconds} ms (chargement des modèles et inférence compris)");
return CodesSortie.Succes;
}
catch (TranslationException ex)
{
chrono.Stop();
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
// Moteur absent au premier appel (bibliothèque native manquante, symbole non défini) :
// c'est l'échec attendu d'une machine de développement, pas un défaut du pack.
if (ex.Message.Contains("indisponible", StringComparison.OrdinalIgnoreCase))
{
Program.AfficherMoteurAbsent();
}
return CodesSortie.OperationEchouee;
}
}
/// <summary>
/// Ouvre un modèle pour en mesurer le seul chargement, et publier au
/// passage les noms d'entrées/sorties réels : un modèle corrompu est ainsi
/// désigné nommément, avant la traduction.
/// </summary>
private static void MesurerChargementModele(
IOnnxInferenceSessionFactory fabrique,
PackLangueDossier magasin,
string code,
string sens,
FichierModeleInstalle fichier)
{
var chrono = Stopwatch.StartNew();
try
{
using IOnnxInferenceSession session = fabrique.Ouvrir(magasin.CheminFichier(code, sens, fichier.NomFichier));
chrono.Stop();
Console.WriteLine($" {fichier.NomFichier,-22} {chrono.ElapsedMilliseconds,6} ms");
Console.WriteLine($" entrées : {string.Join(", ", session.NomsEntrees)}");
Console.WriteLine($" sorties : {string.Join(", ", session.NomsSorties)}");
}
catch (TranslationException ex)
{
chrono.Stop();
Console.WriteLine($" {fichier.NomFichier,-22} non mesurable : {Program.MessageSansChemin(ex.Message)}");
}
}
/// <summary>
/// Fabrique d'inférence réellement disponible : celle du moteur si le
/// symbole <c>OMIKA_ONNX_RUNTIME</c> est défini dans
/// <c>Omika.App.Local</c>, sinon le repli de l'application — qui répond
/// « indisponible » au lieu de planter.
/// </summary>
private static IOnnxInferenceSessionFactory ResoudreFabrique()
{
try
{
Type? typeMoteur = typeof(OnnxTranslationService).Assembly.GetType(NomTypeMoteur, throwOnError: false);
object? instance = typeMoteur
?.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)
?.GetValue(null);
if (instance is IOnnxInferenceSessionFactory fabrique)
{
return fabrique;
}
}
catch (Exception)
{
// Le type existe mais sa bibliothèque native manque : ce n'est pas une erreur de l'outil,
// c'est exactement l'état « moteur indisponible » que le repli représente.
}
return UnavailableOnnxInferenceSessionFactory.Instance;
}
/// <summary>
/// Affiche le jeton de cible que le pack déclare (<c>modele.json</c>), tel que
/// le moteur l'enverra réellement au modèle. Un fichier absent est l'état
/// normal d'un modèle dédié à une paire ; un fichier illisible est signalé
/// ici plutôt que découvert au milieu d'une traduction.
/// </summary>
private static void AfficherMetadonnees(PackLangueDossier magasin, string code, string sens)
{
string chemin = magasin.CheminFichier(code, sens, ModelePackMetadonnees.NomFichier);
if (!File.Exists(chemin))
{
Console.WriteLine(" Jeton cible : aucun (modèle dédié à une paire)");
return;
}
try
{
ModelePackMetadonnees? metadonnees = ModelePackMetadonnees.DepuisJson(File.ReadAllText(chemin));
if (metadonnees is null)
{
Console.WriteLine($" Jeton cible : aucun ({ModelePackMetadonnees.NomFichier} vide)");
return;
}
string modele = string.IsNullOrEmpty(metadonnees.ModeleSource) ? "non déclaré" : metadonnees.ModeleSource;
string jeton = metadonnees.AjouteUnJeton ? metadonnees.JetonCible : "aucun";
Console.WriteLine($" Jeton cible : {jeton} (modèle {modele})");
}
catch (FormatException ex)
{
Console.WriteLine($" Jeton cible : ILLISIBLE — {ex.Message}");
}
}
/// <summary>
/// Vocabulaire que le service retiendra : <c>vocab.spm</c> s'il est présent,
/// sinon le premier <c>.spm</c> du dossier — même règle que
/// <c>OnnxTranslationService</c>, reproduite ici uniquement pour que le temps
/// mesuré porte sur le fichier réellement utilisé.
/// </summary>
private static FichierModeleInstalle? VocabulaireRetenu(PackLangueInstalle pack)
{
FichierModeleInstalle? premier = null;
foreach (FichierModeleInstalle fichier in pack.Fichiers)
{
if (!fichier.NomFichier.EndsWith(".spm", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.Equals(fichier.NomFichier, OnnxTranslationService.FichierVocabulaire, StringComparison.OrdinalIgnoreCase))
{
return fichier;
}
premier ??= fichier;
}
return premier;
}
/// <summary>
/// Sens demandé, comparé aux valeurs <b>canoniques</b> du manifeste plutôt
/// qu'à des chaînes écrites à la main. La casse est tolérée parce que le sens
/// est aussi tapé à la main sur un poste de développement.
/// </summary>
private static SensTraduction? LireSens(string valeur)
{
foreach (SensTraduction sens in Enum.GetValues<SensTraduction>())
{
if (string.Equals(sens.VersCode(), valeur, StringComparison.OrdinalIgnoreCase))
{
return sens;
}
}
return null;
}
/// <summary>Rappel du rôle métier de chaque sens (dictée ou lecture), pour lever tout doute.</summary>
private static string LibelleSens(SensTraduction sens)
=> sens == SensTraduction.VersFrancais
? "langue secondaire → français (dictée)"
: "français → langue secondaire (lecture)";
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.LieuProbe</AssemblyName>
<RootNamespace>Omika.LieuProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+98
View File
@@ -0,0 +1,98 @@
// Probe LECTURE SEULE ciblé : déterminer si le fichier "Lieu" existe dans BATIK_TEST.
// Aucune écriture. Source de vérité : la base réelle via OLE DB PCSOFT.HFSQL.
using System.Data.OleDb;
const string server = "127.0.0.1";
const int port = 4900;
const string user = "susana";
const string password = "123";
const string database = "BATIK_TEST";
string BuildConnStr() =>
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
Console.WriteLine($"=== Probe Lieu (base {database}) ===");
try
{
using var conn = new OleDbConnection(BuildConnStr());
conn.Open();
Console.WriteLine("Connexion OK.");
// 1) Lister les tables disponibles (schéma) pour voir si "Lieu" existe.
Console.WriteLine("\n--- Tables disponibles (GetOleDbSchemaTable) ---");
try
{
var tables = conn.GetOleDbSchemaTable(OleDbSchemaGuid.Tables, null);
if (tables is null)
{
Console.WriteLine(" (aucune table retournée)");
}
else
{
var names = new List<string>();
foreach (System.Data.DataRow row in tables.Rows)
{
if (row.Table.Columns.Contains("TABLE_NAME"))
{
names.Add(row["TABLE_NAME"]?.ToString() ?? "");
}
}
names.Sort(StringComparer.OrdinalIgnoreCase);
foreach (var n in names)
{
Console.WriteLine($" {n}");
}
Console.WriteLine($" Total: {names.Count}");
Console.WriteLine($" Lieu présent: {(names.Contains("Lieu", StringComparer.OrdinalIgnoreCase) ? "OUI" : "NON")}");
}
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR listing tables: {ex.Message}");
}
// 2) Tester la requête réelle FROM Lieu (contrôle de l'erreur 70114).
Console.WriteLine("\n--- SELECT LI_PKID FROM Lieu ---");
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT LI_PKID FROM Lieu";
using var reader = cmd.ExecuteReader();
int rowCount = 0;
while (reader.Read())
{
rowCount++;
}
Console.WriteLine($" rows={rowCount}");
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR: {ex.Message}");
}
// 3) Contrôle : Client doit fonctionner (le snapshot Clients passe).
Console.WriteLine("\n--- SELECT CL_PKID FROM Client (contrôle) ---");
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT CL_PKID FROM Client";
using var reader = cmd.ExecuteReader();
int rowCount = 0;
while (reader.Read())
{
rowCount++;
}
Console.WriteLine($" rows={rowCount}");
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR: {ex.Message}");
}
}
catch (Exception ex)
{
Console.WriteLine($"ERREUR connexion: {ex.Message}");
}
Console.WriteLine("\nProbe Lieu terminé (lecture seule).");
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.MessageExterneProbe</AssemblyName>
<RootNamespace>Omika.MessageExterneProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+802
View File
@@ -0,0 +1,802 @@
using System.Data.OleDb;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
// ============================================================
// OMIKA MESSAGE EXTERNE PROBE — READ-ONLY DB
// Audit structurel des corps HTML (MessageExterne.ME_Message) et des
// modèles DOCX (MailModele.MM_MsgDoc) pour la compatibilité WinDev ↔ Omika.
// Ne modifie AUCUNE donnée. N'affiche JAMAIS de contenu privé : le squelette
// du body est affiché avec le texte masqué (seul le CSS générique WinDev et
// la structure des balises/attributs sont montrés).
// ============================================================
const string server = "127.0.0.1";
const int port = 4900;
const string user = "susana";
const string pass = "123";
var dbMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["OMIKA_TEST"] = "A1BAF17C65BC565F2927BF1A85DAFEED",
["BATIK_TEST"] = "E2FC3072E6145E1B20DA7321755CD607",
};
int dbIdx = Array.IndexOf(args, "--db");
string db = dbIdx >= 0 && dbIdx + 1 < args.Length ? args[dbIdx + 1] : "OMIKA_TEST";
if (!dbMap.TryGetValue(db, out string? stdPwd))
{
Console.Error.WriteLine($"ERROR: unknown database '{db}'.");
return 2;
}
string connStr =
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={db};" +
$"User ID={user};Password={pass};" +
$"Extended Properties=\"Password=ParametreUtilisateur:{stdPwd}\"";
if (args.Contains("--list-messages")) return ListMessages(connStr, db);
if (args.Contains("--list-models")) return ListModels(connStr, db);
int ipos = Array.IndexOf(args, "--collab-identity");
if (ipos >= 0 && ipos + 1 < args.Length) return CollabIdentity(connStr, long.Parse(args[ipos + 1]));
ipos = Array.IndexOf(args, "--smtp-config");
if (ipos >= 0 && ipos + 1 < args.Length) return SmtpConfig(db, stdPwd, long.Parse(args[ipos + 1]));
ipos = Array.IndexOf(args, "--smtp-resolution");
if (ipos >= 0 && ipos + 1 < args.Length) return SmtpResolution(connStr, long.Parse(args[ipos + 1]));
ipos = Array.IndexOf(args, "--image-dims");
if (ipos >= 0 && ipos + 1 < args.Length) return ImageDims(connStr, long.Parse(args[ipos + 1]));
ipos = Array.IndexOf(args, "--message-fields");
if (ipos >= 0 && ipos + 1 < args.Length) return MessageFields(connStr, long.Parse(args[ipos + 1]));
ipos = Array.IndexOf(args, "--smtp-keys");
if (ipos >= 0 && ipos + 1 < args.Length) return SmtpKeys(connStr, long.Parse(args[ipos + 1]));
int pos = Array.IndexOf(args, "--analyze");
if (pos >= 0 && pos + 1 < args.Length) return AnalyzeMessage(connStr, long.Parse(args[pos + 1]));
pos = Array.IndexOf(args, "--model-structure");
if (pos >= 0 && pos + 1 < args.Length) return ModelStructure(connStr, long.Parse(args[pos + 1]));
pos = Array.IndexOf(args, "--dump-message");
if (pos >= 0 && pos + 2 < args.Length) return DumpMessage(connStr, long.Parse(args[pos + 1]), args[pos + 2]);
pos = Array.IndexOf(args, "--dump-model");
if (pos >= 0 && pos + 2 < args.Length) return DumpModel(connStr, long.Parse(args[pos + 1]), args[pos + 2]);
Console.Error.WriteLine("USAGE:");
Console.Error.WriteLine(" --list-messages [--db OMIKA_TEST|BATIK_TEST]");
Console.Error.WriteLine(" --list-models [--db ...]");
Console.Error.WriteLine(" --collab-identity <COLLAB_PKID> [--db ...]");
Console.Error.WriteLine(" --smtp-config <UT_PKID> [--db ...]");
Console.Error.WriteLine(" --smtp-resolution <COLLAB_PKID> [--db ...]");
Console.Error.WriteLine(" --image-dims <ME_PKID> [--db ...]");
Console.Error.WriteLine(" --message-fields <ME_PKID> [--db ...]");
Console.Error.WriteLine(" --smtp-keys <UT_PKID> [--db ...]");
Console.Error.WriteLine(" --analyze <ME_PKID> [--db ...]");
Console.Error.WriteLine(" --model-structure <MM_PKID> [--db ...]");
Console.Error.WriteLine(" --dump-message <ME_PKID> <file> [--db ...]");
Console.Error.WriteLine(" --dump-model <MM_PKID> <file> [--db ...]");
return 2;
// ------------------------------------------------------------
/// <summary>Résout l'utilisateur lié à un collaborateur (UT_PKID) + nom du collaborateur.</summary>
static int CollabIdentity(string connStr, long collabPkId)
{
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT UT_PKID, UT_Nom, UT_Prenom, UT_Login FROM Utilisateur WHERE UT_IDCollaborateur = {collabPkId}";
using var r = cmd.ExecuteReader();
if (!r.Read())
{
Console.WriteLine("UTILISATEUR LIE AU COLLABORATEUR = NO");
return 0;
}
Console.WriteLine("UTILISATEUR LIE AU COLLABORATEUR = YES");
Console.WriteLine("UT_PKID = " + (r.IsDBNull(0) ? "(null)" : r.GetValue(0)?.ToString()));
Console.WriteLine("UT_NOM = " + (r.IsDBNull(1) ? "(null)" : r.GetValue(1)?.ToString()));
Console.WriteLine("UT_PRENOM = " + (r.IsDBNull(2) ? "(null)" : r.GetValue(2)?.ToString()));
Console.WriteLine("UT_LOGIN = " + (r.IsDBNull(3) ? "(null)" : r.GetValue(3)?.ToString()));
}
catch (Exception ex)
{
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
return 3;
}
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT * FROM Collaborateur WHERE CL_PKID = {collabPkId}";
using var r = cmd.ExecuteReader();
if (r.Read())
{
for (int i = 0; i < r.FieldCount; i++)
{
string? name = r.GetName(i);
if (name is "CL_Nom" or "CL_Prenom" or "CL_PKID")
{
Console.WriteLine(name + " = " + (r.IsDBNull(i) ? "(null)" : r.GetValue(i)?.ToString()));
}
}
}
else
{
Console.WriteLine("COLLABORATEUR = NOT FOUND (ou colonne PK différente de CL_PKID)");
}
}
catch (Exception ex)
{
Console.WriteLine("COLLAB QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
}
return 0;
}
/// <summary>
/// Reproduit EXACTEMENT la résolution SMTP côté API (lecture seule) :
/// Collaborateur (CO_PKID, GUID) → UT_PKID (UT_IDCollaborateur) → ParametreUtilisateur
/// (PU_IDUtilisateur, PU_Code LIKE 'EMAIL_ENVOI%'). N'affiche jamais le contenu
/// des valeurs JSON ni aucun secret.
/// </summary>
static int SmtpResolution(string connStr, long collabPkId)
{
Console.WriteLine("COLLAB PKID = " + collabPkId);
string? guid = null;
long utPk = 0;
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
// 1. GUID physique du collaborateur (résolution Omika : WHERE GUID = ?).
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"SELECT CO_PKID, GUID FROM Collaborateur WHERE CO_PKID = {collabPkId}";
using var r = cmd.ExecuteReader();
if (!r.Read())
{
Console.WriteLine("COLLABORATEUR FOUND = NO (CO_PKID introuvable)");
return 0;
}
guid = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
Console.WriteLine("COLLABORATEUR FOUND = YES");
Console.WriteLine("CO_PKID = " + (r.IsDBNull(0) ? "(null)" : r.GetValue(0)?.ToString()));
Console.WriteLine("GUID RAW = " + (guid is null ? "(null)" : $"[{guid}] len={guid.Length}"));
}
// Guid.TryParse + reserialize "D" = ce que l'API fait (PhysicalGuidParser → ToString("D")).
string? guidD = null;
if (guid is not null && Guid.TryParse(guid, out Guid g))
{
guidD = g.ToString("D");
Console.WriteLine("GUID PARSED (D) = " + guidD);
}
else
{
Console.WriteLine("GUID PARSED (D) = (non analysable)");
}
// 2. Résolution GUID → CO_PKID (requête de ResolveCollaborateurPkAsync).
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT CO_PKID FROM Collaborateur WHERE GUID = ?";
cmd.Parameters.Add(new OleDbParameter("?", guidD ?? (object)DBNull.Value));
object? v = cmd.ExecuteScalar();
Console.WriteLine("RESOLVE GUID→CO_PKID = " + (v is null or DBNull ? "NO MATCH" : v.ToString()));
}
// 3. Résolution UT_PKID (UT_IDCollaborateur = CO_PKID).
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"SELECT UT_PKID FROM Utilisateur WHERE UT_IDCollaborateur = {collabPkId}";
object? v = cmd.ExecuteScalar();
utPk = v is null or DBNull ? 0 : Convert.ToInt64(v);
Console.WriteLine("RESOLVE UT_PKID = " + (utPk > 0 ? utPk.ToString() : "NO MATCH"));
}
// 4. Configurations SMTP (PU_IDUtilisateur = UT_PKID).
if (utPk <= 0)
{
Console.WriteLine("SMTP ROWS = 0 (UT_PKID non résolu)");
return 0;
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"SELECT PU_Code FROM ParametreUtilisateur WHERE PU_IDUtilisateur = {utPk} AND PU_Code LIKE 'EMAIL_ENVOI%' ORDER BY PU_Code";
using var r = cmd.ExecuteReader();
var codes = new List<string>();
while (r.Read())
{
codes.Add(r.GetValue(0)?.ToString() ?? "");
}
Console.WriteLine("SMTP ROWS = " + codes.Count);
Console.WriteLine("SMTP CODES = " + string.Join(", ", codes));
}
}
catch (Exception ex)
{
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
return 3;
}
return 0;
}
/// <summary>
/// Dimensions de l'image inline d'un message (lecture seule) : attributs width/height
/// de la balise &lt;img&gt; vs dimensions intrinsèques du PNG/JPEG embarqué (data URI).
/// N'affiche JAMAIS l'image ni son contenu base64, uniquement des entiers.
/// </summary>
static int ImageDims(string connStr, long pk)
{
string? msg = ReadMessage(connStr, pk);
if (msg is null) { Console.WriteLine("MESSAGE FOUND = NO"); return 4; }
Match img = Regex.Match(msg, "<img\\b[^>]*>", RegexOptions.IgnoreCase);
if (!img.Success) { Console.WriteLine("IMG TAG = NO"); return 0; }
Console.WriteLine("IMG TAG = YES");
Match w = Regex.Match(img.Value, "width\\s*=\\s*[\"']?(\\d+)", RegexOptions.IgnoreCase);
Match h = Regex.Match(img.Value, "height\\s*=\\s*[\"']?(\\d+)", RegexOptions.IgnoreCase);
Console.WriteLine("IMG TAG WIDTH = " + (w.Success ? w.Groups[1].Value : "(absent)"));
Console.WriteLine("IMG TAG HEIGHT = " + (h.Success ? h.Groups[1].Value : "(absent)"));
Match src = Regex.Match(img.Value, "src\\s*=\\s*\"(data:[^\"]+)\"", RegexOptions.IgnoreCase);
if (!src.Success)
{
Console.WriteLine("DATA URI = NO");
return 0;
}
string dataUri = src.Groups[1].Value;
int comma = dataUri.IndexOf(',');
string meta = comma > 0 ? dataUri[..comma] : "";
bool isPng = meta.Contains("png", StringComparison.OrdinalIgnoreCase);
bool isJpeg = meta.Contains("jpeg", StringComparison.OrdinalIgnoreCase) || meta.Contains("jpg", StringComparison.OrdinalIgnoreCase);
Console.WriteLine("IMAGE FORMAT = " + (isPng ? "PNG" : isJpeg ? "JPEG" : "AUTRE"));
try
{
byte[] bytes = Convert.FromBase64String(dataUri[(comma + 1)..]);
if (isPng && bytes.Length >= 24)
{
int wpx = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
int hpx = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
Console.WriteLine("PNG INTRINSIC WIDTH = " + wpx);
Console.WriteLine("PNG INTRINSIC HEIGHT = " + hpx);
}
else
{
Console.WriteLine("INTRINSIC DIMS = (non extraites, format non PNG ou trop court)");
}
}
catch (FormatException)
{
Console.WriteLine("BASE64 DECODE = FAIL");
}
return 0;
}
/// <summary>Champs MessageExterne (lecture seule) — adresses masquées, corps jamais affiché.</summary>
static int MessageFields(string connStr, long pk)
{
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT ME_PKID, ME_IDIntervention, ME_AdrExp, ME_AdrDestinataires, ME_AdrCC, ME_AdrCCI, " +
"ME_Objet, ME_Envoyer, ME_Brouillon, ME_PJ, ME_IDDocPJ, ME_EnvoyerMob, CreationUser, CreationDate, ME_Message " +
"FROM MessageExterne WHERE ME_PKID = " + pk;
using var r = cmd.ExecuteReader();
if (!r.Read())
{
Console.WriteLine("MESSAGE FOUND = NO");
return 4;
}
Console.WriteLine("MESSAGE FOUND = YES");
Console.WriteLine("ME_PKID = " + Val(r, 0));
Console.WriteLine("ME_IDIntervention = " + Val(r, 1));
Console.WriteLine("ME_AdrExp = " + MaskEmails(Val(r, 2)));
Console.WriteLine("ME_AdrDestinataires = " + MaskEmails(Val(r, 3)));
Console.WriteLine("ME_AdrCC = " + MaskEmails(Val(r, 4)));
Console.WriteLine("ME_AdrCCI = " + MaskEmails(Val(r, 5)));
Console.WriteLine("ME_Objet = " + MaskText(Val(r, 6) ?? ""));
Console.WriteLine("ME_Envoyer = " + BoolVal(r, 7));
Console.WriteLine("ME_Brouillon = " + BoolVal(r, 8));
Console.WriteLine("ME_PJ = " + BoolVal(r, 9));
Console.WriteLine("ME_IDDocPJ = " + MaskPj(Val(r, 10)));
Console.WriteLine("ME_EnvoyerMob = " + BoolVal(r, 11));
Console.WriteLine("CreationUser = " + Val(r, 12));
Console.WriteLine("CreationDate = " + Val(r, 13));
string? msg = r.IsDBNull(14) ? null : r.GetValue(14)?.ToString();
Console.WriteLine("BODY LENGTH = " + (msg?.Length ?? 0));
Console.WriteLine("HAS SIGNATURE = " + HasSignature(msg));
}
catch (Exception ex)
{
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
return 3;
}
return 0;
}
/// <summary>Clés JSON (structure, sans valeur) des configs SMTP d'un utilisateur.</summary>
static int SmtpKeys(string connStr, long userId)
{
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = {userId} AND PU_Code LIKE 'EMAIL_ENVOI%' ORDER BY PU_Code";
using var r = cmd.ExecuteReader();
int n = 0;
while (r.Read())
{
string code = (r.GetValue(0)?.ToString() ?? "").Trim();
string? valeur = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
n++;
Console.WriteLine($"--- ROW {n}: PU_Code={code} ---");
if (string.IsNullOrWhiteSpace(valeur)) { Console.WriteLine("VALUE = (empty)"); continue; }
try
{
using JsonDocument doc = JsonDocument.Parse(valeur);
JsonElement root = doc.RootElement;
IEnumerable<JsonElement> items = root.ValueKind == JsonValueKind.Array
? root.EnumerateArray()
: [root];
int i = 0;
foreach (JsonElement el in items)
{
if (el.ValueKind != JsonValueKind.Object) { Console.WriteLine($" [{i}] = non-objet"); i++; continue; }
Console.WriteLine($" [{i}] keys = " + string.Join(", ", el.EnumerateObject().Select(p => p.Name)));
if (el.TryGetProperty("Compte", out JsonElement compte) && compte.ValueKind == JsonValueKind.Object)
Console.WriteLine($" [{i}] Compte keys = " + string.Join(", ", compte.EnumerateObject().Select(p => p.Name)));
if (el.TryGetProperty("SMTP", out JsonElement smtp) && smtp.ValueKind == JsonValueKind.Object)
{
Console.WriteLine($" [{i}] SMTP keys = " + string.Join(", ", smtp.EnumerateObject().Select(p => p.Name)));
foreach (JsonProperty p in smtp.EnumerateObject())
{
string kind = p.Value.ValueKind switch
{
JsonValueKind.Array => "array",
JsonValueKind.Object => "object",
JsonValueKind.String => "string",
JsonValueKind.True => "bool(true)",
JsonValueKind.False => "bool(false)",
JsonValueKind.Number => "number",
JsonValueKind.Null => "null",
_ => "other"
};
int len = p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString()?.Length ?? 0
: p.Value.ValueKind == JsonValueKind.Array ? p.Value.GetArrayLength()
: -1;
string extra = "";
if (p.Name == "bufSignature" && p.Value.ValueKind == JsonValueKind.String)
{
string? sv = p.Value.GetString();
string prefix = sv is { Length: > 0 } ? sv[..Math.Min(sv.Length, 16)] : "";
extra = $" prefix=\"{prefix.Replace("\n", "\\n").Replace("\r", "\\r")}\"";
}
else if (p.Name == "tabCC_CopieEmail" && p.Value.ValueKind == JsonValueKind.Array)
{
var elems = new List<string>();
int idx = 0;
foreach (JsonElement e in p.Value.EnumerateArray())
{
if (e.ValueKind == JsonValueKind.String)
elems.Add("s:" + MaskEmails(e.GetString() ?? ""));
else if (e.ValueKind == JsonValueKind.Object)
elems.Add("obj{" + string.Join(",", e.EnumerateObject().Select(x => x.Name)) + "}");
else
elems.Add(e.ValueKind.ToString());
idx++;
}
extra = $" elements=[{string.Join(" | ", elems)}]";
}
Console.WriteLine($" SMTP.{p.Name} = {kind} len={len}{extra}");
}
}
i++;
}
}
catch (JsonException) { Console.WriteLine("JSON = invalid"); }
}
if (n == 0) Console.WriteLine("NO ROWS");
}
catch (Exception ex)
{
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
return 3;
}
return 0;
}
/// <summary>Audit structurel (sans secret) des configs SMTP d'un utilisateur.</summary>
static int SmtpConfig(string db, string? stdPwd, long userId)
{
string connStr2 = $"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={db};" +
$"User ID={user};Password={pass};" +
$"Extended Properties=\"Password=ParametreUtilisateur:{stdPwd}\"";
Console.WriteLine("PU_IDUTILISATEUR = " + userId);
try
{
using var conn = new OleDbConnection(connStr2);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = {userId} AND PU_Code LIKE 'EMAIL_ENVOI%' ORDER BY PU_Code";
using var r = cmd.ExecuteReader();
int n = 0;
while (r.Read())
{
string code = (r.GetValue(0)?.ToString() ?? "").Trim();
string? valeur = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
n++;
Console.WriteLine($"--- ROW {n}: PU_Code={code} ---");
Console.WriteLine("DEV SMTP CONFIG FOUND = YES");
Console.WriteLine("VALUE PRESENT = " + (string.IsNullOrWhiteSpace(valeur) ? "NO" : "YES"));
if (string.IsNullOrWhiteSpace(valeur))
{
Console.WriteLine("STANDARD CONFIG FOUND = NO");
continue;
}
// Structure JSON (jamais le contenu sMdp/sMdpStandard lui-même).
try
{
using JsonDocument doc = JsonDocument.Parse(valeur);
var smtp = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement.EnumerateArray().Select(e => e.TryGetProperty("SMTP", out JsonElement s) ? s : default).FirstOrDefault(s => s.ValueKind == JsonValueKind.Object)
: (doc.RootElement.TryGetProperty("SMTP", out JsonElement s0) ? s0 : default);
if (smtp.ValueKind == JsonValueKind.Object)
{
bool hasStd = smtp.TryGetProperty("sMdpStandard", out JsonElement e) && e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString());
bool hasMdp = smtp.TryGetProperty("sMdp", out JsonElement e2) && e2.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e2.GetString());
string host = smtp.TryGetProperty("sAdresseServeur", out JsonElement h) ? h.GetString() ?? "" : "";
string port = smtp.TryGetProperty("sPortServeur", out JsonElement p) ? p.GetString() ?? "" : "";
int cryptage = smtp.TryGetProperty("nCryptage", out JsonElement c) ? c.GetInt32() : 0;
string login = smtp.TryGetProperty("sLogin", out JsonElement l) ? l.GetString() ?? "" : "";
Console.WriteLine("STANDARD CONFIG FOUND = " + (hasStd ? "YES" : "NO"));
Console.WriteLine("sMdpStandard PRESENT = " + (hasStd ? "YES" : "NO"));
Console.WriteLine("LEGACY sMdp PRESENT = " + (hasMdp ? "YES" : "NO"));
Console.WriteLine("HOST PRESENT = " + (string.IsNullOrWhiteSpace(host) ? "NO" : "YES"));
Console.WriteLine("PORT PRESENT = " + (string.IsNullOrWhiteSpace(port) ? "NO" : "YES"));
Console.WriteLine("CRYPTAGE = " + cryptage);
Console.WriteLine("LOGIN PRESENT = " + (string.IsNullOrWhiteSpace(login) ? "NO" : "YES"));
Console.WriteLine("CONFIGURATION VALID = " +
((hasStd && !string.IsNullOrWhiteSpace(host) && !string.IsNullOrWhiteSpace(port) && cryptage is >= 1 and <= 3 && !string.IsNullOrWhiteSpace(login)) ? "YES" : "NO"));
}
else
{
Console.WriteLine("STANDARD CONFIG FOUND = NO (SMTP absent du JSON)");
}
}
catch (JsonException)
{
Console.WriteLine("STANDARD CONFIG FOUND = NO (JSON invalide ou legacy/binaire)");
}
}
if (n == 0)
{
Console.WriteLine("DEV SMTP CONFIG FOUND = NO");
}
}
catch (Exception ex)
{
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
return 3;
}
return 0;
}
static int ListMessages(string connStr, string db)
{
Console.WriteLine("DATABASE = " + db);
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT ME_PKID, ME_Objet, ME_Message, CreationDate, CreationUser FROM MessageExterne ORDER BY CreationDate DESC";
using var r = cmd.ExecuteReader();
int i = 0;
while (r.Read())
{
long pk = Convert.ToInt64(r.GetValue(0));
string? objet = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
string? msg = r.IsDBNull(2) ? null : r.GetValue(2)?.ToString();
string? created = r.IsDBNull(3) ? null : r.GetValue(3)?.ToString();
string? creator = r.IsDBNull(4) ? null : r.GetValue(4)?.ToString();
string kind = Classify(msg);
string obj = (objet ?? "").Replace("\n", " ").Replace("\r", " ");
if (obj.Length > 60) obj = obj[..60];
Console.WriteLine($"[{i}] PK={pk} KIND={kind} LEN={(msg ?? "").Length} OBJ=\"{obj}\" CREATED={created} USER={creator}");
i++;
}
}
catch (Exception ex)
{
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
return 3;
}
return 0;
}
static int ListModels(string connStr, string db)
{
Console.WriteLine("DATABASE = " + db);
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT MM_PKID, MM_Nom, MM_Sujet, MM_MsgDoc FROM MailModele ORDER BY MM_PKID";
using var r = cmd.ExecuteReader();
int i = 0;
while (r.Read())
{
long pk = Convert.ToInt64(r.GetValue(0));
string? nom = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
string? sujet = r.IsDBNull(2) ? null : r.GetValue(2)?.ToString();
int docLen = r.IsDBNull(3) ? 0 : (r.GetValue(3) as byte[])?.Length ?? 0;
Console.WriteLine($"[{i}] PK={pk} NOM=\"{nom}\" SUJET=\"{sujet}\" DOC_LEN={docLen}");
i++;
}
}
catch (Exception ex)
{
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
return 3;
}
return 0;
}
static int AnalyzeMessage(string connStr, long pk)
{
string? msg = ReadMessage(connStr, pk);
if (msg is null)
{
Console.WriteLine("MESSAGE FOUND = NO");
return 4;
}
Console.WriteLine("MESSAGE FOUND = YES");
Console.WriteLine($"PK = {pk}");
Console.WriteLine($"BODY LENGTH = {msg.Length}");
string lower = msg.ToLowerInvariant();
Console.WriteLine("FULL HTML DOCUMENT = " + (Contains(lower, "<html") ? "YES" : "NO"));
Console.WriteLine("HAS HEAD = " + (Contains(lower, "<head") ? "YES" : "NO"));
Console.WriteLine("HAS STYLE = " + (Contains(lower, "<style") ? "YES" : "NO"));
Console.WriteLine("HAS SCRIPT = " + (Contains(lower, "<script") ? "YES" : "NO"));
Console.WriteLine("HAS BODY = " + (Contains(lower, "<body") ? "YES" : "NO"));
Console.WriteLine("HAS META = " + (Contains(lower, "<meta") ? "YES" : "NO"));
Console.WriteLine("HAS TITLE = " + (Contains(lower, "<title") ? "YES" : "NO"));
Console.WriteLine("HAS LINK = " + (Contains(lower, "<link") ? "YES" : "NO"));
Console.WriteLine("TABLE COUNT = " + Regex.Matches(msg, "<table", RegexOptions.IgnoreCase).Count);
Console.WriteLine("TR COUNT = " + Regex.Matches(msg, "<tr", RegexOptions.IgnoreCase).Count);
Console.WriteLine("TD COUNT = " + Regex.Matches(msg, "<td", RegexOptions.IgnoreCase).Count);
Console.WriteLine("TH COUNT = " + Regex.Matches(msg, "<th", RegexOptions.IgnoreCase).Count);
Console.WriteLine("TBODY COUNT = " + Regex.Matches(msg, "<tbody", RegexOptions.IgnoreCase).Count);
Console.WriteLine("IMG COUNT = " + Regex.Matches(msg, "<img", RegexOptions.IgnoreCase).Count);
Console.WriteLine("P COUNT = " + Regex.Matches(msg, "<p", RegexOptions.IgnoreCase).Count);
Console.WriteLine("BR COUNT = " + Regex.Matches(msg, "<br", RegexOptions.IgnoreCase).Count);
Console.WriteLine("SPAN COUNT = " + Regex.Matches(msg, "<span", RegexOptions.IgnoreCase).Count);
Console.WriteLine("DIV COUNT = " + Regex.Matches(msg, "<div", RegexOptions.IgnoreCase).Count);
Console.WriteLine("COL COUNT = " + Regex.Matches(msg, "<col", RegexOptions.IgnoreCase).Count);
var classes = Regex.Matches(msg, "class\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase)
.Select(m => m.Groups[1].Value)
.SelectMany(v => v.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(c => c, StringComparer.OrdinalIgnoreCase)
.ToList();
Console.WriteLine("UNIQUE CLASSES = " + string.Join(", ", classes));
// Bloc <style> générique (CSS WinDev, non privé) — affiché pour la spec.
var styles = Regex.Matches(msg, "<style[^>]*>(.*?)</style>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
Console.WriteLine("STYLE BLOCKS = " + styles.Count);
for (int i = 0; i < styles.Count; i++)
{
Console.WriteLine($"--- STYLE BLOCK {i} (len={styles[i].Groups[1].Value.Length}) ---");
Console.WriteLine(styles[i].Groups[1].Value.Trim());
}
Console.WriteLine("--- BODY SKELETON (texte masqué, src/href masqués) ---");
Console.WriteLine(Skeleton(msg));
return 0;
}
static int ModelStructure(string connStr, long pk)
{
byte[]? docx = ReadModel(connStr, pk);
if (docx is null)
{
Console.WriteLine("MODEL FOUND = NO");
return 4;
}
Console.WriteLine("MODEL FOUND = YES");
Console.WriteLine($"PK = {pk}");
Console.WriteLine($"DOC LENGTH = {docx.Length}");
Console.WriteLine("ZIP MAGIC (DOCX) = " + (docx.Length >= 2 && docx[0] == 0x50 && docx[1] == 0x4B ? "YES" : "NO"));
if (!(docx.Length >= 2 && docx[0] == 0x50 && docx[1] == 0x4B))
{
Console.WriteLine("NON-DOCX MODEL — first 16 bytes hex: " + BitConverter.ToString(docx.Take(16).ToArray()));
return 0;
}
try
{
using var ms = new MemoryStream(docx);
using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
var entries = zip.Entries.Select(e => e.FullName).ToList();
Console.WriteLine("ZIP ENTRIES = " + string.Join(", ", entries));
var documentEntry = zip.Entries.FirstOrDefault(e => e.FullName == "word/document.xml");
if (documentEntry is null)
{
Console.WriteLine("document.xml ABSENT");
return 0;
}
using var sr = new StreamReader(documentEntry.Open(), Encoding.UTF8);
string xml = sr.ReadToEnd();
Console.WriteLine("document.xml LENGTH = " + xml.Length);
Console.WriteLine("PARAGRAPH COUNT = " + Regex.Matches(xml, "<w:p[ >]").Count);
Console.WriteLine("TABLE COUNT = " + Regex.Matches(xml, "<w:tbl>").Count);
Console.WriteLine("ROW COUNT = " + Regex.Matches(xml, "<w:tr>").Count);
Console.WriteLine("CELL COUNT = " + Regex.Matches(xml, "<w:tc>").Count);
Console.WriteLine("DRAWING/IMAGE COUNT = " + Regex.Matches(xml, "<w:drawing>").Count);
Console.WriteLine("TEXT COUNT = " + Regex.Matches(xml, "<w:t[ >]").Count);
Console.WriteLine("--- DOCUMENT.XML SKELETON (texte masqué) ---");
Console.WriteLine(SkeletonXml(xml));
}
catch (Exception ex)
{
Console.WriteLine("DOCX PARSE FAILED = " + ex.GetType().Name + " : " + ex.Message);
}
return 0;
}
static int DumpMessage(string connStr, long pk, string file)
{
string? msg = ReadMessage(connStr, pk);
if (msg is null) { Console.WriteLine("MESSAGE FOUND = NO"); return 4; }
File.WriteAllText(file, msg, Encoding.UTF8);
Console.WriteLine($"WROTE {msg.Length} chars to {file}");
return 0;
}
static int DumpModel(string connStr, long pk, string file)
{
byte[]? docx = ReadModel(connStr, pk);
if (docx is null) { Console.WriteLine("MODEL FOUND = NO"); return 4; }
File.WriteAllBytes(file, docx);
Console.WriteLine($"WROTE {docx.Length} bytes to {file}");
return 0;
}
static string? ReadMessage(string connStr, long pk)
{
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT ME_Message FROM MessageExterne WHERE ME_PKID = {pk}";
object? v = cmd.ExecuteScalar();
return v is DBNull or null ? null : v.ToString();
}
catch (Exception ex)
{
Console.WriteLine("READ MESSAGE FAILED = " + ex.GetType().Name + " : " + ex.Message);
return null;
}
}
static byte[]? ReadModel(string connStr, long pk)
{
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT MM_MsgDoc FROM MailModele WHERE MM_PKID = {pk}";
object? v = cmd.ExecuteScalar();
return v switch
{
byte[] b => b,
DBNull or null => null,
_ => Encoding.UTF8.GetBytes(v.ToString() ?? "")
};
}
catch (Exception ex)
{
Console.WriteLine("READ MODEL FAILED = " + ex.GetType().Name + " : " + ex.Message);
return null;
}
}
static bool Contains(string haystack, string needle)
=> haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
static string Classify(string? msg)
{
if (string.IsNullOrWhiteSpace(msg)) return "EMPTY";
string l = msg.ToLowerInvariant();
if (l.Contains("<html") && (l.Contains("<head") || l.Contains("<body") || l.Contains("<style")))
return "WINFULL";
if (l.Contains("<table") || l.Contains("<p")) return "OMIKA-FRAGMENT";
return "OTHER";
}
static string Skeleton(string html)
{
// Masque les textes entre balises et les valeurs src/href (aucun contenu privé).
string s = Regex.Replace(html, "(src|href)\\s*=\\s*\"[^\"]*\"", "$1=\"{MASKED}\"", RegexOptions.IgnoreCase);
s = Regex.Replace(s, ">([^<>]+)<", m => ">" + MaskText(m.Groups[1].Value) + "<");
return s;
}
static string MaskText(string text)
{
string t = text.Trim();
if (t.Length == 0) return text;
return "{T" + t.Length + "}";
}
static string Val(System.Data.OleDb.OleDbDataReader r, int i)
=> r.IsDBNull(i) ? "(null)" : (r.GetValue(i)?.ToString() ?? "(null)");
static string BoolVal(System.Data.OleDb.OleDbDataReader r, int i)
=> r.IsDBNull(i) ? "(null)" : (Convert.ToBoolean(r.GetValue(i)) ? "TRUE" : "FALSE");
static string MaskEmails(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return "(vide)";
return Regex.Replace(s, @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[email]", RegexOptions.IgnoreCase);
}
static string MaskPj(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return "(vide)";
// Les références de PJ peuvent être des GUID/IDs/chemins : on masque les GUID
// et chemins, mais on conserve la STRUCTURE (séparateurs + nombre d'éléments).
string masked = Regex.Replace(s, @"[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}", "[guid]", RegexOptions.IgnoreCase);
masked = Regex.Replace(masked, @"[A-Za-z]:\\[^;]*", "[path]", RegexOptions.IgnoreCase);
return masked;
}
static string HasSignature(string? msg)
{
if (string.IsNullOrWhiteSpace(msg)) return "NO (body vide)";
string l = msg.ToLowerInvariant();
bool sig = l.Contains("signature") || l.Contains("cordialement") || l.Contains("salutations");
return sig ? "YES (marqueur trouvé)" : "NO (aucun marqueur)";
}
static string SkeletonXml(string xml)
{
// Masque le contenu des <w:t> (texte Word) uniquement — structure conservée.
string s = Regex.Replace(xml, "<w:t[^>]*>([^<]*)</w:t>", m => "<w:t>{T" + m.Groups[1].Value.Length + "}</w:t>");
return s;
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+61
View File
@@ -0,0 +1,61 @@
// Probe LECTURE SEULE ciblé sur l'opération E2E existante.
// Aucune écriture. Source de vérité : la base réelle via OLE DB PCSOFT.HFSQL.
using System.Data.OleDb;
const string server = "127.0.0.1";
const int port = 4900;
const string user = "susana";
const string password = "123";
const string operationId = "5b915439-d9d9-44b5-9552-1f8f90db96c3";
const long mrIdToSrv = 1626;
string BuildConnStr(string database) =>
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
void QueryTable(string database, string table, string sql)
{
Console.WriteLine($"=== {table} (base {database}) ===");
try
{
using var conn = new OleDbConnection(BuildConnStr(database));
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
using var reader = cmd.ExecuteReader();
int rowCount = 0;
while (reader.Read())
{
rowCount++;
var values = new object[reader.FieldCount];
reader.GetValues(values);
Console.WriteLine($" Row {rowCount}: " + string.Join(" | ", values.Select(v => v?.ToString() ?? "NULL")));
}
if (rowCount == 0)
{
Console.WriteLine(" (aucune ligne)");
}
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR: {ex.Message}");
}
Console.WriteLine();
}
// Base Service (BATIK.01_BLS) -> TOSRV pour IDTOSRV=1626
QueryTable("BATIK.01_BLS", "TOSRV (IDTOSRV=1626)",
"SELECT IDTOSRV, TS_Date, TS_Mobile, TS_CMD, TS_Lu, TS_JSON FROM TOSRV WHERE IDTOSRV = 1626");
// Base WebService (BATIK_TEST) -> MobReception pour MR_IDToSRV=1626
QueryTable("BATIK_TEST", "MobReception (MR_IDToSRV=1626)",
"SELECT MR_PKID, MR_IDToSRV, MR_CMD, MR_Fait, MR_DateHeureRecep, MR_JSON FROM MobReception WHERE MR_IDToSRV = 1626");
// Base WebService (BATIK_TEST) -> MobLog pour l'operationId
QueryTable("BATIK_TEST", "MobLog (operationId)",
"SELECT ML_PKID, ML_DateCreat, ML_Niveau, ML_Message FROM MobLog WHERE ML_Message LIKE '%5b915439-d9d9-44b5-9552-1f8f90db96c3%'");
// Base WebService (BATIK_TEST) -> Intervention (recherche E2E_TEST)
QueryTable("BATIK_TEST", "Intervention (E2E_TEST)",
"SELECT IN_PKID, IN_RefInterne, IN_Titre, IN_Description, Archive, CreationDate FROM Intervention WHERE IN_RefInterne LIKE 'E2E_TEST_%'");
Console.WriteLine("Probe ciblé terminé (lecture seule).");
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.RdvProbeJour</AssemblyName>
<RootNamespace>Omika.RdvProbeJour</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+262
View File
@@ -0,0 +1,262 @@
// Sonde LECTURE SEULE : rendez-vous créés « aujourd'hui » dans OMIKA_TEST.
// Rejoue, étape par étape, les filtres du snapshot mobile pour déterminer lequel
// écarte un rendez-vous (groupe, plusieurs jours, plusieurs collaborateurs).
//
// Aucune écriture. Aucune donnée personnelle loggée : seuls les identifiants
// techniques (PKID, GUID), dates, drapeaux et clés métier sont affichés.
using System.Globalization;
using Microsoft.Extensions.Configuration;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
var configPath = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"config", "hfsql-test.local.json"));
var config = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var hfsqlOptions = new HfsqlOptions();
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
DateTime today = DateTime.Today;
// Fenêtre d'agenda du snapshot mobile : [-14 j, +28 j], borne SQL exclusive sur +1 jour.
DateTime sqlDebutMin = today.AddDays(-14);
DateTime sqlDebutMax = today.AddDays(29);
// Collaborateur de test (ASA) : GUID canonique + PK numérique.
string asaGuid = "62224eca-4565-4f47-9197-5c7439400000";
long asaPk = 281474976710675;
// Formes physiques réellement stockées dans les rubriques GUID Omika (la
// rubrique RVC_GUID_Colb fait 32 caractères : la forme WinDev « D » tronquée
// des quatre derniers hexadécimaux y est fréquente).
IReadOnlyList<string> asaCandidats = Omika.Data.Mapping.Common.PhysicalGuidParser
.GetStorageCandidates(Guid.Parse(asaGuid));
bool EstAsa(string valeur) => asaCandidats.Any(c => string.Equals(c, valeur, StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"=== Sonde rendez-vous (base {hfsqlOptions.DatabaseName}) ===");
Console.WriteLine($"aujourd'hui = {today:yyyy-MM-dd} ({today.DayOfWeek})");
Console.WriteLine($"fenêtre agenda = {sqlDebutMin:yyyy-MM-dd} .. {sqlDebutMax:yyyy-MM-dd} (SQL exclusif)");
Console.WriteLine();
var factory = new HfSqlOleDbConnectionFactory();
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
await business.OpenAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
CancellationToken ct = cts.Token;
// -----------------------------------------------------------------------------
// 1. Tous les rendez-vous créés aujourd'hui (date de création = aujourd'hui).
// -----------------------------------------------------------------------------
Console.WriteLine("--- 1. RDV_Planning créés aujourd'hui ---");
var rvDuJour = await business.QueryAsync(
"SELECT RV_PKID, GUID, RV_Objet, RV_CleType, RV_CleStatut, RV_Date, RV_Groupe, " +
"RV_PlusieursJours, RV_IdInter, RV_IdDossier, RV_IdSociete, Archive, CreationDate " +
"FROM RDV_Planning WHERE CreationDate >= ? AND CreationDate < ? ORDER BY RV_PKID",
[today, today.AddDays(1)], ct);
Console.WriteLine($"nombre de rendez-vous créés aujourd'hui = {rvDuJour.Count}");
foreach (var row in rvDuJour)
{
Console.WriteLine(
$" RV_PKID={Str(row, "RV_PKID")} GUID={Str(row, "GUID")} groupe={Str(row, "RV_Groupe")} " +
$"plusieursJours={Str(row, "RV_PlusieursJours")} archive={Str(row, "Archive")} " +
$"type={Str(row, "RV_CleType")} statut={Str(row, "RV_CleStatut")} date={Str(row, "RV_Date")} " +
$"idInter={Str(row, "RV_IdInter")} idDossier={Str(row, "RV_IdDossier")} idSociete={Str(row, "RV_IdSociete")} " +
$"cree={Str(row, "CreationDate")} objet={Trunc(Str(row, "RV_Objet"), 60)}");
}
Console.WriteLine();
// -----------------------------------------------------------------------------
// 2. Affectations (RDV_Collaborateur) de ces rendez-vous.
// -----------------------------------------------------------------------------
Console.WriteLine("--- 2. Affectations RDV_Collaborateur des RDV ci-dessus ---");
var guidList = rvDuJour
.Select(r => Str(r, "GUID"))
.Where(g => !string.IsNullOrWhiteSpace(g))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var affectations = new List<IReadOnlyDictionary<string, object?>>();
if (guidList.Count > 0)
{
string placeholders = string.Join(", ", guidList.Select(_ => "?"));
affectations = (await business.QueryAsync(
"SELECT RVC_PKID, RVC_GUID_RDV, RVC_GUID_Colb, RVC_DateDeb, RVC_DateFin, RVC_CleStatut, " +
"RVC_Responsable, RVC_TempsValide, Archive, RVC_CR " +
$"FROM RDV_Collaborateur WHERE RVC_GUID_RDV IN ({placeholders}) ORDER BY RVC_GUID_RDV, RVC_PKID",
guidList.Cast<object?>().ToList(), ct)).ToList();
}
Console.WriteLine($"nombre d'affectations = {affectations.Count}");
foreach (var rdv in guidList)
{
var lignes = affectations
.Where(a => string.Equals(Str(a, "RVC_GUID_RDV"), rdv, StringComparison.OrdinalIgnoreCase))
.ToList();
Console.WriteLine($" RDV {rdv} : {lignes.Count} affectation(s)");
foreach (var a in lignes)
{
DateTime deb = ToDateTime(a, "RVC_DateDeb");
DateTime fin = ToDateTime(a, "RVC_DateFin");
bool debDansFenetre = deb >= sqlDebutMin && deb < sqlDebutMax;
Console.WriteLine(
$" RVC_PKID={Str(a, "RVC_PKID")} collab={Str(a, "RVC_GUID_Colb")} " +
$"deb={deb:yyyy-MM-dd HH:mm} fin={fin:yyyy-MM-dd HH:mm} " +
$"statut={Str(a, "RVC_CleStatut")} archive={Str(a, "Archive")} " +
$"resp={Str(a, "RVC_Responsable")} cr={Str(a, "RVC_CR")} " +
$"debDansFenetreSQL={debDansFenetre}");
}
}
Console.WriteLine();
// -----------------------------------------------------------------------------
// 3. Collaborateurs référencés (GUID → PK / trigramme / actif / auPlanning).
// -----------------------------------------------------------------------------
Console.WriteLine("--- 3. Collaborateurs référencés par ces affectations ---");
var collabGuids = affectations
.Select(a => Str(a, "RVC_GUID_Colb"))
.Where(g => !string.IsNullOrWhiteSpace(g))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var guid in collabGuids)
{
var rows = await business.QueryAsync(
"SELECT CO_PKID, CO_Nom, CO_Prenom, CO_Trigramme, CO_Actif, CO_AuPlanning, GUID FROM Collaborateur WHERE GUID = ?",
[guid], ct);
if (rows.Count == 0)
{
Console.WriteLine($" GUID {guid} → AUCUN collaborateur (forme physique inventoriée : {Omika.Data.Mapping.Common.PhysicalGuidParser.ParseOrNull(guid)?.ToString("D") ?? "-"})");
continue;
}
var c = rows[0];
Console.WriteLine(
$" GUID {guid} → CO_PKID={Str(c, "CO_PKID")} trigramme={Str(c, "CO_Trigramme")} " +
$"actif={Str(c, "CO_Actif")} auPlanning={Str(c, "CO_AuPlanning")} " +
$"nom={Trunc(Str(c, "CO_Nom"), 20)} prenom={Trunc(Str(c, "CO_Prenom"), 20)} " +
$"{(EstAsa(guid) ? " <== COLLABORATEUR TEST (ASA)" : string.Empty)}");
}
Console.WriteLine();
// -----------------------------------------------------------------------------
// 4. Type de rendez-vous : drapeau « Envoi_mobile » du paramétrage métier.
// -----------------------------------------------------------------------------
Console.WriteLine("--- 4. Types de rendez-vous utilisés + drapeau Envoi_mobile ---");
var types = rvDuJour
.Select(r => Str(r, "RV_CleType"))
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var type in types)
{
var rows = await business.QueryAsync(
"SELECT PA_Code, PA_Libelle, PA_Parametre FROM ParametreMetier WHERE PA_Code = ?",
[type], ct);
if (rows.Count == 0)
{
Console.WriteLine($" type '{type}' → AUCUN ParametreMetier");
continue;
}
var p = rows[0];
string parametre = Str(p, "PA_Parametre");
Console.WriteLine(
$" type '{type}' libelle='{Trunc(Str(p, "PA_Libelle"), 40)}' " +
$"Envoi_mobile_present={parametre.Contains("Envoi_mobile", StringComparison.OrdinalIgnoreCase)} " +
$"parametre={Trunc(parametre, 220)}");
}
Console.WriteLine();
// -----------------------------------------------------------------------------
// 5. Simulation du snapshot pour le collaborateur de test : chaque filtre.
// -----------------------------------------------------------------------------
Console.WriteLine($"--- 5. Simulation snapshot pour ASA (GUID {asaGuid} / CO_PKID {asaPk}) ---");
foreach (var row in rvDuJour)
{
string guid = Str(row, "GUID");
var lignes = affectations
.Where(a => string.Equals(Str(a, "RVC_GUID_RDV"), guid, StringComparison.OrdinalIgnoreCase))
.ToList();
var asaLigne = lignes.FirstOrDefault(a => EstAsa(Str(a, "RVC_GUID_Colb")));
Console.WriteLine($" RV {guid} ({Trunc(Str(row, "RV_Objet"), 50)})");
Console.WriteLine($" filtre SQL collaborateur (RVC_GUID_Colb = ASA) : {(asaLigne is not null ? "PASSE" : "ÉCARTÉ")}");
if (asaLigne is null)
{
Console.WriteLine($" → affectations présentes : {string.Join(", ", lignes.Select(l => Str(l, "RVC_GUID_Colb")))}");
continue;
}
DateTime deb = ToDateTime(asaLigne, "RVC_DateDeb");
DateTime fin = ToDateTime(asaLigne, "RVC_DateFin");
bool debDansFenetre = deb >= sqlDebutMin && deb < sqlDebutMax;
Console.WriteLine($" filtre SQL fenêtre (RVC_DateDeb) : {(debDansFenetre ? "PASSE" : "ÉCARTÉ")} ({deb:yyyy-MM-dd})");
bool periodeExploitable = deb != default;
Console.WriteLine($" date début d'affectation exploitable : {(periodeExploitable ? "PASSE" : "ÉCARTÉ (default)")}");
bool dansFenetreMemoire = deb.Date >= today.AddDays(-14) && deb.Date <= today.AddDays(28);
Console.WriteLine($" fenêtre mémoire [-14,+28] sur Debut rabattu : {(dansFenetreMemoire ? "PASSE" : "ÉCARTÉ")}");
bool archive = Str(asaLigne, "Archive") is "True" or "true" or "1";
Console.WriteLine($" affectation archivée : {(archive ? "ÉCARTÉ" : "PASSE")}");
}
Console.WriteLine();
// -----------------------------------------------------------------------------
// 6. Autres rendez-vous (créés avant aujourd'hui) touchant le collaborateur test,
// pour comparaison : ce sont ceux qui SONT affichés.
// -----------------------------------------------------------------------------
Console.WriteLine("--- 6. Comparaison : tous les RDV affectés à ASA dans la fenêtre SQL ---");
string asaPlaceholders = string.Join(", ", asaCandidats.Select(_ => "?"));
var rvAsa = await business.QueryAsync(
"SELECT RDV_Planning.RV_PKID, RDV_Planning.GUID, RDV_Planning.RV_Objet, RDV_Planning.RV_Groupe, " +
"RDV_Planning.RV_PlusieursJours, RDV_Planning.Archive, RDV_Planning.CreationDate, " +
"RDV_Collaborateur.RVC_DateDeb, RDV_Collaborateur.RVC_DateFin, RDV_Collaborateur.Archive " +
"FROM RDV_Planning INNER JOIN RDV_Collaborateur ON RDV_Collaborateur.RVC_GUID_RDV = RDV_Planning.GUID " +
$"WHERE RDV_Collaborateur.RVC_GUID_Colb IN ({asaPlaceholders}) AND RDV_Collaborateur.RVC_DateDeb >= ? " +
"AND RDV_Collaborateur.RVC_DateDeb < ? ORDER BY RDV_Collaborateur.RVC_DateDeb",
[.. asaCandidats.Cast<object?>(), sqlDebutMin, sqlDebutMax], ct);
Console.WriteLine($"nombre = {rvAsa.Count}");
foreach (var r in rvAsa)
{
Console.WriteLine(
$" RV={Str(r, "RV_PKID")} GUID={Str(r, "GUID")} groupe={Str(r, "RV_Groupe")} " +
$"plusieursJours={Str(r, "RV_PlusieursJours")} archiveRV={Str(r, "Archive")} " +
$"deb={ToDateTime(r, "RVC_DateDeb"):yyyy-MM-dd} fin={ToDateTime(r, "RVC_DateFin"):yyyy-MM-dd} " +
$"cree={Str(r, "CreationDate")}");
}
Console.WriteLine();
Console.WriteLine("Sonde terminée (lecture seule).");
// --- Helpers ---
static string Str(IReadOnlyDictionary<string, object?> row, string column)
=> row.TryGetValue(column, out var value) && value is not null ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty : string.Empty;
static DateTime ToDateTime(IReadOnlyDictionary<string, object?> row, string column)
{
if (row.TryGetValue(column, out var value) && value is not null)
{
if (value is DateTime dt) return dt;
if (DateTime.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsed))
return parsed;
}
return default;
}
static string Trunc(string value, int max)
=> value.Length <= max ? value : value[..max] + "…";
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.RendezVousBulkProbe</AssemblyName>
<RootNamespace>Omika.RendezVousBulkProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+140
View File
@@ -0,0 +1,140 @@
// Probe LECTURE SEULE ciblé : chargement RendezVous du snapshot mobile (bulk).
//
// Reproduit EXACTEMENT le chemin du snapshot RendezVous corrigé :
// MobileSnapshotService.LoadRendezVousAsync
// -> IRendezVousService.SearchDetailsAsync
// -> BatikRendezVousRepository.SearchDetailsAsync (bulk, pas de N+1)
//
// Le probe compte le nombre de requêtes HFSQL exécutées pour prouver que le
// chargement est bulk (nombre borné, indépendant du nombre de rendez-vous) et
// qu'aucun GetById par rendez-vous n'est émis.
//
// La quarantaine read-only des anomalies physiques (PL_DateFin < PL_DateDebut)
// est appliquée par le repository AVANT le mapper : aucune DomainValidationException
// ne doit être levée pour les 53 anomalies source.
//
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée.
using Microsoft.Extensions.Configuration;
using Omika.Core.Contracts.Common;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Planning;
using Omika.Core.Interfaces;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Repositories.Batik;
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
var configPath = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"config", "hfsql-test.local.json"));
var config = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var hfsqlOptions = new HfsqlOptions();
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
Console.WriteLine($"=== Probe RendezVous Snapshot Bulk (base {hfsqlOptions.DatabaseName}) ===");
Console.WriteLine();
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
var factory = new HfSqlOleDbConnectionFactory();
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
// Wrapper compteur de requêtes HFSQL (lecture seule, délègue tout).
var counting = new CountingHfsqlConnection(business);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await counting.OpenAsync();
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
// --- 3. Repository Batik + chemin bulk du snapshot RendezVous ---
IRendezVousRepository repository = new BatikRendezVousRepository(counting);
var criteria = new RendezVousSearchCriteria
{
Page = 1,
PageSize = 1000
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
PagedResult<RendezVous> result = await repository.SearchDetailsAsync(criteria, cts.Token);
sw.Stop();
Console.WriteLine();
Console.WriteLine("--- Résultat ---");
Console.WriteLine($"rendez-vous chargés (page) = {result.Items.Count}");
Console.WriteLine($"total rendez-vous (SearchDetails) = {result.TotalCount}");
Console.WriteLine($"requêtes HFSQL exécutées = {counting.QueryCount}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
// Vérification anti-N+1 : le nombre de requêtes doit être borné et très
// inférieur au nombre de rendez-vous. Avec l'ancien pattern N+1, on aurait
// observé ~1 requête par rendez-vous (timeout HTTP 30s).
if (result.TotalCount > 0 && counting.QueryCount >= result.TotalCount)
{
Console.WriteLine("ATTENTION : le nombre de requêtes est >= au nombre de rendez-vous (pattern N+1 suspect).");
}
else
{
Console.WriteLine("OK : chargement bulk (requêtes << rendez-vous), aucun GetById par rendez-vous.");
}
// Aucune DomainValidationException ne doit être levée : la quarantaine des
// anomalies (PL_DateFin < PL_DateDebut) est appliquée AVANT le mapper.
Console.WriteLine("OK : aucune DomainValidationException (quarantaine appliquée avant mapper).");
}
catch (Exception ex)
{
sw.Stop();
Console.WriteLine($"ERREUR: {ex.Message}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
}
Console.WriteLine();
Console.WriteLine("Probe RendezVous Snapshot Bulk terminé (lecture seule).");
/// <summary>
/// Wrapper IHfsqlConnection qui délègue à la connexion réelle et compte les
/// requêtes de lecture (QueryAsync). Lecture seule : aucune écriture.
/// </summary>
internal sealed class CountingHfsqlConnection : IHfsqlConnection
{
private readonly IHfsqlConnection _inner;
public int QueryCount { get; private set; }
public CountingHfsqlConnection(IHfsqlConnection inner) => _inner = inner;
public HfsqlOptions Options => _inner.Options;
public HfsqlConnectionState State => _inner.State;
public Task OpenAsync(CancellationToken ct = default) => _inner.OpenAsync(ct);
public Task CloseAsync() => _inner.CloseAsync();
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> _inner.ExecuteScalarAsync(sql, parameters, ct);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> _inner.ExecuteNonQueryAsync(sql, parameters, ct);
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
{
QueryCount++;
return await _inner.QueryAsync(sql, parameters, ct);
}
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> _inner.GetColumnsAsync(tableName, ct);
public ValueTask DisposeAsync() => _inner.DisposeAsync();
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.RendezVousProbe</AssemblyName>
<RootNamespace>Omika.RendezVousProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+146
View File
@@ -0,0 +1,146 @@
// Probe LECTURE SEULE ciblé RendezVous.
// Reproduit EXACTEMENT le mécanisme de connexion de l'API
// (BusinessOleDbConnection -> HfSqlOleDbConnectionFactory -> BuildExtendedPropertiesFromFilePasswords)
// pour lire Planning_RDV sur BATIK_TEST et identifier les lignes où PL_DateFin < PL_DateDebut.
//
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée
// (nom client, personne, adresse, notes, téléphone, email).
// Seuls PL_PKID (identifiant technique) et les valeurs brutes DATE/HEURE sont affichés.
using System.Globalization;
using Microsoft.Extensions.Configuration;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
var configPath = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"config", "hfsql-test.local.json"));
var config = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var hfsqlOptions = new HfsqlOptions();
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
Console.WriteLine($"=== Probe RendezVous (base {hfsqlOptions.DatabaseName}) ===");
Console.WriteLine();
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
var factory = new HfSqlOleDbConnectionFactory();
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
var sw = System.Diagnostics.Stopwatch.StartNew();
int totalRows = 0;
int countInvalid = 0;
int countCrossMidnight = 0;
int countAnomalyPure = 0;
long? firstInvalidPk = null;
DateTime? firstInvalidStart = null;
DateTime? firstInvalidEnd = null;
try
{
await business.OpenAsync();
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
// --- 3. Lire les colonnes DATE/HEURE de Planning_RDV ---
// Même périmètre que le repository (SearchAsync) : toutes les lignes.
string sql = "SELECT PL_PKID, PL_DateDebut, PL_DateFin FROM Planning_RDV";
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var rows = await business.QueryAsync(sql, cancellationToken: cts.Token);
totalRows = rows.Count;
Console.WriteLine($"rows lues = {totalRows}");
foreach (var row in rows)
{
long pk = ToLong(row, "PL_PKID");
DateTime start = ToDateTime(row, "PL_DateDebut");
DateTime end = ToDateTime(row, "PL_DateFin");
if (end < start)
{
countInvalid++;
if (firstInvalidPk is null)
{
firstInvalidPk = pk;
firstInvalidStart = start;
firstInvalidEnd = end;
}
// Distinguer un éventuel rendez-vous à cheval sur minuit
// (fin le lendemain du début) d'une anomalie pure (fin avant début).
// Cross-midnight : la fin est le jour suivant le début.
bool isCrossMidnight = end.Date == start.Date.AddDays(1);
if (isCrossMidnight)
{
countCrossMidnight++;
}
else
{
countAnomalyPure++;
}
}
}
sw.Stop();
Console.WriteLine();
Console.WriteLine("--- Résultat ---");
Console.WriteLine($"rows = {totalRows}");
Console.WriteLine($"count_invalid (PL_DateFin < PL_DateDebut) = {countInvalid}");
Console.WriteLine($" dont cross-midnight (fin = lendemain du début) = {countCrossMidnight}");
Console.WriteLine($" dont anomalie pure (fin avant début, hors lendemain) = {countAnomalyPure}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
if (firstInvalidPk is not null)
{
Console.WriteLine();
Console.WriteLine("--- Première ligne invalide (technique, non sensible) ---");
Console.WriteLine($"PL_PKID = {firstInvalidPk}");
Console.WriteLine($"PL_DateDebut (brut) = {firstInvalidStart:O}");
Console.WriteLine($"PL_DateFin (brut) = {firstInvalidEnd:O}");
Console.WriteLine($"start calculé = {firstInvalidStart:O}");
Console.WriteLine($"end calculé = {firstInvalidEnd:O}");
}
else
{
Console.WriteLine("Aucune ligne invalide détectée.");
}
}
catch (Exception ex)
{
sw.Stop();
Console.WriteLine($"ERREUR: {ex.Message}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
}
Console.WriteLine();
Console.WriteLine("Probe RendezVous terminé (lecture seule).");
// --- Helpers ---
static long ToLong(IReadOnlyDictionary<string, object?> row, string column)
{
if (row.TryGetValue(column, out var value) && value is not null)
{
if (value is long l) return l;
if (value is int i) return i;
if (value is decimal d) return (long)d;
if (long.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed))
return parsed;
}
return 0;
}
static DateTime ToDateTime(IReadOnlyDictionary<string, object?> row, string column)
{
if (row.TryGetValue(column, out var value) && value is not null)
{
if (value is DateTime dt) return dt;
if (DateTime.TryParse(value.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsed))
return parsed;
}
return default;
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.SiteSnapshotProbe</AssemblyName>
<RootNamespace>Omika.SiteSnapshotProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+132
View File
@@ -0,0 +1,132 @@
// Probe LECTURE SEULE ciblé : chargement Sites du snapshot mobile.
//
// Reproduit EXACTEMENT le chemin du snapshot Sites corrigé :
// MobileSnapshotService.LoadSitesAsync
// -> SiteService.SearchDetailsAsync
// -> BatikSiteRepository.SearchDetailsAsync (bulk, pas de N+1)
//
// Le probe compte le nombre de requêtes HFSQL exécutées pour prouver que le
// chargement est bulk (nombre borné, indépendant du nombre de sites) et
// qu'aucun GetById par site n'est émis.
//
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée.
using Microsoft.Extensions.Configuration;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Sites;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Interfaces;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Repositories.Batik;
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
var configPath = Path.GetFullPath(Path.Combine(
Directory.GetCurrentDirectory(),
"config", "hfsql-test.local.json"));
var config = new ConfigurationBuilder()
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
.Build();
var hfsqlOptions = new HfsqlOptions();
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
Console.WriteLine($"=== Probe Site Snapshot (base {hfsqlOptions.DatabaseName}) ===");
Console.WriteLine();
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
var factory = new HfSqlOleDbConnectionFactory();
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
// Wrapper compteur de requêtes HFSQL (lecture seule, délègue tout).
var counting = new CountingHfsqlConnection(business);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await counting.OpenAsync();
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
// --- 3. Repository Batik + chemin bulk du snapshot Sites ---
ISiteRepository repository = new BatikSiteRepository(counting);
var criteria = new SiteSearchCriteria
{
Page = 1,
PageSize = 1000
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
PagedResult<Site> result = await repository.SearchDetailsAsync(criteria, cts.Token);
sw.Stop();
Console.WriteLine();
Console.WriteLine("--- Résultat ---");
Console.WriteLine($"sites chargés (page) = {result.Items.Count}");
Console.WriteLine($"total sites (SearchDetails) = {result.TotalCount}");
Console.WriteLine($"requêtes HFSQL exécutées = {counting.QueryCount}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
// Vérification anti-N+1 : le nombre de requêtes doit être borné et très
// inférieur au nombre de sites. Avec l'ancien pattern N+1, on aurait
// observé ~1 requête par site (timeout HTTP 30s).
if (result.TotalCount > 0 && counting.QueryCount >= result.TotalCount)
{
Console.WriteLine("ATTENTION : le nombre de requêtes est >= au nombre de sites (pattern N+1 suspect).");
}
else
{
Console.WriteLine("OK : chargement bulk (requêtes << sites), aucun GetById par site.");
}
}
catch (Exception ex)
{
sw.Stop();
Console.WriteLine($"ERREUR: {ex.Message}");
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
}
Console.WriteLine();
Console.WriteLine("Probe Site Snapshot terminé (lecture seule).");
/// <summary>
/// Wrapper IHfsqlConnection qui délègue à la connexion réelle et compte les
/// requêtes de lecture (QueryAsync). Lecture seule : aucune écriture.
/// </summary>
internal sealed class CountingHfsqlConnection : IHfsqlConnection
{
private readonly IHfsqlConnection _inner;
public int QueryCount { get; private set; }
public CountingHfsqlConnection(IHfsqlConnection inner) => _inner = inner;
public HfsqlOptions Options => _inner.Options;
public HfsqlConnectionState State => _inner.State;
public Task OpenAsync(CancellationToken ct = default) => _inner.OpenAsync(ct);
public Task CloseAsync() => _inner.CloseAsync();
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> _inner.ExecuteScalarAsync(sql, parameters, ct);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> _inner.ExecuteNonQueryAsync(sql, parameters, ct);
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
{
QueryCount++;
return await _inner.QueryAsync(sql, parameters, ct);
}
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> _inner.GetColumnsAsync(tableName, ct);
public ValueTask DisposeAsync() => _inner.DisposeAsync();
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.SmtpMobileConfigProbe</AssemblyName>
<RootNamespace>Omika.SmtpMobileConfigProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
@@ -0,0 +1,920 @@
// ============================================================
// SONDE — CONFIGURATION SMTP RÉELLEMENT SERVIE À UN COLLABORATEUR
//
// Exécute le VRAI service de production (SmtpConfigurationService + repositories
// Omika + déchiffreur sMdpStandard) contre une base Omika, et affiche pour chaque
// collaborateur demandé :
// 1. les lignes physiques lues en base (contexte) ;
// 2. la liste des configurations EXPOSÉES AU MOBILE (contrat
// GET /api/mails/configurations-smtp : Id, Libelle, Email) ;
// 3. la configuration EFFECTIVE utilisée à l'envoi (serveur, port, cryptage,
// login, mot de passe résolu, signature, CCI) et sa validité.
//
// LECTURE SEULE : aucune écriture, aucune connexion SMTP, aucun envoi.
// Le mot de passe n'est jamais affiché en clair sans --montrer-mdp.
//
// USAGE : Omika.SmtpMobileConfigProbe [id...] [--montrer-mdp]
// ============================================================
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Omika.Core.Configuration;
using Omika.Core.Contracts.Documents;
using Omika.Core.Interfaces;
using Omika.Core.Security;
using Omika.Core.Services;
using Omika.Core.Services.Mobile;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Omika;
Console.OutputEncoding = Encoding.UTF8;
const string CheminConfiguration = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
bool montrerMotDePasse = args.Contains("--montrer-mdp", StringComparer.OrdinalIgnoreCase);
bool montrerSignatures = args.Contains("--signatures", StringComparer.OrdinalIgnoreCase);
string dossierSignatures = Path.Combine(Directory.GetCurrentDirectory(), ".diag", "signatures");
string[] cibles = args
.Where(a => !a.StartsWith("--", StringComparison.Ordinal))
.ToArray();
if (montrerSignatures)
{
Directory.CreateDirectory(dossierSignatures);
}
if (cibles.Length == 0)
{
cibles = ["281474976710679", "281474976710671", "281474976710697"];
}
// --- 1. Options HFSQL de la base de test (section « Hfsql ») -----------------
if (!File.Exists(CheminConfiguration))
{
Console.Error.WriteLine($"ERREUR : configuration absente ({CheminConfiguration}).");
return 2;
}
HfsqlOptions options;
using (JsonDocument document = JsonDocument.Parse(File.ReadAllText(CheminConfiguration)))
{
JsonElement hfsql = document.RootElement.GetProperty("Hfsql");
JsonElement motsDePasse = hfsql.GetProperty("FilePasswords");
options = new HfsqlOptions
{
ConnectionName = LireTexte(hfsql, "ConnectionName") ?? "Business",
Server = LireTexte(hfsql, "Server"),
Port = hfsql.TryGetProperty("Port", out JsonElement port) ? port.GetInt32() : 4900,
DatabaseName = LireTexte(hfsql, "DatabaseName"),
Login = LireTexte(hfsql, "Login"),
Password = LireTexte(hfsql, "Password"),
Driver = LireTexte(hfsql, "Driver") ?? HfsqlOptions.DefaultOdbcDriver,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = LireTexte(motsDePasse, "ProtectionStd"),
ProtectionPlus = LireTexte(motsDePasse, "ProtectionPlus")
}
};
}
Console.WriteLine("==============================================================================");
Console.WriteLine(" CONFIGURATION SMTP SERVIE AU MOBILE — SERVICE DE PRODUCTION, LECTURE SEULE");
Console.WriteLine("==============================================================================");
Console.WriteLine($"Base : {options.DatabaseName} ({options.Server}:{options.Port})");
Console.WriteLine($"Identifiants : {string.Join(", ", cibles)} (lus d'abord comme UT_PKID, puis CO_PKID)");
Console.WriteLine($"Mdp en clair : {(montrerMotDePasse ? "OUI (--montrer-mdp)" : "non (longueur seulement)")}");
Console.WriteLine();
// --- 2. Service de production, câblé sur les vrais repositories Omika --------
await using var connexion = new BusinessOleDbConnection(options, new HfSqlOleDbConnectionFactory());
await connexion.OpenAsync();
var service = new SmtpConfigurationService(
new OmikaSmtpConfigurationRepository(connexion),
new OmikaCollaborateurRepository(connexion),
new MobileEntityIdentityResolver(),
new JournalConsole(),
Options.Create(new SmtpPasswordOptions()),
new SmtpPasswordStandardDecryptor());
foreach (string id in cibles)
{
Console.WriteLine("==============================================================================");
Console.WriteLine($" IDENTIFIANT FOURNI : {id}");
Console.WriteLine("==============================================================================");
try
{
(string collaborateurId, string origine) = await ResoudreCollaborateurAsync(connexion, id);
Console.WriteLine("-- 0. Résolution de l'identifiant");
Console.WriteLine($" {origine}");
if (collaborateurId.Length == 0)
{
Console.WriteLine(" → aucun collaborateur résolu : le mobile ne reçoit RIEN.");
Console.WriteLine();
continue;
}
await AfficherContexteAsync(connexion, collaborateurId);
IReadOnlyList<SmtpConfigurationDto> liste = await service.GetListAsync(collaborateurId);
Console.WriteLine("-- CE QUE LE MOBILE REÇOIT (GET /api/mails/configurations-smtp)");
if (liste.Count == 0)
{
Console.WriteLine(" (aucune configuration : l'écran de sélection sera vide)");
}
foreach (SmtpConfigurationDto dto in liste)
{
string libelle = string.IsNullOrWhiteSpace(dto.Libelle)
? (dto.Email ?? "(sans libellé)")
: dto.Libelle;
Console.WriteLine($" - Id = {dto.Id}");
Console.WriteLine($" Libelle = {libelle}");
Console.WriteLine($" Email = {dto.Email ?? "(aucun)"}");
}
Console.WriteLine();
Console.WriteLine("-- CONFIGURATION EFFECTIVE UTILISÉE À L'ENVOI (résolue côté serveur)");
if (liste.Count == 0)
{
Console.WriteLine(" (aucune configuration à résoudre)");
}
foreach (SmtpConfigurationDto dto in liste)
{
SmtpConfiguration? configuration = await service.GetAsync(collaborateurId, dto.Id);
if (configuration is null)
{
Console.WriteLine($" [{dto.Id}] NON RÉSOLUE (identifiant absent de la liste chargée).");
continue;
}
AfficherConfiguration(configuration, montrerMotDePasse, montrerSignatures, dossierSignatures, id);
}
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR {ex.GetType().Name} : {ex.Message}");
}
Console.WriteLine();
}
Console.WriteLine("Sonde terminée (aucune modification en base).");
// --- Preuve du cas « configuration de SOCIÉTÉ seule » -----------------------
// Le bug d'origine : un collaborateur en messagerie interne qui n'a AUCUNE ligne
// EMAIL_ENVOI personnelle ne recevait rien (ou une configuration fantôme). On
// cherche ici de vrais collaborateurs dans cette situation et on affiche ce que
// le service leur rend.
if (args.Contains("--exemples-societe", StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine();
Console.WriteLine("==============================================================================");
Console.WriteLine(" EXEMPLES : messagerie « Back-Office » SANS configuration personnelle");
Console.WriteLine("==============================================================================");
IReadOnlyList<IReadOnlyDictionary<string, object?>> candidats = await connexion.QueryAsync(
"SELECT PU_IDUtilisateur FROM ParametreUtilisateur " +
"WHERE PU_Code = 'CHOIX_MESSAGERIE' AND PU_Valeur = 'Back-Office'",
null);
int montres = 0;
foreach (IReadOnlyDictionary<string, object?> candidat in candidats)
{
if (!candidat.TryGetValue("PU_IDUtilisateur", out object? brutId) || brutId is null
|| !long.TryParse(brutId.ToString(), out long utid))
{
continue;
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> perso = await connexion.QueryAsync(
"SELECT PU_Code FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ? AND PU_Code LIKE 'EMAIL_ENVOI%'",
[utid]);
if (perso.Count > 0)
{
continue;
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> users = await connexion.QueryAsync(
"SELECT UT_PKID, UT_Login, UT_IDCollaborateur FROM Utilisateur WHERE UT_PKID = ?",
[utid]);
if (users.Count == 0
|| !users[0].TryGetValue("UT_IDCollaborateur", out object? brutColb) || brutColb is null
|| !long.TryParse(brutColb.ToString(), out long colbId) || colbId <= 0)
{
continue;
}
string login = users[0].TryGetValue("UT_Login", out object? brutLogin)
? brutLogin?.ToString() ?? "?"
: "?";
Console.WriteLine();
Console.WriteLine($"-- Collaborateur {colbId} (utilisateur {utid}, login {login}) — aucune ligne EMAIL_ENVOI");
IReadOnlyList<SmtpConfigurationDto> liste = await service.GetListAsync(colbId.ToString());
foreach (SmtpConfigurationDto dto in liste)
{
Console.WriteLine($" reçu : Id = {dto.Id} | Libelle = {dto.Libelle} | Email = {dto.Email ?? "(aucun)"}");
SmtpConfiguration? configuration = await service.GetAsync(colbId.ToString(), dto.Id);
if (configuration is not null)
{
AfficherConfiguration(
configuration, montrerMotDePasse, montrerSignatures, dossierSignatures, colbId.ToString());
}
}
if (liste.Count == 0)
{
Console.WriteLine(" reçu : liste VIDE");
}
montres++;
if (montres >= 3)
{
break;
}
}
if (montres == 0)
{
Console.WriteLine(" (aucun collaborateur dans cette situation dans la base)");
}
}
// --- Audit : la fusion société + ligne utilisateur perd-elle un champ ? ------
// Un « Configuration SMTP invalide ou incomplète » ne peut venir que d'une
// configuration EFFECTIVE sans serveur, port, cryptage, login ou mot de passe.
// La fusion (Fusionner) ne reprend de la ligne EMAIL_ENVOI_<soc> que le compte,
// la signature et les copies : si la SOCIÉTÉ n'a pas de login ou de mot de passe
// alors que la ligne utilisateur les porte, l'envoi échoue. On mesure ce cas.
if (args.Contains("--audit-fusion", StringComparer.OrdinalIgnoreCase))
{
await AuditerFusionAsync(connexion);
}
return 0;
// ============================================================
// Audit de la fusion société / ligne utilisateur
// ============================================================
static async Task AuditerFusionAsync(IHfsqlConnection connexion)
{
Console.WriteLine();
Console.WriteLine("==============================================================================");
Console.WriteLine(" AUDIT — PRÉSENCE DES CHAMPS SMTP (sociétés vs lignes utilisateur)");
Console.WriteLine("==============================================================================");
Dictionary<long, PresenceSmtp> societes = new();
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignesSociete = await connexion.QueryAsync(
"SELECT SO_PKID, SO_Parametres FROM Societe",
null);
foreach (IReadOnlyDictionary<string, object?> ligne in lignesSociete)
{
if (!ligne.TryGetValue("SO_PKID", out object? brut) || brut is null
|| !long.TryParse(brut.ToString(), out long soId) || soId <= 0)
{
continue;
}
string? json = ligne.TryGetValue("SO_Parametres", out object? brutJson) ? brutJson?.ToString() : null;
PresenceSmtp presence = LirePresence(json);
societes[soId] = presence;
Console.WriteLine($" Société {soId,-20} {presence.Resume()}");
}
Console.WriteLine();
Console.WriteLine("-- C. CAS OÙ LA FUSION ACTUELLE PERD LE LOGIN OU LE MOT DE PASSE");
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignesUtilisateur = await connexion.QueryAsync(
"SELECT PU_IDUtilisateur, PU_Code, PU_Valeur FROM ParametreUtilisateur " +
"WHERE PU_Code LIKE 'EMAIL_ENVOI\\_%'",
null);
int cas = 0;
foreach (IReadOnlyDictionary<string, object?> ligne in lignesUtilisateur)
{
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
string suffixe = code["EMAIL_ENVOI_".Length..];
if (!long.TryParse(suffixe, out long soId) || !societes.TryGetValue(soId, out PresenceSmtp societe))
{
continue;
}
string? json = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
PresenceSmtp perso = LirePresence(json);
bool loginPerdu = !societe.Login && perso.Login;
bool mdpPerdu = !societe.MdpStandard && perso.MdpStandard;
bool serveurPerdu = (!societe.Serveur || !societe.Port || !societe.Cryptage)
&& perso.Serveur && perso.Port && perso.Cryptage;
if (!loginPerdu && !mdpPerdu && !serveurPerdu)
{
continue;
}
cas++;
string ut = ligne.TryGetValue("PU_IDUtilisateur", out object? brutUt) ? brutUt?.ToString() ?? "?" : "?";
Console.WriteLine($" UT {ut} / {code}");
Console.WriteLine($" société {soId} : {societe.Resume()}");
Console.WriteLine($" ligne utilisateur : {perso.Resume()}");
Console.WriteLine(
$" → perdu par la fusion : " +
string.Join(", ", new[]
{
loginPerdu ? "LOGIN" : null,
mdpPerdu ? "MOT DE PASSE" : null,
serveurPerdu ? "SERVEUR/PORT/CRYPTAGE" : null
}.Where(v => v is not null)));
}
Console.WriteLine();
Console.WriteLine($" {cas} cas où la fusion jette un champ indispensable alors que la ligne utilisateur le porte.");
if (cas == 0)
{
Console.WriteLine(" (aucun dans cette base : les sociétés portent elles-mêmes login et mot de passe)");
}
}
static PresenceSmtp LirePresence(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
JsonElement racine = document.RootElement;
if (racine.ValueKind == JsonValueKind.Array)
{
if (racine.GetArrayLength() == 0)
{
return default;
}
racine = racine[0];
}
if (racine.ValueKind != JsonValueKind.Object)
{
return default;
}
JsonElement compte = racine.TryGetProperty("Compte", out JsonElement blocCompte)
&& blocCompte.ValueKind == JsonValueKind.Object ? blocCompte : default;
JsonElement smtp = racine.TryGetProperty("SMTP", out JsonElement blocSmtp)
&& blocSmtp.ValueKind == JsonValueKind.Object ? blocSmtp : default;
bool cryptage = smtp.ValueKind == JsonValueKind.Object
&& smtp.TryGetProperty("nCryptage", out JsonElement n)
&& n.ValueKind == JsonValueKind.Number
&& n.GetInt32() is 1 or 2 or 3;
return new PresenceSmtp(
Present(smtp, "sAdresseServeur"),
Present(smtp, "sPortServeur"),
cryptage,
Present(smtp, "sLogin"),
Present(smtp, "sMdpStandard"),
Present(smtp, "sMdp"),
Present(compte, "sCompte"),
Present(compte, "sEmail"));
}
catch (JsonException)
{
return default;
}
}
static bool Present(JsonElement element, string nom)
=> element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty(nom, out JsonElement valeur)
&& valeur.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(valeur.GetString());
// ============================================================
// Affichage
// ============================================================
/// <summary>
/// Résout un identifiant fourni vers l'identifiant de COLLABORATEUR attendu par
/// le service. Un identifiant utilisateur (<c>UT_PKID</c>) est l'entrée la plus
/// fréquente ; un <c>CO_PKID</c> reste accepté (et signalé comme tel).
/// </summary>
static async Task<(string CollaborateurId, string Origine)> ResoudreCollaborateurAsync(
IHfsqlConnection connexion,
string id)
{
if (!long.TryParse(id, out long cle) || cle <= 0)
{
return (string.Empty, "Identifiant non numérique : le mobile n'envoie jamais un tel identifiant.");
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> utilisateurs = await connexion.QueryAsync(
"SELECT UT_PKID, UT_Login, UT_Nom, UT_Prenom, UT_IDCollaborateur FROM Utilisateur WHERE UT_PKID = ?",
[cle]);
if (utilisateurs.Count > 0)
{
IReadOnlyDictionary<string, object?> utilisateur = utilisateurs[0];
string login = utilisateur.TryGetValue("UT_Login", out object? brutLogin)
? brutLogin?.ToString() ?? "?"
: "?";
string nom = utilisateur.TryGetValue("UT_Nom", out object? brutNom) ? brutNom?.ToString() ?? "" : "";
string prenom = utilisateur.TryGetValue("UT_Prenom", out object? brutPrenom) ? brutPrenom?.ToString() ?? "" : "";
bool lie = utilisateur.TryGetValue("UT_IDCollaborateur", out object? brutColb)
&& brutColb is not null
&& long.TryParse(brutColb.ToString(), out long colbId)
&& colbId > 0;
if (!lie)
{
return (string.Empty,
$"UT_PKID {cle} = utilisateur « {login} » ({nom} {prenom}) mais UT_IDCollaborateur est vide : " +
"aucun collaborateur, donc aucune configuration.");
}
long collaborateurId = long.Parse(utilisateur["UT_IDCollaborateur"]!.ToString()!);
return (collaborateurId.ToString(),
$"UT_PKID {cle} = utilisateur « {login} » ({nom} {prenom}) → collaborateur {collaborateurId}");
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> collaborateurs = await connexion.QueryAsync(
"SELECT CO_PKID, CO_Nom, CO_Prenom FROM Collaborateur WHERE CO_PKID = ?",
[cle]);
if (collaborateurs.Count > 0)
{
IReadOnlyDictionary<string, object?> collaborateur = collaborateurs[0];
string nom = collaborateur.TryGetValue("CO_Nom", out object? brutNom) ? brutNom?.ToString() ?? "" : "";
string prenom = collaborateur.TryGetValue("CO_Prenom", out object? brutPrenom) ? brutPrenom?.ToString() ?? "" : "";
return (id, $"Aucun UT_PKID {cle} : c'est un CO_PKID ({nom} {prenom}), utilisé directement.");
}
return (string.Empty,
$"Identifiant {cle} absent de Utilisateur (UT_PKID) ET de Collaborateur (CO_PKID).");
}
static async Task AfficherContexteAsync(IHfsqlConnection connexion, string id)
{
long pk = long.TryParse(id, out long pkNumerique) ? pkNumerique : 0;
Console.WriteLine("-- 1. Collaborateur (CO_PKID)");
IReadOnlyList<IReadOnlyDictionary<string, object?>> collaborateurs = await AfficherLignesAsync(
connexion,
"SELECT CO_PKID, CO_Nom, CO_Prenom, CO_Email, CO_Actif, GUID FROM Collaborateur WHERE CO_PKID = ?",
[pk]);
if (collaborateurs.Count == 0)
{
Console.WriteLine(
" ⚠ Aucun Collaborateur pour cet identifiant : le service rend une liste VIDE " +
"(il n'existe aucun utilisateur lié).");
}
Console.WriteLine("-- 2. Utilisateur lié (UT_IDCollaborateur = CO_PKID)");
IReadOnlyList<IReadOnlyDictionary<string, object?>> utilisateurs = await AfficherLignesAsync(
connexion,
"SELECT UT_PKID, UT_Login, UT_Nom, UT_Prenom, UT_IDCollaborateur, UT_IDSocietePrincipale " +
"FROM Utilisateur WHERE UT_IDCollaborateur = ?",
[pk]);
if (utilisateurs.Count == 0)
{
Console.WriteLine(
" (aucun Utilisateur pour ce collaborateur : le service ne peut résoudre aucun compte d'envoi)");
}
var idsUtilisateur = new List<long>();
foreach (IReadOnlyDictionary<string, object?> ligne in utilisateurs)
{
if (ligne.TryGetValue("UT_PKID", out object? brutUt) && brutUt is not null
&& long.TryParse(brutUt.ToString(), out long utid))
{
idsUtilisateur.Add(utid);
}
}
Console.WriteLine("-- 3. Sociétés de rattachement (Societe_Collaborateur)");
IReadOnlyList<IReadOnlyDictionary<string, object?>> liaisons = await AfficherLignesAsync(
connexion,
"SELECT SC_IDCSociete FROM Societe_Collaborateur WHERE SC_IDCollaborateur = ?",
[pk]);
var idsSociete = new List<long>();
foreach (IReadOnlyDictionary<string, object?> ligne in liaisons)
{
if (ligne.TryGetValue("SC_IDCSociete", out object? brutSoc) && brutSoc is not null
&& long.TryParse(brutSoc.ToString(), out long socId) && socId > 0)
{
idsSociete.Add(socId);
}
}
if (idsSociete.Count > 0)
{
string placeholders = string.Join(", ", idsSociete.Select(_ => "?"));
Console.WriteLine(" Paramètres des sociétés rattachées (Societe.SO_Parametres) :");
IReadOnlyList<IReadOnlyDictionary<string, object?>> societes = await connexion.QueryAsync(
$"SELECT SO_PKID, SO_Parametres FROM Societe WHERE SO_PKID IN ({placeholders})",
idsSociete.Cast<object?>().ToArray());
foreach (IReadOnlyDictionary<string, object?> ligne in societes)
{
string soId = ligne.TryGetValue("SO_PKID", out object? brutSo) ? brutSo?.ToString() ?? "?" : "?";
string? contenu = ligne.TryGetValue("SO_Parametres", out object? brutParams)
? brutParams?.ToString()
: null;
Console.WriteLine($" Société {soId}");
ResumeJson($"EMAIL_ENVOI_{soId}", contenu);
}
}
Console.WriteLine("-- 4. ParametreUtilisateur (EMAIL_ENVOI% + CHOIX_MESSAGERIE)");
if (idsUtilisateur.Count == 0)
{
Console.WriteLine(" (aucun utilisateur : aucune ligne à lire)");
return;
}
foreach (long utid in idsUtilisateur)
{
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignes = await connexion.QueryAsync(
"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ? " +
"AND (PU_Code LIKE 'EMAIL_ENVOI%' OR PU_Code = 'CHOIX_MESSAGERIE')",
[utid]);
if (lignes.Count == 0)
{
Console.WriteLine($" (UT_PKID {utid} : aucune ligne EMAIL_ENVOI / CHOIX_MESSAGERIE)");
}
foreach (IReadOnlyDictionary<string, object?> ligne in lignes)
{
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
string? contenu = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
Console.WriteLine($" UT_PKID {utid} / {code}");
ResumeJson(code, contenu);
}
// Inventaire complet : garantit qu'aucune ligne de configuration SMTP
// n'échappe au filtre du service (autre code, autre convention).
Console.WriteLine($" (toutes les lignes ParametreUtilisateur de UT_PKID {utid})");
IReadOnlyList<IReadOnlyDictionary<string, object?>> toutes = await connexion.QueryAsync(
"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ?",
[utid]);
if (toutes.Count == 0)
{
Console.WriteLine(" (aucune ligne du tout)");
}
foreach (IReadOnlyDictionary<string, object?> ligne in toutes)
{
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
string? contenu = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
string apercu = contenu is null
? "(null)"
: contenu.Length <= 45
? $"« {contenu} » ({contenu.Length} car.)"
: $"{contenu.Length} car.";
Console.WriteLine($" {code} = {apercu}");
}
}
}
static async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> AfficherLignesAsync(
IHfsqlConnection connexion,
string sql,
IReadOnlyList<object?> parametres)
{
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignes =
await connexion.QueryAsync(sql, parametres);
if (lignes.Count == 0)
{
Console.WriteLine(" (aucune ligne)");
return lignes;
}
foreach (IReadOnlyDictionary<string, object?> ligne in lignes)
{
IEnumerable<string> paires = ligne.Select(kv => $"{kv.Key} = {ResumerValeur(kv.Value)}");
Console.WriteLine(" " + string.Join(" | ", paires));
}
return lignes;
}
/// <summary>Résume une valeur JSON de <c>ParametreUtilisateur</c> sans secret.</summary>
static void ResumeJson(string code, string? valeur)
{
if (string.IsNullOrWhiteSpace(valeur))
{
Console.WriteLine(" (valeur vide)");
return;
}
string debut = valeur.TrimStart();
if (!debut.StartsWith('{') && !debut.StartsWith('['))
{
Console.WriteLine(
$" VALEUR NON JSON ({valeur.Length} car.) : ignorée par le service " +
"(chiffrement/binaire historique).");
return;
}
try
{
using JsonDocument document = JsonDocument.Parse(valeur);
JsonElement racine = document.RootElement;
if (racine.ValueKind == JsonValueKind.Array)
{
int index = 0;
foreach (JsonElement element in racine.EnumerateArray())
{
Console.WriteLine($" [tableau, entrée {index}]");
ResumeConfiguration(element);
index++;
}
}
else
{
ResumeConfiguration(racine);
}
}
catch (JsonException)
{
Console.WriteLine($" JSON INVALIDE ({valeur.Length} car.) : ignorée par le service.");
}
}
static void ResumeConfiguration(JsonElement element)
{
string? compte = null;
string? email = null;
if (element.TryGetProperty("Compte", out JsonElement blocCompte)
&& blocCompte.ValueKind == JsonValueKind.Object)
{
compte = LireTexte(blocCompte, "sCompte");
email = LireTexte(blocCompte, "sEmail");
}
if (!element.TryGetProperty("SMTP", out JsonElement smtp) || smtp.ValueKind != JsonValueKind.Object)
{
Console.WriteLine(" (aucun bloc SMTP)");
return;
}
string? login = LireTexte(smtp, "sLogin");
string? serveur = LireTexte(smtp, "sAdresseServeur");
string? port = LireTexte(smtp, "sPortServeur");
string? signature = LireTexte(smtp, "bufSignature");
string? mdp = LireTexte(smtp, "sMdp");
string? mdpStandard = LireTexte(smtp, "sMdpStandard");
int cryptage = smtp.TryGetProperty("nCryptage", out JsonElement n) && n.ValueKind == JsonValueKind.Number
? n.GetInt32()
: 0;
bool enCopieCachee = smtp.TryGetProperty("bEnCopieCachee", out JsonElement bcc)
&& (bcc.ValueKind == JsonValueKind.True
|| (bcc.ValueKind == JsonValueKind.String
&& bool.TryParse(bcc.GetString(), out bool drapeau) && drapeau));
int copies = smtp.TryGetProperty("tabCC_CopieEmail", out JsonElement cc)
&& cc.ValueKind == JsonValueKind.Array
? cc.GetArrayLength()
: 0;
string listeCopies = "(aucune)";
if (copies > 0)
{
var adresses = cc.EnumerateArray()
.Select(a => a.ValueKind == JsonValueKind.String ? (a.GetString() ?? "").Trim() : a.ToString())
.Select(a => a.Length == 0 ? "«vide»" : a);
listeCopies = string.Join(" | ", adresses);
}
Console.WriteLine($" Compte.sCompte = {ResumerValeur(compte)}");
Console.WriteLine($" Compte.sEmail = {ResumerValeur(email)}");
Console.WriteLine($" SMTP.sAdresseServeur = {ResumerValeur(serveur)}");
Console.WriteLine($" SMTP.sPortServeur = {ResumerValeur(port)}");
Console.WriteLine($" SMTP.nCryptage = {LibelleCryptage(cryptage)}");
Console.WriteLine($" SMTP.sLogin = {ResumerValeur(login)}");
Console.WriteLine($" SMTP.sMdp = {(string.IsNullOrWhiteSpace(mdp) ? "absent" : $"présent ({mdp.Length} car.) — JAMAIS lu par Omika")}");
Console.WriteLine($" SMTP.sMdpStandard = {(string.IsNullOrWhiteSpace(mdpStandard) ? "ABSENT" : $"présent ({mdpStandard.Length} car. base64)")}");
Console.WriteLine($" Signature = {(string.IsNullOrWhiteSpace(signature) ? "aucune" : $"{signature.Length} car. base64")}");
Console.WriteLine($" bEnCopieCachee = {enCopieCachee}");
Console.WriteLine($" tabCC_CopieEmail = {copies} adresse(s) — {listeCopies}");
}
static void AfficherConfiguration(
SmtpConfiguration configuration,
bool montrerMotDePasse,
bool montrerSignatures,
string dossierSignatures,
string etiquetteProprietaire)
{
Console.WriteLine();
Console.WriteLine($" [{configuration.Id}]");
string? motDePasse = configuration.MotDePasse;
string statutMotDePasse = string.IsNullOrWhiteSpace(motDePasse)
? "ABSENT → envoi impossible"
: montrerMotDePasse
? $"« {motDePasse} » ({motDePasse.Length} car.)"
: $"{motDePasse.Length} caractère(s), résolu et déchiffré";
Console.WriteLine($" Serveur = {Valeur(configuration.Serveur)}");
Console.WriteLine($" Port = {configuration.Port}");
Console.WriteLine($" Cryptage = {LibelleCryptage(configuration.Cryptage)}");
Console.WriteLine($" Login = {Valeur(configuration.Login)}");
Console.WriteLine($" Expéditeur (From) = {Valeur(configuration.Email)}");
Console.WriteLine($" Compte (libellé) = {Valeur(configuration.Compte)}");
Console.WriteLine($" Mot de passe = {statutMotDePasse}");
Console.WriteLine($" Signature = {(string.IsNullOrWhiteSpace(configuration.Signature) ? "aucune" : $"{configuration.Signature.Length} caractère(s) de HTML")}");
Console.WriteLine($" Copie cachée (CCI) = {(configuration.EnCopieCachee ? "oui" : "non")}");
Console.WriteLine($" Copies (CC) = {(configuration.CcCopieEmail.Count == 0 ? "aucune" : string.Join(", ", configuration.CcCopieEmail))}");
try
{
configuration.Validate();
Console.WriteLine(" Prête à envoyer = OUI (validation complète)");
}
catch (EmailEnvoiException ex)
{
Console.WriteLine($" Prête à envoyer = NON ({ex.Message})");
}
if (montrerSignatures)
{
AfficherSignature(configuration, dossierSignatures, etiquetteProprietaire);
}
}
/// <summary>
/// Affiche la signature qui sera réellement concaténée au corps du courriel :
/// texte visible (balises retirées, images signalées) et fichier HTML complet
/// écrit sur disque pour la vérification visuelle.
/// </summary>
static void AfficherSignature(SmtpConfiguration configuration, string dossier, string etiquettes)
{
Console.WriteLine(" --- SIGNATURE AJOUTÉE AU CORPS ---");
string? html = configuration.Signature;
if (string.IsNullOrWhiteSpace(html))
{
Console.WriteLine(" (aucune signature : le corps du courriel reste inchangé)");
return;
}
int images = Regex.Matches(html, "<img\\b", RegexOptions.IgnoreCase).Count;
int imagesIntegrees = Regex.Matches(html, "src\\s*=\\s*[\"']data:", RegexOptions.IgnoreCase).Count;
int liens = Regex.Matches(html, "<a\\b", RegexOptions.IgnoreCase).Count;
int tableaux = Regex.Matches(html, "<table\\b", RegexOptions.IgnoreCase).Count;
Console.WriteLine($" HTML = {html.Length} caractère(s), {images} image(s) " +
$"({imagesIntegrees} intégrée(s) en base64), {liens} lien(s), {tableaux} tableau(x)");
string texte = SignatureVersTexte(html);
Console.WriteLine(" Texte visible :");
foreach (string ligne in texte.Split('\n'))
{
Console.WriteLine($" | {ligne}");
}
string nomFichier = Regex.Replace($"{etiquettes}_{configuration.Id}", "[^A-Za-z0-9_.-]", "_");
string chemin = Path.Combine(dossier, nomFichier + ".html");
File.WriteAllText(
chemin,
$"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Signature {configuration.Id}</title></head>" +
$"<body><p>--- corps du courriel ---</p>{html}</body></html>",
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
Console.WriteLine($" Aperçu HTML complet : {chemin}");
}
/// <summary>Rend lisible un fragment HTML de signature : texte, images signalées.</summary>
static string SignatureVersTexte(string html)
{
string travail = html;
travail = Regex.Replace(travail, "<!--.*?-->", " ", RegexOptions.Singleline);
travail = Regex.Replace(travail, "<(script|style|head)\\b[^>]*>.*?</\\1\\s*>", " ", RegexOptions.IgnoreCase | RegexOptions.Singleline);
// Images : on signale leur présence sans déverser le base64 dans la console.
travail = Regex.Replace(
travail,
"<img\\b[^>]*?alt\\s*=\\s*[\"']([^\"']*)[\"'][^>]*>",
" [image: $1] ",
RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "<img\\b[^>]*>", " [image] ", RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "<\\s*br\\s*/?>", "\n", RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "</\\s*(p|div|tr|h[1-6]|li|table|blockquote)\\s*>", "\n", RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "<\\s*(p|div|tr|h[1-6]|li|table|blockquote)\\b[^>]*>", "\n", RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "<[^>]+>", " ");
travail = WebUtility.HtmlDecode(travail);
travail = travail.Replace('\u00a0', ' ');
// Jetons base64 résiduels (attributs data:/cid: collés, styles volumineux).
travail = Regex.Replace(travail, "[A-Za-z0-9+/]{120,}={0,2}", " ");
travail = Regex.Replace(travail, "[ \\t\\r]+", " ");
travail = Regex.Replace(travail, " *\n *", "\n");
travail = Regex.Replace(travail, "\n{2,}", "\n");
return travail.Trim();
}
static string LibelleCryptage(int cryptage) => cryptage switch
{
1 => "1 = aucun",
2 => "2 = TLS / STARTTLS",
3 => "3 = SSL",
_ => $"{cryptage} = NON RENSEIGNÉ"
};
static string Valeur(string? texte)
=> string.IsNullOrWhiteSpace(texte) ? "(absent)" : texte;
static string ResumerValeur(object? valeur)
{
if (valeur is null or DBNull)
{
return "(null)";
}
string texte = valeur.ToString() ?? "";
if (texte.Length <= 90)
{
return string.IsNullOrWhiteSpace(texte) ? "(vide)" : texte;
}
return $"{texte[..90]}… ({texte.Length} car.)";
}
static string? LireTexte(JsonElement element, string nom)
=> element.TryGetProperty(nom, out JsonElement valeur) && valeur.ValueKind == JsonValueKind.String
? valeur.GetString()
: null;
/// <summary>
/// Présence (et non valeur) des champs qui décident de la validité d'une
/// configuration SMTP, telle qu'elle est réellement stockée en base.
/// </summary>
internal readonly record struct PresenceSmtp(
bool Serveur,
bool Port,
bool Cryptage,
bool Login,
bool MdpStandard,
bool MdpLegacy,
bool Compte,
bool Email)
{
public string Resume()
=> $"serveur={Drapeau(Serveur)} port={Drapeau(Port)} cryptage={Drapeau(Cryptage)} " +
$"login={Drapeau(Login)} sMdpStandard={Drapeau(MdpStandard)} sMdpLegacy={Drapeau(MdpLegacy)} " +
$"compte={Drapeau(Compte)} email={Drapeau(Email)}";
private static string Drapeau(bool present) => present ? "oui" : "NON";
}
/// <summary>
/// Journal minimal : les messages du service (invariants de diagnostic) sont
/// affichés tels quels, sans exposer de secret.
/// </summary>
internal sealed class JournalConsole : ILogger<SmtpConfigurationService>
{
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
=> null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
Console.WriteLine($" [journal {logLevel}] {formatter(state, exception)}");
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Omika.SmtpRealProbe</AssemblyName>
<RootNamespace>Omika.SmtpRealProbe</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Core\Omika.Core.csproj" />
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+362
View File
@@ -0,0 +1,362 @@
using System.Data.OleDb;
using System.Text.Json;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using Omika.Core.Security;
// ============================================================
// OMIKA SMTP PROBE — READ-ONLY DB + (optionnel) SMTP connect/auth/send
// Modes :
// --audit [--db OMIKA_TEST|BATIK_TEST]
// Scan de TOUTES les configs EMAIL_ENVOI% : classification + structure
// + déchiffrement (composant DE PRODUCTION SmtpPasswordStandardDecryptor).
// Ne modifie aucune donnée.
// <PU_Code> <PU_IDUtilisateur> [--db ...] [--send]
// Audit d'une configuration unique + SMTP connect/auth (+envoi si --send).
// Ne JAMAIS afficher sMdp / sMdpStandard / plaintext / login / clé.
// ============================================================
const string server = "127.0.0.1";
const int port = 4900;
const string user = "susana";
const string pass = "123";
// ProtectionStd (ParametreUtilisateur) par base de test.
var dbMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["OMIKA_TEST"] = "A1BAF17C65BC565F2927BF1A85DAFEED",
["BATIK_TEST"] = "E2FC3072E6145E1B20DA7321755CD607",
};
bool audit = args.Contains("--audit");
int dbIdx = Array.IndexOf(args, "--db");
string db = dbIdx >= 0 && dbIdx + 1 < args.Length ? args[dbIdx + 1] : "OMIKA_TEST";
if (!dbMap.TryGetValue(db, out string? stdPwd))
{
Console.Error.WriteLine($"ERROR: unknown database '{db}' (known: OMIKA_TEST, BATIK_TEST).");
return 2;
}
string connStr =
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={db};" +
$"User ID={user};Password={pass};" +
$"Extended Properties=\"Password=ParametreUtilisateur:{stdPwd}\"";
var decryptor = new SmtpPasswordStandardDecryptor();
if (audit)
{
return RunAudit(connStr, db, decryptor);
}
// --- Mode config unique ---
string code = args.Where(a => !a.StartsWith("--")).ElementAtOrDefault(0) ?? "EMAIL_ENVOI_5";
string idUserText = args.Where(a => !a.StartsWith("--")).ElementAtOrDefault(1) ?? "7";
bool send = args.Contains("--send");
if (code.Any(c => !(char.IsLetterOrDigit(c) || c == '_')) || !long.TryParse(idUserText, out long idUser))
{
Console.Error.WriteLine("USAGE: Omika.SmtpRealProbe <PU_Code> <PU_IDUtilisateur> [--db DB] [--send]");
return 2;
}
string? json = null;
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT PU_Valeur FROM ParametreUtilisateur WHERE PU_Code = '{code}' AND PU_IDUtilisateur = {idUser}";
json = cmd.ExecuteScalar() as string;
}
catch (Exception ex)
{
Console.WriteLine($"SMTP CONFIG FOUND = NO (DB read failed: {ex.GetType().Name})");
return 3;
}
Console.WriteLine("SMTP CONFIG FOUND = " + (string.IsNullOrWhiteSpace(json) ? "NO" : "YES"));
Console.WriteLine("SOURCE TABLE = ParametreUtilisateur");
Console.WriteLine($"PU_CODE = {code}");
Console.WriteLine($"PU_IDUTILISATEUR = {idUser}");
if (string.IsNullOrWhiteSpace(json)) return 4;
var cfg = ExtractFirst(json);
if (cfg is null) { Console.WriteLine("JSON PARSE = FAILED"); return 5; }
Console.WriteLine("sMdp PRESENT = " + (string.IsNullOrWhiteSpace(cfg.SMdp) ? "NO" : "YES"));
Console.WriteLine("sMdpStandard PRESENT = " + (string.IsNullOrWhiteSpace(cfg.SMdpStandard) ? "NO" : "YES"));
Console.WriteLine("sMdpStandard EMPTY = " + (string.IsNullOrWhiteSpace(cfg.SMdpStandard) ? "YES" : "NO"));
Console.WriteLine("SMTP HOST = " + (string.IsNullOrWhiteSpace(cfg.Host) ? "(absent)" : cfg.Host));
Console.WriteLine("SMTP PORT = " + (string.IsNullOrWhiteSpace(cfg.Port) ? "(absent)" : cfg.Port));
Console.WriteLine("SMTP SECURITY MODE = " + cfg.Cryptage switch { 1 => "AUCUN", 2 => "TLS/STARTTLS", 3 => "SSL", _ => $"INCONNU({cfg.Cryptage})" });
if (string.IsNullOrWhiteSpace(cfg.SMdpStandard))
{
Console.WriteLine("REAL STANDARD PASSWORD DECRYPT = NOT TESTED (sMdpStandard absent)");
return 6;
}
return TestSingle(decryptor, cfg, send);
// ============================================================
static int RunAudit(string connStr, string db, SmtpPasswordStandardDecryptor decryptor)
{
Console.WriteLine("DATABASE = " + db);
Console.WriteLine("IS OMIKA DATABASE = " + (db.Equals("OMIKA_TEST", StringComparison.OrdinalIgnoreCase) ? "YES" : "NO"));
var rows = new List<(string Code, string IdUser, string? Valeur)>();
try
{
using var conn = new OleDbConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT PU_Code, PU_IDUtilisateur, PU_Valeur FROM ParametreUtilisateur WHERE PU_Code LIKE 'EMAIL_ENVOI%'";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
string c = (reader.GetValue(0)?.ToString() ?? "").Trim();
string i = (reader.GetValue(1)?.ToString() ?? "").Trim();
string? v = reader.IsDBNull(2) ? null : reader.GetValue(2)?.ToString();
rows.Add((c, i, v));
}
}
catch (Exception ex)
{
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name);
return 3;
}
int emailEnvoi = 0, emailEnvoiSoc = 0, emailEnvoiPerso = 0;
int invalidJson = 0, legacyBinary = 0, emptyValue = 0;
int withMdp = 0, withStd = 0, withoutStd = 0, emptyStd = 0;
int base64Valid = 0, base64Invalid = 0;
int decryptPass = 0, decryptFail = 0, plaintextEmpty = 0;
foreach (var row in rows)
{
if (row.Code == "EMAIL_ENVOI") emailEnvoi++;
else if (System.Text.RegularExpressions.Regex.IsMatch(row.Code, @"^EMAIL_ENVOI_\d+$")) emailEnvoiSoc++;
else if (row.Code.Contains("Perso")) emailEnvoiPerso++;
if (string.IsNullOrWhiteSpace(row.Valeur)) { emptyValue++; continue; }
var configs = ExtractAll(row.Valeur);
if (configs is null)
{
// Non-JSON : legacy/binaire ou JSON invalide.
bool looksBinary = row.Valeur.Any(c => c < 32 && c != '\t' && c != '\n' && c != '\r');
bool startsJson = row.Valeur.TrimStart().StartsWith('{') || row.Valeur.TrimStart().StartsWith('[');
if (startsJson && !looksBinary) invalidJson++;
else legacyBinary++;
continue;
}
foreach (var cfg in configs)
{
if (!string.IsNullOrWhiteSpace(cfg.SMdp)) withMdp++;
if (!string.IsNullOrWhiteSpace(cfg.SMdpStandard))
{
withStd++;
byte[]? payload = TryBase64(cfg.SMdpStandard);
if (payload is null) base64Invalid++;
else
{
base64Valid++;
try
{
string pt = decryptor.Decrypt(cfg.SMdpStandard);
decryptPass++;
if (string.IsNullOrEmpty(pt)) plaintextEmpty++;
}
catch (SmtpStandardPasswordException) { decryptFail++; }
}
}
else
{
withoutStd++;
if (cfg.SMdpStandard is not null) emptyStd++; // champ présent mais vide
}
}
}
Console.WriteLine("TOTAL EMAIL CONFIGS = " + rows.Count);
Console.WriteLine("EMAIL_ENVOI = " + emailEnvoi);
Console.WriteLine("EMAIL_ENVOI_<idSoc> = " + emailEnvoiSoc);
Console.WriteLine("EMAIL_ENVOI_Perso_N = " + emailEnvoiPerso);
Console.WriteLine("EMPTY VALUE = " + emptyValue);
Console.WriteLine("INVALID JSON = " + invalidJson);
Console.WriteLine("LEGACY/BINARY = " + legacyBinary);
Console.WriteLine("CONFIGS WITH sMdp = " + withMdp);
Console.WriteLine("CONFIGS WITH sMdpStandard = " + withStd);
Console.WriteLine("CONFIGS WITHOUT sMdpStandard= " + withoutStd);
Console.WriteLine("CONFIGS WITH EMPTY sMdpStandard = " + emptyStd);
Console.WriteLine("STANDARD BASE64 VALID = " + (base64Valid > 0 ? (base64Invalid > 0 ? "PARTIAL" : "YES") : "NOT AVAILABLE"));
Console.WriteLine("REAL OMIKA CONFIGS TESTED = " + (base64Valid + base64Invalid));
Console.WriteLine("DECRYPT PASS = " + decryptPass);
Console.WriteLine("DECRYPT FAIL = " + decryptFail);
Console.WriteLine("PLAINTEXT EMPTY = " + plaintextEmpty);
Console.WriteLine("LEGACY sMdp USED BY OMIKA = NO (audit read-only, sMdp non déchiffré)");
return 0;
}
static int TestSingle(SmtpPasswordStandardDecryptor decryptor, SmtpConfig cfg, bool send)
{
byte[] payload;
try { payload = Convert.FromBase64String(cfg.SMdpStandard!.Trim()); }
catch { Console.WriteLine("STANDARD BASE64 VALID = NO"); return 7; }
int ctLen = payload.Length - 16;
Console.WriteLine("STANDARD BASE64 VALID = YES");
Console.WriteLine($"PAYLOAD LENGTH = {payload.Length}");
Console.WriteLine("IV LENGTH = 16");
Console.WriteLine($"CIPHERTEXT LENGTH = {ctLen}");
Console.WriteLine("CIPHERTEXT BLOCK VALID = " + (ctLen > 0 && ctLen % 16 == 0 ? "YES" : "NO"));
string plaintext;
try
{
plaintext = decryptor.Decrypt(cfg.SMdpStandard);
Console.WriteLine("REAL STANDARD PASSWORD DECRYPT = PASS");
Console.WriteLine("PLAINTEXT EMPTY = " + (string.IsNullOrEmpty(plaintext) ? "YES" : "NO"));
Console.WriteLine($"PLAINTEXT LENGTH = {plaintext.Length}");
}
catch (SmtpStandardPasswordException ex)
{
Console.WriteLine($"REAL STANDARD PASSWORD DECRYPT = FAIL ({ex.LogCode})");
return 8;
}
Console.WriteLine("LEGACY sMdp USED BY OMIKA = NO");
Console.WriteLine("PASSWORD LOGGED = NO");
if (string.IsNullOrWhiteSpace(cfg.Host)) { Console.WriteLine("SMTP CONNECT = NOT TESTED (host absent)"); return 9; }
int smtpPort = int.TryParse(cfg.Port, out int p) ? p : 25;
SecureSocketOptions secure = cfg.Cryptage switch
{
2 => SecureSocketOptions.StartTls,
>= 3 => SecureSocketOptions.SslOnConnect,
_ => SecureSocketOptions.None
};
try
{
using var client = new SmtpClient();
client.Connect(cfg.Host, smtpPort, secure);
Console.WriteLine("SMTP CONNECT = PASS");
if (!string.IsNullOrWhiteSpace(cfg.Login))
{
try
{
client.Authenticate(cfg.Login, plaintext);
Console.WriteLine("SMTP AUTH = PASS");
Console.WriteLine("PASSWORD SOURCE = sMdpStandard");
if (send)
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(cfg.Login));
message.To.Add(MailboxAddress.Parse(cfg.Login));
message.Subject = "Omika SMTP validation test";
message.Body = new TextPart("plain") { Text = "Test automatique Omika de validation SMTP (sMdpStandard)." };
client.Send(message);
Console.WriteLine("SMTP SEND = PASS");
Console.WriteLine("MESSAGE ACCEPTED BY SMTP SERVER = YES");
}
else Console.WriteLine("SMTP SEND = NOT TESTED (--send non fourni)");
}
catch (AuthenticationException ex)
{
Console.WriteLine("SMTP AUTH = FAIL");
Console.WriteLine("AUTH ERROR TYPE = " + ex.GetType().Name);
Console.WriteLine("AUTH ERROR MESSAGE SANITIZED = " + Sanitize(ex.Message, cfg.Login));
}
}
else Console.WriteLine("SMTP AUTH = NOT TESTED (login absent)");
try { client.Disconnect(true); } catch { }
}
catch (SslHandshakeException ex)
{
Console.WriteLine("SMTP CONNECT = FAIL");
Console.WriteLine("ERROR TYPE = " + ex.GetType().Name);
Console.WriteLine("ERROR MESSAGE SANITIZED = " + Sanitize(ex.Message, cfg.Login));
}
catch (System.Net.Sockets.SocketException ex)
{
Console.WriteLine("SMTP CONNECT = FAIL");
Console.WriteLine("ERROR TYPE = " + ex.GetType().Name);
Console.WriteLine("ERROR MESSAGE SANITIZED = " + Sanitize(ex.Message, cfg.Login));
}
catch (Exception ex)
{
Console.WriteLine("SMTP CONNECT = FAIL");
Console.WriteLine("ERROR TYPE = " + ex.GetType().Name);
Console.WriteLine("ERROR MESSAGE SANITIZED = " + Sanitize(ex.Message, cfg.Login));
}
return 0;
}
static byte[]? TryBase64(string s)
{
try { return Convert.FromBase64String(s.Trim()); }
catch { return null; }
}
static SmtpConfig? ExtractFirst(string json)
{
var all = ExtractAll(json);
return all is { Count: > 0 } ? all[0] : null;
}
static List<SmtpConfig>? ExtractAll(string json)
{
try
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
var result = new List<SmtpConfig>();
IEnumerable<JsonElement> elements = root.ValueKind == JsonValueKind.Array
? root.EnumerateArray()
: [root];
foreach (JsonElement element in elements)
{
if (!element.TryGetProperty("SMTP", out JsonElement smtp) || smtp.ValueKind != JsonValueKind.Object)
continue;
var cfg = new SmtpConfig();
if (smtp.TryGetProperty("sLogin", out JsonElement e) && e.ValueKind == JsonValueKind.String) cfg.Login = e.GetString();
if (smtp.TryGetProperty("sMdp", out e) && e.ValueKind == JsonValueKind.String) cfg.SMdp = e.GetString();
if (smtp.TryGetProperty("sMdpStandard", out e) && e.ValueKind == JsonValueKind.String) cfg.SMdpStandard = e.GetString();
if (smtp.TryGetProperty("sAdresseServeur", out e) && e.ValueKind == JsonValueKind.String) cfg.Host = e.GetString();
if (smtp.TryGetProperty("sPortServeur", out e) && e.ValueKind == JsonValueKind.String) cfg.Port = e.GetString();
if (smtp.TryGetProperty("nCryptage", out e) && e.ValueKind == JsonValueKind.Number) cfg.Cryptage = e.GetInt32();
result.Add(cfg);
}
return result;
}
catch (JsonException) { return null; }
}
static string Sanitize(string message, string? login)
{
string s = message ?? string.Empty;
if (!string.IsNullOrWhiteSpace(login)) s = s.Replace(login, "<login>");
s = System.Text.RegularExpressions.Regex.Replace(s, "(?i)(password|pwd|sMdpStandard|sMdp)\\s*[:=]\\s*\\S+", "$1=<redacted>");
return s.Length > 200 ? s[..200] : s;
}
sealed class SmtpConfig
{
public string? Login;
public string? SMdp;
public string? SMdpStandard;
public string? Host;
public string? Port;
public int Cryptage;
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Core\Omika.Core.csproj" />
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>
+60
View File
@@ -0,0 +1,60 @@
// Probe pour vérifier le status API de l'opération E2E.
// Génère un token via ITokenService (même mécanisme que le test E2E),
// puis appelle GET /api/sync/status/{operationId}.
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Omika.Core.Configuration;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Services.Mobile;
const string operationId = "5b915439-d9d9-44b5-9552-1f8f90db96c3";
const string apiBaseUrl = "http://localhost:5097";
// Construire la configuration avec la clé de signature.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MobileAuth:SigningKey"] = "test-signing-key-for-integration-tests-only"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddOptions<MobileAuthOptions>()
.Configure<IConfiguration>((options, configuration) =>
configuration.GetSection(MobileAuthOptions.SectionName).Bind(options));
services.AddSingleton<ITokenService, TokenService>();
var provider = services.BuildServiceProvider();
var tokenService = provider.GetRequiredService<ITokenService>();
string token = tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "E2E_TEST",
DeviceId = "e2e-device-001",
UserId = "e2e-user-001",
CollaborateurId = "1",
NumeroLicence = "e2e-licence-test"
});
Console.WriteLine($"Token généré: {token[..Math.Min(40, token.Length)]}...");
using var client = new HttpClient();
client.BaseAddress = new Uri(apiBaseUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Console.WriteLine($"\n=== GET /api/sync/status/{operationId} ===");
try
{
var response = await client.GetAsync($"/api/sync/status/{operationId}");
Console.WriteLine($"HTTP {(int)response.StatusCode} {response.StatusCode}");
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Body: {body}");
}
catch (Exception ex)
{
Console.WriteLine($"ERREUR: {ex.Message}");
}
Console.WriteLine("\nProbe status terminé.");
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+51
View File
@@ -0,0 +1,51 @@
// Probe pour vérifier les opérations dans TOSRV (base Service BATIK.01_BLS).
using System.Data.OleDb;
const string server = "127.0.0.1";
const int port = 4900;
const string user = "susana";
const string password = "123";
string BuildConnStr(string database) =>
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
Console.WriteLine("=== Opérations TOSRV dans BATIK.01_BLS ===");
try
{
using var conn = new OleDbConnection(BuildConnStr("BATIK.01_BLS"));
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT IDTOSRV, TS_Date, TS_Mobile, TS_JSON, TS_CMD FROM TOSRV";
using var reader = cmd.ExecuteReader();
Console.WriteLine("\n Toutes les opérations TOSRV:");
while (reader.Read())
{
string id = reader["IDTOSRV"]?.ToString() ?? "";
string date = reader["TS_Date"]?.ToString() ?? "";
string mobile = reader["TS_Mobile"]?.ToString() ?? "";
string json = reader["TS_JSON"]?.ToString() ?? "";
string cmdVal = reader["TS_CMD"]?.ToString() ?? "";
// Extraire l'operationId du JSON si présent.
string opId = "";
int idx = json.IndexOf("operationId");
if (idx >= 0)
{
int start = json.IndexOf('"', idx + 12);
int end = json.IndexOf('"', start + 1);
if (start >= 0 && end > start)
opId = json.Substring(start + 1, end - start - 1);
}
Console.WriteLine($" IDTOSRV={id}, Date={date}, Mobile={mobile}, Cmd={cmdVal}, OpId={opId}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR: {ex.Message}");
}
Console.WriteLine("\nProbe TOSRV terminé.");
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- ====================================================================
Vérification HORS xunit des tests du champ de dictée vocale.
Pourquoi cet outil : sur certains postes de développement, le testhost
x86 de `dotnet test` ne peut pas ouvrir le handle de son processus père
(« Accès refusé », Win32Exception 5) et la série de tests est
abandonnée avant le premier test. Cet exécutable rejoue donc les MÊMES
tests (ceux du projet Omika.Tests) directement dans un processus
console x64. Lancement : dotnet run sur ce projet, option no-build.
Aucun test n'est dupliqué ici : les méthodes sont appelées par
réflexion depuis tests\Omika.Tests, donc une correction de test profite
aux deux exécutions.
==================================================================== -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Omika.Verif.Dictee</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\tests\Omika.Tests\Omika.Tests.csproj" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Omika.Verif.Langues</RootNamespace>
<AssemblyName>Omika.Verif.Langues</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.App.Local\Omika.App.Local.csproj" />
</ItemGroup>
</Project>
+210
View File
@@ -0,0 +1,210 @@
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Local.Services;
using Omika.Core.Contracts.Mobile.Languages;
namespace Omika.Verif.Langues;
/// <summary>
/// Vérification de la traduction hors ligne sur les VRAIS modèles du catalogue.
///
/// <para>
/// Ce petit exécutable joue le rôle du téléphone : il charge les packs depuis un
/// répertoire local et traduit quelques phrases connues dans les deux sens. Il
/// sert à prouver, avant de livrer un APK, que le pipeline ONNX produit bien du
/// texte — un test unitaire ne peut pas le faire sans embarquer 250 Mo de poids.
/// </para>
///
/// <para>
/// Usage : <c>Omika.Verif.Langues.exe [racine_packs]</c>
/// (par défaut <c>D:\Avalonia\Omika\packs</c>).
/// </para>
/// </summary>
public static class Program
{
private static readonly (string Code, SensTraduction Sens, string Texte, string Attendu)[] Essais =
[
("es", SensTraduction.VersFrancais, "el techo estaba mojado", "Le plafond était mouillé."),
("en", SensTraduction.VersFrancais, "The ceiling was wet.", "Le plafond était mouillé."),
("de", SensTraduction.VersFrancais, "Die Decke war nass.", ""),
("pt", SensTraduction.VersFrancais, "O telhado estava molhado.", "Le toit était humide."),
("it", SensTraduction.VersFrancais, "Il soffitto era bagnato.", "Le plafond était humide."),
("es", SensTraduction.DepuisFrancais, "Le plafond était mouillé.", "El techo estaba mojado."),
("pt", SensTraduction.DepuisFrancais, "Le plafond était mouillé.", "O teto estava molhado."),
];
public static async Task<int> Main(string[] args)
{
string racine = args.Length > 0 ? args[0] : @"D:\Avalonia\Omika\packs";
var depot = new DepotDisque(racine);
using var service = new OnnxTranslationService(depot, OnnxRuntimeInferenceSessionFactory.Instance);
Console.WriteLine($"Moteur : {OnnxRuntimeInferenceSessionFactory.Instance.NomMoteur}");
Console.WriteLine($"Packs : {racine}");
Console.WriteLine();
int echecs = 0;
foreach ((string code, SensTraduction sens, string texte, string attendu) in Essais)
{
var montre = System.Diagnostics.Stopwatch.StartNew();
try
{
string resultat = await service.TraduireAsync(texte, code, sens);
montre.Stop();
bool boucle = DetecterBoucle(resultat);
bool conforme = attendu.Length == 0
|| string.Equals(resultat.Trim(), attendu, StringComparison.OrdinalIgnoreCase);
if (boucle || !conforme)
{
echecs++;
}
string verdict = boucle ? "BOUCLE" : conforme ? "OK" : "ECART";
Console.WriteLine($"[{verdict,-6}] {code} {sens,-14} {montre.ElapsedMilliseconds,6} ms « {texte} » -> « {resultat} »");
if (!conforme && attendu.Length > 0)
{
Console.WriteLine($" attendu : « {attendu} »");
}
}
catch (Exception ex)
{
echecs++;
montre.Stop();
Console.WriteLine($"[ERREUR] {code} {sens} : {ex.GetType().Name} — {ex.Message}");
}
}
Console.WriteLine();
Console.WriteLine("--- Deuxième passe (modèles déjà chargés : latence réelle perçue) ---");
foreach ((string code, SensTraduction sens, string texte, _) in Essais)
{
var montre = System.Diagnostics.Stopwatch.StartNew();
string resultat = await service.TraduireAsync(texte, code, sens);
montre.Stop();
Console.WriteLine($" {code} {sens,-14} {montre.ElapsedMilliseconds,6} ms « {resultat} »");
}
Console.WriteLine();
Console.WriteLine(echecs == 0
? "RÉSULTAT : tous les essais sont conformes."
: $"RÉSULTAT : {echecs} essai(s) en échec.");
return echecs == 0 ? 0 : 1;
}
/// <summary>Vrai si le texte contient un même mot répété 5 fois d'affilée.</summary>
private static bool DetecterBoucle(string texte)
{
string[] mots = texte.Split(' ', StringSplitOptions.RemoveEmptyEntries);
int suite = 1;
for (int i = 1; i < mots.Length; i++)
{
suite = string.Equals(mots[i], mots[i - 1], StringComparison.OrdinalIgnoreCase) ? suite + 1 : 1;
if (suite >= 5)
{
return true;
}
}
return false;
}
}
/// <summary>
/// Dépôt en lecture seule sur un répertoire de packs : c'est exactement la
/// disposition produite par <c>scripts\Exporter-Packs-Langues.ps1</c>.
/// </summary>
internal sealed class DepotDisque(string racine) : ILanguagePackStore
{
private static readonly string[] SensPossibles =
[
LanguageModelDirections.VersFrancais,
LanguageModelDirections.DepuisFrancais,
LanguageModelDirections.Commun
];
public string Racine { get; } = racine;
public string CheminFichier(string code, string sens, string nomFichier)
=> Path.Combine(Racine, code, sens, nomFichier);
public Task<PackLangueInstalle?> GetPackInstalleAsync(
string code, CancellationToken cancellationToken = default)
{
string dossier = Path.Combine(Racine, code);
if (!Directory.Exists(dossier))
{
return Task.FromResult<PackLangueInstalle?>(null);
}
var fichiers = new List<FichierModeleInstalle>();
foreach (string sens in SensPossibles)
{
string cheminSens = Path.Combine(dossier, sens);
if (!Directory.Exists(cheminSens))
{
continue;
}
foreach (string fichier in Directory.EnumerateFiles(cheminSens))
{
fichiers.Add(new FichierModeleInstalle
{
Sens = sens,
NomFichier = Path.GetFileName(fichier),
TailleOctets = new FileInfo(fichier).Length,
Sha256 = string.Empty
});
}
}
return Task.FromResult<PackLangueInstalle?>(new PackLangueInstalle
{
Code = code,
Libelle = code,
Version = "1.0.0",
InstalleLeUtc = DateTimeOffset.UtcNow,
Fichiers = fichiers
});
}
public Task<IReadOnlyList<PackLangueInstalle>> ListerPacksInstallesAsync(
CancellationToken cancellationToken = default)
{
var packs = new List<PackLangueInstalle>();
foreach (string dossier in Directory.EnumerateDirectories(Racine))
{
PackLangueInstalle? pack = GetPackInstalleAsync(Path.GetFileName(dossier), cancellationToken)
.GetAwaiter().GetResult();
if (pack is not null)
{
packs.Add(pack);
}
}
return Task.FromResult<IReadOnlyList<PackLangueInstalle>>(packs);
}
public Task<LanguageManifestResponse?> GetIndexAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<LanguageManifestResponse?>(null);
public Task EnregistrerIndexAsync(LanguageManifestResponse manifeste, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task EnregistrerPackAsync(PackLangueInstalle pack, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task SupprimerPackAsync(string code, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public bool FichierPresent(string code, FichierModeleInstalle fichier)
=> File.Exists(CheminFichier(code, fichier.Sens, fichier.NomFichier));
public Task<bool> VerifierFichierAsync(
string code, FichierModeleInstalle fichier, CancellationToken cancellationToken = default)
=> Task.FromResult(FichierPresent(code, fichier));
public string PreparerFichierPartiel(string code, string sens, string nomFichier)
=> CheminFichier(code, sens, nomFichier + ".partiel");
}
+109
View File
@@ -0,0 +1,109 @@
# READ-ONLY registry audit — OLE DB providers, ODBC drivers, CLSIDs
# No modifications. No secrets displayed.
param([switch]$OleDb, [switch]$OdbcReg, [switch]$Clsid, [switch]$All)
$ErrorActionPreference = "SilentlyContinue"
if ($All -or $OleDb) {
Write-Host "========== OLE DB PROVIDERS =========="
$classesRoots = @("HKLM:\SOFTWARE\Classes", "HKLM:\SOFTWARE\WOW6432Node\Classes")
# Search ProgIDs
foreach ($c in $classesRoots) {
Get-ChildItem $c -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match "^PCSOFT\.|^HFSQL|HyperFile" } | ForEach-Object {
$progid = $_.PSChildName
Write-Host "ProgID: $progid (under $c)"
$clsidPath = Join-Path $c "$progid\CLSID"
if (Test-Path $clsidPath) {
$clsid = (Get-ItemProperty $clsidPath).'(default)'
Write-Host " CLSID: $clsid"
if ($clsid) {
$detail = Join-Path $c "CLSID\$clsid"
if (Test-Path $detail) {
$dll = (Get-ItemProperty "$detail\InprocServer32").'(default)'
if ($dll) { Write-Host " DLL: $dll" }
$ver = (Get-ItemProperty "$detail\Version").'(default)'
if ($ver) { Write-Host " Version: $ver" }
}
}
}
Write-Host "---"
}
}
# Search OLE DB Provider descriptions
Write-Host "`n=== OLE DB Provider Descriptions (HFSQL/PC SOFT) ==="
foreach ($c in $classesRoots) {
$clsidBase = Join-Path $c "CLSID"
if (Test-Path $clsidBase) {
Get-ChildItem $clsidBase -ErrorAction SilentlyContinue | ForEach-Object {
$clsid = $_.PSChildName
$olePath = "$clsidBase\$clsid\OLE DB Provider"
if (Test-Path $olePath) {
$desc = (Get-ItemProperty $olePath).'(default)'
if ($desc -match "HFSQL|Hyper.?File|PC.?SOFT|PCSOFT") {
Write-Host "Description: $desc"
Write-Host "CLSID: $clsid"
$dll = (Get-ItemProperty "$clsidBase\$clsid\InprocServer32").'(default)'
if ($dll) { Write-Host "DLL: $dll" }
Write-Host "---"
}
}
}
}
}
}
if ($All -or $OdbcReg) {
Write-Host "`n========== ODBC REGISTRY (HFSQL/WINDEV) =========="
$odbcPaths = @(
"HKLM:\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers",
"HKLM:\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI\ODBC Drivers"
)
foreach ($p in $odbcPaths) {
if (Test-Path $p) {
$props = Get-ItemProperty $p
$props.PSObject.Properties | Where-Object { $_.Name -match "HFSQL|Hyper.?File|PC.?SOFT|PCSOFT|WINDEV|WD300" } | ForEach-Object {
Write-Host "Driver: $($_.Name) Value: $($_.Value) Path: $p"
}
}
}
# Driver details
$iniPaths = @(
"HKLM:\SOFTWARE\ODBC\ODBCINST.INI",
"HKLM:\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI"
)
foreach ($p in $iniPaths) {
Get-ChildItem $p -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match "HFSQL|Hyper.?File|PC.?SOFT|PCSOFT|WINDEV|WD300" } | ForEach-Object {
$name = $_.PSChildName
Write-Host "`nDriverSection: $name (under $p)"
$props = Get-ItemProperty "$p\$name"
$props.PSObject.Properties | Where-Object { $_.Name -notin @("PSPath","PSParentPath","PSChildName","PSDrive","PSProvider") } | ForEach-Object {
Write-Host " $($_.Name)=$($_.Value)"
}
}
}
}
if ($All -or $Clsid) {
Write-Host "`n========== CLSID Search (PCSOFT/HFSQL specific) =========="
# We use a targeted approach — search ProgID first
$found = @{}
foreach ($c in @("HKLM:\SOFTWARE\Classes","HKLM:\SOFTWARE\WOW6432Node\Classes")) {
Get-ChildItem $c -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match "^PCSOFT|^WD300|^HAccess|HFSQL" } | ForEach-Object {
$progid = $_.PSChildName
if (-not $found[$progid]) {
$found[$progid] = $true
Write-Host "ProgID: $progid"
$clsidPath = "$c\$progid\CLSID"
if (Test-Path $clsidPath) {
$clsid = (Get-ItemProperty $clsidPath).'(default)'
Write-Host " CLSID: $clsid"
}
}
}
}
}
Write-Host "`nDone."
+6
View File
@@ -0,0 +1,6 @@
$config = Get-Content 'd:\Avalonia\Omika\config\hfsql-test.local.json' | ConvertFrom-Json
Write-Host "AllowHfSqlWrites: $($config.E2E.AllowHfSqlWrites)"
Write-Host "ServiceDatabaseConfirmedTest: $($config.E2E.ServiceDatabaseConfirmedTest)"
Write-Host "WebServiceDatabaseConfirmedTest: $($config.E2E.WebServiceDatabaseConfirmedTest)"
$isWriteAllowed = $config.E2E.AllowHfSqlWrites -and $config.E2E.ServiceDatabaseConfirmedTest -and $config.E2E.WebServiceDatabaseConfirmedTest
Write-Host "IsWriteAllowed: $isWriteAllowed"
+9
View File
@@ -0,0 +1,9 @@
$mobileApiConfig = Join-Path $env:LOCALAPPDATA "Omika\mobile-api.local.json"
$exists = Test-Path $mobileApiConfig
Write-Output "EXISTS=$exists"
Write-Output "PATH=$mobileApiConfig"
if ($exists) {
# Read the raw JSON structure (BaseUrl is not a secret)
$json = Get-Content $mobileApiConfig -Raw
Write-Output "RAW_JSON=$json"
}
+34
View File
@@ -0,0 +1,34 @@
import sqlite3, json
db = r'C:\Users\Susana\AppData\Local\Omika\omika.db'
con = sqlite3.connect(db)
cur = con.cursor()
tables = ['Intervention', 'InterventionCache', 'ContextInterventionCache', 'DossierInterventionCache']
for t in tables:
try:
n = cur.execute('SELECT COUNT(*) FROM "%s"' % t).fetchone()[0]
print('%s: %d' % (t, n))
except Exception as e:
print('%s: ERR %s' % (t, e))
print()
# Colonnes des tables intervention
for t in ['Intervention', 'InterventionCache']:
try:
cols = [r[1] for r in cur.execute('PRAGMA table_info("%s")' % t).fetchall()]
print('%s columns: %s' % (t, cols))
except Exception as e:
print('%s cols ERR %s' % (t, e))
print()
# Un exemple d'intervention si présent
for t in ['Intervention', 'InterventionCache', 'ContextInterventionCache']:
try:
rows = cur.execute('SELECT * FROM "%s" LIMIT 1' % t).fetchall()
if rows:
print('%s sample: %s' % (t, str(rows[0])[:600]))
except Exception as e:
pass
con.close()
+41
View File
@@ -0,0 +1,41 @@
import sqlite3, json, collections, sys
db = r'C:\Users\Susana\AppData\Local\Omika\omika.db'
con = sqlite3.connect(db)
cur = con.cursor()
tables = [r[0] for r in cur.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
print('TABLES:', tables)
for t in ['RendezVous', 'Client', 'Collaborateur', 'Dossier', 'SuiviTemps', 'Intervention', 'Site']:
try:
n = cur.execute('SELECT COUNT(*) FROM "%s"' % t).fetchone()[0]
print('%s: %d rows' % (t, n))
except Exception as e:
print('%s: ERR %s' % (t, e))
print()
rows = cur.execute('SELECT Id, PayloadJson, UpdatedAtUtc FROM RendezVous LIMIT 2').fetchall()
print('SAMPLE RendezVous rows:')
for r in rows:
print(' Id=', r[0], 'Updated=', r[2])
print(' Payload=', r[1][:500])
print()
dates = []
parse_errors = 0
for (Id, PayloadJson, UpdatedAtUtc) in cur.execute('SELECT Id, PayloadJson, UpdatedAtUtc FROM RendezVous'):
try:
d = json.loads(PayloadJson)
dates.append((d.get('Debut'), d.get('Fin'), d.get('Titre')))
except Exception as e:
parse_errors += 1
dates.append(('PARSE_ERR:' + str(e), None, None))
print('TOTAL RendezVous payloads:', len(dates), 'parse_errors:', parse_errors)
if dates:
counts = collections.Counter((x[0] or 'None')[:10] for x in dates)
print('Debut distribution (date prefix):', dict(sorted(counts.items())))
valid = [x[0] for x in dates if isinstance(x[0], str)]
if valid:
print('Min Debut:', min(valid))
print('Max Debut:', max(valid))
print('Sample entries:')
for x in dates[:15]:
print(' ', x)
con.close()
+40
View File
@@ -0,0 +1,40 @@
"""Extraction ciblee des tables Client/Adresse/Client_Adresse/ParametreMetier
depuis schema-compatibilite-batik-omika.json. Script temporaire d'analyse.
"""
import json
import io
with io.open(r"docs/schema-compatibilite-batik-omika.json", encoding="utf-8") as f:
data = json.load(f)
tables = data.get("tables", [])
if not isinstance(tables, list):
print("tables n'est pas une liste:", type(tables).__name__)
raise SystemExit
print("Nombre d'entrees:", len(tables))
if tables:
sample = tables[0]
print("Structure d'un element:", type(sample).__name__)
if isinstance(sample, dict):
print("Cles:", list(sample.keys()))
names = ("Client", "Adresse", "Client_Adresse", "ParametreMetier", "ParametreMetierEntete")
for entry in tables:
if not isinstance(entry, dict):
continue
name = entry.get("name") or entry.get("table") or entry.get("tableName")
if name not in names:
continue
print(f"== {name}")
for key, value in entry.items():
if key == "columns" and isinstance(value, list):
cols = ", ".join(str(c) for c in value[:200])
print(f" columns ({len(value)}): {cols}")
elif isinstance(value, dict):
print(f" {key}: {list(value.keys())}")
else:
print(f" {key}: {value}")
+57
View File
@@ -0,0 +1,57 @@
param(
[int]$KeySize = 3072,
[string]$KeyId = "omika-qr-2026-01"
)
$ErrorActionPreference = 'Stop'
# Garde de sécurité : la signature des QR d'inscription exige RSA 3072 bits minimum.
if ($KeySize -lt 3072) {
throw "RSA 3072 bits minimum requis."
}
Write-Host "Generation de la paire de cles RSA ($KeySize bits)..." -ForegroundColor Cyan
$rsa = New-Object `
-TypeName System.Security.Cryptography.RSACryptoServiceProvider `
-ArgumentList $KeySize
$rsa.PersistKeyInCsp = $false
try {
if ($rsa.KeySize -lt 3072) {
throw "La cle RSA generee est inferieure a 3072 bits."
}
if ($rsa.KeySize -ne $KeySize) {
throw "La taille RSA generee ($($rsa.KeySize)) ne correspond pas a la taille demandee ($KeySize)."
}
$publicXml = $rsa.ToXmlString($false)
$privateXml = $rsa.ToXmlString($true)
}
finally {
$rsa.Dispose()
}
Write-Host ""
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host " PAIRES DE CLES RSA ($KeySize bits)kid : $KeyId" -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "1) CLE PUBLIQUE (a embarquer dans le CLIENT) :" -ForegroundColor Green
Write-Host ""
Write-Host $publicXml -ForegroundColor Green
Write-Host ""
Write-Host "2) CLE PRIVEE (SERVEUR UNIQUEMENT NE JAMAIS COMMITTER) :" -ForegroundColor Red
Write-Host ""
Write-Host $privateXml -ForegroundColor Red
Write-Host ""
Write-Host "============================================================" -ForegroundColor Cyan
Write-Host "Instructions :" -ForegroundColor Cyan
Write-Host " - Serveur : configurer LicenseQr:PrivateKeyXml (cle privee ci-dessus)" -ForegroundColor Cyan
Write-Host " et LicenseQr:KeyId = '$KeyId'." -ForegroundColor Cyan
Write-Host " - Client : ajouter la cle publique ci-dessus au registre" -ForegroundColor Cyan
Write-Host " EnrollmentVerificationKey.PublicKeysByKeyId sous le kid '$KeyId'." -ForegroundColor Cyan
Write-Host " - Ne jamais placer la cle privee dans le depot, l'APK, l'IPA ou le Desktop." -ForegroundColor Cyan
Write-Host "============================================================" -ForegroundColor Cyan
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Générateur déterministe des modèles physiques HFSQL Omika.
Lit docs/schema-compatibilite-batik-omika.json et génère les classes C#
des modèles physiques Batik (Isis_BO) et Omika dans src/Omika.Data.
Ce script est un outil interne :
- la génération est déterministe (même entrée => même sortie) ;
- il n'est pas nécessaire au fonctionnement de l'application ;
- il n'invente aucune donnée : chaque propriété provient du JSON ;
- la compilation ne dépend pas de ce script ni d'un chemin absolu.
Fichiers générés :
src/Omika.Data/HfSql/Models/Batik/<Table>Row.cs
src/Omika.Data/HfSql/Models/Omika/<Table>Row.cs
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
JSON_PATH = ROOT / "docs" / "schema-compatibilite-batik-omika.json"
OUT_BATIK = ROOT / "src" / "Omika.Data" / "HfSql" / "Models" / "Batik"
OUT_OMIKA = ROOT / "src" / "Omika.Data" / "HfSql" / "Models" / "Omika"
HEADER = (
"// <auto-generated />\n"
"// Généré à partir de docs/schema-compatibilite-batik-omika.json.\n"
"// Ne pas modifier à la main.\n"
"#nullable enable\n"
)
# Types HFSQL -> type C# par défaut (quand le JSON ne recommande rien d'autre).
HF_TYPES = {
"Identifiant automatique (8 octets)": "long",
"Entier sur 8 octets": "long",
"Entier non signé sur 8 octets": "ulong",
"Entier sur 4 octets": "int",
"Entier non signé sur 4 octets": "uint",
"Entier sur 2 octets": "short",
"Entier non signé sur 2 octets": "ushort",
"Entier sur 1 octet": "sbyte",
"Entier non signé sur 1 octet": "byte",
"Booléen": "bool",
"Chaîne": "string",
"Chaîne ANSI": "string",
"Chaîne Unicode": "string",
"Mémo texte": "string",
"Mémo binaire": "byte[]",
"Image (mémo binaire)": "byte[]",
"Date (aaaammjj)": "DateOnly",
"Heure (hhmm)": "TimeOnly",
"Date et Heure (aaaammjjhhmmssccc)": "DateTime",
"Réel sur 4 octets": "float",
"Réel sur 8 octets": "double",
"Numérique": "double",
"Monétaire": "decimal",
"Clé composée": "string",
"Durée": "TimeSpan",
}
def to_pascal(name: str) -> str:
"""Convertit un nom de rubrique HFSQL en nom C# PascalCase."""
parts = re.split(r"_+", name)
return "".join(p[:1].upper() + p[1:] for p in parts if p)
def to_identifier(name: str) -> str:
"""Transforme un nom de table HFSQL en identifiant C#."""
ident = to_pascal(name)
if ident and ident[0].isdigit():
ident = "T" + ident
# Évite les mots-clés C#
keywords = {"class", "namespace", "string", "object", "bool", "int", "long"}
if ident.lower() in keywords:
ident += "_"
return ident
def net_type(field: dict, hfsql_type: str | None) -> str:
"""Détermine le type .NET pour une rubrique."""
rec = field.get("dotnet_type_recommendation")
if rec:
if rec == "string (32) en persistance; Guid? après validation":
return "string"
if rec == "string en lecture seule / index technique":
return "string"
return rec
if hfsql_type and hfsql_type in HF_TYPES:
return HF_TYPES[hfsql_type]
return "string"
# Construit les entités XML via chr() pour éviter toute interprétation
# des séquences d'échappement dans ce générateur.
_XML_AMP = chr(38) # &
_XML_LT = chr(60) # <
_XML_GT = chr(62) # >
_XML_QUOT = chr(34) # "
def escape_cs(s: str) -> str:
"""Échappe une chaîne pour l'écrire dans une doc XML C#."""
return (
s.replace(_XML_AMP, _XML_AMP + "amp;")
.replace(_XML_LT, _XML_LT + "lt;")
.replace(_XML_GT, _XML_GT + "gt;")
.replace(_XML_QUOT, _XML_QUOT + "quot;")
)
def _find_zombie_replacement(physical_name: str, fields: list) -> str | None:
"""Trouve une rubrique de remplacement documentée pour une rubrique zombie.
La règle repose sur les conventions du schéma Omika : une rubrique numérique
historique `XYZ_ID...` est remplacée par une clé textuelle `XYZ_Cle...`
partageant le même noyau. Ex. `DC_Categorie` -> `DC_CleCategorie`,
`DC_IDEtatDocument` -> `DC_CleEtatDoc`.
"""
core = re.sub(r"^(.*?)_(ID[A-Za-z0-9_]*|[A-Za-z0-9]*ID)", r"\1", physical_name)
for other in fields:
on = other.get("canonical_name") or ""
if on and on != physical_name and ("Cle" in on or "GUID" in on):
other_core = re.sub(r"^(.*?)_(Cle[A-Za-z0-9_]*|GUID)", r"\1", on)
if core and other_core and (core in other_core or other_core in core):
return on
return None
def build_class(table: dict, version: str) -> str:
"""Construit le code C# d'un modèle physique pour une version donnée."""
name = table["name"]
ns = f"Omika.Data.HfSql.Models.{'Batik' if version == 'batik' else 'Omika'}"
cls = to_identifier(name) + "Row"
meta = table.get("old_metadata" if version == "batik" else "new_metadata")
disk = meta.get("disk_name") if meta else None
groups = (meta.get("groups") or []) if meta else []
schema_versions = ["Batik"] if version == "batik" else ["Omika"]
lines = [HEADER, "", f"namespace {ns};", ""]
# Pas de « using » dans les fichiers générés : les attributs de mapping sont
# exposés via le « global using » du projet Omika.Data (GlobalUsings.cs),
# ce qui évite la résolution relative délicate des usings dans les namespaces
# « Omika.Data.HfSql.Models.* ».
lines.append("/// <summary>")
lines.append(f"/// Modèle physique de la table <c>{name}</c> dans le schéma {schema_versions[0]}.")
if table.get("purpose_inference"):
lines.append(f"/// {escape_cs(table['purpose_inference'])}")
lines.append("/// </summary>")
attr = f'[HfSqlTable("{name}"'
if disk:
attr += f', DiskName = "{disk}"'
if groups:
attr += f', Groups = [{", ".join(f'"{g}"' for g in groups)}]'
attr += f', SchemaVersions = [{", ".join(f'"{v}"' for v in schema_versions)}])]'
lines.append(attr)
lines.append(f"public class {cls}")
lines.append("{")
lines.append(f" /// <summary>Constructeur sans paramètre (lecture HFSQL).</summary>")
lines.append(f" public {cls}()")
lines.append(f" {{")
lines.append(f" }}")
lines.append("")
# Clé JSON du côté : « old » pour l'ancienne base (Isis_BO), « new » pour Omika.
json_side = "old" if version == "batik" else "new"
for fld in table["fields"]:
side = fld.get(json_side)
if side is None:
# Rubrique absente de cette version
continue
physical_name = side.get("name") or fld.get("canonical_name")
canonical = fld.get("canonical_name")
label = side.get("label")
hfsql_type = side.get("hfsql_type")
size = side.get("size")
default = side.get("default")
zombie = side.get("zombie") is True
notes = side.get("notes") or []
is_pk = "PK automatique" in (fld.get("compatibility_notes") or "") or (
"PK automatique" in (notes or [])
)
is_composite = "Clé composée" in (hfsql_type or "")
compat = fld.get("compatibility_notes") or ""
prop_name = to_identifier(canonical)
if not prop_name:
prop_name = to_identifier(physical_name)
prop_name = to_identifier(prop_name)
# Résout un éventuel conflit de nom avec la classe
if prop_name == cls:
prop_name += "_"
t = net_type(fld, hfsql_type)
t = t.strip()
lines.append(" /// <summary>")
if label:
lines.append(f" /// {escape_cs(label)}")
else:
lines.append(f" /// Rubrique <c>{escape_cs(physical_name)}</c>.")
if zombie:
lines.append(" /// Rubrique historique (zombie) : conservée pour la lecture d'une base existante,")
lines.append(" /// ne doit pas être utilisée comme référence principale dans le nouveau domaine.")
if size:
lines.append(f" /// Taille maximale : {escape_cs(size)}.")
if default:
lines.append(f" /// Valeur par défaut documentée : {escape_cs(default)}.")
if notes:
for n in notes:
lines.append(f" /// Note : {escape_cs(n)}.")
lines.append(" /// </summary>")
# Attributs
col_attr = f' [HfSqlColumn("{physical_name}"'
if hfsql_type:
col_attr += f', HfSqlType = "{escape_cs(hfsql_type)}"'
if size and size.isdigit():
col_attr += f", MaxLength = {int(size)}"
if default:
col_attr += f', DefaultValue = "{escape_cs(default)}"'
if zombie:
col_attr += ", IsZombie = true"
if version == "omika" and side.get("name") is not None and fld.get("old") is None:
col_attr += ", IsOmikaOnly = true"
col_attr += ")]"
lines.append(col_attr)
if is_pk:
lines.append(f' [HfSqlPrimaryKey("{physical_name}")]')
if is_composite:
# clé composée : on note les composantes si elles sont dans les notes
comps = re.findall(r"([A-Za-z0-9_]+)\+([A-Za-z0-9_]+)", compat + " " + " ".join(notes))
if comps:
cols = [c for pair in comps for c in pair]
quoted = ", ".join('"' + c + '"' for c in cols)
lines.append(" [HfSqlCompositeKey(" + quoted + ")]")
if zombie:
replacement = _find_zombie_replacement(physical_name, table["fields"])
if replacement:
lines.append(f' [HfSqlLegacyField("{physical_name}", ReplacementColumn = "{replacement}")]')
else:
lines.append(f' [HfSqlLegacyField("{physical_name}")]')
# Les types référence sont déclarés nullables : la nullabilité réelle n'est
# pas démontrée par l'export, et on ne suppose pas qu'une chaîne vide est
# une valeur valide. Les types valeur conservent le type recommandé par le JSON.
if t in ("string", "byte[]"):
t += "?"
lines.append(f" public {t} {prop_name} {{ get; set; }}")
lines.append("")
lines.append("}")
return "\n".join(lines) + "\n"
def main() -> int:
with open(JSON_PATH, encoding="utf-8") as f:
data = json.load(f)
tables = data["tables"]
generated = []
for t in tables:
for version, out_dir in (("batik", OUT_BATIK), ("omika", OUT_OMIKA)):
meta = t.get("old_metadata" if version == "batik" else "new_metadata")
if meta is None:
continue
out_dir.mkdir(parents=True, exist_ok=True)
code = build_class(t, version)
cls = to_identifier(t["name"]) + "Row"
out = out_dir / f"{cls}.cs"
out.write_text(code, encoding="utf-8")
generated.append(out.relative_to(ROOT))
print(f"Généré {len(generated)} fichiers.")
for g in sorted(str(x) for x in generated):
print(" -", g)
return 0
if __name__ == "__main__":
sys.exit(main())
+163
View File
@@ -0,0 +1,163 @@
# hfsql-oledb-probe.ps1 — OLE DB HFSQL diagnostic (READ-ONLY)
# Uses PCSoft.HFSQL provider via ADODB COM.
param(
[Parameter(Mandatory)] [string]$DatabaseName,
[Parameter(Mandatory)] [string]$Server,
[Parameter(Mandatory)] [int]$Port,
[Parameter(Mandatory)] [string]$User,
[Parameter(Mandatory)] [SecureString]$ServerCredential
)
$ErrorActionPreference = "Stop"
function Write-Step { param([string]$Msg) Write-Host " $Msg" }
# ---- 0. Unwrap secure parameter ----
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServerCredential)
try { $svc = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
Write-Host "========== HFSQL OLE DB Probe =========="
Write-Host "Database : $DatabaseName"
Write-Host "Server : ${Server}:${Port}"
Write-Host "User : $User"
Write-Host ""
# ---- 1. Compute FLS = MD5(database_name) ----
$md5 = [System.Security.Cryptography.MD5]::Create()
$hb = $md5.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($DatabaseName))
$fls = [System.BitConverter]::ToString($hb).Replace("-", "").ToLowerInvariant()
if ($fls.Length -ne 32) {
Write-Host "ERROR: FLS length = $($fls.Length)"
} else {
Write-Host "FLS derived : yes (len=32)"
}
# ---- 2. Build CS1 (no FLS) ----
$kw = "P" + "assword"
$cs1 = "Provider=PCSoft.HFSQL;Data Source=${Server};Port=${Port};Initial Catalog=${DatabaseName};User ID=${User};${kw}=${svc};"
# ---- 3. Connection.Open ----
Write-Host ""
Write-Host "--- Test 1: Server connection ---"
try {
$cn1 = New-Object -ComObject ADODB.Connection
$cn1.ConnectionTimeout = 15
Write-Step "Provider instantiated : PASS"
} catch {
Write-Host "Provider instantiated : FAIL"
$svc = $null; return
}
try {
$cn1.Open($cs1)
Write-Step "Connection.Open : PASS"
} catch {
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
$err = $err -replace [regex]::Escape($User), "***"
Write-Host "Connection.Open : FAIL"
Write-Host " Error: $err"
$cn1 = $null; $svc = $null; return
}
# ---- 4. SELECT Client ----
Write-Host ""
Write-Host "--- Test 2: SELECT Client (control) ---"
try {
$rs = New-Object -ComObject ADODB.Recordset
$rs.Open("SELECT * FROM Client WHERE 1=0", $cn1, 1, 1)
Write-Step "SELECT Client : PASS"
Write-Step " FieldCount : $($rs.Fields.Count)"
$cols = @(); for ($i = 0; $i -lt [Math]::Min($rs.Fields.Count, 10); $i++) { $cols += $rs.Fields.Item($i).Name }
Write-Step " Columns (first 10) : $($cols -join ', ')"
$rs.Close()
} catch {
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
Write-Host "SELECT Client : FAIL — $err"
}
# ---- 5. SELECT ParametreMetier (no FLS) ----
Write-Host ""
Write-Host "--- Test 3: SELECT ParametreMetier (no FLS) ---"
$passDirect = $false
try {
$rs = New-Object -ComObject ADODB.Recordset
$rs.Open("SELECT * FROM ParametreMetier WHERE 1=0", $cn1, 1, 1)
$passDirect = $true
Write-Step "SELECT ParametreMetier: PASS (no FLS needed)"
Write-Step " FieldCount : $($rs.Fields.Count)"
$cols = @(); for ($i = 0; $i -lt [Math]::Min($rs.Fields.Count, 20); $i++) { $cols += $rs.Fields.Item($i).Name }
Write-Step " Columns : $($cols -join ', ')"
$rs.Close()
Write-Step " Access : DIRECT"
} catch {
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
Write-Host "SELECT ParametreMetier: FAIL (may need FLS)"
Write-Host " Error: $err"
}
if ($passDirect) {
Write-Host ""
Write-Host "--- Verdict ---"
Write-Host "Server OLE DB path : VERIFIED"
Write-Host "ParametreMetier access : DIRECT (no FLS needed)"
$cn1.Close(); $cn1 = $null; $svc = $null; return
}
# ---- 6. Close, new connection WITH FLS ----
$cn1.Close(); $cn1 = $null
Write-Host ""
Write-Host "--- Test 4: ParametreMetier WITH FLS ---"
Write-Host "FLS supplied : yes"
$epVal = "${kw}=ParametreMetier:${fls}"
$cs2 = "Provider=PCSoft.HFSQL;Data Source=${Server};Port=${Port};Initial Catalog=${DatabaseName};User ID=${User};${kw}=${svc};Extended Properties=`"${epVal};`""
$passFl = $false
try {
$cn2 = New-Object -ComObject ADODB.Connection
$cn2.ConnectionTimeout = 15
$cn2.Open($cs2)
Write-Step "Connection.Open (FLS) : PASS"
} catch {
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
$err = $err -replace [regex]::Escape($fls), "***"
Write-Host "Connection.Open (FLS) : FAIL"
Write-Host " Error: $err"
$svc = $null; return
}
try {
$rs2 = New-Object -ComObject ADODB.Recordset
$rs2.Open("SELECT * FROM ParametreMetier WHERE 1=0", $cn2, 1, 1)
$passFl = $true
Write-Step "SELECT ParametreMetier: PASS"
Write-Step " FieldCount : $($rs2.Fields.Count)"
$cols = @(); for ($i = 0; $i -lt [Math]::Min($rs2.Fields.Count, 20); $i++) { $cols += $rs2.Fields.Item($i).Name }
Write-Step " Columns : $($cols -join ', ')"
$rs2.Close()
} catch {
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
$err = $err -replace [regex]::Escape($fls), "***"
Write-Host "SELECT ParametreMetier: FAIL"
Write-Host " Error: $err"
$passFl = $false
}
$cn2.Close(); $cn2 = $null; $svc = $null
Write-Host ""
Write-Host "--- Verdict ---"
if ($passFl) {
Write-Host "OLE DB SERVER CONNECTION : VERIFIED"
Write-Host "PROTECTIONSTD(current-name) : VERIFIED"
Write-Host "PARAMETREMETIER ACCESS : VERIFIED"
Write-Host "PROTECTED HFSQL ACCESS : VERIFIED"
Write-Host "Original database name needed : NO"
} else {
Write-Host "Server OLE DB path : VERIFIED"
Write-Host "ProtectionStd current-name : FAILED"
Write-Host "Protected-file access : NOT VERIFIED"
Write-Host "Original database name needed : NOT YET"
}
+45
View File
@@ -0,0 +1,45 @@
# Lecture seule — timing des patterns SQL BATIK_TEST (OLE DB HFSQL)
$ErrorActionPreference = "Stop"
$connStr = "Provider=PCSoft.HFSQL;Data Source=127.0.0.1;Port=4900;Initial Catalog=BATIK_TEST;User ID=susana;Password=123;"
function TimeQuery($cn, $sql) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$rs = New-Object -ComObject ADODB.Recordset
$rs.Open($sql, $cn, 1, 1)
$n = 0
while (-not $rs.EOF) { $n++; $rs.MoveNext() }
$rs.Close()
$sw.Stop()
return "$([math]::Round($sw.Elapsed.TotalSeconds,2))s rows=$n"
}
$cn = New-Object -ComObject ADODB.Connection
$cn.ConnectionTimeout = 15
$cn.Open($connStr)
Write-Host "CONNECTED"
# Full scans (reproduces the current bottleneck)
Write-Host "--- FULL SCANS ---"
Write-Host ("Intervention (25 cols) : " + (TimeQuery $cn "SELECT IN_PKID, IN_RefInterne, IN_IDClient, IN_IDLieu, IN_IDDossier, IN_IDTypeIntervention, IN_IDEtatIntervention, IN_Titre, IN_NumIntervention, IN_DateIntervention, IN_DureePrevue, IN_NumDevis, IN_NumFacture, Archive, IN_NoteInterne, IN_Description, IN_RefExterne, IN_IDSociete, IN_IDDemandeur, IN_IDSuiviPar, IN_IDImport, IN_AttestationTVAPresente FROM Intervention"))
Write-Host ("Intervention_Personne (3 cols): " + (TimeQuery $cn "SELECT IP_PKID, IP_IDIntervention, IP_IDPersonne FROM Intervention_Personne"))
Write-Host ("EtatIntervention (6 cols) : " + (TimeQuery $cn "SELECT EI_PKID, EI_IDEtat, EI_DateDebut, EI_DateFin, EI_Description, EI_IDIntervention FROM EtatIntervention"))
Write-Host ("Personne (3 cols) : " + (TimeQuery $cn "SELECT PE_PKID, PE_Nom, PE_Prenom FROM Personne"))
# LIMIT pagination test
Write-Host "--- LIMIT / TOP ---"
Write-Host ("Intervention LIMIT 0,200 : " + (TimeQuery $cn "SELECT IN_PKID, IN_RefInterne, IN_Titre FROM Intervention ORDER BY IN_DateIntervention DESC LIMIT 0,200"))
Write-Host ("Intervention TOP 200 : " + (TimeQuery $cn "SELECT TOP 200 IN_PKID, IN_RefInterne, IN_Titre FROM Intervention ORDER BY IN_DateIntervention DESC"))
# WHERE IN test (200 ids) on Intervention_Personne
$rsIds = New-Object -ComObject ADODB.Recordset
$rsIds.Open("SELECT IN_PKID FROM Intervention ORDER BY IN_DateIntervention DESC LIMIT 0,200", $cn, 1, 1)
$list = New-Object System.Collections.Generic.List[string]
while (-not $rsIds.EOF -and $list.Count -lt 200) { $list.Add([string]$rsIds.Fields.Item("IN_PKID").Value); $rsIds.MoveNext() }
$rsIds.Close()
$inClause = ($list -join ",")
Write-Host "--- WHERE IN (200 intervention ids) ---"
Write-Host ("Intervention_Personne WHERE IN : " + (TimeQuery $cn "SELECT IP_PKID, IP_IDIntervention, IP_IDPersonne FROM Intervention_Personne WHERE IP_IDIntervention IN ($inClause)"))
Write-Host ("EtatIntervention WHERE IN : " + (TimeQuery $cn "SELECT EI_PKID, EI_IDEtat, EI_DateDebut, EI_DateFin, EI_Description, EI_IDIntervention FROM EtatIntervention WHERE EI_IDIntervention IN ($inClause)"))
$cn.Close()
Write-Host "DONE"
+56
View File
@@ -0,0 +1,56 @@
# ============================================================
# Probe de diagnostic du crash natif wd300vm64.dll à la FERMETURE
# du process (Stop-Service OmikaApi).
#
# But : isoler si le provider OLE DB PCSOFT.HFSQL plante au
# teardown du POOL DE SESSIONS OLE DB à la sortie du process.
#
# Lancer DEUX FOIS, chacune dans son propre process pwsh :
# 1) pooling ACTIF (défaut) : sans -DisablePooling -> attendu CRASH à la sortie
# 2) pooling DÉSACTIVÉ : avec -DisablePooling -> attendu AUCUN crash
#
# LECTURE SEULE : n'effectue qu'un SELECT 1. Aucun secret
# n'est loggé (le mot de passe n'est jamais affiché).
# ============================================================
param(
[Parameter(Mandatory = $true)][string]$Server,
[int]$Port = 4900,
[Parameter(Mandatory = $true)][string]$Database,
[Parameter(Mandatory = $true)][string]$User,
[string]$Password = "",
[switch]$DisablePooling
)
$ErrorActionPreference = "Stop"
# Chaîne de connexion sans jamais afficher le mot de passe.
$connStr = "Provider=PCSOFT.HFSQL;Data Source=${Server}:${Port};Initial Catalog=${Database};User ID=${User}"
if ($Password) { $connStr += ";Password=$Password" }
if ($DisablePooling) { $connStr += ";OLE DB Services=-1" }
Write-Host "=== PROBE SHUTDOWN OLE DB HFSQL ==="
Write-Host "Mode pooling : $(if ($DisablePooling) { 'DESACTIVE (OLE DB Services=-1)' } else { 'ACTIF (defaut)' })"
Write-Host "Serveur : ${Server}:${Port}"
Write-Host "Base : $Database"
Write-Host "Compte : $User"
Write-Host ""
$conn = New-Object System.Data.OleDb.OleDbConnection($connStr)
$conn.Open()
Write-Host "Connexion ouverte : $($conn.State)"
$cmd = $conn.CreateCommand()
$cmd.CommandText = "SELECT 1"
$result = $cmd.ExecuteScalar()
Write-Host "SELECT 1 : $result"
$cmd.Dispose()
$conn.Close()
Write-Host "Connexion fermee : $($conn.State)"
$conn.Dispose()
Write-Host "Connexion disposee."
Write-Host ""
Write-Host "FIN DU SCRIPT : le process pwsh va se terminer maintenant."
Write-Host ">>> OBSERVER ICI : crash natif (Application Error 1000, wd300vm64.dll,"
Write-Host " 0xc0000005 / 0xc0000409) ou sortie propre sans evenement. <<<"
+80
View File
@@ -0,0 +1,80 @@
# ============================================================
# Probe de LECTURE SEULE de la base Licence HFSQL via OLEDB.
# N'effectue AUCUNE écriture. N'expose aucun secret.
# ============================================================
param(
[string]$ConfigPath = "config/hfsql-test.local.json",
[string]$Section = "LicenseConnection",
[string]$DatabaseOverride = ""
)
$ErrorActionPreference = "Stop"
# --- Lecture de la config locale (contient les secrets) ---
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$db = $config.$Section
if ($null -eq $db) {
Write-Output "SECTION $Section : ABSENTE"
exit 1
}
$server = $db.Server
$port = $db.Port
$database = if ([string]::IsNullOrWhiteSpace($DatabaseOverride)) { $db.DatabaseName } else { $DatabaseOverride }
$login = $db.Login
$password = $db.Password
Write-Output "=== PROBE LECTURE SEULE OLEDB HFSQL ==="
Write-Output "Section : $Section"
Write-Output "Server : $server"
Write-Output "Port : $port"
Write-Output "Database : $database"
Write-Output "Login : $login"
Write-Output "Provider : PCSOFT.HFSQL"
Write-Output "Mode : LECTURE SEULE (aucune ecriture)"
Write-Output ""
# --- Construction de la connection string OLEDB ---
$connStr = "Provider=PCSOFT.HFSQL;Data Source=$server`:$port;Initial Catalog=$database;User ID=$login;Password=$password"
try {
$conn = New-Object System.Data.OleDb.OleDbConnection($connStr)
$conn.Open()
Write-Output "CONNEXION : OUVERTE"
Write-Output "State : $($conn.State)"
Write-Output ""
# --- Liste des tables ---
$schema = $conn.GetOleDbSchemaTable([System.Data.OleDb.OleDbSchemaGuid]::Tables, $null)
if ($null -eq $schema) {
Write-Output "TABLES : schema non disponible"
} else {
Write-Output "TABLES ($($schema.Rows.Count)) :"
foreach ($row in $schema.Rows) {
$tableName = $row["TABLE_NAME"]
$tableType = $row["TABLE_TYPE"]
Write-Output " - $tableName [$tableType]"
}
}
Write-Output ""
# --- Test SELECT 1 ---
$cmd = $conn.CreateCommand()
$cmd.CommandText = "SELECT 1"
$result = $cmd.ExecuteScalar()
Write-Output "SELECT 1 : $result"
$conn.Close()
Write-Output ""
Write-Output "CONNEXION : FERMEE"
Write-Output "RESULTAT : OK (lecture seule)"
exit 0
}
catch {
Write-Output "CONNEXION : ECHEC"
Write-Output "Erreur : $($_.Exception.Message)"
Write-Output "RESULTAT : KO"
exit 3
}
+55
View File
@@ -0,0 +1,55 @@
# ============================================================
# Liste les bases (catalogues) disponibles sur le serveur HFSQL.
# Se connecte à une base accessible (BATIK_TEST) puis liste les
# catalogues via le schéma OLEDB. LECTURE SEULE.
# ============================================================
param(
[string]$ConfigPath = "config/hfsql-test.local.json"
)
$ErrorActionPreference = "Stop"
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$db = $config.Hfsql
$server = $db.Server
$port = $db.Port
$database = $db.DatabaseName
$login = $db.Login
$password = $db.Password
Write-Output "=== LISTE DES BASES HFSQL (serveur $server`:$port) ==="
Write-Output "Connexion d'ancrage : $database"
Write-Output ""
$connStr = "Provider=PCSOFT.HFSQL;Data Source=$server`:$port;Initial Catalog=$database;User ID=$login;Password=$password"
try {
$conn = New-Object System.Data.OleDb.OleDbConnection($connStr)
$conn.Open()
Write-Output "CONNEXION : OUVERTE"
Write-Output ""
# --- Liste des catalogues (bases) ---
$schema = $conn.GetOleDbSchemaTable([System.Data.OleDb.OleDbSchemaGuid]::Catalogs, $null)
if ($null -eq $schema) {
Write-Output "CATALOGUES : schema non disponible"
} else {
Write-Output "CATALOGUES ($($schema.Rows.Count)) :"
foreach ($row in $schema.Rows) {
$catalogName = $row["CATALOG_NAME"]
Write-Output " - $catalogName"
}
}
$conn.Close()
Write-Output ""
Write-Output "RESULTAT : OK"
exit 0
}
catch {
Write-Output "CONNEXION : ECHEC"
Write-Output "Erreur : $($_.Exception.Message)"
Write-Output "RESULTAT : KO"
exit 3
}
+172
View File
@@ -0,0 +1,172 @@
# ============================================================
# Probe LECTURE SEULE — Audit physique de OMIKA_TEST
# Aucune écriture. Aucune modification de configuration.
# Connexion OLE DB PCSOFT.HFSQL (ADODB COM) sur le même serveur
# que BATIK_TEST mais catalogue/base = OMIKA_TEST.
# ============================================================
$ErrorActionPreference = "Stop"
$server = "127.0.0.1"
$port = 4900
$database = "OMIKA_TEST"
$user = "susana"
$password = "123"
# --- Schéma Omika attendu (source : docs/schema-compatibilite-batik-omika.json,
# presence = "Commun" ou "Omika uniquement") ---
$expectedOmika = @(
"Doc_Aide","Document","DocumentNote","MailCat","MailModele","MessageExterne",
"MessageInterne","ModeleRapport","ModeleRapportMob","MotClef","RubriquesDocument",
"Action","CheckList","CheckList_Collab","CompteRendu","Dossier","EtatIntervention",
"Intervention","Intervention_ChampLibre","Intervention_Personne","BddExterne",
"Client_BddExterne","ElementModifie","ElementSupprime","Intervention_BddExterne",
"Mobile","MobLog","MobReception","Collaborateur","Equipe","Equipe_Collaborateur",
"Societe","Societe_Collaborateur","Compteur","Etats","ParametreApplication",
"ParametreMetier","ParametreMetierEntete","ParametreSysteme","ParametreUtilisateur",
"Planning_RDV","Planning_RDV_Personne","RDV_Collaborateur","RDV_Contact","RDV_Planning",
"SuiviDesTemps","Adresse","Client","Client_Adresse","Client_Lieu","Lieu","Lieu_Personne",
"Personne","Telephone","AccesEtDroit","CategorieAcces","Role","Utilisateur"
)
# --- Tables importantes pour le snapshot (noms physiques Omika) ---
$importantTables = @(
"Client", # Clients
"Collaborateur", # Collaborateurs
"Personne", # Personnes / Contacts
"Lieu", # Sites / Lieux
"Dossier", # Dossiers
"Intervention", # Interventions
"RDV_Planning", # RendezVous
"SuiviDesTemps", # SuiviTemps
"Document", # Documents
"ParametreMetier" # ParametreMetier
)
Write-Host "========== AUDIT OMIKA_TEST (READ-ONLY) =========="
Write-Host "Base : $database"
Write-Host "Serveur : ${server}:${port}"
Write-Host ""
$connStr = "Provider=PCSoft.HFSQL;Data Source=${server};Port=${port};Initial Catalog=${database};User ID=${user};Password=${password};"
$cn = $null
try {
$cn = New-Object -ComObject ADODB.Connection
$cn.ConnectionTimeout = 15
$cn.Open($connStr)
Write-Host "CONNEXION : OUVERTE"
Write-Host ""
# ---- 1. Lister les tables physiques via OpenSchema (adSchemaTables = 20) ----
$tables = New-Object System.Collections.Generic.List[string]
try {
$rs = $cn.OpenSchema(20) # adSchemaTables
while (-not $rs.EOF) {
$name = $rs.Fields.Item("TABLE_NAME").Value
if ($name) { $tables.Add([string]$name) }
$rs.MoveNext()
}
$rs.Close()
} catch {
Write-Host " (OpenSchema Tables indisponible: $($_.Exception.Message))"
}
$actualSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($t in $tables) { [void]$actualSet.Add($t) }
Write-Host "NOMBRE TABLES OMIKA_TEST REELLES : $($actualSet.Count)"
Write-Host "NOMBRE TABLES OMIKA ATTENDUES : $($expectedOmika.Count)"
Write-Host ""
# ---- 2. Tables manquantes (EXPECTED - ACTUAL) ----
$missing = @()
foreach ($e in $expectedOmika) {
if (-not $actualSet.Contains($e)) { $missing += $e }
}
Write-Host "--- TABLES MANQUANTES (EXPECTED_OMIKA - ACTUAL_OMIKA) ---"
if ($missing.Count -eq 0) { Write-Host " (aucune)" } else { $missing | ForEach-Object { Write-Host " - $_" } }
Write-Host ""
# ---- 3. Tables supplémentaires (ACTUAL - EXPECTED) ----
$extra = @()
foreach ($a in $actualSet) {
if ($expectedOmika -notcontains $a) { $extra += $a }
}
Write-Host "--- TABLES SUPPLEMENTAIRES (ACTUAL_OMIKA - EXPECTED_OMIKA) ---"
if ($extra.Count -eq 0) { Write-Host " (aucune)" } else { $extra | Sort-Object | ForEach-Object { Write-Host " - $_" } }
Write-Host ""
# ---- 4. Détection de schéma ----
Write-Host "--- DETECTION DE SCHEMA ---"
$criticalOmika = @("Client","Collaborateur","Personne","Lieu","Dossier","Intervention","RDV_Planning","SuiviDesTemps","Document","ParametreMetier")
$criticalMissing = ($criticalOmika | Where-Object { -not $actualSet.Contains($_) }).Count
Write-Host " Tables critiques Omika manquantes : $criticalMissing / $($criticalOmika.Count)"
if ($missing.Count -eq 0 -and $criticalMissing -eq 0) {
Write-Host " SCHEMA DETECTE : OMIKA (complet)"
} elseif ($criticalMissing -gt 0) {
Write-Host " SCHEMA DETECTE : OMIKA_INCOMPLET"
} else {
Write-Host " SCHEMA DETECTE : OMIKA (avec écarts non critiques)"
}
Write-Host ""
# ---- 5. Comptage des tables importantes ----
Write-Host "--- COUNTS TABLES IMPORTANTES (SELECT COUNT(*)) ---"
foreach ($table in $importantTables) {
if ($actualSet.Contains($table)) {
try {
$rs2 = New-Object -ComObject ADODB.Recordset
$rs2.Open("SELECT COUNT(*) AS C FROM $table", $cn, 1, 1)
$count = $rs2.Fields.Item("C").Value
$rs2.Close()
Write-Host (" {0,-20} : PRESENT count={1}" -f $table, $count)
} catch {
Write-Host (" {0,-20} : PRESENT count=ERREUR ({1})" -f $table, $_.Exception.Message)
}
} else {
Write-Host (" {0,-20} : ABSENT" -f $table)
}
}
Write-Host ""
# ---- 6. ParametreMetier : TypeTemps ----
# Test direct SELECT même si la table n'apparaît pas dans le listing
# OpenSchema : distingue "table absente" de "table protégée (FLS)".
Write-Host "--- PARAMETREMETIER (TypeTemps) ---"
$pmDirect = $null
try {
$rs3 = New-Object -ComObject ADODB.Recordset
$rs3.Open("SELECT COUNT(*) AS C FROM ParametreMetier", $cn, 1, 1)
$pmDirect = $rs3.Fields.Item("C").Value
$rs3.Close()
Write-Host " ParametreMetier : PRESENT (SELECT direct) count=$pmDirect"
try {
$rs4 = New-Object -ComObject ADODB.Recordset
$rs4.Open("SELECT COUNT(*) AS C FROM ParametreMetier WHERE PA_Categorie = 'TypeTemps'", $cn, 1, 1)
$ttCount = $rs4.Fields.Item("C").Value
$rs4.Close()
Write-Host " TypeTemps (PA_Categorie='TypeTemps') : count=$ttCount"
} catch {
Write-Host " TypeTemps : non déterminé (colonne PA_Categorie absente ou autre)"
}
} catch {
$err = $_.Exception.Message
if ($err -match "introuvable|not found|inexistante|does not exist|28000001") {
Write-Host " ParametreMetier : ABSENT (table introuvable)"
} else {
Write-Host " ParametreMetier : PRESENT mais PROTEGE (SELECT direct échoue: $err)"
}
}
$cn.Close()
Write-Host ""
Write-Host "RESULTAT : OK (lecture seule)"
exit 0
}
catch {
Write-Host "CONNEXION : ECHEC"
Write-Host "Erreur : $($_.Exception.Message)"
Write-Host "RESULTAT : KO"
exit 3
}
+48
View File
@@ -0,0 +1,48 @@
# Probe LECTURE SEULE de la structure réelle des tables de synchronisation
# TOSRV (base Service) / MobReception, MobLog (base Business/WebService).
# Aucune écriture. Source de vérité : la base réelle via OLE DB PCSOFT.HFSQL.
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Data.OleDb
function Get-TableColumns {
param(
[string]$Server,
[int]$Port,
[string]$Database,
[string]$User,
[string]$Password,
[string]$Table
)
$connStr = "Provider=PCSOFT.HFSQL;Data Source=$Server`:$Port;Initial Catalog=$Database;User ID=$User;Password=$Password"
$conn = New-Object System.Data.OleDb.OleDbConnection($connStr)
try {
$conn.Open()
Write-Host "=== Table: $Table (base $Database) ==="
$schema = $conn.GetSchema("Columns", @($null, $null, $Table))
if ($schema.Rows.Count -eq 0) {
Write-Host " (aucune colonne retournée / table introuvable)"
} else {
foreach ($row in $schema.Rows) {
$name = $row["COLUMN_NAME"]
$type = $row["DATA_TYPE"]
$size = $row["CHARACTER_MAXIMUM_LENGTH"]
Write-Host (" {0,-25} type={1,-10} size={2}" -f $name, $type, $size)
}
}
Write-Host ""
} finally {
if ($conn.State -eq [System.Data.ConnectionState]::Open) { $conn.Close() }
$conn.Dispose()
}
}
# Base Service (BATIK.01_BLS) -> TOSRV
Get-TableColumns -Server "127.0.0.1" -Port 4900 -Database "BATIK.01_BLS" -User "susana" -Password "123" -Table "TOSRV"
# Base Business/WebService (BATIK_TEST) -> MobReception, MobLog
Get-TableColumns -Server "127.0.0.1" -Port 4900 -Database "BATIK_TEST" -User "susana" -Password "123" -Table "MobReception"
Get-TableColumns -Server "127.0.0.1" -Port 4900 -Database "BATIK_TEST" -User "susana" -Password "123" -Table "MobLog"
Write-Host "Probe terminé (lecture seule)."
+104
View File
@@ -0,0 +1,104 @@
====================================================================
PROBE HFSQL SHUTDOWN CRASH — OUTIL DE DIAGNOSTIC (SRV-OMIKA)
====================================================================
But
---
Reproduire, de façon TOTALEMENT indépendante d'Omika.Api, le crash
natif du module wd300vm64.dll observé à chaque Stop-Service.
Cet outil n'utilise NI ASP.NET, NI Generic Host, NI conteneur DI,
NI repository Omika, NI RSA/QR/licence. Il ne charge que le provider
OLE DB PC SOFT (PCSOFT.HFSQL) via System.Data.OleDb.
Prérequis
---------
- Windows PowerShell 5.1 (intégré à Windows) : lancer avec powershell.exe.
- Le provider OLE DB PC SOFT (PCSOFT.HFSQL) doit être installé sur la machine.
- Compte HFSQL technique autorisé à ouvrir une session sur le serveur ciblé.
Les 3 modes
-----------
Mode A — NoConnection
Le process ne crée AUCUNE connexion OLE DB puis se termine.
=> Contrôle : vérifie que le process ne crashe pas SANS HFSQL.
Si ce mode crashe, le problème n'est PAS lié à HFSQL.
Mode B — OpenClose
Crée une OleDbConnection (Provider=PCSOFT.HFSQL), Open(),
exécute un SELECT lecture seule, ferme le reader, dispose la
commande, Close(), Dispose(), affiche PROBE_TERMINE, quitte.
=> Reproduit le cycle minimal open/close sans Omika.Api.
Si ce mode crashe à la sortie, le crash est dans le provider
lui-même (indépendant d'Omika.Api).
Mode C — OpenCloseReleasePool
Identique au Mode B, puis appelle
OleDbConnection.ReleaseObjectPool() avant la sortie.
=> Teste si la libération explicite du pool évite le crash.
Paramètres
----------
-Mode : NoConnection | OpenClose | OpenCloseReleasePool (obligatoire)
-Server : hôte du serveur HFSQL Client/Serveur (obligatoire)
-Port : port HFSQL (défaut 4900)
-Database : nom de la base/analyse (obligatoire)
-User : compte technique HFSQL (obligatoire)
-Query : requête SELECT lecture seule (défaut "SELECT 1")
Le mot de passe N'EST PAS un paramètre : il est demandé de façon
interactive (Read-Host -AsSecureString) et n'est jamais affiché.
Commandes Windows PowerShell 5.1
--------------------------------
Ouvrir une console PowerShell 5.1 (powershell.exe), puis :
# MODE A (contrôle, sans HFSQL)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
"C:\Chemin\Vers\probe_hfsql_shutdown_crash.ps1" `
-Mode NoConnection -Server SERVEUR -Database BASE -User COMPTE
# MODE B (open/close)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
"C:\Chemin\Vers\probe_hfsql_shutdown_crash.ps1" `
-Mode OpenClose -Server SERVEUR -Port 4900 -Database BASE -User COMPTE
# MODE C (open/close + ReleaseObjectPool)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
"C:\Chemin\Vers\probe_hfsql_shutdown_crash.ps1" `
-Mode OpenCloseReleasePool -Server SERVEUR -Port 4900 -Database BASE -User COMPTE
Le mot de passe est saisi à l'invite (non affiché).
Contrôle des événements Windows après chaque exécution
------------------------------------------------------
Après CHAQUE exécution, vérifier l'Observateur d'événements :
1. eventvwr.msc > Journaux Windows > Application
2. Filtrer / rechercher les événements récents :
- "Application Error" (Event ID 1000)
- ".NET Runtime" (Event ID 1026)
- "Windows Error Reporting" (Event ID 1001)
3. Dans les détails, chercher :
- module fautif : wd300vm64.dll
- code exception : 0xc0000005 ou 0xc0000409
Ou via commande (après chaque run, noter l'heure) :
Get-WinEvent -LogName Application -MaxEvents 30 |
Where-Object { $_.Id -in 1000,1026,1001 } |
Format-List TimeCreated, Id, ProviderName, Message
Interprétation
--------------
- MODE A crashe -> le crash n'est PAS spécifique à HFSQL.
- MODE B crashe -> le provider PCSOFT.HFSQL crashe à la sortie,
indépendamment d'Omika.Api.
- MODE C ne crashe pas -> ReleaseObjectPool() (ou l'ordre de libération)
change le comportement à la sortie.
- Aucun mode ne crashe -> le crash PROD implique l'environnement Omika.Api
(DI, hosted services, lifetime) et non le provider seul.
Ne PAS utiliser : OLE DB Services=-1 ni -2 (cassent PCSOFT.HFSQL).
Ne PAS tenter de migration ODBC ici.
====================================================================
@@ -0,0 +1,159 @@
# ============================================================
# PROBE HFSQL SHUTDOWN CRASH — diagnostic isolé du crash natif
# wd300vm64.dll à la fermeture du process.
#
# Objectif : reproduire le crash SANS Omika.Api, sans ASP.NET,
# sans Generic Host, sans DI, sans repository, sans RSA/QR/licence.
#
# Compatible Windows PowerShell 5.1 (.NET Framework 4.x).
# Aucune dépendance applicative.
#
# TROIS MODES :
# NoConnection : aucune connexion OLE DB (contrôle).
# OpenClose : Open + SELECT + Close + Dispose.
# OpenCloseReleasePool : idem + OleDbConnection.ReleaseObjectPool().
#
# Sécurité : le mot de passe est demandé interactivement
# (Read-Host -AsSecureString), converti en texte uniquement en
# mémoire, puis le BSTR est nettoyé (ZeroFreeBSTR). Ni le mot de
# passe ni la chaîne de connexion complète ne sont jamais affichés.
# ============================================================
param(
[Parameter(Mandatory = $true)]
[ValidateSet('NoConnection', 'OpenClose', 'OpenCloseReleasePool')]
[string]$Mode,
[Parameter(Mandatory = $true)]
[string]$Server,
[int]$Port = 4900,
[Parameter(Mandatory = $true)]
[string]$Database,
[Parameter(Mandatory = $true)]
[string]$User,
[string]$Query = 'SELECT 1'
)
$ErrorActionPreference = 'Stop'
Write-Host '=== PROBE HFSQL SHUTDOWN CRASH (PS 5.1) ==='
Write-Host "Mode : $Mode"
Write-Host "Serveur : ${Server}:${Port}"
Write-Host "Base : $Database"
Write-Host "Compte : $User"
Write-Host ''
# ------------------------------------------------------------
# MODE A : aucune connexion OLE DB n'est créée.
# ------------------------------------------------------------
if ($Mode -eq 'NoConnection') {
Write-Host 'PROBE_TERMINE (NoConnection)'
Write-Host '>>> Le process se termine sans avoir touche a HFSQL. <<<'
exit 0
}
# ------------------------------------------------------------
# Mot de passe interactif (jamais affiché).
# ------------------------------------------------------------
$secure = Read-Host -Prompt 'Mot de passe HFSQL' -AsSecureString
$plain = ''
if ($null -ne $secure -and $secure.Length -gt 0) {
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try {
$plain = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
}
finally {
# Nettoyage du BSTR (mémoire non managée).
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
$secure.Dispose()
}
# ------------------------------------------------------------
# Construction de la chaîne de connexion (jamais affichée).
# ------------------------------------------------------------
$csb = New-Object System.Data.OleDb.OleDbConnectionStringBuilder
$csb.Provider = 'PCSOFT.HFSQL'
$csb.DataSource = "$($Server):$($Port)"
$csb['Initial Catalog'] = $Database
$csb['User ID'] = $User
if ($plain -ne '') {
$csb['Password'] = $plain
}
$conn = New-Object System.Data.OleDb.OleDbConnection($csb.ConnectionString)
# ------------------------------------------------------------
# Open()
# ------------------------------------------------------------
try {
$conn.Open()
Write-Host "Ouverture OK : $($conn.State)"
}
catch {
Write-Host "ERREUR_OUVERTURE : $($_.Exception.GetType().Name) : $($_.Exception.Message)"
try { $conn.Dispose() } catch { }
$plain = $null
exit 2
}
# ------------------------------------------------------------
# SELECT lecture seule, puis fermeture reader / command.
# ------------------------------------------------------------
$cmd = $null
$reader = $null
try {
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Query
$reader = $cmd.ExecuteReader()
$rows = 0
while ($reader.Read()) { $rows++ }
Write-Host "SELECT_OK : $rows ligne(s)"
}
catch {
Write-Host "ERREUR_SELECT : $($_.Exception.GetType().Name) : $($_.Exception.Message)"
}
finally {
if ($null -ne $reader) {
try { $reader.Close() } catch { }
try { $reader.Dispose() } catch { }
}
if ($null -ne $cmd) {
try { $cmd.Dispose() } catch { }
}
}
# ------------------------------------------------------------
# Close() / Dispose()
# ------------------------------------------------------------
try {
if ($conn.State -eq [System.Data.ConnectionState]::Open) {
$conn.Close()
}
}
catch {
Write-Host "ERREUR_CLOSE : $($_.Exception.GetType().Name) : $($_.Exception.Message)"
}
$conn.Dispose()
Write-Host 'Connexion close/disposee.'
# ------------------------------------------------------------
# MODE C : ReleaseObjectPool() avant la sortie.
# ------------------------------------------------------------
if ($Mode -eq 'OpenCloseReleasePool') {
[System.Data.OleDb.OleDbConnection]::ReleaseObjectPool()
Write-Host 'ReleaseObjectPool() appele.'
}
# ------------------------------------------------------------
# Nettoyage mémoire du mot de passe (best effort côté managé).
# ------------------------------------------------------------
$plain = $null
$secure = $null
Write-Host "PROBE_TERMINE ($Mode)"
Write-Host '>>> Le process se termine maintenant. Observer un eventuel crash natif (wd300vm64.dll). <<<'
+16
View File
@@ -0,0 +1,16 @@
# Démarre l'API E2E avec la config locale HFSQL de test.
$env:ASPNETCORE_ENVIRONMENT = 'Development'
$env:DOTNET_ENVIRONMENT = 'Development'
# Vider les fichiers de sortie précédents.
Remove-Item 'd:\Avalonia\Omika\api_e2e_out.txt' -ErrorAction SilentlyContinue
Remove-Item 'd:\Avalonia\Omika\api_e2e_err.txt' -ErrorAction SilentlyContinue
Start-Process -FilePath 'dotnet' `
-ArgumentList 'run','--project','src/Omika.Api/Omika.Api.csproj','-c','Debug','--no-build' `
-WorkingDirectory 'd:\Avalonia\Omika' `
-RedirectStandardOutput 'd:\Avalonia\Omika\api_e2e_out.txt' `
-RedirectStandardError 'd:\Avalonia\Omika\api_e2e_err.txt' `
-NoNewWindow
Write-Host "API E2E demarree (ASPNETCORE_ENVIRONMENT=Development)."
+8
View File
@@ -0,0 +1,8 @@
$env:ASPNETCORE_ENVIRONMENT = 'Development'
Start-Process -FilePath 'dotnet' `
-ArgumentList 'run','--project','src/Omika.Worker/Omika.Worker.csproj','-c','Debug' `
-WorkingDirectory 'd:\Avalonia\Omika' `
-RedirectStandardOutput 'd:\Avalonia\Omika\worker_e2e_out.txt' `
-RedirectStandardError 'd:\Avalonia\Omika\worker_e2e_err.txt' `
-NoNewWindow
Write-Host "Worker E2E démarré en arrière-plan."
+18
View File
@@ -0,0 +1,18 @@
# Démarre UN SEUL Worker E2E avec la config locale HFSQL de test.
# La variable d'environnement ASPNETCORE_ENVIRONMENT est définie dans le processus
# enfant pour que le loader HfSqlTestConfigurationLoader charge config/hfsql-test.local.json.
$env:ASPNETCORE_ENVIRONMENT = 'Development'
$env:DOTNET_ENVIRONMENT = 'Development'
# Vider les fichiers de sortie précédents.
Remove-Item 'd:\Avalonia\Omika\worker_e2e_out.txt' -ErrorAction SilentlyContinue
Remove-Item 'd:\Avalonia\Omika\worker_e2e_err.txt' -ErrorAction SilentlyContinue
Start-Process -FilePath 'dotnet' `
-ArgumentList 'run','--project','src/Omika.Worker/Omika.Worker.csproj','-c','Debug','--no-build' `
-WorkingDirectory 'd:\Avalonia\Omika' `
-RedirectStandardOutput 'd:\Avalonia\Omika\worker_e2e_out.txt' `
-RedirectStandardError 'd:\Avalonia\Omika\worker_e2e_err.txt' `
-NoNewWindow
Write-Host "Worker E2E demarre (ASPNETCORE_ENVIRONMENT=Development)."
+10
View File
@@ -0,0 +1,10 @@
# Arrête proprement le Worker E2E Omika s'il tourne encore.
$procs = Get-CimInstance Win32_Process -Filter "name='dotnet.exe'" | Where-Object { $_.CommandLine -like '*Omika.Worker*' }
if ($procs) {
foreach ($p in $procs) {
Stop-Process -Id $p.ProcessId -Force
Write-Host "Worker E2E arrêté (PID $($p.ProcessId))."
}
} else {
Write-Host "Aucun Worker E2E en cours."
}