< Summary

Information
Class: LOCKnet.Data.Repositories.VaultMigrationRepository
Assembly: LOCKnet.Data
File(s): /home/runner/work/LOCKnet/LOCKnet/src/LOCKnet.Data/Repositories/VaultMigrationRepository.cs
Line coverage
82%
Covered lines: 273
Uncovered lines: 58
Coverable lines: 331
Total lines: 432
Line coverage: 82.4%
Branch coverage
73%
Covered branches: 81
Total branches: 110
Branch coverage: 73.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
GetAllCredentials()100%22100%
ApplyMigration(...)92.3%262696.9%
HasPendingStorageArtifacts()100%11100%
CompactStorage()76.47%693468.93%
MapCompactionFailure(...)66.66%201262.5%
TryFinalizeExistingArtifacts(...)66.66%241256.81%
BuildRewriteCandidate(...)100%11100%
VerifyRewriteCandidate(...)66.66%6693.33%
ConfigureMigrationConnection(...)100%11100%
ToSqliteStringLiteral(...)100%11100%
MapCredential(...)50%1818100%

File(s)

/home/runner/work/LOCKnet/LOCKnet/src/LOCKnet.Data/Repositories/VaultMigrationRepository.cs

#LineLine coverage
 1using LOCKnet.Core.DataAbstractions;
 2using Microsoft.Data.Sqlite;
 3using System.Runtime.InteropServices;
 4
 5namespace LOCKnet.Data.Repositories;
 6
 7/// <summary>
 8/// SQLite-Implementierung von <see cref="IVaultMigrationRepository"/> fuer atomare Header- und Credential-Migrationen.
 9/// </summary>
 10public sealed class VaultMigrationRepository : RepositoryBase, IVaultMigrationRepository
 11{
 12  private readonly StorageRewriteHooks? _rewriteHooks;
 13
 14  /// <summary>
 15  /// Initialisiert eine neue Instanz von <see cref="VaultMigrationRepository"/>.
 16  /// </summary>
 2717  public VaultMigrationRepository(string connectionString) : this(connectionString, null)
 2718  {
 2719  }
 20
 21  /// <summary>
 22  /// Initialisiert eine neue Instanz von <see cref="VaultMigrationRepository"/>.
 23  /// </summary>
 24  /// <param name="connectionFactory">Factory fuer Storage-spezifische SQLite-Verbindungen.</param>
 2125  public VaultMigrationRepository(ISqliteConnectionFactory connectionFactory) : this(connectionFactory, null)
 2126  {
 2127  }
 28
 3129  internal VaultMigrationRepository(string connectionString, StorageRewriteHooks? rewriteHooks) : base(connectionString)
 3130  {
 3131    _rewriteHooks = rewriteHooks;
 3132  }
 33
 2434  internal VaultMigrationRepository(ISqliteConnectionFactory connectionFactory, StorageRewriteHooks? rewriteHooks) : bas
 2435  {
 2436    _rewriteHooks = rewriteHooks;
 2437  }
 38
 39  /// <inheritdoc/>
 40  public IReadOnlyList<CredentialRecord> GetAllCredentials()
 1941  {
 1942    var list = new List<CredentialRecord>();
 1943    using var conn = GetConnection();
 1944    using var cmd = conn.CreateCommand();
 1945    cmd.CommandText = "SELECT Id, Title, Username, EncryptedPassword, EncryptedMetadata, CredentialUuid, SecretFormatVer
 46
 1947    using var reader = cmd.ExecuteReader();
 2448    while (reader.Read())
 549      list.Add(MapCredential(reader));
 50
 1951    return list;
 1952  }
 53
 54  /// <inheritdoc/>
 55  public void ApplyMigration(VaultHeader header, IReadOnlyList<CredentialRecord> credentials)
 456  {
 457    ArgumentNullException.ThrowIfNull(header);
 458    ArgumentNullException.ThrowIfNull(credentials);
 59
 1960    foreach (var credential in credentials)
 461      StoredCredentialGuard.ValidateForPersistence(credential);
 62
 363    using var conn = GetConnection();
 364    ConfigureMigrationConnection(conn);
 65
 366    var began = false;
 67    try
 368    {
 369      using (var begin = conn.CreateCommand())
 370      {
 371        begin.CommandText = "BEGIN EXCLUSIVE;";
 372        begin.ExecuteNonQuery();
 373        began = true;
 374      }
 75
 1476      foreach (var credential in credentials)
 377      {
 378        using var updateCredential = conn.CreateCommand();
 379        updateCredential.CommandText = @"
 380                    UPDATE Credentials
 381                    SET Title = $title,
 382                        Username = $username,
 383                        EncryptedPassword = $password,
 384                        EncryptedMetadata = $encryptedMetadata,
 385                        CredentialUuid = $credentialUuid,
 386                        SecretFormatVersion = $secretFormatVersion,
 387                        MetadataFormatVersion = $metadataFormatVersion,
 388                        URL = $url,
 389                        Notes = $notes,
 390                        IconKey = $iconKey,
 391                        CredentialType = $credentialType,
 392                        UpdatedAt = CURRENT_TIMESTAMP
 393                    WHERE Id = $id;";
 394        updateCredential.Parameters.AddWithValue("$id", credential.Id);
 395        updateCredential.Parameters.AddWithValue("$title", credential.Title);
 396        updateCredential.Parameters.AddWithValue("$username", (object?)credential.Username ?? DBNull.Value);
 397        updateCredential.Parameters.AddWithValue("$password", credential.EncryptedPassword);
 398        updateCredential.Parameters.AddWithValue("$encryptedMetadata", (object?)credential.EncryptedMetadata ?? DBNull.V
 399        updateCredential.Parameters.AddWithValue("$credentialUuid", credential.CredentialUuid);
 3100        updateCredential.Parameters.AddWithValue("$secretFormatVersion", credential.SecretFormatVersion);
 3101        updateCredential.Parameters.AddWithValue("$metadataFormatVersion", credential.MetadataFormatVersion);
 3102        updateCredential.Parameters.AddWithValue("$url", (object?)credential.Url ?? DBNull.Value);
 3103        updateCredential.Parameters.AddWithValue("$notes", (object?)credential.Notes ?? DBNull.Value);
 3104        updateCredential.Parameters.AddWithValue("$iconKey", (object?)credential.IconKey ?? DBNull.Value);
 3105        updateCredential.Parameters.AddWithValue("$credentialType", (int)credential.CredentialType);
 3106        updateCredential.ExecuteNonQuery();
 2107      }
 108
 2109      using (var updateHeader = conn.CreateCommand())
 2110      {
 2111        updateHeader.CommandText = @"
 2112                    UPDATE MasterKey
 2113                    SET PasswordHash = $hash,
 2114                        FormatVersion = $formatVersion,
 2115                        KdfIdentifier = $kdfIdentifier,
 2116                        KdfParameters = $kdfParameters,
 2117                        Salt = $salt,
 2118                        WrappedVaultKey = $wrappedVaultKey,
 2119                        UsesLegacyKeyMaterial = $usesLegacyKeyMaterial,
 2120                        RequiresStorageCompaction = $requiresStorageCompaction,
 2121                        LastStorageCompactionAttemptUtc = $lastStorageCompactionAttemptUtc,
 2122                        LastStorageCompactionFailureKind = $lastStorageCompactionFailureKind,
 2123                        LastStorageCompactionError = $lastStorageCompactionError,
 2124                        UpdatedAt = CURRENT_TIMESTAMP
 2125                    WHERE Id = 1;";
 2126        updateHeader.Parameters.AddWithValue("$hash", header.LegacyPasswordHash);
 2127        updateHeader.Parameters.AddWithValue("$formatVersion", header.FormatVersion);
 2128        updateHeader.Parameters.AddWithValue("$kdfIdentifier", header.KdfIdentifier);
 2129        updateHeader.Parameters.AddWithValue("$kdfParameters", header.KdfParameters.Serialize());
 2130        updateHeader.Parameters.AddWithValue("$salt", header.Salt);
 2131        updateHeader.Parameters.AddWithValue("$wrappedVaultKey", header.WrappedVaultKey);
 2132        updateHeader.Parameters.AddWithValue("$usesLegacyKeyMaterial", header.UsesLegacyKeyMaterial ? 1 : 0);
 2133        updateHeader.Parameters.AddWithValue("$requiresStorageCompaction", header.RequiresStorageCompaction ? 1 : 0);
 2134        updateHeader.Parameters.AddWithValue("$lastStorageCompactionAttemptUtc", (object?)header.LastStorageCompactionAt
 2135        updateHeader.Parameters.AddWithValue("$lastStorageCompactionFailureKind", (int)header.LastStorageCompactionFailu
 2136        updateHeader.Parameters.AddWithValue("$lastStorageCompactionError", (object?)header.LastStorageCompactionError ?
 2137        updateHeader.ExecuteNonQuery();
 2138      }
 139
 2140      using var commit = conn.CreateCommand();
 2141      commit.CommandText = "COMMIT;";
 2142      commit.ExecuteNonQuery();
 2143      began = false;
 2144    }
 1145    catch
 1146    {
 1147      if (began)
 1148      {
 149        try
 1150        {
 1151          using var rollback = conn.CreateCommand();
 1152          rollback.CommandText = "ROLLBACK;";
 1153          rollback.ExecuteNonQuery();
 1154        }
 0155        catch (SqliteException)
 0156        {
 0157        }
 1158      }
 159
 1160      throw;
 161    }
 4162  }
 163
 164  /// <inheritdoc/>
 2165  public bool HasPendingStorageArtifacts() => StorageRewriteArtifacts.HasPendingArtifacts(_databasePath);
 166
 167  /// <inheritdoc/>
 168  public StorageCompactionInfo CompactStorage()
 14169  {
 170    try
 14171    {
 14172      if (_databasePath is null)
 1173      {
 1174        return new StorageCompactionInfo
 1175        {
 1176          IsPending = true,
 1177          FailureKind = StorageCompactionFailureKind.Unknown,
 1178          UserMessage = "Speicherbereinigung noch offen: Fuer diese SQLite-Verbindung steht kein dateibasierter Rewrite 
 1179          LastError = "Dateibasierter Rewrite ist fuer nicht-dateibasierte SQLite-Verbindungen nicht verfuegbar."
 1180        };
 181      }
 182
 13183      var primaryPath = _databasePath;
 13184      var tempPath = StorageRewriteArtifacts.GetTempPath(primaryPath);
 13185      var backupPath = StorageRewriteArtifacts.GetBackupPath(primaryPath);
 186
 13187      var artifactFinalization = TryFinalizeExistingArtifacts(primaryPath, tempPath, backupPath);
 13188      if (artifactFinalization is not null)
 3189        return artifactFinalization;
 190
 10191      if (File.Exists(tempPath) && !StorageRewriteArtifacts.TryDeleteFile(tempPath))
 0192      {
 0193        return new StorageCompactionInfo
 0194        {
 0195          IsPending = true,
 0196          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 0197          UserMessage = "Speicherbereinigung noch offen: Ein altes Rewrite-Artefakt konnte nicht entfernt werden.",
 0198          LastError = $"Rewrite-Tempdatei konnte nicht entfernt werden: {tempPath}"
 0199        };
 200      }
 201
 10202      if (File.Exists(backupPath) && !StorageRewriteArtifacts.TryDeleteFile(backupPath))
 0203      {
 0204        return new StorageCompactionInfo
 0205        {
 0206          IsPending = true,
 0207          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 0208          UserMessage = "Speicherbereinigung noch offen: Eine alte Rewrite-Sicherung blockiert einen neuen Bereinigungsv
 0209          LastError = $"Rewrite-Sicherung konnte nicht entfernt werden: {backupPath}"
 0210        };
 211      }
 212
 10213      _rewriteHooks?.BeforeVacuumInto?.Invoke(tempPath);
 6214      BuildRewriteCandidate(tempPath);
 5215      VerifyRewriteCandidate(tempPath);
 4216      _rewriteHooks?.AfterVacuumInto?.Invoke(tempPath);
 217
 3218      StorageRewriteArtifacts.ReplacePrimaryDatabase(tempPath, primaryPath, backupPath);
 3219      _rewriteHooks?.AfterReplace?.Invoke(primaryPath, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? backupPath 
 220
 3221      if (File.Exists(backupPath) && !StorageRewriteArtifacts.TryDeleteFile(backupPath))
 0222      {
 0223        return new StorageCompactionInfo
 0224        {
 0225          IsPending = true,
 0226          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 0227          UserMessage = "Speicherbereinigung noch offen: Die alte Vault-Datei konnte nach dem Rewrite noch nicht entfern
 0228          LastError = $"Rewrite-Sicherung konnte nach dem Austausch nicht entfernt werden: {backupPath}"
 0229        };
 230      }
 231
 3232      if (File.Exists(tempPath) && !StorageRewriteArtifacts.TryDeleteFile(tempPath))
 0233      {
 0234        return new StorageCompactionInfo
 0235        {
 0236          IsPending = true,
 0237          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 0238          UserMessage = "Speicherbereinigung noch offen: Das temporare Rewrite-Artefakt konnte nach dem Austausch nicht 
 0239          LastError = $"Rewrite-Tempdatei konnte nach dem Austausch nicht entfernt werden: {tempPath}"
 0240        };
 241      }
 242
 3243      return new StorageCompactionInfo
 3244      {
 3245        IsPending = false,
 3246        FailureKind = StorageCompactionFailureKind.None,
 3247        UserMessage = "Speicherbereinigung durch Rewrite abgeschlossen.",
 3248      };
 249    }
 2250    catch (SqliteException ex)
 2251    {
 2252      var (failureKind, userMessage) = MapCompactionFailure(ex);
 2253      return new StorageCompactionInfo
 2254      {
 2255        IsPending = true,
 2256        FailureKind = failureKind,
 2257        UserMessage = userMessage,
 2258        LastError = ex.Message,
 2259      };
 260    }
 2261    catch (InvalidOperationException ex)
 2262    {
 2263      return new StorageCompactionInfo
 2264      {
 2265        IsPending = true,
 2266        FailureKind = StorageCompactionFailureKind.Corruption,
 2267        UserMessage = "Speicherbereinigung noch offen: Die neu geschriebene Vault-Datei ist inkonsistent. Backup pruefen
 2268        LastError = ex.Message,
 2269      };
 270    }
 2271    catch (IOException ex)
 2272    {
 2273      return new StorageCompactionInfo
 2274      {
 2275        IsPending = true,
 2276        FailureKind = StorageCompactionFailureKind.Io,
 2277        UserMessage = "Speicherbereinigung noch offen: Die Vault-Datei konnte nicht sicher neu geschrieben oder ersetzt 
 2278        LastError = ex.Message,
 2279      };
 280    }
 1281    catch (UnauthorizedAccessException ex)
 1282    {
 1283      return new StorageCompactionInfo
 1284      {
 1285        IsPending = true,
 1286        FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 1287        UserMessage = "Speicherbereinigung noch offen: Die Vault-Datei ist noch gesperrt oder nicht schreibbar.",
 1288        LastError = ex.Message,
 1289      };
 290    }
 14291  }
 292
 293  private static (StorageCompactionFailureKind failureKind, string userMessage) MapCompactionFailure(SqliteException ex)
 2294    => ex.SqliteErrorCode switch
 2295    {
 0296      5 or 6 => (StorageCompactionFailureKind.BusyOrLocked, "Speicherbereinigung noch offen: Die Vault-Datei ist gerade 
 0297      10 => (StorageCompactionFailureKind.Io, "Speicherbereinigung noch offen: Beim Rewrite der Vault-Datei ist ein I/O-
 1298      11 or 26 => (StorageCompactionFailureKind.Corruption, "Speicherbereinigung noch offen: Die Datenbank meldet Integr
 0299      13 => (StorageCompactionFailureKind.InsufficientSpace, "Speicherbereinigung noch offen: Fuer den Rewrite ist nicht
 1300      _ => (StorageCompactionFailureKind.Unknown, "Speicherbereinigung noch offen: SQLite konnte den Rewrite nicht absch
 2301    };
 302
 303  private StorageCompactionInfo? TryFinalizeExistingArtifacts(string primaryPath, string tempPath, string backupPath)
 13304  {
 13305    var mainValid = StorageRewriteArtifacts.IsUsableSqliteDatabase(primaryPath);
 306
 13307    if (File.Exists(backupPath))
 3308    {
 3309      if (!mainValid)
 1310      {
 1311        return new StorageCompactionInfo
 1312        {
 1313          IsPending = true,
 1314          FailureKind = StorageCompactionFailureKind.Corruption,
 1315          UserMessage = "Speicherbereinigung noch offen: Vorhandene Rewrite-Artefakte muessen beim Neustart wiederherges
 1316          LastError = "Rewrite-Sicherung vorhanden, aber die Hauptdatenbank ist momentan nicht gueltig."
 1317        };
 318      }
 319
 2320      if (!StorageRewriteArtifacts.TryDeleteFile(backupPath))
 0321      {
 0322        return new StorageCompactionInfo
 0323        {
 0324          IsPending = true,
 0325          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 0326          UserMessage = "Speicherbereinigung noch offen: Die alte Rewrite-Sicherung konnte noch nicht entfernt werden.",
 0327          LastError = $"Rewrite-Sicherung konnte nicht entfernt werden: {backupPath}"
 0328        };
 329      }
 330
 2331      if (File.Exists(tempPath))
 1332        StorageRewriteArtifacts.TryDeleteFile(tempPath);
 333
 2334      return new StorageCompactionInfo
 2335      {
 2336        IsPending = false,
 2337        FailureKind = StorageCompactionFailureKind.None,
 2338        UserMessage = "Speicherbereinigung abgeschlossen.",
 2339      };
 340    }
 341
 10342    if (File.Exists(tempPath) && mainValid)
 0343    {
 0344      if (!StorageRewriteArtifacts.TryDeleteFile(tempPath))
 0345      {
 0346        return new StorageCompactionInfo
 0347        {
 0348          IsPending = true,
 0349          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 0350          UserMessage = "Speicherbereinigung noch offen: Ein unvollstaendiges Rewrite-Artefakt konnte noch nicht entfern
 0351          LastError = $"Rewrite-Tempdatei konnte nicht entfernt werden: {tempPath}"
 0352        };
 353      }
 0354    }
 355
 10356    return null;
 13357  }
 358
 359  private void BuildRewriteCandidate(string tempPath)
 6360  {
 6361    using var conn = GetConnection();
 5362    ConfigureMigrationConnection(conn);
 363
 5364    using (var checkpoint = conn.CreateCommand())
 5365    {
 5366      checkpoint.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
 5367      checkpoint.ExecuteNonQuery();
 5368    }
 369
 5370    using var cmd = conn.CreateCommand();
 5371    cmd.CommandText = $"VACUUM INTO {ToSqliteStringLiteral(tempPath)};";
 5372    cmd.ExecuteNonQuery();
 10373  }
 374
 375  private static void VerifyRewriteCandidate(string tempPath)
 5376  {
 5377    if (!StorageRewriteArtifacts.IsUsableSqliteDatabase(tempPath))
 0378      throw new InvalidOperationException("Rewrite-Zieldatei ist keine verwendbare SQLite-Datenbank.");
 379
 5380    var builder = new SqliteConnectionStringBuilder
 5381    {
 5382      DataSource = tempPath,
 5383      Mode = SqliteOpenMode.ReadOnly,
 5384    };
 385
 5386    using var connection = new SqliteConnection(builder.ToString());
 5387    connection.Open();
 388
 5389    using var masterKeyCount = connection.CreateCommand();
 5390    masterKeyCount.CommandText = "SELECT COUNT(*) FROM MasterKey;";
 5391    if (Convert.ToInt64(masterKeyCount.ExecuteScalar() ?? 0L) != 1)
 1392      throw new InvalidOperationException("Rewrite-Zieldatei enthaelt keinen konsistenten MasterKey-Header.");
 8393  }
 394
 395  private static void ConfigureMigrationConnection(SqliteConnection conn)
 8396  {
 8397    using var cmd = conn.CreateCommand();
 8398    cmd.CommandText = @"
 8399                PRAGMA journal_mode = DELETE;
 8400                PRAGMA synchronous = FULL;
 8401                PRAGMA locking_mode = EXCLUSIVE;
 8402                PRAGMA busy_timeout = 5000;";
 8403    cmd.ExecuteNonQuery();
 16404  }
 405
 5406  private static string ToSqliteStringLiteral(string value) => $"'{value.Replace("'", "''")}'";
 407
 5408  private static CredentialRecord MapCredential(SqliteDataReader reader) => new()
 5409  {
 5410    Id = reader.GetInt32(0),
 5411    Title = reader.GetString(1),
 5412    Username = reader.IsDBNull(2) ? null : reader.GetString(2),
 5413    EncryptedPassword = (byte[])reader[3],
 5414    EncryptedMetadata = reader.IsDBNull(4) ? [] : (byte[])reader[4],
 5415    CredentialUuid = reader.IsDBNull(5) ? string.Empty : reader.GetString(5),
 5416    SecretFormatVersion = reader.IsDBNull(6) ? CredentialSecretFormatVersion.Legacy : reader.GetInt32(6),
 5417    MetadataFormatVersion = reader.IsDBNull(7) ? CredentialMetadataFormatVersion.Legacy : reader.GetInt32(7),
 5418    Url = reader.IsDBNull(8) ? null : reader.GetString(8),
 5419    Notes = reader.IsDBNull(9) ? null : reader.GetString(9),
 5420    CreatedAt = reader.GetDateTime(10),
 5421    UpdatedAt = reader.GetDateTime(11),
 5422    IconKey = reader.IsDBNull(12) ? null : reader.GetString(12),
 5423    CredentialType = reader.IsDBNull(13) ? CredentialType.Password : (CredentialType)reader.GetInt32(13),
 5424  };
 425}
 426
 427internal sealed class StorageRewriteHooks
 428{
 429  public Action<string>? BeforeVacuumInto { get; init; }
 430  public Action<string>? AfterVacuumInto { get; init; }
 431  public Action<string, string?>? AfterReplace { get; init; }
 432}