diff --git a/sources/EncodingChecker.Tests/ConversionMetadataTests.cs b/sources/EncodingChecker.Tests/ConversionMetadataTests.cs new file mode 100644 index 0000000..6de01ef --- /dev/null +++ b/sources/EncodingChecker.Tests/ConversionMetadataTests.cs @@ -0,0 +1,260 @@ +using System.Text; +using System.Text.Json; + +namespace EncodingChecker.Tests; + +/// +/// The sidecar exists to answer one question without reference to any external report: +/// how can this original be restored, and how can the conversion be reconstructed? +/// +/// An audit measured that 99.2% of bad conversions were byte-recoverable — but only for +/// someone who still knew which codec produced them, which lived solely in the conversion +/// report. A GUI user has no reason to keep one, so "recoverable" was theoretical. These +/// tests pin the mechanism that makes it operational. +/// +public sealed class ConversionMetadataTests : IDisposable +{ + private readonly string _root = + Directory.CreateTempSubdirectory("ec_meta_").FullName; + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup. + } + } + + private string Convert(string name, string text, string sourceCharset, string target) + { + string path = Path.Combine(_root, name); + File.WriteAllBytes(path, Encoding.GetEncoding(sourceCharset).GetBytes(text)); + + var entry = new ConversionReportEntry + { + FilePath = path, + SourceEncoding = sourceCharset, + SourceHasBom = false, + TargetEncoding = sourceCharset, + TargetHasBom = false, + }; + + ScanEngine.ConvertFiles( + [entry], target, targetWriteBom: false, + ScanEngine.DefaultMaxParallelism, + whatIf: false, backup: true, _ => { }, CancellationToken.None); + + return path; + } + + [Fact] + public void AConversionWithBackupWritesASidecarDescribingIt() + { + string path = Convert("described.txt", "café — naïve", "windows-1252", "utf-8"); + + string metadataPath = ConversionMetadataStore.MetadataPathFor(path); + Assert.True(File.Exists(metadataPath), "no sidecar was written"); + + var metadata = JsonSerializer.Deserialize( + File.ReadAllText(metadataPath))!; + + Assert.Equal(1, metadata.MetadataVersion); + + // The code page is what identifies the codec unambiguously; a name may not. + Assert.Equal(1252, metadata.DetectedCodePage); + Assert.Equal(65001, metadata.TargetCodePage); + Assert.False(metadata.DetectedBom); + + // The recorded hash must actually be the backup's. + Assert.Equal( + ConversionMetadataStore.ComputeSha256(path + ".bak"), + metadata.BackupSha256); + + // And the backup must be the original, not merely some file. + Assert.Equal(metadata.OriginalSha256, metadata.BackupSha256); + + Assert.NotEmpty(metadata.ConversionId); + Assert.NotEmpty(metadata.ConversionTimestampUtc); + Assert.NotEmpty(metadata.ECVersion); + } + + [Fact] + public void TheSidecarAloneIsEnoughToReverseTheConversion() + { + // The whole point: recover the original without the conversion report, using + // only what sits next to the file. + const string text = "Grüße — café — naïve"; + string path = Convert("reversible.txt", text, "windows-1252", "utf-8"); + + var metadata = JsonSerializer.Deserialize( + File.ReadAllText(ConversionMetadataStore.MetadataPathFor(path)))!; + + // Read the converted file as the recorded target, re-encode as the recorded + // source. Nothing here consults a report or guesses an encoding. + string converted = Encoding.GetEncoding(metadata.TargetCodePage) + .GetString(File.ReadAllBytes(path)); + + byte[] reconstructed = Encoding.GetEncoding(metadata.DetectedCodePage) + .GetBytes(converted); + + Assert.Equal(File.ReadAllBytes(path + ".bak"), reconstructed); + Assert.Equal(text, Encoding.GetEncoding(metadata.DetectedCodePage) + .GetString(reconstructed)); + } + + [Fact] + public void RestoreIsAvailableWhenBackupAndMetadataAgree() + { + string path = Convert("restorable.txt", "café", "windows-1252", "utf-8"); + + RestoreStatus status = ConversionMetadataStore.Inspect(path); + + Assert.Equal(RestoreAvailability.Available, status.Availability); + Assert.True(status.CanRestore); + Assert.NotNull(status.Metadata); + } + + [Fact] + public void AMissingBackupIsReportedAsSuchRatherThanAsCorruption() + { + string path = Convert("nobackup.txt", "café", "windows-1252", "utf-8"); + File.Delete(path + ".bak"); + + RestoreStatus status = ConversionMetadataStore.Inspect(path); + + Assert.Equal(RestoreAvailability.BackupMissing, status.Availability); + Assert.False(status.CanRestore); + } + + [Fact] + public void ABackupWithNoMetadataIsNotTreatedAsRestorable() + { + // The case this whole mechanism exists for: a ".bak" whose encoding nobody + // recorded. Its existence is not evidence that anything can be recovered. + string path = Convert("orphan.txt", "café", "windows-1252", "utf-8"); + File.Delete(ConversionMetadataStore.MetadataPathFor(path)); + + RestoreStatus status = ConversionMetadataStore.Inspect(path); + + Assert.Equal(RestoreAvailability.MetadataMissing, status.Availability); + Assert.False(status.CanRestore); + } + + [Fact] + public void ACorruptedBackupIsDetectedBeforeItCouldBeRestored() + { + string path = Convert("corrupt.txt", "café", "windows-1252", "utf-8"); + File.WriteAllBytes(path + ".bak", [0x00, 0x01, 0x02]); + + RestoreStatus status = ConversionMetadataStore.Inspect(path); + + Assert.Equal(RestoreAvailability.BackupCorrupted, status.Availability); + Assert.False(status.CanRestore); + Assert.Contains("hashes to", status.Detail); + } + + [Fact] + public void UnreadableMetadataIsDistinguishedFromMissingMetadata() + { + string path = Convert("garbled.txt", "café", "windows-1252", "utf-8"); + File.WriteAllText(ConversionMetadataStore.MetadataPathFor(path), "{ not json"); + + RestoreStatus status = ConversionMetadataStore.Inspect(path); + + Assert.Equal(RestoreAvailability.MetadataUnreadable, status.Availability); + Assert.False(status.CanRestore); + } + + [Fact] + public void AnUnsupportedMetadataVersionIsRefusedRatherThanGuessedAt() + { + string path = Convert("future.txt", "café", "windows-1252", "utf-8"); + string metadataPath = ConversionMetadataStore.MetadataPathFor(path); + + File.WriteAllText( + metadataPath, + File.ReadAllText(metadataPath).Replace( + "\"MetadataVersion\": 1", "\"MetadataVersion\": 99")); + + RestoreStatus status = ConversionMetadataStore.Inspect(path); + + Assert.Equal(RestoreAvailability.MetadataUnreadable, status.Availability); + Assert.Contains("99", status.Detail); + } + + [Fact] + public void NoBackupMeansNoSidecar() + { + // Without a backup there is nothing to restore from, so a record would + // describe a conversion that cannot be undone. + string path = Path.Combine(_root, "nobackup2.txt"); + File.WriteAllBytes(path, Encoding.GetEncoding("windows-1252").GetBytes("café")); + + var entry = new ConversionReportEntry + { + FilePath = path, + SourceEncoding = "windows-1252", + SourceHasBom = false, + TargetEncoding = "windows-1252", + TargetHasBom = false, + }; + + ScanEngine.ConvertFiles( + [entry], "utf-8", targetWriteBom: false, + ScanEngine.DefaultMaxParallelism, + whatIf: false, backup: false, _ => { }, CancellationToken.None); + + Assert.False(File.Exists(ConversionMetadataStore.MetadataPathFor(path))); + } + + [Fact] + public void AStaleBackupFromAnEarlierRunIsNotRecordedAsThisOriginal() + { + // A ".bak" left by a previous conversion holds different content. Recording it + // would produce metadata that restores the wrong file, so the conversion must + // refuse instead. + string path = Path.Combine(_root, "stale.txt"); + File.WriteAllBytes(path, Encoding.GetEncoding("windows-1252").GetBytes("current")); + File.WriteAllBytes(path + ".bak", Encoding.UTF8.GetBytes("something else entirely")); + File.SetAttributes(path + ".bak", FileAttributes.ReadOnly); + + try + { + var entry = new ConversionReportEntry + { + FilePath = path, + SourceEncoding = "windows-1252", + SourceHasBom = false, + TargetEncoding = "windows-1252", + TargetHasBom = false, + }; + + var completed = new List(); + ScanEngine.ConvertFiles( + [entry], "utf-8", targetWriteBom: false, + ScanEngine.DefaultMaxParallelism, + whatIf: false, backup: true, completed.Add, CancellationToken.None); + + // Either the backup was replaced correctly and the metadata matches it, or + // the conversion refused. What must not happen is a converted file whose + // metadata points at content that was never its original. + RestoreStatus status = ConversionMetadataStore.Inspect(path); + + if (status.CanRestore) + { + Assert.Equal( + ConversionMetadataStore.ComputeSha256(path + ".bak"), + status.Metadata!.OriginalSha256); + } + } + finally + { + if (File.Exists(path + ".bak")) + File.SetAttributes(path + ".bak", FileAttributes.Normal); + } + } +} diff --git a/sources/EncodingChecker/ConversionMetadata.cs b/sources/EncodingChecker/ConversionMetadata.cs new file mode 100644 index 0000000..c904cf7 --- /dev/null +++ b/sources/EncodingChecker/ConversionMetadata.cs @@ -0,0 +1,273 @@ +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EncodingChecker; + +/// +/// The sidecar written next to a backup, recording how to reverse a conversion. +/// +/// +/// A conversion is only reversible for someone who still knows which codec produced it, +/// and that knowledge previously existed solely in the conversion report - an artifact a +/// user running the GUI has no reason to keep. Recovery that depends on a file nobody +/// kept is not recovery. +/// +/// Written as a plain JSON file rather than an NTFS alternate data stream: an ADS is lost +/// by ordinary copying, archiving and most cloud sync, which are exactly the operations +/// that separate a backup from its origin. A sidecar survives them and can be read +/// without EncodingChecker. +/// +/// +internal sealed record ConversionMetadata +{ + /// Schema version, so a later reader can tell what it is looking at. + [JsonPropertyOrder(0)] + public int MetadataVersion { get; init; } = 1; + + [JsonPropertyOrder(1)] + public required string ConversionId { get; init; } + + [JsonPropertyOrder(2)] + public required string ConversionTimestampUtc { get; init; } + + [JsonPropertyOrder(3)] + public required string ECVersion { get; init; } + + [JsonPropertyOrder(4)] + public required string OriginalPath { get; init; } + + [JsonPropertyOrder(5)] + public required long OriginalSize { get; init; } + + [JsonPropertyOrder(6)] + public required string OriginalSha256 { get; init; } + + [JsonPropertyOrder(7)] + public required string BackupPath { get; init; } + + [JsonPropertyOrder(8)] + public required string BackupSha256 { get; init; } + + [JsonPropertyOrder(9)] + public required string DetectedEncoding { get; init; } + + /// + /// The code page identifies the codec where a name may not: "cp949" and + /// "ks_c_5601-1987" are one encoding, and only the number says so unambiguously. + /// + [JsonPropertyOrder(10)] + public required int DetectedCodePage { get; init; } + + [JsonPropertyOrder(11)] + public required bool DetectedBom { get; init; } + + [JsonPropertyOrder(12)] + public required string TargetEncoding { get; init; } + + [JsonPropertyOrder(13)] + public required int TargetCodePage { get; init; } + + [JsonPropertyOrder(14)] + public required bool TargetBom { get; init; } + + [JsonPropertyOrder(15)] + public required string SourceTextSha256 { get; init; } + + [JsonPropertyOrder(16)] + public required string OutputTextSha256 { get; init; } + + [JsonPropertyOrder(17)] + public required long UnicodeScalars { get; init; } +} + +/// Whether a converted file can be restored from what is on disk. +internal enum RestoreAvailability +{ + /// Backup and metadata are present and agree. + Available, + + /// No backup file exists. + BackupMissing, + + /// A backup exists but no metadata describes it. + MetadataMissing, + + /// Metadata exists but cannot be read or is not a version we understand. + MetadataUnreadable, + + /// The backup's hash does not match what the metadata recorded. + BackupCorrupted, +} + +internal sealed record RestoreStatus +{ + internal required RestoreAvailability Availability { get; init; } + + internal ConversionMetadata? Metadata { get; init; } + + internal string? Detail { get; init; } + + internal bool CanRestore => Availability == RestoreAvailability.Available; +} + +/// Reads and writes the conversion sidecar. +internal static class ConversionMetadataStore +{ + internal const string Suffix = ".ecmeta.json"; + + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + }; + + internal static string MetadataPathFor(string filePath) => filePath + Suffix; + + internal static string ComputeSha256(string path) + { + using FileStream stream = new( + path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var sha = SHA256.Create(); + return Convert.ToHexStringLower(sha.ComputeHash(stream)); + } + + /// + /// Writes the sidecar and reads it back, returning on success + /// or a message describing the failure. + /// + /// + /// Read back deliberately. A write that appeared to succeed but produced a file that + /// cannot be parsed would leave the caller believing the conversion is reversible + /// when it is not, which is the failure this whole mechanism exists to prevent. + /// + internal static string? Write(string filePath, ConversionMetadata metadata) + { + string path = MetadataPathFor(filePath); + + try + { + File.WriteAllText( + path, JsonSerializer.Serialize(metadata, Options), new UTF8Encoding(false)); + + ConversionMetadata? readBack = + JsonSerializer.Deserialize(File.ReadAllText(path)); + + if (readBack is null) + return $"'{path}' was written but could not be read back."; + + if (readBack.BackupSha256 != metadata.BackupSha256 || + readBack.OriginalSha256 != metadata.OriginalSha256) + { + return $"'{path}' was written but does not describe the expected file."; + } + + return null; + } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException or JsonException) + { + return $"{ex.Message}"; + } + } + + /// + /// Determines whether can be restored, verifying the + /// backup against the recorded hash rather than trusting that a ".bak" exists. + /// + internal static RestoreStatus Inspect(string filePath) + { + string backupPath = filePath + ".bak"; + string metadataPath = MetadataPathFor(filePath); + + if (!File.Exists(backupPath)) + { + return new RestoreStatus + { + Availability = RestoreAvailability.BackupMissing, + Detail = $"No backup at '{backupPath}'.", + }; + } + + if (!File.Exists(metadataPath)) + { + return new RestoreStatus + { + Availability = RestoreAvailability.MetadataMissing, + Detail = + $"A backup exists at '{backupPath}' but no '{Suffix}' describes it, " + + "so the encoding it was converted from is unknown.", + }; + } + + ConversionMetadata? metadata; + + try + { + metadata = JsonSerializer.Deserialize( + File.ReadAllText(metadataPath)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return new RestoreStatus + { + Availability = RestoreAvailability.MetadataUnreadable, + Detail = ex.Message, + }; + } + + if (metadata is null || metadata.MetadataVersion != 1) + { + return new RestoreStatus + { + Availability = RestoreAvailability.MetadataUnreadable, + Detail = metadata is null + ? "The metadata file is empty." + : string.Format( + CultureInfo.InvariantCulture, + "Metadata version {0} is not supported.", + metadata.MetadataVersion), + }; + } + + string actual; + + try + { + actual = ComputeSha256(backupPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new RestoreStatus + { + Availability = RestoreAvailability.BackupCorrupted, + Metadata = metadata, + Detail = ex.Message, + }; + } + + // Both comparisons matter and they are not the same check. The first says the + // backup is the file the metadata describes; the second says that file is the + // original. A backup that matches neither is not a restore candidate. + if (actual != metadata.BackupSha256 || actual != metadata.OriginalSha256) + { + return new RestoreStatus + { + Availability = RestoreAvailability.BackupCorrupted, + Metadata = metadata, + Detail = + $"The backup hashes to {actual}, but the record expects " + + $"{metadata.BackupSha256} (original {metadata.OriginalSha256}).", + }; + } + + return new RestoreStatus + { + Availability = RestoreAvailability.Available, + Metadata = metadata, + }; + } +} diff --git a/sources/EncodingChecker/DirectoryTraversal.cs b/sources/EncodingChecker/DirectoryTraversal.cs index 9b0f521..bdfd392 100644 --- a/sources/EncodingChecker/DirectoryTraversal.cs +++ b/sources/EncodingChecker/DirectoryTraversal.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -47,6 +47,7 @@ internal static class DirectoryTraversal /// private static bool IsAlwaysExcludedFile(string fileName) => fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(ConversionMetadataStore.Suffix, StringComparison.OrdinalIgnoreCase) || fileName.EndsWith("." + EncodingConverter.TEMP_FILE_SUFFIX, StringComparison.OrdinalIgnoreCase); /// diff --git a/sources/EncodingChecker/EncodingConverter.cs b/sources/EncodingChecker/EncodingConverter.cs index 3f3640e..9aa82dd 100644 --- a/sources/EncodingChecker/EncodingConverter.cs +++ b/sources/EncodingChecker/EncodingConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.IO; using System.Runtime.InteropServices; @@ -59,6 +59,59 @@ internal sealed record ConversionOptions /// Ignored for a different destination. Defaults to . /// internal bool PreserveTimestamps { get; init; } + + /// + /// Invoked after verification succeeds and before the converted file is installed, + /// to record how this conversion can be undone. Returns on + /// success, or a message describing why the record could not be written. + /// + /// + /// Deliberately inside the safety boundary rather than after it. Recovery information + /// written once the original has already been replaced is recovery information that + /// might not exist when it is needed, which is the gap it exists to close: a + /// conversion is only reversible for someone who still knows which codec produced it. + /// A failure here therefore aborts the conversion with the original intact. + /// + internal Func? RecordConversion { get; init; } +} + +/// +/// Everything needed to reverse or independently reconstruct one conversion, without +/// reference to any external report. +/// +internal sealed record ConversionRecord +{ + internal required string SourcePath { get; init; } + + internal required long SourceBytes { get; init; } + + /// SHA-256 of the source file's bytes, before conversion. + internal required string SourceSha256 { get; init; } + + /// SHA-256 over the decoded source text, independent of encoding. + internal required string SourceTextSha256 { get; init; } + + /// SHA-256 over the decoded converted text. Equal to the source text hash + /// on a verified conversion; recorded so that equality is checkable later. + internal required string OutputTextSha256 { get; init; } + + internal required string SourceEncoding { get; init; } + + /// + /// The code page, which identifies the codec unambiguously where a name may not: + /// "cp949" and "ks_c_5601-1987" name one encoding, and only the number says so. + /// + internal required int SourceCodePage { get; init; } + + internal required bool SourceHasBom { get; init; } + + internal required string TargetEncoding { get; init; } + + internal required int TargetCodePage { get; init; } + + internal required bool TargetHasBom { get; init; } + + internal required long UnicodeScalars { get; init; } } /// Progress reported by bytes processed. @@ -184,6 +237,10 @@ internal static ConversionResult Convert( long sourceBytesProcessed = 0; long targetBytesWritten = 0; + // Whether the source carried a BOM, captured for the conversion record, + // which is written after the stream that observed it has closed. + bool sourceHadBom = false; + try { bool sameFile = IsSameFile(sourcePath, destinationPath); @@ -238,6 +295,7 @@ internal static ConversionResult Convert( () => ConsumePreambleIfPresent(sourceStream, sourceEncoding)); sourceBytesProcessed += sourcePreambleLength; + sourceHadBom = sourcePreambleLength > 0; if (options.WriteBom) { @@ -343,6 +401,64 @@ internal static ConversionResult Convert( }; } + // Record how to undo this, before anything is overwritten. Ordered here on + // purpose: the backup already exists, the conversion is verified, and the + // original is still in place, so a failure to record leaves nothing to + // recover from and nothing needing recovery. + if (options.RecordConversion is not null) + { + // Hashed only when a record is being written, so the extra pass is not + // paid for by conversions that do not need it. + string sourceFileSha; + try + { + sourceFileSha = ComputeFileSha256(sourcePath); + } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException) + { + sourceFileSha = string.Empty; + } + + string? recordError = options.RecordConversion(new ConversionRecord + { + SourcePath = sourcePath, + SourceBytes = sourceBytesProcessed, + SourceSha256 = sourceFileSha, + SourceTextSha256 = System.Convert.ToHexStringLower(sourceDigest.Hash), + OutputTextSha256 = System.Convert.ToHexStringLower(sourceDigest.Hash), + SourceEncoding = sourceEncoding.WebName, + SourceCodePage = sourceEncoding.CodePage, + SourceHasBom = sourceHadBom, + TargetEncoding = targetEncoding.WebName, + TargetCodePage = targetEncoding.CodePage, + TargetHasBom = options.WriteBom, + UnicodeScalars = verification.ScalarsCompared, + }); + + if (recordError is not null) + { + return new ConversionResult + { + Success = false, + ErrorCode = ConversionErrorCode.TargetWriteError, + ErrorMessage = + "Conversion and verification succeeded, but the record needed to " + + $"reverse it could not be written: {recordError} The original " + + "was left unmodified rather than replaced with a conversion " + + "that could not be undone.", + SourceEncoding = sourceEncoding, + TargetEncoding = targetEncoding, + SourceBytes = sourceBytesProcessed, + TargetBytes = targetBytesWritten, + UnicodeScalarsVerified = verification.ScalarsCompared, + VerificationPassed = true, + BomVerificationPassed = true, + ReplacementCommitted = false, + }; + } + } + // Final cancellation checkpoint before installation. cancellationToken.ThrowIfCancellationRequested(); @@ -845,6 +961,14 @@ private readonly record struct ContentDigest( /// Hashes the exact decoded UTF-16 content and returns scalar/code-unit counts. /// No normalization is performed. /// + /// SHA-256 over a file's raw bytes. + private static string ComputeFileSha256(string path) + { + using FileStream stream = OpenReadShared(path, DEFAULT_BUFFER_SIZE); + using var sha = SHA256.Create(); + return System.Convert.ToHexStringLower(sha.ComputeHash(stream)); + } + private static ContentDigest ComputeContentDigest( Stream stream, Encoding encoding, diff --git a/sources/EncodingChecker/ScanEngine.cs b/sources/EncodingChecker/ScanEngine.cs index ee42ed0..effe811 100644 --- a/sources/EncodingChecker/ScanEngine.cs +++ b/sources/EncodingChecker/ScanEngine.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -426,6 +427,13 @@ private static void ApplyConversion( var conversionOptions = new ConversionOptions { WriteBom = targetWriteBom, + + // Only when a backup exists is there anything to describe. Without one + // there is nothing to restore from, so a record would document a + // conversion that cannot be undone. + RecordConversion = backup + ? record => WriteConversionMetadata(path, record) + : null, }; // Without the token, Parallel.ForEach could only observe cancellation between @@ -481,6 +489,63 @@ private static void ApplyConversion( /// Writes ".bak" via temp-file-then-atomic-replace, so a /// crash mid-write can't leave a truncated backup (unlike a plain File.Copy). /// + /// + /// Writes the sidecar describing how to undo this conversion, next to the backup. + /// + /// + /// Called from inside after verification and + /// before installation, so a failure here aborts with the original intact rather than + /// leaving a converted file whose provenance is unrecorded. + /// + private static string? WriteConversionMetadata(string path, ConversionRecord record) + { + string backupPath = path + ".bak"; + + string backupHash; + + try + { + backupHash = ConversionMetadataStore.ComputeSha256(backupPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return $"the backup at '{backupPath}' could not be read: {ex.Message}"; + } + + // The backup must be the file being converted, not merely present. A stale + // ".bak" left by an earlier run would otherwise be recorded as this + // conversion's original and silently restore the wrong content. + if (!string.IsNullOrEmpty(record.SourceSha256) && + !backupHash.Equals(record.SourceSha256, StringComparison.OrdinalIgnoreCase)) + { + return $"the backup at '{backupPath}' does not match the file being " + + "converted, so it is not a valid restore point."; + } + + return ConversionMetadataStore.Write(path, new ConversionMetadata + { + ConversionId = Guid.NewGuid().ToString("D"), + ConversionTimestampUtc = + DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), + ECVersion = typeof(ScanEngine).Assembly.GetName().Version?.ToString() + ?? "unknown", + OriginalPath = path, + OriginalSize = record.SourceBytes, + OriginalSha256 = record.SourceSha256, + BackupPath = backupPath, + BackupSha256 = backupHash, + DetectedEncoding = record.SourceEncoding, + DetectedCodePage = record.SourceCodePage, + DetectedBom = record.SourceHasBom, + TargetEncoding = record.TargetEncoding, + TargetCodePage = record.TargetCodePage, + TargetBom = record.TargetHasBom, + SourceTextSha256 = record.SourceTextSha256, + OutputTextSha256 = record.OutputTextSha256, + UnicodeScalars = record.UnicodeScalars, + }); + } + private static void CreateBackup(string path) { string? directory = Path.GetDirectoryName(path);