Add Rust Core WASM scaffolding to AliasVault.Client (#1404)

This commit is contained in:
Leendert de Borst
2025-12-18 16:04:05 +01:00
parent 351bb09332
commit 75377d795e
20 changed files with 2578 additions and 261 deletions

View File

@@ -10,6 +10,7 @@ using AliasVault.Client;
using AliasVault.Client.Main.Services;
using AliasVault.Client.Providers;
using AliasVault.Client.Services;
using AliasVault.Client.Services.Native;
using AliasVault.RazorComponents.Services;
using AliasVault.Shared.Core;
using Blazor.WebAssembly.DynamicCulture.Loader;
@@ -99,6 +100,7 @@ builder.Services.AddScoped<ClipboardCopyService>();
builder.Services.AddScoped<ConfirmModalService>();
builder.Services.AddScoped<QuickCreateStateService>();
builder.Services.AddScoped<LanguageService>();
builder.Services.AddScoped<RustCore>();
builder.Services.AddAuthorizationCore();
builder.Services.AddBlazoredLocalStorage();

View File

@@ -1,162 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="DbMergeUtility.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Database;
using System.Globalization;
using Microsoft.Data.Sqlite;
/// <summary>
/// Class with helper methods to merge two or more vaults.
/// </summary>
public static class DbMergeUtility
{
/// <summary>
/// Retrieves the names of all tables in the SQLite database.
/// </summary>
/// <param name="connection">The SQLite connection to use.</param>
/// <returns>A list of table names.</returns>
/// <returns>List of table names.</returns>
public static async Task<List<string>> GetTableNames(SqliteConnection connection)
{
var tables = new List<string>();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
tables.Add(reader.GetString(0));
}
return tables;
}
/// <summary>
/// Merges data from a source table into a base table.
/// </summary>
/// <param name="baseConnection">The connection to the base database.</param>
/// <param name="sourceConnection">The connection to the source database.</param>
/// <param name="tableName">The name of the table to merge.</param>
/// <param name="logger">ILogger instance.</param>
/// <returns>Task.</returns>
public static async Task MergeTable(SqliteConnection baseConnection, SqliteConnection sourceConnection, string tableName, ILogger<DbService> logger)
{
await using var baseCommand = baseConnection.CreateCommand();
await using var sourceCommand = sourceConnection.CreateCommand();
baseCommand.CommandText = $"PRAGMA table_info({tableName})";
var columns = new List<string>();
// Get column names from the base table.
await using (var reader = await baseCommand.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
string columnName = reader.GetString(1);
columns.Add(columnName);
}
}
// Check if the table has Id, UpdatedAt and IsDeleted columns which are required in order to merge.
// If columns are missing, skip the table.
if (!columns.Contains("Id") || !columns.Contains("UpdatedAt") || !columns.Contains("IsDeleted"))
{
return;
}
// Get all records from the source table.
sourceCommand.CommandText = $"SELECT * FROM {tableName}";
await using var sourceReader = await sourceCommand.ExecuteReaderAsync();
logger.LogDebug("Got records for {tableName}.", tableName);
while (await sourceReader.ReadAsync())
{
var id = sourceReader.GetValue(0);
var updatedAt = sourceReader.GetDateTime(columns.IndexOf("UpdatedAt"));
// Check if the record exists in the base table.
baseCommand.CommandText = $"SELECT UpdatedAt FROM {tableName} WHERE Id = @Id";
baseCommand.Parameters.Clear();
baseCommand.Parameters.AddWithValue("@Id", id);
logger.LogDebug("Checking if record exists in {tableName}.", tableName);
var existingRecord = await baseCommand.ExecuteScalarAsync();
if (existingRecord != null)
{
logger.LogDebug("Record exists in {tableName}.", tableName);
// Record exists, compare UpdatedAt if it exists.
logger.LogDebug("Comparing UpdatedAt in {tableName}.", tableName);
logger.LogDebug("UpdatedAt: {existingRecord}", existingRecord);
var baseUpdatedAt = DateTime.Parse((string)existingRecord, CultureInfo.InvariantCulture);
if (updatedAt > baseUpdatedAt)
{
// Source record is newer, update the base record.
await UpdateRecord(baseConnection, tableName, sourceReader, columns);
}
else
{
// Base record is newer, skip.
logger.LogDebug("Base record is newer, skipping {tableName}.", tableName);
}
}
else
{
// Record doesn't exist in base, add it.
await InsertRecord(baseConnection, tableName, sourceReader, columns);
}
}
logger.LogDebug("Finished merging {tableName}.", tableName);
}
/// <summary>
/// Inserts a new record into the specified table with data from the source reader.
/// </summary>
/// <param name="connection">The SQLite connection to use.</param>
/// <param name="tableName">The name of the table to insert into.</param>
/// <param name="sourceReader">The data reader containing the source record.</param>
/// <param name="columns">The list of column names in the table.</param>
/// <returns>Task.</returns>
private static async Task InsertRecord(SqliteConnection connection, string tableName, SqliteDataReader sourceReader, List<string> columns)
{
await using var command = connection.CreateCommand();
var columnNames = string.Join(", ", columns);
var parameterNames = string.Join(", ", columns.Select(c => $"@{c}"));
command.CommandText = $"INSERT INTO {tableName} ({columnNames}) VALUES ({parameterNames})";
for (int i = 0; i < columns.Count; i++)
{
command.Parameters.AddWithValue($"@{columns[i]}", sourceReader.GetValue(i));
}
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Updates a record in the specified table with data from the source reader.
/// </summary>
/// <param name="connection">The SQLite connection to use.</param>
/// <param name="tableName">The name of the table to update.</param>
/// <param name="sourceReader">The data reader containing the source record.</param>
/// <param name="columns">The list of column names in the table.</param>
/// <returns>Task.</returns>
private static async Task UpdateRecord(SqliteConnection connection, string tableName, SqliteDataReader sourceReader, List<string> columns)
{
await using var command = connection.CreateCommand();
var updateColumns = string.Join(", ", columns.Select(c => $"{c} = @{c}"));
command.CommandText = $"UPDATE {tableName} SET {updateColumns} WHERE Id = @Id";
for (int i = 0; i < columns.Count; i++)
{
command.Parameters.AddWithValue($"@{columns[i]}", sourceReader.GetValue(i));
}
await command.ExecuteNonQueryAsync();
}
}

View File

