refactor: split node agent and Go control plane
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System.Text;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public sealed record DatabaseMetadata(string CipherVersion, string SqliteVersion, long SchemaObjectCount, IReadOnlyList<string> SchemaObjects, bool WritesRejected);
|
||||
|
||||
public static class SqlCipherDatabaseReader
|
||||
{
|
||||
private static readonly Lazy<bool> Initialized = new(() =>
|
||||
{
|
||||
SQLitePCL.Batteries_V2.Init();
|
||||
return true;
|
||||
});
|
||||
|
||||
public static async Task<DatabaseMetadata> ReadMetadataAsync(string databasePath, string hexKey, CancellationToken cancellationToken)
|
||||
{
|
||||
Validate(databasePath, hexKey);
|
||||
_ = Initialized.Value;
|
||||
try
|
||||
{
|
||||
await using var connection = await OpenReadOnlyAsync(databasePath, hexKey, cancellationToken).ConfigureAwait(false);
|
||||
var cipherVersion = await ReadCipherVersionAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
var sqliteVersion = await ReadSqliteVersionAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
var count = await ReadSchemaCountAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
var names = new List<string>();
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "SELECT name FROM sqlite_master WHERE name IS NOT NULL ORDER BY name LIMIT 100;";
|
||||
command.CommandTimeout = 10;
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var name = reader.GetString(0);
|
||||
names.Add(name.Length <= 256 ? name : name[..256]);
|
||||
}
|
||||
}
|
||||
|
||||
// Inspect SQLite's connection state; never attempt writes against the user's database.
|
||||
var writesRejected = SQLitePCL.raw.sqlite3_db_readonly(connection.Handle, "main") == 1;
|
||||
|
||||
if (!writesRejected)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher connection did not report read-only mode.");
|
||||
}
|
||||
|
||||
return new DatabaseMetadata(cipherVersion, sqliteVersion, count, names, writesRejected);
|
||||
}
|
||||
catch (SqliteException exception)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not open or read the selected database in read-only mode.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs a caller-provided read-only SQL query and returns rows keyed by column name. Values are limited to scalars and UTF-8 strings.</summary>
|
||||
public static async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryRowsAsync(
|
||||
string databasePath, string hexKey, string sql, IReadOnlyList<KeyValuePair<string, object?>>? parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
Validate(databasePath, hexKey);
|
||||
_ = Initialized.Value;
|
||||
var rows = new List<IReadOnlyDictionary<string, object?>>();
|
||||
try
|
||||
{
|
||||
await using var connection = await OpenReadOnlyAsync(databasePath, hexKey, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = 10;
|
||||
if (parameters is not null)
|
||||
{
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
command.Parameters.AddWithValue(parameter.Key, parameter.Value ?? DBNull.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < reader.FieldCount; index++)
|
||||
{
|
||||
var value = reader.IsDBNull(index) ? null : reader.GetValue(index);
|
||||
row[reader.GetName(index)] = Normalize(value);
|
||||
}
|
||||
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
catch (SqliteException exception)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not run the read-only query.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Validate(string databasePath, string hexKey)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
|
||||
if (hexKey.Length != 64 || !hexKey.All(Uri.IsHexDigit))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database key must be exactly 64 hexadecimal characters.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SqliteConnection> OpenReadOnlyAsync(string databasePath, string hexKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = new byte[SqlCipherPageVerifier.PageSize];
|
||||
await using (var file = new FileStream(databasePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete,
|
||||
SqlCipherPageVerifier.PageSize, FileOptions.Asynchronous))
|
||||
await file.ReadExactlyAsync(page, cancellationToken).ConfigureAwait(false);
|
||||
if (!SqlCipherPageVerifier.VerifyHexKey(page, hexKey))
|
||||
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "The cached key failed current database page-1 HMAC verification; refresh the selected account key.");
|
||||
var connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = Path.GetFullPath(databasePath),
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
Cache = SqliteCacheMode.Private,
|
||||
Pooling = false,
|
||||
DefaultTimeout = 10
|
||||
}.ToString();
|
||||
|
||||
var connection = new SqliteConnection(connectionString);
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
// nosemgrep:csharp-sqli - hexKey is validated to exactly 64 hex chars before this call; no injection is possible.
|
||||
command.CommandText = $"PRAGMA key = \"x'{hexKey}'\";";
|
||||
command.CommandTimeout = 10;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA cipher_compatibility = 4; PRAGMA query_only = ON;";
|
||||
command.CommandTimeout = 10;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static object? Normalize(object? value) => value switch
|
||||
{
|
||||
byte[] bytes => bytes.Length <= 256 ? Encoding.UTF8.GetString(bytes) : $"[bytes:{bytes.Length}]",
|
||||
DBNull => null,
|
||||
_ => value
|
||||
};
|
||||
|
||||
private static async Task<string> ReadCipherVersionAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "PRAGMA cipher_version;";
|
||||
command.CommandTimeout = 10;
|
||||
return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadSqliteVersionAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT sqlite_version();";
|
||||
command.CommandTimeout = 10;
|
||||
return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<long> ReadSchemaCountAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT count(*) FROM sqlite_master;";
|
||||
command.CommandTimeout = 10;
|
||||
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user