< Summary

Information
Class: LOCKnet.Data.Repositories.StorageRewriteHooks
Assembly: LOCKnet.Data
File(s): /home/runner/work/LOCKnet/LOCKnet/src/LOCKnet.Data/Repositories/VaultMigrationRepository.cs
Line coverage
100%
Covered lines: 3
Uncovered lines: 0
Coverable lines: 3
Total lines: 432
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_BeforeVacuumInto()100%11100%
get_AfterVacuumInto()100%11100%
get_AfterReplace()100%11100%

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>
 17  public VaultMigrationRepository(string connectionString) : this(connectionString, null)
 18  {
 19  }
 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>
 25  public VaultMigrationRepository(ISqliteConnectionFactory connectionFactory) : this(connectionFactory, null)
 26  {
 27  }
 28
 29  internal VaultMigrationRepository(string connectionString, StorageRewriteHooks? rewriteHooks) : base(connectionString)
 30  {
 31    _rewriteHooks = rewriteHooks;
 32  }
 33
 34  internal VaultMigrationRepository(ISqliteConnectionFactory connectionFactory, StorageRewriteHooks? rewriteHooks) : bas
 35  {
 36    _rewriteHooks = rewriteHooks;
 37  }
 38
 39  /// <inheritdoc/>
 40  public IReadOnlyList<CredentialRecord> GetAllCredentials()
 41  {
 42    var list = new List<CredentialRecord>();
 43    using var conn = GetConnection();
 44    using var cmd = conn.CreateCommand();
 45    cmd.CommandText = "SELECT Id, Title, Username, EncryptedPassword, EncryptedMetadata, CredentialUuid, SecretFormatVer
 46
 47    using var reader = cmd.ExecuteReader();
 48    while (reader.Read())
 49      list.Add(MapCredential(reader));
 50
 51    return list;
 52  }
 53
 54  /// <inheritdoc/>
 55  public void ApplyMigration(VaultHeader header, IReadOnlyList<CredentialRecord> credentials)
 56  {
 57    ArgumentNullException.ThrowIfNull(header);
 58    ArgumentNullException.ThrowIfNull(credentials);
 59
 60    foreach (var credential in credentials)
 61      StoredCredentialGuard.ValidateForPersistence(credential);
 62
 63    using var conn = GetConnection();
 64    ConfigureMigrationConnection(conn);
 65
 66    var began = false;
 67    try
 68    {
 69      using (var begin = conn.CreateCommand())
 70      {
 71        begin.CommandText = "BEGIN EXCLUSIVE;";
 72        begin.ExecuteNonQuery();
 73        began = true;
 74      }
 75
 76      foreach (var credential in credentials)
 77      {
 78        using var updateCredential = conn.CreateCommand();
 79        updateCredential.CommandText = @"
 80                    UPDATE Credentials
 81                    SET Title = $title,
 82                        Username = $username,
 83                        EncryptedPassword = $password,
 84                        EncryptedMetadata = $encryptedMetadata,
 85                        CredentialUuid = $credentialUuid,
 86                        SecretFormatVersion = $secretFormatVersion,
 87                        MetadataFormatVersion = $metadataFormatVersion,
 88                        URL = $url,
 89                        Notes = $notes,
 90                        IconKey = $iconKey,
 91                        CredentialType = $credentialType,
 92                        UpdatedAt = CURRENT_TIMESTAMP
 93                    WHERE Id = $id;";
 94        updateCredential.Parameters.AddWithValue("$id", credential.Id);
 95        updateCredential.Parameters.AddWithValue("$title", credential.Title);
 96        updateCredential.Parameters.AddWithValue("$username", (object?)credential.Username ?? DBNull.Value);
 97        updateCredential.Parameters.AddWithValue("$password", credential.EncryptedPassword);
 98        updateCredential.Parameters.AddWithValue("$encryptedMetadata", (object?)credential.EncryptedMetadata ?? DBNull.V
 99        updateCredential.Parameters.AddWithValue("$credentialUuid", credential.CredentialUuid);
 100        updateCredential.Parameters.AddWithValue("$secretFormatVersion", credential.SecretFormatVersion);
 101        updateCredential.Parameters.AddWithValue("$metadataFormatVersion", credential.MetadataFormatVersion);
 102        updateCredential.Parameters.AddWithValue("$url", (object?)credential.Url ?? DBNull.Value);
 103        updateCredential.Parameters.AddWithValue("$notes", (object?)credential.Notes ?? DBNull.Value);
 104        updateCredential.Parameters.AddWithValue("$iconKey", (object?)credential.IconKey ?? DBNull.Value);
 105        updateCredential.Parameters.AddWithValue("$credentialType", (int)credential.CredentialType);
 106        updateCredential.ExecuteNonQuery();
 107      }
 108
 109      using (var updateHeader = conn.CreateCommand())
 110      {
 111        updateHeader.CommandText = @"
 112                    UPDATE MasterKey
 113                    SET PasswordHash = $hash,
 114                        FormatVersion = $formatVersion,
 115                        KdfIdentifier = $kdfIdentifier,
 116                        KdfParameters = $kdfParameters,
 117                        Salt = $salt,
 118                        WrappedVaultKey = $wrappedVaultKey,
 119                        UsesLegacyKeyMaterial = $usesLegacyKeyMaterial,
 120                        RequiresStorageCompaction = $requiresStorageCompaction,
 121                        LastStorageCompactionAttemptUtc = $lastStorageCompactionAttemptUtc,
 122                        LastStorageCompactionFailureKind = $lastStorageCompactionFailureKind,
 123                        LastStorageCompactionError = $lastStorageCompactionError,
 124                        UpdatedAt = CURRENT_TIMESTAMP
 125                    WHERE Id = 1;";
 126        updateHeader.Parameters.AddWithValue("$hash", header.LegacyPasswordHash);
 127        updateHeader.Parameters.AddWithValue("$formatVersion", header.FormatVersion);
 128        updateHeader.Parameters.AddWithValue("$kdfIdentifier", header.KdfIdentifier);
 129        updateHeader.Parameters.AddWithValue("$kdfParameters", header.KdfParameters.Serialize());
 130        updateHeader.Parameters.AddWithValue("$salt", header.Salt);
 131        updateHeader.Parameters.AddWithValue("$wrappedVaultKey", header.WrappedVaultKey);
 132        updateHeader.Parameters.AddWithValue("$usesLegacyKeyMaterial", header.UsesLegacyKeyMaterial ? 1 : 0);
 133        updateHeader.Parameters.AddWithValue("$requiresStorageCompaction", header.RequiresStorageCompaction ? 1 : 0);
 134        updateHeader.Parameters.AddWithValue("$lastStorageCompactionAttemptUtc", (object?)header.LastStorageCompactionAt
 135        updateHeader.Parameters.AddWithValue("$lastStorageCompactionFailureKind", (int)header.LastStorageCompactionFailu
 136        updateHeader.Parameters.AddWithValue("$lastStorageCompactionError", (object?)header.LastStorageCompactionError ?
 137        updateHeader.ExecuteNonQuery();
 138      }
 139
 140      using var commit = conn.CreateCommand();
 141      commit.CommandText = "COMMIT;";
 142      commit.ExecuteNonQuery();
 143      began = false;
 144    }
 145    catch
 146    {
 147      if (began)
 148      {
 149        try
 150        {
 151          using var rollback = conn.CreateCommand();
 152          rollback.CommandText = "ROLLBACK;";
 153          rollback.ExecuteNonQuery();
 154        }
 155        catch (SqliteException)
 156        {
 157        }
 158      }
 159
 160      throw;
 161    }
 162  }
 163
 164  /// <inheritdoc/>
 165  public bool HasPendingStorageArtifacts() => StorageRewriteArtifacts.HasPendingArtifacts(_databasePath);
 166
 167  /// <inheritdoc/>
 168  public StorageCompactionInfo CompactStorage()
 169  {
 170    try
 171    {
 172      if (_databasePath is null)
 173      {
 174        return new StorageCompactionInfo
 175        {
 176          IsPending = true,
 177          FailureKind = StorageCompactionFailureKind.Unknown,
 178          UserMessage = "Speicherbereinigung noch offen: Fuer diese SQLite-Verbindung steht kein dateibasierter Rewrite 
 179          LastError = "Dateibasierter Rewrite ist fuer nicht-dateibasierte SQLite-Verbindungen nicht verfuegbar."
 180        };
 181      }
 182
 183      var primaryPath = _databasePath;
 184      var tempPath = StorageRewriteArtifacts.GetTempPath(primaryPath);
 185      var backupPath = StorageRewriteArtifacts.GetBackupPath(primaryPath);
 186
 187      var artifactFinalization = TryFinalizeExistingArtifacts(primaryPath, tempPath, backupPath);
 188      if (artifactFinalization is not null)
 189        return artifactFinalization;
 190
 191      if (File.Exists(tempPath) && !StorageRewriteArtifacts.TryDeleteFile(tempPath))
 192      {
 193        return new StorageCompactionInfo
 194        {
 195          IsPending = true,
 196          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 197          UserMessage = "Speicherbereinigung noch offen: Ein altes Rewrite-Artefakt konnte nicht entfernt werden.",
 198          LastError = $"Rewrite-Tempdatei konnte nicht entfernt werden: {tempPath}"
 199        };
 200      }
 201
 202      if (File.Exists(backupPath) && !StorageRewriteArtifacts.TryDeleteFile(backupPath))
 203      {
 204        return new StorageCompactionInfo
 205        {
 206          IsPending = true,
 207          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 208          UserMessage = "Speicherbereinigung noch offen: Eine alte Rewrite-Sicherung blockiert einen neuen Bereinigungsv
 209          LastError = $"Rewrite-Sicherung konnte nicht entfernt werden: {backupPath}"
 210        };
 211      }
 212
 213      _rewriteHooks?.BeforeVacuumInto?.Invoke(tempPath);
 214      BuildRewriteCandidate(tempPath);
 215      VerifyRewriteCandidate(tempPath);
 216      _rewriteHooks?.AfterVacuumInto?.Invoke(tempPath);
 217
 218      StorageRewriteArtifacts.ReplacePrimaryDatabase(tempPath, primaryPath, backupPath);
 219      _rewriteHooks?.AfterReplace?.Invoke(primaryPath, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? backupPath 
 220
 221      if (File.Exists(backupPath) && !StorageRewriteArtifacts.TryDeleteFile(backupPath))
 222      {
 223        return new StorageCompactionInfo
 224        {
 225          IsPending = true,
 226          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 227          UserMessage = "Speicherbereinigung noch offen: Die alte Vault-Datei konnte nach dem Rewrite noch nicht entfern
 228          LastError = $"Rewrite-Sicherung konnte nach dem Austausch nicht entfernt werden: {backupPath}"
 229        };
 230      }
 231
 232      if (File.Exists(tempPath) && !StorageRewriteArtifacts.TryDeleteFile(tempPath))
 233      {
 234        return new StorageCompactionInfo
 235        {
 236          IsPending = true,
 237          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 238          UserMessage = "Speicherbereinigung noch offen: Das temporare Rewrite-Artefakt konnte nach dem Austausch nicht 
 239          LastError = $"Rewrite-Tempdatei konnte nach dem Austausch nicht entfernt werden: {tempPath}"
 240        };
 241      }
 242
 243      return new StorageCompactionInfo
 244      {
 245        IsPending = false,
 246        FailureKind = StorageCompactionFailureKind.None,
 247        UserMessage = "Speicherbereinigung durch Rewrite abgeschlossen.",
 248      };
 249    }
 250    catch (SqliteException ex)
 251    {
 252      var (failureKind, userMessage) = MapCompactionFailure(ex);
 253      return new StorageCompactionInfo
 254      {
 255        IsPending = true,
 256        FailureKind = failureKind,
 257        UserMessage = userMessage,
 258        LastError = ex.Message,
 259      };
 260    }
 261    catch (InvalidOperationException ex)
 262    {
 263      return new StorageCompactionInfo
 264      {
 265        IsPending = true,
 266        FailureKind = StorageCompactionFailureKind.Corruption,
 267        UserMessage = "Speicherbereinigung noch offen: Die neu geschriebene Vault-Datei ist inkonsistent. Backup pruefen
 268        LastError = ex.Message,
 269      };
 270    }
 271    catch (IOException ex)
 272    {
 273      return new StorageCompactionInfo
 274      {
 275        IsPending = true,
 276        FailureKind = StorageCompactionFailureKind.Io,
 277        UserMessage = "Speicherbereinigung noch offen: Die Vault-Datei konnte nicht sicher neu geschrieben oder ersetzt 
 278        LastError = ex.Message,
 279      };
 280    }
 281    catch (UnauthorizedAccessException ex)
 282    {
 283      return new StorageCompactionInfo
 284      {
 285        IsPending = true,
 286        FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 287        UserMessage = "Speicherbereinigung noch offen: Die Vault-Datei ist noch gesperrt oder nicht schreibbar.",
 288        LastError = ex.Message,
 289      };
 290    }
 291  }
 292
 293  private static (StorageCompactionFailureKind failureKind, string userMessage) MapCompactionFailure(SqliteException ex)
 294    => ex.SqliteErrorCode switch
 295    {
 296      5 or 6 => (StorageCompactionFailureKind.BusyOrLocked, "Speicherbereinigung noch offen: Die Vault-Datei ist gerade 
 297      10 => (StorageCompactionFailureKind.Io, "Speicherbereinigung noch offen: Beim Rewrite der Vault-Datei ist ein I/O-
 298      11 or 26 => (StorageCompactionFailureKind.Corruption, "Speicherbereinigung noch offen: Die Datenbank meldet Integr
 299      13 => (StorageCompactionFailureKind.InsufficientSpace, "Speicherbereinigung noch offen: Fuer den Rewrite ist nicht
 300      _ => (StorageCompactionFailureKind.Unknown, "Speicherbereinigung noch offen: SQLite konnte den Rewrite nicht absch
 301    };
 302
 303  private StorageCompactionInfo? TryFinalizeExistingArtifacts(string primaryPath, string tempPath, string backupPath)
 304  {
 305    var mainValid = StorageRewriteArtifacts.IsUsableSqliteDatabase(primaryPath);
 306
 307    if (File.Exists(backupPath))
 308    {
 309      if (!mainValid)
 310      {
 311        return new StorageCompactionInfo
 312        {
 313          IsPending = true,
 314          FailureKind = StorageCompactionFailureKind.Corruption,
 315          UserMessage = "Speicherbereinigung noch offen: Vorhandene Rewrite-Artefakte muessen beim Neustart wiederherges
 316          LastError = "Rewrite-Sicherung vorhanden, aber die Hauptdatenbank ist momentan nicht gueltig."
 317        };
 318      }
 319
 320      if (!StorageRewriteArtifacts.TryDeleteFile(backupPath))
 321      {
 322        return new StorageCompactionInfo
 323        {
 324          IsPending = true,
 325          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 326          UserMessage = "Speicherbereinigung noch offen: Die alte Rewrite-Sicherung konnte noch nicht entfernt werden.",
 327          LastError = $"Rewrite-Sicherung konnte nicht entfernt werden: {backupPath}"
 328        };
 329      }
 330
 331      if (File.Exists(tempPath))
 332        StorageRewriteArtifacts.TryDeleteFile(tempPath);
 333
 334      return new StorageCompactionInfo
 335      {
 336        IsPending = false,
 337        FailureKind = StorageCompactionFailureKind.None,
 338        UserMessage = "Speicherbereinigung abgeschlossen.",
 339      };
 340    }
 341
 342    if (File.Exists(tempPath) && mainValid)
 343    {
 344      if (!StorageRewriteArtifacts.TryDeleteFile(tempPath))
 345      {
 346        return new StorageCompactionInfo
 347        {
 348          IsPending = true,
 349          FailureKind = StorageCompactionFailureKind.BusyOrLocked,
 350          UserMessage = "Speicherbereinigung noch offen: Ein unvollstaendiges Rewrite-Artefakt konnte noch nicht entfern
 351          LastError = $"Rewrite-Tempdatei konnte nicht entfernt werden: {tempPath}"
 352        };
 353      }
 354    }
 355
 356    return null;
 357  }
 358
 359  private void BuildRewriteCandidate(string tempPath)
 360  {
 361    using var conn = GetConnection();
 362    ConfigureMigrationConnection(conn);
 363
 364    using (var checkpoint = conn.CreateCommand())
 365    {
 366      checkpoint.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
 367      checkpoint.ExecuteNonQuery();
 368    }
 369
 370    using var cmd = conn.CreateCommand();
 371    cmd.CommandText = $"VACUUM INTO {ToSqliteStringLiteral(tempPath)};";
 372    cmd.ExecuteNonQuery();
 373  }
 374
 375  private static void VerifyRewriteCandidate(string tempPath)
 376  {
 377    if (!StorageRewriteArtifacts.IsUsableSqliteDatabase(tempPath))
 378      throw new InvalidOperationException("Rewrite-Zieldatei ist keine verwendbare SQLite-Datenbank.");
 379
 380    var builder = new SqliteConnectionStringBuilder
 381    {
 382      DataSource = tempPath,
 383      Mode = SqliteOpenMode.ReadOnly,
 384    };
 385
 386    using var connection = new SqliteConnection(builder.ToString());
 387    connection.Open();
 388
 389    using var masterKeyCount = connection.CreateCommand();
 390    masterKeyCount.CommandText = "SELECT COUNT(*) FROM MasterKey;";
 391    if (Convert.ToInt64(masterKeyCount.ExecuteScalar() ?? 0L) != 1)
 392      throw new InvalidOperationException("Rewrite-Zieldatei enthaelt keinen konsistenten MasterKey-Header.");
 393  }
 394
 395  private static void ConfigureMigrationConnection(SqliteConnection conn)
 396  {
 397    using var cmd = conn.CreateCommand();
 398    cmd.CommandText = @"
 399                PRAGMA journal_mode = DELETE;
 400                PRAGMA synchronous = FULL;
 401                PRAGMA locking_mode = EXCLUSIVE;
 402                PRAGMA busy_timeout = 5000;";
 403    cmd.ExecuteNonQuery();
 404  }
 405
 406  private static string ToSqliteStringLiteral(string value) => $"'{value.Replace("'", "''")}'";
 407
 408  private static CredentialRecord MapCredential(SqliteDataReader reader) => new()
 409  {
 410    Id = reader.GetInt32(0),
 411    Title = reader.GetString(1),
 412    Username = reader.IsDBNull(2) ? null : reader.GetString(2),
 413    EncryptedPassword = (byte[])reader[3],
 414    EncryptedMetadata = reader.IsDBNull(4) ? [] : (byte[])reader[4],
 415    CredentialUuid = reader.IsDBNull(5) ? string.Empty : reader.GetString(5),
 416    SecretFormatVersion = reader.IsDBNull(6) ? CredentialSecretFormatVersion.Legacy : reader.GetInt32(6),
 417    MetadataFormatVersion = reader.IsDBNull(7) ? CredentialMetadataFormatVersion.Legacy : reader.GetInt32(7),
 418    Url = reader.IsDBNull(8) ? null : reader.GetString(8),
 419    Notes = reader.IsDBNull(9) ? null : reader.GetString(9),
 420    CreatedAt = reader.GetDateTime(10),
 421    UpdatedAt = reader.GetDateTime(11),
 422    IconKey = reader.IsDBNull(12) ? null : reader.GetString(12),
 423    CredentialType = reader.IsDBNull(13) ? CredentialType.Password : (CredentialType)reader.GetInt32(13),
 424  };
 425}
 426
 427internal sealed class StorageRewriteHooks
 428{
 11429  public Action<string>? BeforeVacuumInto { get; init; }
 4430  public Action<string>? AfterVacuumInto { get; init; }
 4431  public Action<string, string?>? AfterReplace { get; init; }
 432}