@@ -9,11 +9,13 @@ namespace AliasVault.Client.Services.Database;
using System.Data;
using System.Net.Http.Json;
using System.Text.Json;
using AliasClientDb;
using AliasClientDb.Models;
using AliasVault.Client.Services;
using AliasVault.Client.Services.Auth;
using AliasVault.Client.Services.JsInterop.Models;
using AliasVault.Client.Services.Native;
using AliasVault.Client.Utilities;
using AliasVault.Shared.Models.Enums;
using AliasVault.Shared.Models.WebApi.Vault;
@@ -30,6 +32,7 @@ public sealed class DbService : IDisposable
private const string _UNKNOWN_VERSION = "Unknown";
private readonly AuthService _authService;
private readonly JsInteropService _jsInteropService;
private readonly RustCore _rustCore;
private readonly HttpClient _httpClient;
private readonly DbServiceState _state = new();
private readonly Config _config;
@@ -48,14 +51,16 @@ public sealed class DbService : IDisposable
/// </summary>
/// <param name="authService">AuthService.</param>
/// <param name="jsInteropService">JsInteropService.</param>
/// <param name="rustCore">RustCore service for WASM interop.</param>
/// <param name="httpClient">HttpClient.</param>
/// <param name="config">Config instance.</param>
/// <param name="globalNotificationService">Global notification service.</param>
/// <param name="logger">ILogger instance.</param>
public DbService(AuthService authService, JsInteropService jsInteropService, HttpClient httpClient, Config config, GlobalNotificationService globalNotificationService, ILogger<DbService> logger)
public DbService(AuthService authService, JsInteropService jsInteropService, RustCore rustCore, HttpClient httpClient, Config config, GlobalNotificationService globalNotificationService, ILogger<DbService> logger)
{
_authService = authService;
_jsInteropService = jsInteropService;
_rustCore = rustCore;
_httpClient = httpClient;
_config = config;
_globalNotificationService = globalNotificationService;
@@ -113,7 +118,7 @@ public sealed class DbService : IDisposable
}
/// <summary>
/// Merges two or more databases into one.
/// Merges two or more databases into one using the Rust WASM merge logic.
/// </summary>
/// <returns>Bool which indicates if merging was successful.</returns>
public async Task<bool> MergeDatabasesAsync()
@@ -128,51 +133,61 @@ public sealed class DbService : IDisposable
return false;
}
var sqlConnections = new List<SqliteConnection>();
_logger.LogInformation("Merging databases...");
_logger.LogInformation("Merging databases using Rust WASM...");
// Decrypt and instantiate each vault as a separate in-memory SQLite database.
// Get the list of syncable table names from Rust core.
var tableNames = await _rustCore.GetSyncableTableNamesAsync();
// Read local tables as JSON.
var localTables = await ReadTablesAsJsonAsync(_sqlConnection!, tableNames);
_logger.LogDebug("Read {Count} local tables.", localTables.Count);
// Process each vault to merge.
foreach (var vault in vaultsToMerge.Vaults)
{
// Store username of the loaded vault in memory to send to server as sanity check when updating the vault later.
_authService.StoreUsername(vault.Username);
var decryptedBase64String = await _jsInteropService.SymmetricDecrypt(vault.Blob, _authService.GetEncryptionKeyAsBase64Async());
_logger.LogInformation("Decrypted vault {VaultUpdatedAt}.", vault.UpdatedAt);
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await ImportDbContextFromBase64Async(decryptedBase64String, connection);
sqlConnections.Add(connection);
// Create a temporary in-memory SQLite database for the server vault.
await using var serverConnection = new SqliteConnection("Data Source=:memory:");
await serverConnection.OpenAsync();
await ImportDbContextFromBase64Async(decryptedBase64String, serverConnection);
// Read server tables as JSON.
var serverTables = await ReadTablesAsJsonAsync(serverConnection, tableNames);
_logger.LogDebug("Read {Count} server tables.", serverTables.Count);
// Create the merge input.
var mergeInput = new MergeInput
{
LocalTables = localTables,
ServerTables = serverTables,
};
// Call Rust WASM merge.
var mergeOutput = await _rustCore.MergeVaultsAsync(mergeInput);
_logger.LogInformation(
"Merge completed: {TablesProcessed} tables, {FromLocal} kept local, {FromServer} from server, {Inserted} inserted, {Conflicts} conflicts.",
mergeOutput.Stats.TablesProcessed,
mergeOutput.Stats.RecordsFromLocal,
mergeOutput.Stats.RecordsFromServer,
mergeOutput.Stats.RecordsInserted,
mergeOutput.Stats.Conflicts);
// Execute the SQL statements returned by the merge.
await ExecuteMergeSqlStatementsAsync(mergeOutput.Statements);
// Update local tables for the next merge iteration (if there are multiple vaults).
localTables = await ReadTablesAsJsonAsync(_sqlConnection!, tableNames);
}
// Get all table names from the current base database.
var tables = await DbMergeUtility.GetTableNames(_sqlConnection!);
// Disable foreign key checks on the base connection.
// Verify foreign key integrity after merge.
await using (var command = _sqlConnection!.CreateCommand())
{
command.CommandText = "PRAGMA foreign_keys = OFF;";
await command.ExecuteNonQueryAsync();
}
// Merge every remote database into the current database.
foreach (var connection in sqlConnections)
{
foreach (var table in tables)
{
_logger.LogInformation("Merging table {Table}.", table);
await DbMergeUtility.MergeTable(_sqlConnection, connection, table, _logger);
}
}
// Re-enable foreign key checks and verify integrity.
await using (var command = _sqlConnection.CreateCommand())
{
command.CommandText = "PRAGMA foreign_keys = ON;";
await command.ExecuteNonQueryAsync();
// Verify foreign key integrity.
command.CommandText = "PRAGMA foreign_key_check;";
await using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
@@ -186,13 +201,6 @@ public sealed class DbService : IDisposable
// Update the db context with the new merged database.
_dbContext = new AliasClientDbContext(_sqlConnection, log => _logger.LogDebug("{Message}", log));
// Clean up other connections.
foreach (var connection in sqlConnections)
{
await connection.CloseAsync();
await connection.DisposeAsync();
}
// Update the current vault revision number to the highest revision number in the merged database(s).
// This is important so the server knows that the local client has successfully merged the databases
// and should overwrite the existing database on the server with the new merged database.
@@ -671,6 +679,25 @@ public sealed class DbService : IDisposable
File.Delete(tempFileName);
}
/// <summary>
/// Converts a JsonElement to its appropriate .NET value for SQLite parameters.
/// </summary>
/// <param name="element">The JsonElement to convert.</param>
/// <returns>The converted value.</returns>
private static object? ConvertJsonElementToValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var longVal) ? longVal : element.GetDouble(),
JsonValueKind.True => 1L, // SQLite stores booleans as integers
JsonValueKind.False => 0L,
JsonValueKind.Null => null,
JsonValueKind.Undefined => null,
_ => element.ToString(),
};
}
/// <summary>
/// Checks if there are any pending migrations.
/// </summary>
@@ -894,6 +921,136 @@ public sealed class DbService : IDisposable
}
}
/// <summary>
/// Reads all specified tables from a SQLite connection as JSON data for the Rust merge.
/// </summary>
/// <param name="connection">The SQLite connection to read from.</param>
/// <param name="tableNames">The names of tables to read.</param>
/// <returns>List of TableData objects containing the table records.</returns>
private async Task<List<TableData>> ReadTablesAsJsonAsync(SqliteConnection connection, string[] tableNames)
{
var tables = new List<TableData>();
foreach (var tableName in tableNames)
{
var tableData = new TableData { Name = tableName };
// Check if table exists in the database.
await using var checkCommand = connection.CreateCommand();
checkCommand.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name=@tableName";
checkCommand.Parameters.AddWithValue("@tableName", tableName);
var exists = await checkCommand.ExecuteScalarAsync();
if (exists == null)
{
// Table doesn't exist, add empty table data.
tables.Add(tableData);
continue;
}
// Get column names for the table.
await using var columnsCommand = connection.CreateCommand();
columnsCommand.CommandText = $"PRAGMA table_info({tableName})";
var columns = new List<string>();
await using (var columnsReader = await columnsCommand.ExecuteReaderAsync())
{
while (await columnsReader.ReadAsync())
{
columns.Add(columnsReader.GetString(1));
}
}
// Read all records from the table.
await using var selectCommand = connection.CreateCommand();
selectCommand.CommandText = $"SELECT * FROM {tableName}";
await using var reader = await selectCommand.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var record = new Dictionary<string, object?>();
for (var i = 0; i < columns.Count; i++)
{
var value = reader.GetValue(i);
// Convert DBNull to null for proper JSON serialization.
record[columns[i]] = value == DBNull.Value ? null : value;
}
tableData.Records.Add(record);
}
tables.Add(tableData);
}
return tables;
}
/// <summary>
/// Executes the SQL statements returned by the Rust merge operation.
/// </summary>
/// <param name="statements">The SQL statements to execute.</param>
/// <returns>Task.</returns>
private async Task ExecuteMergeSqlStatementsAsync(List<SqlStatement> statements)
{
if (statements.Count == 0)
{
_logger.LogDebug("No SQL statements to execute from merge.");
return;
}
_logger.LogDebug("Executing {Count} SQL statements from merge.", statements.Count);
// Disable foreign key checks during merge execution.
await using (var pragmaCommand = _sqlConnection!.CreateCommand())
{
pragmaCommand.CommandText = "PRAGMA foreign_keys = OFF;";
await pragmaCommand.ExecuteNonQueryAsync();
}
try
{
foreach (var statement in statements)
{
await using var command = _sqlConnection!.CreateCommand();
command.CommandText = statement.Sql;
// Add parameters in order (SQLite uses positional parameters with ?).
for (var i = 0; i < statement.Params.Count; i++)
{
var value = statement.Params[i];
// Handle JsonElement values from deserialization.
if (value is JsonElement jsonElement)
{
value = ConvertJsonElementToValue(jsonElement);
}
command.Parameters.AddWithValue($"@p{i}", value ?? DBNull.Value);
}
// Replace ? placeholders with named parameters.
var paramIndex = 0;
var sql = statement.Sql;
while (sql.Contains('?'))
{
var pos = sql.IndexOf('?');
sql = sql[..pos] + $"@p{paramIndex}" + sql[(pos + 1)..];
paramIndex++;
}
command.CommandText = sql;
await command.ExecuteNonQueryAsync();
}
}
finally
{
// Re-enable foreign key checks.
await using var pragmaCommand = _sqlConnection!.CreateCommand();
pragmaCommand.CommandText = "PRAGMA foreign_keys = ON;";
await pragmaCommand.ExecuteNonQueryAsync();
}
}
/// <summary>
/// Disposes the service.
/// </summary>

View File

@@ -0,0 +1,28 @@
//-----------------------------------------------------------------------
// <copyright file="MergeInput.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Native;
using System.Text.Json.Serialization;
/// <summary>
/// Input for the vault merge operation.
/// </summary>
public class MergeInput
{
/// <summary>
/// Gets or sets tables from the local database.
/// </summary>
[JsonPropertyName("local_tables")]
public List<TableData> LocalTables { get; set; } = [];
/// <summary>
/// Gets or sets tables from the server database.
/// </summary>
[JsonPropertyName("server_tables")]
public List<TableData> ServerTables { get; set; } = [];
}

View File

@@ -0,0 +1,40 @@
//-----------------------------------------------------------------------
// <copyright file="MergeOutput.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Native;
using System.Text.Json.Serialization;
/// <summary>
/// Output of the merge operation.
/// </summary>
public class MergeOutput
{
/// <summary>
/// Gets or sets a value indicating whether the merge was successful.
/// </summary>
[JsonPropertyName("success")]
public bool Success { get; set; }
/// <summary>
/// Gets or sets SQL statements to execute on the local database (in order).
/// </summary>
[JsonPropertyName("statements")]
public List<SqlStatement> Statements { get; set; } = [];
/// <summary>
/// Gets or sets overall statistics.
/// </summary>
[JsonPropertyName("stats")]
public MergeStats Stats { get; set; } = new();
/// <summary>
/// Gets or sets error message if success is false.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; set; }
}

View File

@@ -0,0 +1,52 @@
//-----------------------------------------------------------------------
// <copyright file="MergeStats.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Native;
using System.Text.Json.Serialization;
/// <summary>
/// Statistics about what was merged.
/// </summary>
public class MergeStats
{
/// <summary>
/// Gets or sets the number of tables processed.
/// </summary>
[JsonPropertyName("tables_processed")]
public int TablesProcessed { get; set; }
/// <summary>
/// Gets or sets records where local version was kept.
/// </summary>
[JsonPropertyName("records_from_local")]
public int RecordsFromLocal { get; set; }
/// <summary>
/// Gets or sets records where server version was used (updates).
/// </summary>
[JsonPropertyName("records_from_server")]
public int RecordsFromServer { get; set; }
/// <summary>
/// Gets or sets records that only existed locally (created offline).
/// </summary>
[JsonPropertyName("records_created_locally")]
public int RecordsCreatedLocally { get; set; }
/// <summary>
/// Gets or sets number of conflicts resolved (both had the record).
/// </summary>
[JsonPropertyName("conflicts")]
public int Conflicts { get; set; }
/// <summary>
/// Gets or sets records inserted from server (server-only records).
/// </summary>
[JsonPropertyName("records_inserted")]
public int RecordsInserted { get; set; }
}

View File

