diff --git a/src/Evolve.Cli/Evolve.Cli.csproj b/src/Evolve.Cli/Evolve.Cli.csproj index 8a1937ed..0c3d7be0 100644 --- a/src/Evolve.Cli/Evolve.Cli.csproj +++ b/src/Evolve.Cli/Evolve.Cli.csproj @@ -3,7 +3,7 @@ Exe - net9.0 + net10.0 true EvolveDb.Cli diff --git a/src/Evolve.Cli/EvolveFactory.cs b/src/Evolve.Cli/EvolveFactory.cs index e384f288..ad1688e4 100644 --- a/src/Evolve.Cli/EvolveFactory.cs +++ b/src/Evolve.Cli/EvolveFactory.cs @@ -45,6 +45,7 @@ public static Evolve Build(Program options, Action logInfoDelegate = nul RetryRepeatableMigrationsUntilNoError = options.RetryRepeatableMigrationsUntilNoError, TransactionMode = options.TransactionMode, SkipNextMigrations = options.SkipNextMigrations, + ChecksumAlgorithm = options.ChecksumAlgorithm, }; if (options.Placeholders != null) diff --git a/src/Evolve.Cli/Program.cs b/src/Evolve.Cli/Program.cs index 58df45cc..6715558b 100644 --- a/src/Evolve.Cli/Program.cs +++ b/src/Evolve.Cli/Program.cs @@ -6,6 +6,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Security.Cryptography; using AllowedValuesAttribute = McMaster.Extensions.CommandLineUtils.AllowedValuesAttribute; [Command(ResponseFileHandling = ResponseFileHandling.ParseArgsAsSpaceSeparated)] @@ -129,6 +130,13 @@ private int OnExecute(CommandLineApplication app, IConsole console) [Option("--skip-next-migrations", "When set, mark all subsequent migrations as applied. Default: false", CommandOptionType.SingleValue)] public bool SkipNextMigrations { get; } + [Option("--checksum-algorithm", "Hash algorithm name (e.g., MD5, SHA256, SHA384, SHA512, SHA3-256). Must be supported by the system. Default: MD5", CommandOptionType.SingleValue)] + public string ChecksumAlgorithmName { get; } = "MD5"; + + public HashAlgorithmName ChecksumAlgorithm => string.IsNullOrWhiteSpace(ChecksumAlgorithmName) + ? HashAlgorithmName.MD5 + : new HashAlgorithmName(ChecksumAlgorithmName); + // Cassandra [Option("--keyspace", "A list of keyspaces managed by Evolve (Cassandra only).", CommandOptionType.MultipleValue)] public string[] Keyspaces { get; } diff --git a/src/Evolve.Tool/Evolve.Tool.csproj b/src/Evolve.Tool/Evolve.Tool.csproj index 9141e82a..257054d2 100644 --- a/src/Evolve.Tool/Evolve.Tool.csproj +++ b/src/Evolve.Tool/Evolve.Tool.csproj @@ -3,7 +3,7 @@ Exe - net9.0 + net10.0 true evolve EvolveDb.Tool diff --git a/src/Evolve/Configuration/IEvolveConfiguration.cs b/src/Evolve/Configuration/IEvolveConfiguration.cs index aad44a61..18e0109c 100644 --- a/src/Evolve/Configuration/IEvolveConfiguration.cs +++ b/src/Evolve/Configuration/IEvolveConfiguration.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Reflection; +using System.Security.Cryptography; using System.Text; using EvolveDb.Migration; @@ -214,9 +215,23 @@ public interface IEvolveConfiguration bool SkipNextMigrations { get; } /// - /// A custom used to load all migrations (applied, pending, ignored...) + /// A custom used to load all migrations (applied, pending, ignored...) /// that replaces the built-in ones ( ) /// IMigrationLoader MigrationLoader { get; } + + /// + /// + /// Hash algorithm used for calculating migration checksums. (default: MD5) + /// + /// + /// SHA-256, SHA-384, and SHA-512 are FIPS-compliant alternatives. + /// + /// + /// Note: When validating checksums, Evolve will accept both the configured + /// algorithm and MD5 for backward compatibility with existing databases. + /// + /// + HashAlgorithmName ChecksumAlgorithm { get; } } } diff --git a/src/Evolve/Dialect/CockroachDb/CockroachDbMetadataTable.cs b/src/Evolve/Dialect/CockroachDb/CockroachDbMetadataTable.cs index 4f3152e3..0e6e2c0e 100644 --- a/src/Evolve/Dialect/CockroachDb/CockroachDbMetadataTable.cs +++ b/src/Evolve/Dialect/CockroachDb/CockroachDbMetadataTable.cs @@ -86,7 +86,7 @@ protected override void InternalCreate() "version VARCHAR(50), " + "description VARCHAR(200) NOT NULL, " + "name VARCHAR(300) NOT NULL, " + - "checksum VARCHAR(32), " + + "checksum VARCHAR(128), " + "installed_by VARCHAR(100) NOT NULL, " + "installed_on TIMESTAMP NOT NULL DEFAULT now(), " + "success BOOLEAN NOT NULL " + diff --git a/src/Evolve/Dialect/MySQL/MySQLMetadataTable.cs b/src/Evolve/Dialect/MySQL/MySQLMetadataTable.cs index c1fe244e..4c974d43 100644 --- a/src/Evolve/Dialect/MySQL/MySQLMetadataTable.cs +++ b/src/Evolve/Dialect/MySQL/MySQLMetadataTable.cs @@ -43,7 +43,7 @@ protected override void InternalCreate() "version VARCHAR(50), " + "description VARCHAR(200) NOT NULL, " + "name VARCHAR(300) NOT NULL, " + - "checksum VARCHAR(32), " + + "checksum VARCHAR(128), " + "installed_by VARCHAR(100) NOT NULL, " + "installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "success BOOL NOT NULL " + diff --git a/src/Evolve/Dialect/PostgreSQL/PostgreSQLMetadataTable.cs b/src/Evolve/Dialect/PostgreSQL/PostgreSQLMetadataTable.cs index 635ddcd2..23cadb09 100644 --- a/src/Evolve/Dialect/PostgreSQL/PostgreSQLMetadataTable.cs +++ b/src/Evolve/Dialect/PostgreSQL/PostgreSQLMetadataTable.cs @@ -44,7 +44,7 @@ protected override void InternalCreate() "version VARCHAR(50), " + "description VARCHAR(200) NOT NULL, " + "name VARCHAR(300) NOT NULL, " + - "checksum VARCHAR(32), " + + "checksum VARCHAR(128), " + "installed_by VARCHAR(100) NOT NULL, " + "installed_on TIMESTAMP NOT NULL DEFAULT now(), " + "success BOOLEAN NOT NULL " + diff --git a/src/Evolve/Dialect/SQLServer/SQLServerMetadataTable.cs b/src/Evolve/Dialect/SQLServer/SQLServerMetadataTable.cs index b2fd82f3..3c493471 100644 --- a/src/Evolve/Dialect/SQLServer/SQLServerMetadataTable.cs +++ b/src/Evolve/Dialect/SQLServer/SQLServerMetadataTable.cs @@ -43,7 +43,7 @@ protected override void InternalCreate() "version VARCHAR(50), " + "description VARCHAR(200) NOT NULL, " + "name VARCHAR(300) NOT NULL, " + - "checksum VARCHAR(32), " + + "checksum VARCHAR(128), " + "installed_by VARCHAR(100) NOT NULL, " + "installed_on DATETIME NOT NULL DEFAULT GETDATE(), " + "success BIT NOT NULL " + diff --git a/src/Evolve/Dialect/SQLite/SQLiteMetadataTable.cs b/src/Evolve/Dialect/SQLite/SQLiteMetadataTable.cs index 47ea2b8c..6648ef0f 100644 --- a/src/Evolve/Dialect/SQLite/SQLiteMetadataTable.cs +++ b/src/Evolve/Dialect/SQLite/SQLiteMetadataTable.cs @@ -41,7 +41,7 @@ protected override void InternalCreate() "version VARCHAR(50), " + "description VARCHAR(200) NOT NULL, " + "name VARCHAR(300) NOT NULL, " + - "checksum VARCHAR(32), " + + "checksum VARCHAR(128), " + "installed_by VARCHAR(100) NOT NULL, " + "installed_on TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), " + "success BOOLEAN NOT NULL " + diff --git a/src/Evolve/Evolve.cs b/src/Evolve/Evolve.cs index 64510adb..68173c1b 100644 --- a/src/Evolve/Evolve.cs +++ b/src/Evolve/Evolve.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using System.Text; using System.Threading; using System.Transactions; @@ -90,6 +91,8 @@ public IMigrationLoader MigrationLoader set { _migrationLoader = value; } } + public HashAlgorithmName ChecksumAlgorithm { get; set; } = HashAlgorithmName.MD5; + #endregion #region Properties diff --git a/src/Evolve/Evolve.csproj b/src/Evolve/Evolve.csproj index 1fbeeaec..154d9936 100644 --- a/src/Evolve/Evolve.csproj +++ b/src/Evolve/Evolve.csproj @@ -2,7 +2,7 @@ - net9.0 + net10.0 true true latest diff --git a/src/Evolve/Migration/EmbeddedResourceMigrationLoader.cs b/src/Evolve/Migration/EmbeddedResourceMigrationLoader.cs index 6e31edb8..4af29677 100644 --- a/src/Evolve/Migration/EmbeddedResourceMigrationLoader.cs +++ b/src/Evolve/Migration/EmbeddedResourceMigrationLoader.cs @@ -58,7 +58,11 @@ public virtual IEnumerable GetMigrations() encoding); }) .ToList() - .ForEach(x => migrations.Add(x)); + .ForEach(x => + { + x.HashAlgorithm = _options.ChecksumAlgorithm; + migrations.Add(x); + }); } return migrations.CheckForDuplicateVersion() @@ -100,7 +104,11 @@ public virtual IEnumerable GetRepeatableMigrations() encoding); }) .ToList() - .ForEach(x => migrations.Add(x)); + .ForEach(x => + { + x.HashAlgorithm = _options.ChecksumAlgorithm; + migrations.Add(x); + }); } return migrations.CheckForDuplicateName() diff --git a/src/Evolve/Migration/FileMigrationLoader.cs b/src/Evolve/Migration/FileMigrationLoader.cs index d1b70473..9e6d9360 100644 --- a/src/Evolve/Migration/FileMigrationLoader.cs +++ b/src/Evolve/Migration/FileMigrationLoader.cs @@ -59,7 +59,11 @@ public virtual IEnumerable GetMigrations() return new FileMigrationScript(path: f.FullName, version, description, MetadataType.Migration, encoding); }) .ToList() - .ForEach(x => migrations.Add(x)); + .ForEach(x => + { + x.HashAlgorithm = _options.ChecksumAlgorithm; + migrations.Add(x); + }); } return migrations.CheckForDuplicateVersion() @@ -100,7 +104,11 @@ public virtual IEnumerable GetRepeatableMigrations() return new FileMigrationScript(f.FullName, version: null, description, MetadataType.RepeatableMigration, encoding); }) .ToList() - .ForEach(x => migrations.Add(x)); + .ForEach(x => + { + x.HashAlgorithm = _options.ChecksumAlgorithm; + migrations.Add(x); + }); } return migrations.CheckForDuplicateName() diff --git a/src/Evolve/Migration/FileMigrationScript.cs b/src/Evolve/Migration/FileMigrationScript.cs index ef947d6e..10388c30 100644 --- a/src/Evolve/Migration/FileMigrationScript.cs +++ b/src/Evolve/Migration/FileMigrationScript.cs @@ -44,22 +44,32 @@ public override void ValidateChecksum(string? checksum) } catch { - if (checksum != FallbackCheck()) + // Try fallback methods for backward compatibility (pre v1.8.0 hashing of file stream) + if (checksum == FallbackCheck() || + checksum == FallbackCheckWithAlgorithm(HashAlgorithm)) { - throw; + return; } + throw; } } /// - /// Calculate the checksum with the pre v1.8.0 version. + /// Calculate the checksum with the pre v1.8.0 version (reads file directly with MD5). /// private string FallbackCheck() { - using var md5 = MD5.Create(); + return FallbackCheckWithAlgorithm(HashAlgorithmName.MD5); + } + + /// + /// Calculate the checksum with the pre v1.8.0 version using specified algorithm. + /// + /// The hash algorithm to use. + private string FallbackCheckWithAlgorithm(HashAlgorithmName algorithm) + { using FileStream stream = File.OpenRead(Path); - byte[] checksum = md5.ComputeHash(stream); - return BitConverter.ToString(checksum).Replace("-", string.Empty); + return ChecksumCalculator.Calculate(stream, algorithm); } } } diff --git a/src/Evolve/Migration/MigrationScript.cs b/src/Evolve/Migration/MigrationScript.cs index efa4ff3f..4a86624d 100644 --- a/src/Evolve/Migration/MigrationScript.cs +++ b/src/Evolve/Migration/MigrationScript.cs @@ -25,6 +25,11 @@ protected MigrationScript(string? version, string description, string name, stri /// public string Content { get; } + /// + /// Gets or sets the hash algorithm used for checksum calculation. + /// + internal HashAlgorithmName HashAlgorithm { get; set; } = HashAlgorithmName.MD5; + /// /// Returns false if the special comment "evolve-tx-off" is found in the first line of the script, true otherwise. /// @@ -37,7 +42,7 @@ protected MigrationScript(string? version, string description, string name, stri /// /// Validates the against the actual migration one. - /// Throws on mismatch. + /// Supports dual validation: accepts both the configured algorithm and MD5 for backward compatibility. /// /// The applied migration checksum. /// @@ -45,21 +50,56 @@ public virtual void ValidateChecksum(string? checksum) { Check.NotNull(checksum, nameof(checksum)); - if (checksum != CalculateChecksum()) + string currentChecksum = CalculateChecksum(); + + // Direct match with configured algorithm + if (checksum == currentChecksum) + { + return; + } + + // Dual validation: try MD5 for backward compatibility if using SHA-2 + if (HashAlgorithm != HashAlgorithmName.MD5) + { + string md5Checksum = CalculateChecksumWithAlgorithm(HashAlgorithmName.MD5); + if (checksum == md5Checksum) + { + return; + } + } + + // Detect stored algorithm and recalculate if different + var detectedAlgorithm = ChecksumCalculator.DetectAlgorithm(checksum); + if (detectedAlgorithm.HasValue && detectedAlgorithm.Value != HashAlgorithm) { - throw new EvolveValidationException(string.Format(IncorrectMigrationChecksum, Name)); + string recalculated = CalculateChecksumWithAlgorithm(detectedAlgorithm.Value); + if (checksum == recalculated) + { + return; + } } + + throw new EvolveValidationException(string.Format(IncorrectMigrationChecksum, Name)); } /// /// Returns the checksum where crlf and lf line endings have been previously normalized to lf. /// - /// + /// The checksum calculated with the configured hash algorithm. public virtual string CalculateChecksum() { - using var md5 = MD5.Create(); - byte[] checksum = md5.ComputeHash(Encoding.UTF8.GetBytes(NormalizeLineEndings(Content))); - return BitConverter.ToString(checksum).Replace("-", string.Empty); + return CalculateChecksumWithAlgorithm(HashAlgorithm); + } + + /// + /// Calculates checksum using a specific algorithm. + /// + /// The hash algorithm to use. + /// The checksum calculated with the specified algorithm. + protected string CalculateChecksumWithAlgorithm(HashAlgorithmName algorithm) + { + string normalizedContent = NormalizeLineEndings(Content); + return ChecksumCalculator.Calculate(normalizedContent, algorithm); } /// diff --git a/src/Evolve/Utilities/ChecksumCalculator.cs b/src/Evolve/Utilities/ChecksumCalculator.cs new file mode 100644 index 00000000..335df4fa --- /dev/null +++ b/src/Evolve/Utilities/ChecksumCalculator.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace EvolveDb.Utilities +{ + /// + /// Utility class for calculating migration checksums with configurable algorithms. + /// + internal static class ChecksumCalculator + { + /// + /// Known algorithm lengths for detection. + /// + private static readonly Dictionary LengthToAlgorithm = new() + { + { 32, HashAlgorithmName.MD5 }, + { 64, HashAlgorithmName.SHA256 }, + { 96, HashAlgorithmName.SHA384 }, + { 128, HashAlgorithmName.SHA512 } + }; + + /// + /// Returns the expected hex string length for each algorithm. + /// + /// The hash algorithm name. + /// The hex string length (32 for MD5, 64 for SHA256, 96 for SHA384, 128 for SHA512). + public static int GetHashLength(HashAlgorithmName algorithm) + { + if (algorithm == HashAlgorithmName.MD5) return 32; + if (algorithm == HashAlgorithmName.SHA256) return 64; + if (algorithm == HashAlgorithmName.SHA384) return 96; + if (algorithm == HashAlgorithmName.SHA512) return 128; + // For unknown algorithms, compute a sample hash to determine length + return CryptographicOperations.HashData(algorithm, Array.Empty()).Length * 2; + } + + /// + /// Detects the hash algorithm based on checksum string length. + /// + /// The checksum string to analyze. + /// The detected algorithm name, or null if length doesn't match any known algorithm. + public static HashAlgorithmName? DetectAlgorithm(string? checksum) + { + if (string.IsNullOrEmpty(checksum)) + { + return null; + } + + return LengthToAlgorithm.TryGetValue(checksum.Length, out var algorithm) ? algorithm : null; + } + + /// + /// Calculates checksum of string content using the specified algorithm. + /// + /// The string content to hash. + /// The hash algorithm to use. + /// The uppercase hex string representation of the hash. + public static string Calculate(string content, HashAlgorithmName algorithm) + { + byte[] data = Encoding.UTF8.GetBytes(content); + byte[] hash = CryptographicOperations.HashData(algorithm, data); + return Convert.ToHexString(hash); + } + + /// + /// Calculates checksum from a stream using the specified algorithm. + /// + /// The stream to hash. + /// The hash algorithm to use. + /// The uppercase hex string representation of the hash. + public static string Calculate(Stream stream, HashAlgorithmName algorithm) + { + byte[] hash = CryptographicOperations.HashData(algorithm, stream); + return Convert.ToHexString(hash); + } + } +} diff --git a/test/Evolve.Tests/Evolve.Tests.csproj b/test/Evolve.Tests/Evolve.Tests.csproj index 59c52dc4..f7cf4600 100644 --- a/test/Evolve.Tests/Evolve.Tests.csproj +++ b/test/Evolve.Tests/Evolve.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 false latest EvolveDb.Tests diff --git a/test/Evolve.Tests/EvolveConfiguration.cs b/test/Evolve.Tests/EvolveConfiguration.cs index afcce44f..2cf30da8 100644 --- a/test/Evolve.Tests/EvolveConfiguration.cs +++ b/test/Evolve.Tests/EvolveConfiguration.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Security.Cryptography; using System.Text; using EvolveDb.Configuration; using EvolveDb.Migration; @@ -41,6 +42,7 @@ public string MetadataTableSchema public bool RetryRepeatableMigrationsUntilNoError { get; set; } public TransactionKind TransactionMode { get; set; } = TransactionKind.CommitEach; public bool SkipNextMigrations { get; set; } = false; + public HashAlgorithmName ChecksumAlgorithm { get; set; } = HashAlgorithmName.MD5; private IMigrationLoader _migrationLoader; public IMigrationLoader MigrationLoader diff --git a/test/Evolve.Tests/Migration/FileMigrationScriptTest.cs b/test/Evolve.Tests/Migration/FileMigrationScriptTest.cs index 5890c091..f6e7f38f 100644 --- a/test/Evolve.Tests/Migration/FileMigrationScriptTest.cs +++ b/test/Evolve.Tests/Migration/FileMigrationScriptTest.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using EvolveDb.Metadata; using EvolveDb.Migration; +using EvolveDb.Utilities; using Xunit; using static EvolveDb.Tests.TestContext; using static EvolveDb.Tests.TestUtil; @@ -115,5 +116,73 @@ private static string FallbackCheck(string path) byte[] checksum = md5.ComputeHash(stream); return BitConverter.ToString(checksum).Replace("-", string.Empty); } + + [Fact] + [Category(Test.Migration)] + public void ValidateChecksum_accepts_MD5_when_configured_for_SHA256() + { + // Arrange + var script = BuildFileMigrationScript(CrLfScriptPath, "2.3.1"); + string md5Checksum = script.CalculateChecksum(); // Default is MD5 + + // Switch to SHA-256 + script.HashAlgorithm = HashAlgorithmName.SHA256; + + // Act & Assert - should not throw due to dual validation + script.ValidateChecksum(md5Checksum); + } + + [Fact] + [Category(Test.Migration)] + public void ValidateChecksum_accepts_SHA256_when_configured_for_SHA256() + { + // Arrange + var script = BuildFileMigrationScript(CrLfScriptPath, "2.3.1"); + script.HashAlgorithm = HashAlgorithmName.SHA256; + string sha256Checksum = script.CalculateChecksum(); + + // Act & Assert - should not throw + script.ValidateChecksum(sha256Checksum); + } + + [Fact] + [Category(Test.Migration)] + public void CalculateChecksum_returns_different_lengths_for_different_algorithms() + { + // Arrange + var script = BuildFileMigrationScript(CrLfScriptPath, "2.3.1"); + + // Act + script.HashAlgorithm = HashAlgorithmName.MD5; + string md5 = script.CalculateChecksum(); + + script.HashAlgorithm = HashAlgorithmName.SHA256; + string sha256 = script.CalculateChecksum(); + + script.HashAlgorithm = HashAlgorithmName.SHA512; + string sha512 = script.CalculateChecksum(); + + // Assert + Assert.Equal(32, md5.Length); + Assert.Equal(64, sha256.Length); + Assert.Equal(128, sha512.Length); + } + + [Fact] + [Category(Test.Migration)] + public void ValidateChecksum_detects_algorithm_from_stored_checksum_length() + { + // Arrange - script configured for SHA-512 + var script = BuildFileMigrationScript(CrLfScriptPath, "2.3.1"); + script.HashAlgorithm = HashAlgorithmName.SHA512; + + // Calculate SHA-256 checksum separately + var tempScript = BuildFileMigrationScript(CrLfScriptPath, "2.3.1"); + tempScript.HashAlgorithm = HashAlgorithmName.SHA256; + string sha256Checksum = tempScript.CalculateChecksum(); + + // Act & Assert - should detect SHA-256 from length and validate + script.ValidateChecksum(sha256Checksum); + } } } diff --git a/test/Evolve.Tests/Utilities/ChecksumCalculatorTest.cs b/test/Evolve.Tests/Utilities/ChecksumCalculatorTest.cs new file mode 100644 index 00000000..abb1bdd8 --- /dev/null +++ b/test/Evolve.Tests/Utilities/ChecksumCalculatorTest.cs @@ -0,0 +1,127 @@ +using System.Security.Cryptography; +using EvolveDb.Utilities; +using Xunit; + +namespace EvolveDb.Tests.Utilities +{ + public class ChecksumCalculatorTest + { + private const string TestContent = "SELECT 1;"; + + [Fact] + [Category(Test.Migration)] + public void MD5_returns_32_character_hash() + { + var result = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.MD5); + Assert.Equal(32, result.Length); + } + + [Fact] + [Category(Test.Migration)] + public void SHA256_returns_64_character_hash() + { + var result = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.SHA256); + Assert.Equal(64, result.Length); + } + + [Fact] + [Category(Test.Migration)] + public void SHA384_returns_96_character_hash() + { + var result = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.SHA384); + Assert.Equal(96, result.Length); + } + + [Fact] + [Category(Test.Migration)] + public void SHA512_returns_128_character_hash() + { + var result = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.SHA512); + Assert.Equal(128, result.Length); + } + + [Fact] + [Category(Test.Migration)] + public void DetectAlgorithm_correctly_identifies_MD5_by_length() + { + var checksum = new string('A', 32); + Assert.Equal(HashAlgorithmName.MD5, ChecksumCalculator.DetectAlgorithm(checksum)); + } + + [Fact] + [Category(Test.Migration)] + public void DetectAlgorithm_correctly_identifies_SHA256_by_length() + { + var checksum = new string('A', 64); + Assert.Equal(HashAlgorithmName.SHA256, ChecksumCalculator.DetectAlgorithm(checksum)); + } + + [Fact] + [Category(Test.Migration)] + public void DetectAlgorithm_correctly_identifies_SHA384_by_length() + { + var checksum = new string('A', 96); + Assert.Equal(HashAlgorithmName.SHA384, ChecksumCalculator.DetectAlgorithm(checksum)); + } + + [Fact] + [Category(Test.Migration)] + public void DetectAlgorithm_correctly_identifies_SHA512_by_length() + { + var checksum = new string('A', 128); + Assert.Equal(HashAlgorithmName.SHA512, ChecksumCalculator.DetectAlgorithm(checksum)); + } + + [Fact] + [Category(Test.Migration)] + public void DetectAlgorithm_returns_null_for_unknown_length() + { + var checksum = new string('A', 50); // Invalid length + Assert.Null(ChecksumCalculator.DetectAlgorithm(checksum)); + } + + [Fact] + [Category(Test.Migration)] + public void DetectAlgorithm_returns_null_for_null_or_empty() + { + Assert.Null(ChecksumCalculator.DetectAlgorithm(null)); + Assert.Null(ChecksumCalculator.DetectAlgorithm("")); + } + + [Fact] + [Category(Test.Migration)] + public void Same_content_produces_same_hash() + { + var hash1 = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.SHA256); + var hash2 = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.SHA256); + Assert.Equal(hash1, hash2); + } + + [Fact] + [Category(Test.Migration)] + public void Different_algorithms_produce_different_hashes() + { + var md5 = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.MD5); + var sha256 = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.SHA256); + Assert.NotEqual(md5, sha256); + } + + [Fact] + [Category(Test.Migration)] + public void GetHashLength_returns_correct_values() + { + Assert.Equal(32, ChecksumCalculator.GetHashLength(HashAlgorithmName.MD5)); + Assert.Equal(64, ChecksumCalculator.GetHashLength(HashAlgorithmName.SHA256)); + Assert.Equal(96, ChecksumCalculator.GetHashLength(HashAlgorithmName.SHA384)); + Assert.Equal(128, ChecksumCalculator.GetHashLength(HashAlgorithmName.SHA512)); + } + + [Fact] + [Category(Test.Migration)] + public void Hash_output_is_uppercase_hex() + { + var hash = ChecksumCalculator.Calculate(TestContent, HashAlgorithmName.MD5); + Assert.Matches("^[0-9A-F]+$", hash); + } + } +}