@@ -0,0 +1,166 @@
//-----------------------------------------------------------------------
// <copyright file="RustCore.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Native;
using System.Text.Json;
using Microsoft.JSInterop;
/// <summary>
/// JavaScript interop wrapper for the Rust WASM core library.
/// Provides vault merge and credential matching functionality via WASM.
/// </summary>
public class RustCore : IAsyncDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
};
private readonly IJSRuntime jsRuntime;
private bool? isAvailable;
/// <summary>
/// Initializes a new instance of the <see cref="RustCore"/> class.
/// </summary>
/// <param name="jsRuntime">The JS runtime for interop.</param>
public RustCore(IJSRuntime jsRuntime)
{
this.jsRuntime = jsRuntime;
}
/// <summary>
/// Check if the Rust WASM module is available.
/// </summary>
/// <returns>True if the WASM module is loaded and available.</returns>
public async Task<bool> IsAvailableAsync()
{
if (isAvailable.HasValue)
{
return isAvailable.Value;
}
try
{
isAvailable = await jsRuntime.InvokeAsync<bool>("rustCoreIsAvailable");
return isAvailable.Value;
}
catch
{
isAvailable = false;
return false;
}
}
/// <summary>
/// Merge two vaults using Last-Write-Wins (LWW) strategy.
/// </summary>
/// <param name="input">The merge input containing local and server tables.</param>
/// <returns>The merge output with SQL statements to execute.</returns>
/// <exception cref="InvalidOperationException">Thrown if merge fails or WASM module is unavailable.</exception>
public async Task<MergeOutput> MergeVaultsAsync(MergeInput input)
{
if (!await IsAvailableAsync())
{
throw new InvalidOperationException("Rust WASM module is not available.");
}
var inputJson = JsonSerializer.Serialize(input, JsonOptions);
var resultJson = await jsRuntime.InvokeAsync<string>("rustCoreMergeVaults", inputJson);
if (string.IsNullOrEmpty(resultJson))
{
throw new InvalidOperationException("Merge operation returned empty result.");
}
var result = JsonSerializer.Deserialize<MergeOutput>(resultJson, JsonOptions);
if (result == null)
{
throw new InvalidOperationException("Failed to deserialize merge result.");
}
if (!result.Success && !string.IsNullOrEmpty(result.Error))
{
throw new InvalidOperationException($"Merge failed: {result.Error}");
}
return result;
}
/// <summary>
/// Get the list of table names that need to be synced.
/// </summary>
/// <returns>Array of table names.</returns>
public async Task<string[]> GetSyncableTableNamesAsync()
{
if (!await IsAvailableAsync())
{
return SyncableTables.Names;
}
try
{
var result = await jsRuntime.InvokeAsync<string[]>("rustCoreGetSyncableTableNames");
return result ?? SyncableTables.Names;
}
catch
{
return SyncableTables.Names;
}
}
/// <summary>
/// Extract domain from URL.
/// </summary>
/// <param name="url">The URL to extract domain from.</param>
/// <returns>The extracted domain.</returns>
public async Task<string> ExtractDomainAsync(string url)
{
if (!await IsAvailableAsync())
{
return string.Empty;
}
try
{
return await jsRuntime.InvokeAsync<string>("rustCoreExtractDomain", url);
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Extract root domain from a domain string.
/// </summary>
/// <param name="domain">The domain to extract root from.</param>
/// <returns>The root domain.</returns>
public async Task<string> ExtractRootDomainAsync(string domain)
{
if (!await IsAvailableAsync())
{
return string.Empty;
}
try
{
return await jsRuntime.InvokeAsync<string>("rustCoreExtractRootDomain", domain);
}
catch
{
return string.Empty;
}
}
/// <inheritdoc/>
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,28 @@
//-----------------------------------------------------------------------
// <copyright file="SqlStatement.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Native;
using System.Text.Json.Serialization;
/// <summary>
/// A SQL statement with its parameter values, generated by the merge operation.
/// </summary>
public class SqlStatement
{
/// <summary>
/// Gets or sets the SQL query with ? placeholders.
/// </summary>
[JsonPropertyName("sql")]
public string Sql { get; set; } = string.Empty;
/// <summary>
/// Gets or sets parameter values in order.
/// </summary>
[JsonPropertyName("params")]
public List<object?> Params { get; set; } = [];
}

View File

@@ -0,0 +1,32 @@
//-----------------------------------------------------------------------
// <copyright file="SyncableTables.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Native;
/// <summary>
/// List of syncable table names that need to be read for merge operations.
/// </summary>
public static class SyncableTables
{
/// <summary>
/// Table names that need LWW merge.
/// </summary>
public static readonly string[] Names =
[
"Items",
"FieldValues",
"Folders",
"Tags",
"ItemTags",
"Attachments",
"TotpCodes",
"Passkeys",
"FieldDefinitions",
"FieldHistories",
"Logos",
];
}

View File

@@ -0,0 +1,28 @@
//-----------------------------------------------------------------------
// <copyright file="TableData.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.Native;
using System.Text.Json.Serialization;
/// <summary>
/// Data for a single database table, used in vault merge operations.
/// </summary>
public class TableData
{
/// <summary>
/// Gets or sets the table name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets all records in this table as dictionaries.
/// </summary>
[JsonPropertyName("records")]
public List<Dictionary<string, object?>> Records { get; set; } = [];
}

View File

@@ -0,0 +1,175 @@
// Rust Core WASM Interop for Blazor
// This module provides JavaScript functions that Blazor can call via JSInterop
// to access the Rust WASM merge and credential matching functionality.
let wasmModule = null;
let isInitialized = false;
let initPromise = null;
/**
* Initialize the Rust WASM module.
* @returns {Promise<boolean>} True if initialization succeeded.
*/
async function initRustCore() {
if (isInitialized) {
return true;
}
if (initPromise) {
return initPromise;
}
initPromise = (async () => {
try {
// Fetch the WASM binary first
const wasmResponse = await fetch('/wasm/aliasvault_core_bg.wasm');
if (!wasmResponse.ok) {
throw new Error(`Failed to fetch WASM: ${wasmResponse.status}`);
}
const wasmBytes = await wasmResponse.arrayBuffer();
// Dynamically import the ES module
const module = await import('/wasm/aliasvault_core.js');
// Initialize the WASM module with the binary bytes
await module.default(wasmBytes);
// Call init to set up panic hook
if (typeof module.init === 'function') {
module.init();
}
wasmModule = module;
isInitialized = true;
console.log('[RustCore] WASM module initialized successfully');
return true;
} catch (error) {
console.error('[RustCore] Failed to initialize WASM module:', error);
isInitialized = false;
initPromise = null; // Allow retry on failure
return false;
}
})();
return initPromise;
}
/**
* Check if the Rust WASM module is available.
* @returns {Promise<boolean>} True if available.
*/
window.rustCoreIsAvailable = async function() {
return await initRustCore();
};
/**
* Merge two vaults using LWW strategy.
* @param {string} inputJson - JSON string containing MergeInput.
* @returns {Promise<string>} JSON string containing MergeOutput.
*/
window.rustCoreMergeVaults = async function(inputJson) {
if (!await initRustCore()) {
return JSON.stringify({
success: false,
error: 'Rust WASM module not available',
statements: [],
stats: {}
});
}
try {
const result = wasmModule.mergeVaultsJson(inputJson);
return result;
} catch (error) {
console.error('[RustCore] Merge failed:', error);
return JSON.stringify({
success: false,
error: error.toString(),
statements: [],
stats: {}
});
}
};
/**
* Filter credentials for autofill.
* @param {string} inputJson - JSON string containing CredentialMatcherInput.
* @returns {Promise<string>} JSON string containing CredentialMatcherOutput.
*/
window.rustCoreFilterCredentials = async function(inputJson) {
if (!await initRustCore()) {
return JSON.stringify({
matches: [],
error: 'Rust WASM module not available'
});
}
try {
const result = wasmModule.filterCredentialsJson(inputJson);
return result;
} catch (error) {
console.error('[RustCore] Filter credentials failed:', error);
return JSON.stringify({
matches: [],
error: error.toString()
});
}
};
/**
* Get the list of syncable table names.
* @returns {Promise<string[]>} Array of table names.
*/
window.rustCoreGetSyncableTableNames = async function() {
if (!await initRustCore()) {
// Return default list if WASM not available
return [
'Items', 'FieldValues', 'Folders', 'Tags', 'ItemTags',
'Attachments', 'TotpCodes', 'Passkeys', 'FieldDefinitions',
'FieldHistories', 'Logos'
];
}
try {
return wasmModule.getSyncableTableNames();
} catch (error) {
console.error('[RustCore] Get syncable table names failed:', error);
return [];
}
};
/**
* Extract domain from URL.
* @param {string} url - The URL to extract domain from.
* @returns {Promise<string>} The extracted domain.
*/
window.rustCoreExtractDomain = async function(url) {
if (!await initRustCore()) {
return '';
}
try {
return wasmModule.extractDomain(url);
} catch (error) {
console.error('[RustCore] Extract domain failed:', error);
return '';
}
};
/**
* Extract root domain from a domain string.
* @param {string} domain - The domain to extract root from.
* @returns {Promise<string>} The root domain.
*/
window.rustCoreExtractRootDomain = async function(domain) {
if (!await initRustCore()) {
return '';
}
try {
return wasmModule.extractRootDomain(domain);
} catch (error) {
console.error('[RustCore] Extract root domain failed:', error);
return '';
}
};

View File

@@ -0,0 +1,743 @@
let wasm;
function addHeapObject(obj) {
if (heap_next === heap.length) heap.push(heap.length + 1);
const idx = heap_next;
heap_next = heap[idx];
heap[idx] = obj;
return idx;
}
function debugString(val) {
// primitive types
const type = typeof val;
if (type == 'number' || type == 'boolean' || val == null) {
return `${val}`;
}
if (type == 'string') {
return `"${val}"`;
}
if (type == 'symbol') {
const description = val.description;
if (description == null) {
return 'Symbol';
} else {
return `Symbol(${description})`;
}
}
if (type == 'function') {
const name = val.name;
if (typeof name == 'string' && name.length > 0) {
return `Function(${name})`;
} else {
return 'Function';
}
}
// objects
if (Array.isArray(val)) {
const length = val.length;
let debug = '[';
if (length > 0) {
debug += debugString(val[0]);
}
for(let i = 1; i < length; i++) {
debug += ', ' + debugString(val[i]);
}
debug += ']';
return debug;
}
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches && builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
return toString.call(val);
}
if (className == 'Object') {
// we're a user defined class or Object
// JSON.stringify avoids problems with cycles, and is generally much
// easier than looping through ownProperties of `val`.
try {
return 'Object(' + JSON.stringify(val) + ')';
} catch (_) {
return 'Object';
}
}
// errors
if (val instanceof Error) {
return `${val.name}: ${val.message}\n${val.stack}`;
}
// TODO we could test for more things here, like `Set`s and `Map`s.
return className;
}
function dropObject(idx) {
if (idx < 132) return;
heap[idx] = heap_next;
heap_next = idx;
}
function getArrayJsValueFromWasm0(ptr, len) {
ptr = ptr >>> 0;
const mem = getDataViewMemory0();
const result = [];
for (let i = ptr; i < ptr + 4 * len; i += 4) {
result.push(takeObject(mem.getUint32(i, true)));
}
return result;
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function getObject(idx) { return heap[idx]; }
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
wasm.__wbindgen_export3(addHeapObject(e));
}
}
let heap = new Array(128).fill(undefined);
heap.push(undefined, null, true, false);
let heap_next = heap.length;
function isLikeNone(x) {
return x === undefined || x === null;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeObject(idx) {
const ret = getObject(idx);
dropObject(idx);
return ret;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
}
}
let WASM_VECTOR_LEN = 0;
/**
* Extract domain from URL.
*
* Handles both full URLs and partial domains, returning normalized domain
* without protocol, www prefix, path, query, or fragment.
* @param {string} url
* @returns {string}
*/
export function extractDomain(url) {
let deferred2_0;
let deferred2_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(url, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len0 = WASM_VECTOR_LEN;
wasm.extractDomain(retptr, ptr0, len0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
deferred2_0 = r0;
deferred2_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export4(deferred2_0, deferred2_1, 1);
}
}
/**
* Extract root domain from a domain string.
*
* E.g., "sub.example.com" -> "example.com"
* E.g., "sub.example.co.uk" -> "example.co.uk"
* @param {string} domain
* @returns {string}
*/
export function extractRootDomain(domain) {
let deferred2_0;
let deferred2_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(domain, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len0 = WASM_VECTOR_LEN;
wasm.extractRootDomain(retptr, ptr0, len0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
deferred2_0 = r0;
deferred2_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export4(deferred2_0, deferred2_1, 1);
}
}
/**
* Filter credentials for autofill.
*
* Takes a JsValue (CredentialMatcherInput) and returns a JsValue (CredentialMatcherOutput).
* @param {any} input
* @returns {any}
*/
export function filterCredentials(input) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
wasm.filterCredentials(retptr, addHeapObject(input));
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
if (r2) {
throw takeObject(r1);
}
return takeObject(r0);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* Filter credentials using JSON strings (alternative API).
*
* Takes a JSON string and returns a JSON string.
* @param {string} input_json
* @returns {string}
*/
export function filterCredentialsJson(input_json) {
let deferred3_0;
let deferred3_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(input_json, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len0 = WASM_VECTOR_LEN;
wasm.filterCredentialsJson(retptr, ptr0, len0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
var ptr2 = r0;
var len2 = r1;
if (r3) {
ptr2 = 0; len2 = 0;
throw takeObject(r2);
}
deferred3_0 = ptr2;
deferred3_1 = len2;
return getStringFromWasm0(ptr2, len2);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export4(deferred3_0, deferred3_1, 1);
}
}
/**
* Get the list of table names that need to be synced.
* @returns {string[]}
*/
export function getSyncableTableNames() {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
wasm.getSyncableTableNames(retptr);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var v1 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_export4(r0, r1 * 4, 4);
return v1;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* Initialize panic hook for better error messages.
*/
export function init() {
wasm.init();
}
/**
* Merge vaults using LWW strategy.
*
* Takes a JsValue (MergeInput) and returns a JsValue (MergeOutput).
* @param {any} input
* @returns {any}
*/
export function mergeVaults(input) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
wasm.mergeVaults(retptr, addHeapObject(input));
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
if (r2) {
throw takeObject(r1);
}
return takeObject(r0);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* Merge vaults using JSON strings (alternative API).
*
* Takes a JSON string and returns a JSON string.
* @param {string} input_json
* @returns {string}
*/
export function mergeVaultsJson(input_json) {
let deferred3_0;
let deferred3_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(input_json, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len0 = WASM_VECTOR_LEN;
wasm.mergeVaultsJson(retptr, ptr0, len0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
var ptr2 = r0;
var len2 = r1;
if (r3) {
ptr2 = 0; len2 = 0;
throw takeObject(r2);
}
deferred3_0 = ptr2;
deferred3_1 = len2;
return getStringFromWasm0(ptr2, len2);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export4(deferred3_0, deferred3_1, 1);
}
}
const EXPECTED_RESPONSE_TYPES = new Set(['basic', 'cors', 'default']);
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && EXPECTED_RESPONSE_TYPES.has(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else {
throw e;
}
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
}
function __wbg_get_imports() {
const imports = {};
imports.wbg = {};
imports.wbg.__wbg_Error_52673b7de5a0ca89 = function(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return addHeapObject(ret);
};
imports.wbg.__wbg_String_8f0eb39a4a4c2f66 = function(arg0, arg1) {
const ret = String(getObject(arg1));
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
imports.wbg.__wbg___wbindgen_bigint_get_as_i64_6e32f5e6aff02e1d = function(arg0, arg1) {
const v = getObject(arg1);
const ret = typeof(v) === 'bigint' ? v : undefined;
getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
};
imports.wbg.__wbg___wbindgen_boolean_get_dea25b33882b895b = function(arg0) {
const v = getObject(arg0);
const ret = typeof(v) === 'boolean' ? v : undefined;
return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0;
};
imports.wbg.__wbg___wbindgen_debug_string_adfb662ae34724b6 = function(arg0, arg1) {
const ret = debugString(getObject(arg1));
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
imports.wbg.__wbg___wbindgen_in_0d3e1e8f0c669317 = function(arg0, arg1) {
const ret = getObject(arg0) in getObject(arg1);
return ret;
};
imports.wbg.__wbg___wbindgen_is_bigint_0e1a2e3f55cfae27 = function(arg0) {
const ret = typeof(getObject(arg0)) === 'bigint';
return ret;
};
imports.wbg.__wbg___wbindgen_is_function_8d400b8b1af978cd = function(arg0) {
const ret = typeof(getObject(arg0)) === 'function';
return ret;
};
imports.wbg.__wbg___wbindgen_is_object_ce774f3490692386 = function(arg0) {
const val = getObject(arg0);
const ret = typeof(val) === 'object' && val !== null;
return ret;
};
imports.wbg.__wbg___wbindgen_is_string_704ef9c8fc131030 = function(arg0) {
const ret = typeof(getObject(arg0)) === 'string';
return ret;
};
imports.wbg.__wbg___wbindgen_is_undefined_f6b95eab589e0269 = function(arg0) {
const ret = getObject(arg0) === undefined;
return ret;
};
imports.wbg.__wbg___wbindgen_jsval_eq_b6101cc9cef1fe36 = function(arg0, arg1) {
const ret = getObject(arg0) === getObject(arg1);
return ret;
};
imports.wbg.__wbg___wbindgen_jsval_loose_eq_766057600fdd1b0d = function(arg0, arg1) {
const ret = getObject(arg0) == getObject(arg1);
return ret;
};
imports.wbg.__wbg___wbindgen_number_get_9619185a74197f95 = function(arg0, arg1) {
const obj = getObject(arg1);
const ret = typeof(obj) === 'number' ? obj : undefined;
getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
};
imports.wbg.__wbg___wbindgen_string_get_a2a31e16edf96e42 = function(arg0, arg1) {
const obj = getObject(arg1);
const ret = typeof(obj) === 'string' ? obj : undefined;
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
imports.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e = function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};
imports.wbg.__wbg_call_abb4ff46ce38be40 = function() { return handleError(function (arg0, arg1) {
const ret = getObject(arg0).call(getObject(arg1));
return addHeapObject(ret);
}, arguments) };
imports.wbg.__wbg_done_62ea16af4ce34b24 = function(arg0) {
const ret = getObject(arg0).done;
return ret;
};
imports.wbg.__wbg_entries_83c79938054e065f = function(arg0) {
const ret = Object.entries(getObject(arg0));
return addHeapObject(ret);
};
imports.wbg.__wbg_error_7534b8e9a36f1ab4 = function(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_export4(deferred0_0, deferred0_1, 1);
}
};
imports.wbg.__wbg_get_6b7bd52aca3f9671 = function(arg0, arg1) {
const ret = getObject(arg0)[arg1 >>> 0];
return addHeapObject(ret);
};
imports.wbg.__wbg_get_af9dab7e9603ea93 = function() { return handleError(function (arg0, arg1) {
const ret = Reflect.get(getObject(arg0), getObject(arg1));
return addHeapObject(ret);
}, arguments) };
imports.wbg.__wbg_get_with_ref_key_1dc361bd10053bfe = function(arg0, arg1) {
const ret = getObject(arg0)[getObject(arg1)];
return addHeapObject(ret);
};
imports.wbg.__wbg_instanceof_ArrayBuffer_f3320d2419cd0355 = function(arg0) {
let result;
try {
result = getObject(arg0) instanceof ArrayBuffer;
} catch (_) {
result = false;
}
const ret = result;
return ret;
};
imports.wbg.__wbg_instanceof_Map_084be8da74364158 = function(arg0) {
let result;
try {
result = getObject(arg0) instanceof Map;
} catch (_) {
result = false;
}
const ret = result;
return ret;
};
imports.wbg.__wbg_instanceof_Uint8Array_da54ccc9d3e09434 = function(arg0) {
let result;
try {
result = getObject(arg0) instanceof Uint8Array;
} catch (_) {
result = false;
}
const ret = result;
return ret;
};
imports.wbg.__wbg_isArray_51fd9e6422c0a395 = function(arg0) {
const ret = Array.isArray(getObject(arg0));
return ret;
};
imports.wbg.__wbg_isSafeInteger_ae7d3f054d55fa16 = function(arg0) {
const ret = Number.isSafeInteger(getObject(arg0));
return ret;
};
imports.wbg.__wbg_iterator_27b7c8b35ab3e86b = function() {
const ret = Symbol.iterator;
return addHeapObject(ret);
};
imports.wbg.__wbg_length_22ac23eaec9d8053 = function(arg0) {
const ret = getObject(arg0).length;
return ret;
};
imports.wbg.__wbg_length_d45040a40c570362 = function(arg0) {
const ret = getObject(arg0).length;
return ret;
};
imports.wbg.__wbg_new_1ba21ce319a06297 = function() {
const ret = new Object();
return addHeapObject(ret);
};
imports.wbg.__wbg_new_25f239778d6112b9 = function() {
const ret = new Array();
return addHeapObject(ret);
};
imports.wbg.__wbg_new_6421f6084cc5bc5a = function(arg0) {
const ret = new Uint8Array(getObject(arg0));
return addHeapObject(ret);
};
imports.wbg.__wbg_new_8a6f238a6ece86ea = function() {
const ret = new Error();
return addHeapObject(ret);
};
imports.wbg.__wbg_new_b546ae120718850e = function() {
const ret = new Map();
return addHeapObject(ret);
};
imports.wbg.__wbg_next_138a17bbf04e926c = function(arg0) {
const ret = getObject(arg0).next;
return addHeapObject(ret);
};
imports.wbg.__wbg_next_3cfe5c0fe2a4cc53 = function() { return handleError(function (arg0) {
const ret = getObject(arg0).next();
return addHeapObject(ret);
}, arguments) };
imports.wbg.__wbg_prototypesetcall_dfe9b766cdc1f1fd = function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), getObject(arg2));
};
imports.wbg.__wbg_set_3f1d0b984ed272ed = function(arg0, arg1, arg2) {
getObject(arg0)[takeObject(arg1)] = takeObject(arg2);
};
imports.wbg.__wbg_set_7df433eea03a5c14 = function(arg0, arg1, arg2) {
getObject(arg0)[arg1 >>> 0] = takeObject(arg2);
};
imports.wbg.__wbg_set_efaaf145b9377369 = function(arg0, arg1, arg2) {
const ret = getObject(arg0).set(getObject(arg1), getObject(arg2));
return addHeapObject(ret);
};
imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) {
const ret = getObject(arg1).stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
imports.wbg.__wbg_value_57b7b035e117f7ee = function(arg0) {
const ret = getObject(arg0).value;
return addHeapObject(ret);
};
imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return addHeapObject(ret);
};
imports.wbg.__wbindgen_cast_4625c577ab2ec9ee = function(arg0) {
// Cast intrinsic for `U64 -> Externref`.
const ret = BigInt.asUintN(64, arg0);
return addHeapObject(ret);
};
imports.wbg.__wbindgen_cast_9ae0607507abb057 = function(arg0) {
// Cast intrinsic for `I64 -> Externref`.
const ret = arg0;
return addHeapObject(ret);
};
imports.wbg.__wbindgen_cast_d6cd19b81560fd6e = function(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return addHeapObject(ret);
};
imports.wbg.__wbindgen_object_clone_ref = function(arg0) {
const ret = getObject(arg0);
return addHeapObject(ret);
};
imports.wbg.__wbindgen_object_drop_ref = function(arg0) {
takeObject(arg0);
};
return imports;
}
function __wbg_finalize_init(instance, module) {
wasm = instance.exports;
__wbg_init.__wbindgen_wasm_module = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (typeof module !== 'undefined') {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (typeof module_or_path !== 'undefined') {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (typeof module_or_path === 'undefined') {
module_or_path = new URL('aliasvault_core_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync };
export default __wbg_init;

View File

Binary file not shown.

View File

@@ -0,0 +1,289 @@
// <auto-generated />
// This file is auto-generated from core/models/src/vault/SystemFieldRegistry.ts
// Do not edit this file directly. Run 'npm run generate:models' to regenerate.
#nullable enable
namespace AliasClientDb.Models;
/// <summary>
/// Field categories for grouping in UI.
/// </summary>
public enum FieldCategory
{
/// <summary>Primary fields.</summary>
Primary,
/// <summary>Login fields.</summary>
Login,
/// <summary>Alias fields.</summary>
Alias,
/// <summary>Card fields.</summary>
Card,
/// <summary>Custom fields.</summary>
Custom,
/// <summary>Notes fields.</summary>
Notes,
/// <summary>Metadata fields.</summary>
Metadata
}
/// <summary>
/// Per-item-type configuration for a system field.
/// </summary>
/// <param name="ShowByDefault">Whether this field is shown by default in create mode (vs. hidden behind an "add" button)</param>
public record ItemTypeFieldConfig(bool ShowByDefault);
/// <summary>
/// System field definition with metadata.
/// </summary>
/// <param name="FieldKey">Unique system field key (e.g., 'login.username')</param>
/// <param name="FieldType">Field type for rendering/validation</param>
/// <param name="IsHidden">Whether field is hidden/masked by default</param>
/// <param name="IsMultiValue">Whether field supports multiple values</param>
/// <param name="ApplicableToTypes">ApplicableToTypes</param>
/// <param name="EnableHistory">Whether to track field value history</param>
/// <param name="Category">Category for grouping in UI. 'Primary' fields are shown in the name block.</param>
/// <param name="DefaultDisplayOrder">Default display order within category (lower = first)</param>
public record SystemFieldDefinition(
string FieldKey,
string FieldType,
bool IsHidden,
bool IsMultiValue,
IReadOnlyDictionary<string, ItemTypeFieldConfig> ApplicableToTypes,
bool EnableHistory,
FieldCategory Category,
int DefaultDisplayOrder);
/// <summary>
/// Registry of all system-defined fields.
/// These fields are immutable and their metadata is defined in code.
/// </summary>
public static class SystemFieldRegistry
{
/// <summary>
/// All system field definitions indexed by field key.
/// </summary>
public static readonly IReadOnlyDictionary<string, SystemFieldDefinition> Fields =
new Dictionary<string, SystemFieldDefinition>
{
[FieldKey.LoginUsername] = new SystemFieldDefinition(
FieldKey: "login.username",
FieldType: "Text",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(true), ["Alias"] = new ItemTypeFieldConfig(true) },
EnableHistory: true,
Category: FieldCategory.Login,
DefaultDisplayOrder: 10),
[FieldKey.LoginPassword] = new SystemFieldDefinition(
FieldKey: "login.password",
FieldType: "Password",
IsHidden: true,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(true), ["Alias"] = new ItemTypeFieldConfig(true) },
EnableHistory: true,
Category: FieldCategory.Login,
DefaultDisplayOrder: 20),
[FieldKey.LoginEmail] = new SystemFieldDefinition(
FieldKey: "login.email",
FieldType: "Email",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(false), ["Alias"] = new ItemTypeFieldConfig(true) },
EnableHistory: true,
Category: FieldCategory.Login,
DefaultDisplayOrder: 15),
[FieldKey.LoginUrl] = new SystemFieldDefinition(
FieldKey: "login.url",
FieldType: "URL",
IsHidden: false,
IsMultiValue: true,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(true), ["Alias"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Primary,
DefaultDisplayOrder: 5),
[FieldKey.AliasFirstName] = new SystemFieldDefinition(
FieldKey: "alias.first_name",
FieldType: "Text",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Alias"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Alias,
DefaultDisplayOrder: 20),
[FieldKey.AliasLastName] = new SystemFieldDefinition(
FieldKey: "alias.last_name",
FieldType: "Text",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Alias"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Alias,
DefaultDisplayOrder: 30),
[FieldKey.AliasGender] = new SystemFieldDefinition(
FieldKey: "alias.gender",
FieldType: "Text",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Alias"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Alias,
DefaultDisplayOrder: 50),
[FieldKey.AliasBirthdate] = new SystemFieldDefinition(
FieldKey: "alias.birthdate",
FieldType: "Date",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Alias"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Alias,
DefaultDisplayOrder: 60),
[FieldKey.CardCardholderName] = new SystemFieldDefinition(
FieldKey: "card.cardholder_name",
FieldType: "Text",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Card,
DefaultDisplayOrder: 10),
[FieldKey.CardNumber] = new SystemFieldDefinition(
FieldKey: "card.number",
FieldType: "Hidden",
IsHidden: true,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Card,
DefaultDisplayOrder: 20),
[FieldKey.CardExpiryMonth] = new SystemFieldDefinition(
FieldKey: "card.expiry_month",
FieldType: "Text",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Card,
DefaultDisplayOrder: 30),
[FieldKey.CardExpiryYear] = new SystemFieldDefinition(
FieldKey: "card.expiry_year",
FieldType: "Text",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Card,
DefaultDisplayOrder: 40),
[FieldKey.CardCvv] = new SystemFieldDefinition(
FieldKey: "card.cvv",
FieldType: "Hidden",
IsHidden: true,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Card,
DefaultDisplayOrder: 50),
[FieldKey.CardPin] = new SystemFieldDefinition(
FieldKey: "card.pin",
FieldType: "Hidden",
IsHidden: true,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(false) },
EnableHistory: false,
Category: FieldCategory.Card,
DefaultDisplayOrder: 60),
[FieldKey.NotesContent] = new SystemFieldDefinition(
FieldKey: "notes.content",
FieldType: "TextArea",
IsHidden: false,
IsMultiValue: false,
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(false), ["Alias"] = new ItemTypeFieldConfig(false), ["CreditCard"] = new ItemTypeFieldConfig(false), ["Note"] = new ItemTypeFieldConfig(true) },
EnableHistory: false,
Category: FieldCategory.Notes,
DefaultDisplayOrder: 100)
};
/// <summary>
/// Get system field definition by key.
/// </summary>
/// <param name="fieldKey">The field key to look up.</param>
/// <returns>The field definition, or null if not found.</returns>
public static SystemFieldDefinition? GetSystemField(string fieldKey)
{
return Fields.TryGetValue(fieldKey, out var field) ? field : null;
}
/// <summary>
/// Check if a field key represents a system field.
/// </summary>
/// <param name="fieldKey">The field key to check.</param>
/// <returns>True if the field key is a system field.</returns>
public static bool IsSystemField(string fieldKey)
{
return Fields.ContainsKey(fieldKey);
}
/// <summary>
/// Check if a field applies to a specific item type.
/// </summary>
/// <param name="field">The field definition.</param>
/// <param name="itemType">The item type to check.</param>
/// <returns>True if the field applies to the item type.</returns>
public static bool FieldAppliesToType(SystemFieldDefinition field, string itemType)
{
return field.ApplicableToTypes.ContainsKey(itemType);
}
/// <summary>
/// Get all system fields applicable to a specific item type.
/// Results are sorted by DefaultDisplayOrder.
/// </summary>
/// <param name="itemType">The item type.</param>
/// <returns>Fields applicable to the item type.</returns>
public static IEnumerable<SystemFieldDefinition> GetFieldsForItemType(string itemType)
{
return Fields.Values
.Where(f => f.ApplicableToTypes.ContainsKey(itemType))
.OrderBy(f => f.DefaultDisplayOrder);
}
/// <summary>
/// Get system fields that should be shown by default for a specific item type.
/// Results are sorted by DefaultDisplayOrder.
/// </summary>
/// <param name="itemType">The item type.</param>
/// <returns>Fields shown by default for the item type.</returns>
public static IEnumerable<SystemFieldDefinition> GetDefaultFieldsForItemType(string itemType)
{
return Fields.Values
.Where(f => f.ApplicableToTypes.TryGetValue(itemType, out var config) && config.ShowByDefault)
.OrderBy(f => f.DefaultDisplayOrder);
}
/// <summary>
/// Get system fields that are NOT shown by default for a specific item type.
/// These are the fields that can be added via an "add field" button.
/// Results are sorted by DefaultDisplayOrder.
/// </summary>
/// <param name="itemType">The item type.</param>
/// <returns>Optional fields for the item type.</returns>
public static IEnumerable<SystemFieldDefinition> GetOptionalFieldsForItemType(string itemType)
{
return Fields.Values
.Where(f => f.ApplicableToTypes.TryGetValue(itemType, out var config) && !config.ShowByDefault)
.OrderBy(f => f.DefaultDisplayOrder);
}
/// <summary>
/// Check if a field key matches a known system field prefix.
/// </summary>
/// <param name="fieldKey">The field key to check.</param>
/// <returns>True if the field key has a system field prefix.</returns>
public static bool IsSystemFieldPrefix(string fieldKey)
{
return fieldKey.StartsWith("login.") ||
fieldKey.StartsWith("alias.") ||
fieldKey.StartsWith("card.") ||
fieldKey.StartsWith("notes.");
}
}

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* Generates FieldKey constants for C#, Swift, and Kotlin from TypeScript source.
* Generates FieldKey constants and SystemFieldRegistry for C#, Swift, and Kotlin from TypeScript source.
* All type definitions are dynamically extracted from the TypeScript source files.
*/
const fs = require('fs');
@@ -9,7 +10,9 @@ const path = require('path');
// Paths
const REPO_ROOT = path.join(__dirname, '../../..');
const TS_SOURCE = path.join(REPO_ROOT, 'core/models/src/vault/FieldKey.ts');
const TS_REGISTRY_SOURCE = path.join(REPO_ROOT, 'core/models/src/vault/SystemFieldRegistry.ts');
const CS_OUTPUT = path.join(REPO_ROOT, 'apps/server/Databases/AliasClientDb/Models/FieldKey.cs');
const CS_REGISTRY_OUTPUT = path.join(REPO_ROOT, 'apps/server/Databases/AliasClientDb/Models/SystemFieldRegistry.cs');
const SWIFT_OUTPUT = path.join(REPO_ROOT, 'apps/mobile-app/ios/VaultModels/FieldKey.swift');
const KOTLIN_OUTPUT = path.join(REPO_ROOT, 'apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/FieldKey.kt');
@@ -50,7 +53,140 @@ function parseTypeScriptFieldKeys(tsContent) {
}
/**
* Generate C# static class
* Parse FieldCategories from TypeScript source
* Returns an array of category names in order
*/
function parseFieldCategories(tsContent) {
const categories = [];
// Find the FieldCategories constant
const match = tsContent.match(/export const FieldCategories\s*=\s*\{([^}]+)\}/s);
if (!match) {
console.warn('Warning: Could not find FieldCategories in source');
return categories;
}
const body = match[1];
// Match each category: Primary: 'Primary',
const categoryRegex = /(\w+):\s*'(\w+)'/g;
let categoryMatch;
while ((categoryMatch = categoryRegex.exec(body)) !== null) {
categories.push(categoryMatch[1]);
}
return categories;
}
/**
* Parse ItemTypeFieldConfig type from TypeScript source
* Returns an array of property definitions
*/
function parseItemTypeFieldConfig(tsContent) {
const properties = [];
// Find the ItemTypeFieldConfig type
const match = tsContent.match(/export type ItemTypeFieldConfig\s*=\s*\{([^}]+)\}/s);
if (!match) {
console.warn('Warning: Could not find ItemTypeFieldConfig in source');
return properties;
}
const body = match[1];
// Match properties with comments
const lines = body.split('\n');
let currentComment = '';
for (const line of lines) {
const trimmed = line.trim();
// Capture comments
const commentMatch = trimmed.match(/\/\*\*\s*(.+)\s*\*\//);
if (commentMatch) {
currentComment = commentMatch[1].trim();
}
// Match property: PropertyName: type;
const propMatch = trimmed.match(/^(\w+):\s*(\w+);?$/);
if (propMatch) {
properties.push({
name: propMatch[1],
type: propMatch[2],
comment: currentComment
});
currentComment = '';
}
}
return properties;
}
/**
* Parse SystemFieldDefinition type from TypeScript source
* Returns an array of property definitions
*/
function parseSystemFieldDefinition(tsContent) {
const properties = [];
// Find the SystemFieldDefinition type
const match = tsContent.match(/export type SystemFieldDefinition\s*=\s*\{([\s\S]*?)\n\};/);
if (!match) {
console.warn('Warning: Could not find SystemFieldDefinition in source');
return properties;
}
const body = match[1];
const lines = body.split('\n');
let currentComment = '';
for (const line of lines) {
const trimmed = line.trim();
// Capture single-line comments
const singleCommentMatch = trimmed.match(/\/\*\*\s*(.+)\s*\*\//);
if (singleCommentMatch) {
currentComment = singleCommentMatch[1].trim();
continue;
}
// Match property definition
const propMatch = trimmed.match(/^(\w+):\s*(.+);?$/);
if (propMatch && !trimmed.startsWith('/*') && !trimmed.startsWith('*')) {
let propType = propMatch[2].replace(/;$/, '').trim();
// Simplify complex types for C# mapping
let csharpType = mapTypeToCSharp(propType);
properties.push({
name: propMatch[1],
tsType: propType,
csharpType: csharpType,
comment: currentComment
});
currentComment = '';
}
}
return properties;
}
/**
* Map TypeScript type to C# type
*/
function mapTypeToCSharp(tsType) {
if (tsType === 'string') return 'string';
if (tsType === 'boolean') return 'bool';
if (tsType === 'number') return 'int';
if (tsType === 'FieldType') return 'string'; // FieldType is a string union
if (tsType === 'FieldCategory') return 'FieldCategory';
if (tsType.includes('Partial<Record<ItemType, ItemTypeFieldConfig>>')) {
return 'IReadOnlyDictionary<string, ItemTypeFieldConfig>';
}
return 'string'; // Default fallback
}
/**
* Generate C# static class for FieldKey
*/
function generateCSharp(fieldKeys) {
const header = `// <auto-generated />
@@ -148,6 +284,322 @@ object FieldKey {`;
return header + '\n' + fields + footer;
}
/**
* Parse the TypeScript SystemFieldRegistry.ts file and extract field definitions
*/
function parseSystemFieldRegistry(tsContent) {
const fields = {};
// Find the start of SystemFieldRegistry definition
const registryStart = tsContent.indexOf('export const SystemFieldRegistry');
if (registryStart === -1) {
return fields;
}
// Extract just the registry content
const registryContent = tsContent.slice(registryStart);
// Match each field definition block using a state machine approach
// Look for patterns like: 'login.username': {
const fieldStartRegex = /'([a-z]+\.[a-z_]+)':\s*\{/g;
let match;
while ((match = fieldStartRegex.exec(registryContent)) !== null) {
const fieldKey = match[1];
const startIdx = match.index + match[0].length;
// Find matching closing brace by counting braces
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < registryContent.length) {
if (registryContent[endIdx] === '{') braceCount++;
if (registryContent[endIdx] === '}') braceCount--;
endIdx++;
}
const fieldBody = registryContent.slice(startIdx, endIdx - 1);
// Skip if this doesn't look like a SystemFieldDefinition (needs FieldKey property)
if (!fieldBody.includes('FieldKey:')) {
continue;
}
const field = {
FieldKey: fieldKey,
FieldType: extractStringValue(fieldBody, 'FieldType'),
IsHidden: extractBoolValue(fieldBody, 'IsHidden'),
IsMultiValue: extractBoolValue(fieldBody, 'IsMultiValue'),
EnableHistory: extractBoolValue(fieldBody, 'EnableHistory'),
Category: extractEnumValue(fieldBody, 'Category', 'FieldCategories'),
DefaultDisplayOrder: extractNumberValue(fieldBody, 'DefaultDisplayOrder'),
ApplicableToTypes: extractApplicableToTypes(fieldBody)
};
fields[fieldKey] = field;
}
return fields;
}
/**
* Extract a string value from a field body
*/
function extractStringValue(body, propName) {
const match = body.match(new RegExp(`${propName}:\\s*'([^']+)'`));
return match ? match[1] : '';
}
/**
* Extract a boolean value from a field body
*/
function extractBoolValue(body, propName) {
const match = body.match(new RegExp(`${propName}:\\s*(true|false)`));
return match ? match[1] === 'true' : false;
}
/**
* Extract a number value from a field body
*/
function extractNumberValue(body, propName) {
const match = body.match(new RegExp(`${propName}:\\s*(\\d+)`));
return match ? parseInt(match[1], 10) : 0;
}
/**
* Extract an enum value from a field body (e.g., FieldCategories.Login -> Login)
*/
function extractEnumValue(body, propName, enumName) {
const match = body.match(new RegExp(`${propName}:\\s*${enumName}\\.(\\w+)`));
return match ? match[1] : '';
}
/**
* Extract ApplicableToTypes object from field body
*/
function extractApplicableToTypes(body) {
const applicableToTypes = {};
// Find the start of ApplicableToTypes
const startMatch = body.match(/ApplicableToTypes:\s*\{/);
if (!startMatch) {
return applicableToTypes;
}
const startIdx = startMatch.index + startMatch[0].length;
// Find matching closing brace by counting braces
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < body.length) {
if (body[endIdx] === '{') braceCount++;
if (body[endIdx] === '}') braceCount--;
endIdx++;
}
const typesBody = body.slice(startIdx, endIdx - 1);
// Match each item type configuration: ItemType: { ShowByDefault: bool }
const itemTypeRegex = /(\w+):\s*\{\s*ShowByDefault:\s*(true|false)\s*\}/g;
let typeMatch;
while ((typeMatch = itemTypeRegex.exec(typesBody)) !== null) {
const itemType = typeMatch[1];
const showByDefault = typeMatch[2] === 'true';
applicableToTypes[itemType] = { ShowByDefault: showByDefault };
}
return applicableToTypes;
}
/**
* Generate C# SystemFieldRegistry with full metadata
* All types are dynamically generated from the parsed TypeScript
*/
function generateCSharpSystemFieldRegistry(fields, categories, itemTypeFieldConfigProps, systemFieldDefProps) {
// Generate FieldCategory enum dynamically
const categoryEnum = categories.map((cat, index) => {
return ` /// <summary>${cat} fields.</summary>
${cat}${index < categories.length - 1 ? ',' : ''}`;
}).join('\n');
// Generate ItemTypeFieldConfig record dynamically
const itemTypeFieldConfigParams = itemTypeFieldConfigProps
.map(p => `${mapTypeToCSharp(p.type)} ${p.name}`)
.join(', ');
// Generate SystemFieldDefinition record dynamically
const systemFieldDefParams = systemFieldDefProps
.map(p => `${p.csharpType} ${p.name}`)
.join(',\n ');
const systemFieldDefXmlParams = systemFieldDefProps
.map(p => `/// <param name="${p.name}">${p.comment || p.name}</param>`)
.join('\n');
const header = `// <auto-generated />
// This file is auto-generated from core/models/src/vault/SystemFieldRegistry.ts
// Do not edit this file directly. Run 'npm run generate:models' to regenerate.
#nullable enable
namespace AliasClientDb.Models;
/// <summary>
/// Field categories for grouping in UI.
/// </summary>
public enum FieldCategory
{
${categoryEnum}
}
/// <summary>
/// Per-item-type configuration for a system field.
/// </summary>
${itemTypeFieldConfigProps.map(p => `/// <param name="${p.name}">${p.comment || p.name}</param>`).join('\n')}
public record ItemTypeFieldConfig(${itemTypeFieldConfigParams});
/// <summary>
/// System field definition with metadata.
/// </summary>
${systemFieldDefXmlParams}
public record SystemFieldDefinition(
${systemFieldDefParams});
/// <summary>
/// Registry of all system-defined fields.
/// These fields are immutable and their metadata is defined in code.
/// </summary>
public static class SystemFieldRegistry
{
/// <summary>
/// All system field definitions indexed by field key.
/// </summary>
public static readonly IReadOnlyDictionary<string, SystemFieldDefinition> Fields =
new Dictionary<string, SystemFieldDefinition>
{
`;
const fieldEntries = Object.entries(fields)
.map(([key, field]) => {
const applicableTypes = Object.entries(field.ApplicableToTypes)
.map(([type, config]) => `["${type}"] = new ItemTypeFieldConfig(${config.ShowByDefault})`)
.join(', ');
return ` [FieldKey.${fieldKeyToPropertyName(key)}] = new SystemFieldDefinition(
FieldKey: "${field.FieldKey}",
FieldType: "${field.FieldType}",
IsHidden: ${field.IsHidden.toString().toLowerCase()},
IsMultiValue: ${field.IsMultiValue.toString().toLowerCase()},
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ${applicableTypes} },
EnableHistory: ${field.EnableHistory.toString().toLowerCase()},
Category: FieldCategory.${field.Category},
DefaultDisplayOrder: ${field.DefaultDisplayOrder})`;
})
.join(',\n');
// Extract unique prefixes from field keys for IsSystemFieldPrefix
const prefixes = [...new Set(Object.keys(fields).map(k => k.split('.')[0]))];
const prefixChecks = prefixes.map(p => `fieldKey.StartsWith("${p}.")`).join(' ||\n ');
const methods = `
};
/// <summary>
/// Get system field definition by key.
/// </summary>
/// <param name="fieldKey">The field key to look up.</param>
/// <returns>The field definition, or null if not found.</returns>
public static SystemFieldDefinition? GetSystemField(string fieldKey)
{
return Fields.TryGetValue(fieldKey, out var field) ? field : null;
}
/// <summary>
/// Check if a field key represents a system field.
/// </summary>
/// <param name="fieldKey">The field key to check.</param>
/// <returns>True if the field key is a system field.</returns>
public static bool IsSystemField(string fieldKey)
{
return Fields.ContainsKey(fieldKey);
}
/// <summary>
/// Check if a field applies to a specific item type.
/// </summary>
/// <param name="field">The field definition.</param>
/// <param name="itemType">The item type to check.</param>
/// <returns>True if the field applies to the item type.</returns>
public static bool FieldAppliesToType(SystemFieldDefinition field, string itemType)
{
return field.ApplicableToTypes.ContainsKey(itemType);
}
/// <summary>
/// Get all system fields applicable to a specific item type.
/// Results are sorted by DefaultDisplayOrder.
/// </summary>
/// <param name="itemType">The item type.</param>
/// <returns>Fields applicable to the item type.</returns>
public static IEnumerable<SystemFieldDefinition> GetFieldsForItemType(string itemType)
{
return Fields.Values
.Where(f => f.ApplicableToTypes.ContainsKey(itemType))
.OrderBy(f => f.DefaultDisplayOrder);
}
/// <summary>
/// Get system fields that should be shown by default for a specific item type.
/// Results are sorted by DefaultDisplayOrder.
/// </summary>
/// <param name="itemType">The item type.</param>
/// <returns>Fields shown by default for the item type.</returns>
public static IEnumerable<SystemFieldDefinition> GetDefaultFieldsForItemType(string itemType)
{
return Fields.Values
.Where(f => f.ApplicableToTypes.TryGetValue(itemType, out var config) && config.ShowByDefault)
.OrderBy(f => f.DefaultDisplayOrder);
}
/// <summary>
/// Get system fields that are NOT shown by default for a specific item type.
/// These are the fields that can be added via an "add field" button.
/// Results are sorted by DefaultDisplayOrder.
/// </summary>
/// <param name="itemType">The item type.</param>
/// <returns>Optional fields for the item type.</returns>
public static IEnumerable<SystemFieldDefinition> GetOptionalFieldsForItemType(string itemType)
{
return Fields.Values
.Where(f => f.ApplicableToTypes.TryGetValue(itemType, out var config) && !config.ShowByDefault)
.OrderBy(f => f.DefaultDisplayOrder);
}
/// <summary>
/// Check if a field key matches a known system field prefix.
/// </summary>
/// <param name="fieldKey">The field key to check.</param>
/// <returns>True if the field key has a system field prefix.</returns>
public static bool IsSystemFieldPrefix(string fieldKey)
{
return ${prefixChecks};
}
}
`;
return header + fieldEntries + methods;
}
/**
* Convert field key to C# property name (e.g., 'login.username' -> 'LoginUsername')
*/
function fieldKeyToPropertyName(fieldKey) {
return fieldKey
.split('.')
.map(part => part.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(''))
.join('');
}
/**
* Ensure directory exists
*/
@@ -162,8 +614,7 @@ function ensureDir(filePath) {
* Main execution
*/
function main() {
// Read TypeScript source
// Read TypeScript FieldKey source
if (!fs.existsSync(TS_SOURCE)) {
throw new Error(`Source file not found: ${TS_SOURCE}`);
}
@@ -175,20 +626,59 @@ function main() {
throw new Error('No field keys found in source file');
}
// Generate C#
// Read TypeScript SystemFieldRegistry source
if (!fs.existsSync(TS_REGISTRY_SOURCE)) {
throw new Error(`Source file not found: ${TS_REGISTRY_SOURCE}`);
}
const tsRegistryContent = fs.readFileSync(TS_REGISTRY_SOURCE, 'utf8');
// Parse types dynamically from TypeScript
const categories = parseFieldCategories(tsRegistryContent);
const itemTypeFieldConfigProps = parseItemTypeFieldConfig(tsRegistryContent);
const systemFieldDefProps = parseSystemFieldDefinition(tsRegistryContent);
const systemFields = parseSystemFieldRegistry(tsRegistryContent);
if (Object.keys(systemFields).length === 0) {
throw new Error('No system fields found in registry source file');
}
console.log(`Parsed ${Object.keys(fieldKeys).length} field keys`);
console.log(`Parsed ${categories.length} field categories: ${categories.join(', ')}`);
console.log(`Parsed ${itemTypeFieldConfigProps.length} ItemTypeFieldConfig properties`);
console.log(`Parsed ${systemFieldDefProps.length} SystemFieldDefinition properties`);
console.log(`Parsed ${Object.keys(systemFields).length} system field definitions`);
// Generate C# FieldKey
ensureDir(CS_OUTPUT);
const csContent = generateCSharp(fieldKeys);
fs.writeFileSync(CS_OUTPUT, csContent, 'utf8');
console.log(`Generated: ${CS_OUTPUT}`);
// Generate C# SystemFieldRegistry
ensureDir(CS_REGISTRY_OUTPUT);
const csRegistryContent = generateCSharpSystemFieldRegistry(
systemFields,
categories,
itemTypeFieldConfigProps,
systemFieldDefProps
);
fs.writeFileSync(CS_REGISTRY_OUTPUT, csRegistryContent, 'utf8');
console.log(`Generated: ${CS_REGISTRY_OUTPUT}`);
// Generate Swift
ensureDir(SWIFT_OUTPUT);
const swiftContent = generateSwift(fieldKeys);
fs.writeFileSync(SWIFT_OUTPUT, swiftContent, 'utf8');
console.log(`Generated: ${SWIFT_OUTPUT}`);
// Generate Kotlin
ensureDir(KOTLIN_OUTPUT);
const kotlinContent = generateKotlin(fieldKeys);
fs.writeFileSync(KOTLIN_OUTPUT, kotlinContent, 'utf8');
console.log(`Generated: ${KOTLIN_OUTPUT}`);
console.log('\nCode generation complete!');
}
main();

View File

@@ -20,6 +20,8 @@ default = []
uniffi = ["dep:uniffi"]
# Feature for WASM builds (browser extension)
wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:console_error_panic_hook"]
# Feature for C FFI exports (.NET P/Invoke)
ffi = []
[dependencies]
# Serialization - core dependency for JSON handling

View File

@@ -17,9 +17,11 @@ cd "$SCRIPT_DIR"
# Output directories
DIST_DIR="$SCRIPT_DIR/dist"
WASM_DIR="$DIST_DIR/wasm"
DOTNET_DIR="$DIST_DIR/dotnet"
# Target directories in consumer apps
BROWSER_EXT_DIST="$SCRIPT_DIR/../../apps/browser-extension/src/utils/dist/core/rust"
BLAZOR_CLIENT_DIST="$SCRIPT_DIR/../../apps/server/AliasVault.Client/wwwroot/wasm"
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} AliasVault Rust Core Build Script${NC}"
@@ -47,6 +49,7 @@ echo -e " Rust version: ${GREEN}$RUST_VERSION${NC}"
# Build mode selection
BUILD_ALL=false
BUILD_BROWSER=false
BUILD_DOTNET=false
FAST_MODE=false
# Parse arguments
@@ -56,6 +59,15 @@ while [[ $# -gt 0 ]]; do
BUILD_BROWSER=true
shift
;;
--dotnet)
BUILD_DOTNET=true
shift
;;
--all)
BUILD_BROWSER=true
BUILD_DOTNET=true
shift
;;
--fast|--dev)
FAST_MODE=true
echo -e "${YELLOW}Fast/dev mode enabled${NC}"
@@ -65,7 +77,9 @@ while [[ $# -gt 0 ]]; do
echo "Usage: $0 [options]"
echo ""
echo "Target options:"
echo " --browser Build WASM for browser extension"
echo " --browser Build WASM for browser extension and Blazor WASM client"
echo " --dotnet Build native library for .NET server-side use (macOS/Linux/Windows)"
echo " --all Build all targets"
echo ""
echo "Speed options:"
echo " --fast, --dev Faster builds (for development)"
@@ -73,9 +87,6 @@ while [[ $# -gt 0 ]]; do
echo "Other options:"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " ./build.sh --browser # Build WASM for browser extension"
echo " ./build.sh --browser --fast # Fast dev build"
exit 0
;;
*)
@@ -86,11 +97,12 @@ while [[ $# -gt 0 ]]; do
done
# If no targets specified, show help
if ! $BUILD_BROWSER; then
if ! $BUILD_BROWSER && ! $BUILD_DOTNET; then
echo "No target specified. Use --help for usage."
echo ""
echo "Quick start:"
echo " ./build.sh --browser # Build for browser extension"
echo " ./build.sh --dotnet # Build for .NET"
exit 0
fi
@@ -163,9 +175,99 @@ README_EOF
echo -e "${GREEN}Distributed to: $BROWSER_EXT_DIST${NC}"
ls -lh "$BROWSER_EXT_DIST/"
# Also distribute to Blazor client
echo ""
echo -e "${BLUE}Distributing to Blazor client...${NC}"
rm -rf "$BLAZOR_CLIENT_DIST"
mkdir -p "$BLAZOR_CLIENT_DIST"
cp "$WASM_DIR"/aliasvault_core_bg.wasm "$BLAZOR_CLIENT_DIST/"
cp "$WASM_DIR"/aliasvault_core.js "$BLAZOR_CLIENT_DIST/"
echo -e "${GREEN}Distributed to: $BLAZOR_CLIENT_DIST${NC}"
ls -lh "$BLAZOR_CLIENT_DIST/"
fi
}
# ============================================
# .NET Build (Native Library with FFI)
# ============================================
build_dotnet() {
echo ""
echo -e "${BLUE}Building native library for .NET...${NC}"
local start_time=$(date +%s)
# Detect current platform
local os_name
local arch_name
local lib_name
local target_dir
case "$(uname -s)" in
Darwin)
os_name="macos"
lib_name="libaliasvault_core.dylib"
;;
Linux)
os_name="linux"
lib_name="libaliasvault_core.so"
;;
MINGW*|MSYS*|CYGWIN*)
os_name="windows"
lib_name="aliasvault_core.dll"
;;
*)
echo -e "${RED}Unsupported OS: $(uname -s)${NC}"
exit 1
;;
esac
case "$(uname -m)" in
x86_64|amd64)
arch_name="x64"
;;
arm64|aarch64)
arch_name="arm64"
;;
*)
arch_name="$(uname -m)"
;;
esac
target_dir="$DOTNET_DIR/${os_name}-${arch_name}"
mkdir -p "$target_dir"
echo -e " Platform: ${YELLOW}${os_name}-${arch_name}${NC}"
# Build with cargo
echo -e " Running cargo build..."
if $FAST_MODE; then
cargo build --features ffi
local cargo_target="target/debug"
else
cargo build --release --features ffi
local cargo_target="target/release"
fi
# Copy the library
if [ -f "$cargo_target/$lib_name" ]; then
cp "$cargo_target/$lib_name" "$target_dir/"
local lib_size
lib_size=$(ls -lh "$target_dir/$lib_name" | awk '{print $5}')
echo -e "${GREEN}Native library built! ${NC}"
echo -e " Output: ${YELLOW}$target_dir/$lib_name${NC}"
echo -e " Size: ${YELLOW}$lib_size${NC}"
else
echo -e "${RED}Build failed: $lib_name not found${NC}"
exit 1
fi
local end_time=$(date +%s)
local duration=$((end_time - start_time))
echo -e "${GREEN}.NET build complete! (${duration}s)${NC}"
}
# ============================================
# Main Build Process
# ============================================
@@ -176,6 +278,13 @@ if $BUILD_BROWSER; then
distribute_browser
fi
if $BUILD_DOTNET; then
build_dotnet
# Note: dotnet native libs are built to dist/dotnet/ but not distributed
# Blazor WASM uses the WASM module via JS interop instead
echo -e "${YELLOW}Note: Native library built to dist/dotnet/ (for server-side .NET use)${NC}"
fi
TOTAL_END=$(date +%s)
TOTAL_DURATION=$((TOTAL_END - TOTAL_START))

181
core/rust/src/ffi.rs Normal file
View File

@@ -0,0 +1,181 @@
//! C FFI exports for .NET P/Invoke.
//!
//! These functions provide a C-compatible interface for calling Rust functions from C#.
//! All functions use JSON strings for input/output to simplify marshalling.
use std::ffi::{c_char, CStr, CString};
use std::ptr;
use crate::credential_matcher::{filter_credentials, CredentialMatcherInput};
use crate::merge::{merge_vaults, MergeInput, SYNCABLE_TABLE_NAMES};
/// Merge two vaults using LWW strategy.
///
/// # Safety
///
/// - `input_json` must be a valid null-terminated C string
/// - The returned pointer must be freed by calling `free_string`
///
/// # Returns
///
/// A null-terminated C string containing the JSON result (MergeOutput).
/// Returns null on error.
#[no_mangle]
pub unsafe extern "C" fn merge_vaults_ffi(input_json: *const c_char) -> *mut c_char {
if input_json.is_null() {
return ptr::null_mut();
}
let c_str = match CStr::from_ptr(input_json).to_str() {
Ok(s) => s,
Err(_) => return ptr::null_mut(),
};
let input: MergeInput = match serde_json::from_str(c_str) {
Ok(i) => i,
Err(e) => {
return create_error_response(&format!("Failed to parse input: {}", e));
}
};
let output = match merge_vaults(input) {
Ok(o) => o,
Err(e) => {
return create_error_response(&format!("Merge failed: {}", e));
}
};
match serde_json::to_string(&output) {
Ok(json) => string_to_c_char(json),
Err(e) => create_error_response(&format!("Failed to serialize output: {}", e)),
}
}
/// Filter credentials for autofill.
///
/// # Safety
///
/// - `input_json` must be a valid null-terminated C string
/// - The returned pointer must be freed by calling `free_string`
///
/// # Returns
///
/// A null-terminated C string containing the JSON result (CredentialMatcherOutput).
/// Returns null on error.
#[no_mangle]
pub unsafe extern "C" fn filter_credentials_ffi(input_json: *const c_char) -> *mut c_char {
if input_json.is_null() {
return ptr::null_mut();
}
let c_str = match CStr::from_ptr(input_json).to_str() {
Ok(s) => s,
Err(_) => return ptr::null_mut(),
};
let input: CredentialMatcherInput = match serde_json::from_str(c_str) {
Ok(i) => i,
Err(e) => {
return create_error_response(&format!("Failed to parse input: {}", e));
}
};
let output = filter_credentials(input);
match serde_json::to_string(&output) {
Ok(json) => string_to_c_char(json),
Err(e) => create_error_response(&format!("Failed to serialize output: {}", e)),
}
}
/// Get the list of syncable table names as a JSON array.
///
/// # Safety
///
/// - The returned pointer must be freed by calling `free_string`
///
/// # Returns
///
/// A null-terminated C string containing a JSON array of table names.
#[no_mangle]
pub extern "C" fn get_syncable_table_names_ffi() -> *mut c_char {
let names: Vec<&str> = SYNCABLE_TABLE_NAMES.iter().map(|s| *s).collect();
match serde_json::to_string(&names) {
Ok(json) => string_to_c_char(json),
Err(_) => ptr::null_mut(),
}
}
/// Free a string that was allocated by Rust.
///
/// # Safety
///
/// - `s` must be a pointer that was returned by one of the FFI functions
/// - This function must only be called once per pointer
/// - After calling this function, the pointer is invalid
#[no_mangle]
pub unsafe extern "C" fn free_string(s: *mut c_char) {
if !s.is_null() {
drop(CString::from_raw(s));
}
}
/// Convert a Rust string to a C string pointer.
fn string_to_c_char(s: String) -> *mut c_char {
match CString::new(s) {
Ok(c_string) => c_string.into_raw(),
Err(_) => ptr::null_mut(),
}
}
/// Create an error response JSON string.
fn create_error_response(message: &str) -> *mut c_char {
let error_json = format!(r#"{{"success":false,"error":"{}"}}"#, message.replace('"', r#"\""#));
string_to_c_char(error_json)
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CString;
#[test]
fn test_get_syncable_table_names() {
let result = get_syncable_table_names_ffi();
assert!(!result.is_null());
unsafe {
let c_str = CStr::from_ptr(result);
let json = c_str.to_str().unwrap();
let names: Vec<String> = serde_json::from_str(json).unwrap();
assert!(names.contains(&"Items".to_string()));
assert!(names.contains(&"FieldValues".to_string()));
free_string(result);
}
}
#[test]
fn test_null_input() {
unsafe {
let result = merge_vaults_ffi(ptr::null());
assert!(result.is_null());
let result = filter_credentials_ffi(ptr::null());
assert!(result.is_null());
}
}
#[test]
fn test_invalid_json_input() {
let invalid_json = CString::new("not valid json").unwrap();
unsafe {
let result = merge_vaults_ffi(invalid_json.as_ptr());
assert!(!result.is_null());
let c_str = CStr::from_ptr(result);
let json = c_str.to_str().unwrap();
assert!(json.contains("error"));
free_string(result);
}
}
}

View File

@@ -41,6 +41,10 @@ pub mod wasm;
#[cfg(feature = "wasm")]
pub use wasm::*;
// C FFI exports for .NET P/Invoke
#[cfg(feature = "ffi")]
pub mod ffi;
// UniFFI scaffolding
#[cfg(feature = "uniffi")]
uniffi::setup_scaffolding!();

View File

@@ -231,29 +231,10 @@ fn merge_table_by_composite_key(
) -> Vec<SqlStatement> {
let mut statements: Vec<SqlStatement> = Vec::new();
#[cfg(feature = "wasm")]
{
use crate::wasm::log;
log(&format!(
"[merge_composite] Processing {} - local={} records, server={} records, key_cols={:?}",
table_name,
local_records.len(),
server_records.len(),
key_columns
));
}
// Create map of server records by composite key
let mut server_map: HashMap<String, &Record> = HashMap::new();
for record in server_records {
let key = get_composite_key(record, key_columns);
#[cfg(feature = "wasm")]
{
use crate::wasm::log;
let value = record.get("Value").and_then(|v| v.as_str()).unwrap_or("(no value)");
let updated = record.get("UpdatedAt").and_then(|v| v.as_str()).unwrap_or("(no ts)");
log(&format!("[merge_composite] SERVER record: key={}, value={}, updated={}", key, value, updated));
}
// Keep the one with latest UpdatedAt if duplicate keys
if let Some(existing) = server_map.get(&key) {
if get_updated_at(record) > get_updated_at(existing) {
@@ -273,14 +254,6 @@ fn merge_table_by_composite_key(
None => continue,
};
#[cfg(feature = "wasm")]
{
use crate::wasm::log;
let value = local_record.get("Value").and_then(|v| v.as_str()).unwrap_or("(no value)");
let updated = local_record.get("UpdatedAt").and_then(|v| v.as_str()).unwrap_or("(no ts)");
log(&format!("[merge_composite] LOCAL record: key={}, value={}, updated={}", composite_key, value, updated));
}
if let Some(server_record) = server_map.get(&composite_key) {
// Record exists in both - compare UpdatedAt
let local_ts = get_updated_at(local_record);
@@ -292,44 +265,24 @@ fn merge_table_by_composite_key(
stats.conflicts += 1;
stats.records_from_server += 1;
if let Some(stmt) = generate_update_sql(table_name, server_record, &local_id) {
#[cfg(feature = "wasm")]
{
use crate::wasm::log;
log(&format!("[merge_composite] SERVER WINS: key={}", composite_key));
}
statements.push(stmt);
}
}
_ => {
// Local wins - no action needed
stats.records_from_local += 1;
#[cfg(feature = "wasm")]
{
use crate::wasm::log;
log(&format!("[merge_composite] LOCAL WINS: key={}", composite_key));
}
}
}
server_map.remove(&composite_key);
} else {
// Only in local - no action needed
stats.records_created_locally += 1;
#[cfg(feature = "wasm")]
{
use crate::wasm::log;
log(&format!("[merge_composite] LOCAL ONLY: key={}", composite_key));
}
}
}
// Server-only records (by composite key) - generate INSERTs
for (_key, server_record) in &server_map {
stats.records_inserted += 1;
#[cfg(feature = "wasm")]
{
use crate::wasm::log;
log(&format!("[merge_composite] SERVER ONLY (INSERT): key={}", _key));
}
if let Some(stmt) = generate_insert_sql(table_name, server_record) {
statements.push(stmt);
}