diff --git a/CHANGELOG.md b/CHANGELOG.md index 6213879..417bfe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # История версий SignService +## v1.4.1 — 20.10.2026 + +- **Чужая подпись не теряется при одновременном соподписании.** Объединение + и контроль изменений используют один снимок файла, снятый до PIN-кода и TSA. + Изменение, удаление или появление файла назначения останавливает запись, + в том числе в режиме «Создать новую». Перед атомарной заменой выполняется + повторная проверка; исчезнувшая выбранная подпись не подменяется другим путём. +- **Подписанная копия со штампом защищена для всех форматов CMS.** Учитываются + `.sig`, `.p7s`, `.p7m` и подписи с другими именами, найденные по содержимому + копии, включая обычный PKCS#7 без подписанных атрибутов. Проверка выполняется + и непосредственно перед сохранением PDF/Word, исходные файлы сохраняются. +- Добавлены регрессионные тесты конкурентной записи, удаления и появления + подписи, а также защиты подписанных копий PDF и Word. + ## v1.4.0 — 20.10.2026 - **Штамп в документах Word (.docx, .docm).** С галочкой «Штамп на документ» diff --git a/README.en.md b/README.en.md index 6c24f98..77a2d1f 100644 --- a/README.en.md +++ b/README.en.md @@ -49,6 +49,8 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas - before replacing a signature file without merging and before excluding signers the app asks for confirmation and keeps a backup next to it; the result shows in the file row: "Подписан: 9 (8 + ваша) → X.sig"; +- if the destination signature changes, disappears or appears during signing, + publication stops and preserves the other version, in both signing modes; - re-signing with the same certificate **replaces** your previous signature instead of duplicating it; - every merged signature is **cryptographically verified**, including the diff --git a/README.md b/README.md index b701f59..c30099a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ - перед заменой файла подписи без объединения и перед исключением подписантов программа спрашивает подтверждение и сохраняет резервную копию рядом; итог виден в строке файла: «Подписан: 9 (8 + ваша) → X.sig»; +- если файл назначения изменился, исчез или появился во время подписания, + запись отменяется и чужая версия сохраняется — в обоих режимах подписания; - повторная подпись тем же сертификатом **заменяет** свою старую, а не дублирует; - каждая объединяемая подпись **проверяется криптографически**: хеш документа, значение подписи и привязка сертификата CAdES — в том числе подписи КриптоПро diff --git a/src/SignService/Services/AtomicFile.cs b/src/SignService/Services/AtomicFile.cs index a5e3050..629736f 100644 --- a/src/SignService/Services/AtomicFile.cs +++ b/src/SignService/Services/AtomicFile.cs @@ -8,7 +8,7 @@ namespace SignService.Services; internal static class AtomicFile { public static async Task WriteAsync(string path, byte[] bytes, bool overwrite = true, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, Action? beforeCommit = null) { var fullPath = Path.GetFullPath(path); var temporary = Path.Combine(Path.GetDirectoryName(fullPath)!, ".signservice-" + Guid.NewGuid().ToString("N") + ".tmp"); @@ -22,6 +22,7 @@ public static async Task WriteAsync(string path, byte[] bytes, bool overwrite = stream.Flush(flushToDisk: true); } cancellationToken.ThrowIfCancellationRequested(); + beforeCommit?.Invoke(); File.Move(temporary, fullPath, overwrite); } finally @@ -30,6 +31,6 @@ public static async Task WriteAsync(string path, byte[] bytes, bool overwrite = } } - public static void Write(string path, byte[] bytes, bool overwrite = false) => - WriteAsync(path, bytes, overwrite).GetAwaiter().GetResult(); + public static void Write(string path, byte[] bytes, bool overwrite = false, Action? beforeCommit = null) => + WriteAsync(path, bytes, overwrite, beforeCommit: beforeCommit).GetAwaiter().GetResult(); } diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs index 5cfa01e..9373742 100644 --- a/src/SignService/Services/DocumentSigner.cs +++ b/src/SignService/Services/DocumentSigner.cs @@ -42,6 +42,9 @@ public sealed record SignOptions /// internal Action? BeforePublish { get; init; } + // Позволяет воспроизвести внешнее изменение после чтения входов, до подписи. + internal Action? AfterSignatureInputsRead { get; init; } + /// Пути к .sig других подписантов для объединения. public IReadOnlyList ExtraSignatures { get; init; } = Array.Empty(); @@ -201,25 +204,14 @@ public async Task SignFileAsync( : null; var signaturePath = options.MergeWithExisting && !string.IsNullOrWhiteSpace(existingSignaturePath) - && File.Exists(existingSignaturePath) ? existingSignaturePath : canonicalSignaturePath; - // Свою подпись создаём ДО чтения объединяемых файлов, чтобы ошибка - // подписания не оставила .sig наполовину обработанным. - var own = Sign(data, certificate, options.Detached); - - // Штамп времени TSA (CAdES-T) — неподписанный атрибут на значение подписи. - if (options.Timestamp) - { - if (string.IsNullOrWhiteSpace(options.TsaUrl)) - throw new InvalidOperationException( - "Для подписи со штампом времени укажите адрес службы штампов времени (TSA)."); - - var token = await _timestampClient.RequestTokenAsync( - CmsMerger.GetSignatureValue(own), IsGost(certificate), options.TsaUrl, cancellationToken); - own = CmsMerger.AddUnsignedAttribute(own, TimestampClient.TimeStampTokenOid, token); - } + // Один снимок назначения — и для объединения, и для контроля изменений. + // Читаем до PIN/TSA, в том числе в режиме замены без объединения. + var previousBytes = await ReadExistingSignatureAsync(signaturePath, cancellationToken); + if (options.MergeWithExisting && !string.IsNullOrWhiteSpace(existingSignaturePath) && previousBytes is null) + throw new IOException("Выбранный файл подписи больше не существует. Повторите поиск подписей."); var inputPaths = new List(); void AddInputPath(string? path) @@ -235,7 +227,7 @@ void AddInputPath(string? path) if (options.MergeWithExisting) { if (!string.IsNullOrWhiteSpace(existingSignaturePath)) - AddInputPath(existingSignaturePath); + inputPaths.Add(existingSignaturePath); else AddInputPath(canonicalSignaturePath); } @@ -245,20 +237,25 @@ void AddInputPath(string? path) var inputs = new List(); foreach (var path in inputPaths) - inputs.Add(await File.ReadAllBytesAsync(path, cancellationToken)); - inputs.Add(own); // своя — последней: при совпадении подписанта она побеждает + inputs.Add(SignatureDiscovery.SamePath(path, signaturePath) && previousBytes is not null + ? previousBytes : await File.ReadAllBytesAsync(path, cancellationToken)); var appended = inputPaths.Any(path => string.Equals( Path.GetFullPath(path), Path.GetFullPath(signaturePath), StringComparison.OrdinalIgnoreCase)); - // Снимок того, что вошло в объединение. Перед записью файл читается ещё раз: - // за время PIN-кода или пакетного подписания его могли обновить. - byte[]? previousBytes = null; - var previousSignerCount = 0; - if (appended) + var previousSignerCount = appended && previousBytes is not null ? CmsMerger.CountSigners(previousBytes) : 0; + options.AfterSignatureInputsRead?.Invoke(); + + var own = Sign(data, certificate, options.Detached); + if (options.Timestamp) { - previousBytes = await File.ReadAllBytesAsync(signaturePath, cancellationToken); - previousSignerCount = CmsMerger.CountSigners(previousBytes); + if (string.IsNullOrWhiteSpace(options.TsaUrl)) + throw new InvalidOperationException( + "Для подписи со штампом времени укажите адрес службы штампов времени (TSA)."); + var token = await _timestampClient.RequestTokenAsync( + CmsMerger.GetSignatureValue(own), IsGost(certificate), options.TsaUrl, cancellationToken); + own = CmsMerger.AddUnsignedAttribute(own, TimestampClient.TimeStampTokenOid, token); } + inputs.Add(own); // своя — последней: при совпадении подписанта она побеждает int signerCount; IReadOnlyList excluded; @@ -291,16 +288,17 @@ void AddInputPath(string? path) // Галочка добавляет копию и при обычном дописывании. Если файл успели // изменить после чтения — не пишем ничего, в том числе файлы МЧД. string? backupPath = null; - if (File.Exists(signaturePath)) + var latest = await ReadExistingSignatureAsync(signaturePath, cancellationToken); + ThrowIfSignatureChanged(previousBytes, latest); + if (latest is not null) { - var latest = await File.ReadAllBytesAsync(signaturePath, cancellationToken); - ThrowIfSignatureChanged(previousBytes, latest); var riskyOverwrite = !appended || excluded.Count > 0 || LosesEmbeddedDocument(latest, output); if (riskyOverwrite || options.CreateBackup) backupPath = SignatureBackup.Create(signaturePath); } if (poaPackage is not null) PowerOfAttorneyService.CopyPackage(poaPackage, targetPath); - await AtomicFile.WriteAsync(signaturePath, output, overwrite: true, cancellationToken); + await AtomicFile.WriteAsync(signaturePath, output, overwrite: previousBytes is not null, cancellationToken, + beforeCommit: () => ThrowIfSignatureChanged(previousBytes, ReadExistingSignature(signaturePath))); // Режим «копия отдельно»: штампованная копия БЕЗ подписи с подписантами // итогового .sig — пересоздаётся после каждого подписания. Плашки — только @@ -341,13 +339,27 @@ void AddInputPath(string? path) /// Отказ от записи, если файл подписи сменился после того, как его прочитали /// для объединения. Иначе более новая чужая подпись была бы затёрта нашей копией. /// - internal static void ThrowIfSignatureChanged(byte[]? snapshot, byte[] latest) + internal static void ThrowIfSignatureChanged(byte[]? snapshot, byte[]? latest) { - if (snapshot is not null && !snapshot.AsSpan().SequenceEqual(latest)) + if (snapshot is null ? latest is not null : latest is null || !snapshot.AsSpan().SequenceEqual(latest)) throw new IOException( "Файл подписи изменился во время подписания. Подпись не сохранена; повторите операцию."); } + private static async Task ReadExistingSignatureAsync(string path, CancellationToken cancellationToken) + { + try { return await File.ReadAllBytesAsync(path, cancellationToken); } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + } + + private static byte[]? ReadExistingSignature(string path) + { + try { return File.ReadAllBytes(path); } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + } + /// /// true, если в существующей прикреплённой подписи есть документ, а в новой /// записи его не будет или он станет другим. Сами значения подписей при этом diff --git a/src/SignService/Services/DocumentStamper.cs b/src/SignService/Services/DocumentStamper.cs index a48c41c..806e9c4 100644 --- a/src/SignService/Services/DocumentStamper.cs +++ b/src/SignService/Services/DocumentStamper.cs @@ -20,17 +20,35 @@ public static string StampedCopyPath(string filePath) => WordStamper.IsWord(filePath) ? WordStamper.StampedCopyPath(filePath) : PdfStamper.StampedCopyPath(filePath); /// - /// Отказ, если у копии со штампом есть своя подпись («… (со штампом).pdf.sig»): + /// Отказ, если у копии со штампом есть своя подпись (в том числе с другим именем): /// новая копия — другой файл, и та подпись перестала бы ему соответствовать. /// public static void ThrowIfCopySigned(string filePath) { var copy = StampedCopyPath(filePath); - if (!File.Exists(copy + ".sig")) + var signature = new[] { ".sig", ".p7s", ".p7m" } + .Select(extension => copy + extension).FirstOrDefault(File.Exists); + if (signature is null && File.Exists(copy)) + { + var digests = new DocumentDigests(File.ReadAllBytes(copy)); + var directory = Path.GetDirectoryName(Path.GetFullPath(copy))!; + foreach (var candidate in Directory.EnumerateFiles(directory).Where(SignatureDiscovery.IsSignatureFile)) + { + var report = SignatureVerifier.VerifyWithDigests(File.ReadAllBytes(candidate), digests, + VerificationOptions.CryptographyOnly); + // Не затираем и при неопределённой криптопроверке, если хеш документа совпал. + if (!report.Signers.Any(s => s.Document.State == VerificationState.Valid + && s.Signature.State != VerificationState.Invalid)) + continue; + signature = candidate; + break; + } + } + if (signature is null) return; var name = Path.GetFileName(copy); throw new InvalidOperationException( - $"Копия «{name}» уже подписана («{name}.sig»), новая копия со штампом сделала бы эту подпись " + + $"Копия «{name}» уже подписана («{Path.GetFileName(signature)}»), новая копия со штампом сделала бы эту подпись " + $"недействительной. Чтобы добавить подпись к копии, подпишите сам файл «{name}» без штампа; " + "чтобы сделать копию заново, удалите или переименуйте её файл подписи."); } diff --git a/src/SignService/Services/PdfStamper.cs b/src/SignService/Services/PdfStamper.cs index 81b4d7a..502bf76 100644 --- a/src/SignService/Services/PdfStamper.cs +++ b/src/SignService/Services/PdfStamper.cs @@ -121,7 +121,8 @@ public static string CreateStampedCopy(string pdfPath, IReadOnlyList DocumentStamper.ThrowIfCopySigned(pdfPath)); return outputPath; } diff --git a/src/SignService/Services/WordStamper.cs b/src/SignService/Services/WordStamper.cs index 862dbb1..53d7f5f 100644 --- a/src/SignService/Services/WordStamper.cs +++ b/src/SignService/Services/WordStamper.cs @@ -95,7 +95,8 @@ public static string CreateStampedCopy( } } - AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true); + AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true, + beforeCommit: () => DocumentStamper.ThrowIfCopySigned(docPath)); return outputPath; } diff --git a/src/SignService/SignService.csproj b/src/SignService/SignService.csproj index 1553bc3..c85d6a0 100644 --- a/src/SignService/SignService.csproj +++ b/src/SignService/SignService.csproj @@ -10,7 +10,7 @@ true SignService — подписание документов ЭЦП SignService - 1.4.0 + 1.4.1 Зайнуллин Марат Илгамович Зайнуллин Марат Илгамович © 2026 Зайнуллин Марат Илгамович, +7-963-694-2461, zaynullinmi@gmail.com diff --git a/tests/SignService.Tests/Program.cs b/tests/SignService.Tests/Program.cs index 6f22ce3..2e7579a 100644 --- a/tests/SignService.Tests/Program.cs +++ b/tests/SignService.Tests/Program.cs @@ -828,6 +828,7 @@ await File.WriteAllTextAsync(expiredXmlPath, await ReviewRegressionTests.RunAsync(tempRoot, signer); await CoSignTests.RunAsync(tempRoot, signer); await WordStampTests.RunAsync(tempRoot, signer); +await PublicationSafetyTests.RunAsync(tempRoot, signer); UiSmokeTests.Run(tempRoot); try { Directory.Delete(tempRoot, true); } catch { } Console.WriteLine("ALL TESTS PASSED"); diff --git a/tests/SignService.Tests/PublicationSafetyTests.cs b/tests/SignService.Tests/PublicationSafetyTests.cs new file mode 100644 index 0000000..a6a8537 --- /dev/null +++ b/tests/SignService.Tests/PublicationSafetyTests.cs @@ -0,0 +1,194 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.Pkcs; +using System.Security.Cryptography.X509Certificates; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Org.BouncyCastle.Cms; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using SignService.Services; + +internal static class PublicationSafetyTests +{ + public static async Task RunAsync(string tempRoot, DocumentSigner signer) + { + var root = Path.Combine(tempRoot, "publication-safety"); + Directory.CreateDirectory(root); + using var a = Certificate("Original signer"); + using var b = Certificate("Concurrent signer"); + using var c = Certificate("Our signer"); + var data = "publication regression"u8.ToArray(); + var original = signer.Sign(data, a); + var newer = CmsMerger.Merge(new[] { original, signer.Sign(data, b) }); + + foreach (var append in new[] { false, true }) + { + var dir = Directory.CreateDirectory(Path.Combine(root, append ? "append" : "replace")).FullName; + var document = Path.Combine(dir, "document.txt"); + var signature = document + ".sig"; + await File.WriteAllBytesAsync(document, data); + await File.WriteAllBytesAsync(signature, original); + await ExpectChanged(() => signer.SignFileAsync(document, c, new DocumentSigner.SignOptions + { + MergeWithExisting = append, + AfterSignatureInputsRead = () => File.WriteAllBytes(signature, newer), + })); + Assert(File.ReadAllBytes(signature).SequenceEqual(newer), "the actual input snapshot must protect a concurrent signer"); + Assert(Directory.GetFiles(dir, "*.bak").Length == 0, "aborting must not produce a misleading backup"); + + await File.WriteAllBytesAsync(signature, original); + await ExpectChanged(() => signer.SignFileAsync(document, c, new DocumentSigner.SignOptions + { + MergeWithExisting = append, BeforePublish = () => File.Delete(signature), + })); + Assert(!File.Exists(signature), "an external deletion must not be undone"); + + var ok = await signer.SignFileAsync(document, c, new DocumentSigner.SignOptions { MergeWithExisting = append }); + Assert(signer.VerifyDetached(data, File.ReadAllBytes(ok.SignaturePath)), "normal creation still works"); + var replacement = await signer.SignFileAsync(document, a, new DocumentSigner.SignOptions { MergeWithExisting = append }); + Assert(replacement.SignerCount == (append ? 2 : 1), "normal append/replacement still works"); + Assert(append || replacement.BackupPath is not null, "intentional replacement retains its backup"); + } + + var appearingDoc = Path.Combine(root, "appearing.txt"); + File.WriteAllBytes(appearingDoc, data); + await ExpectChanged(() => signer.SignFileAsync(appearingDoc, c, new DocumentSigner.SignOptions + { + BeforePublish = () => File.WriteAllBytes(appearingDoc + ".sig", newer), + })); + Assert(File.ReadAllBytes(appearingDoc + ".sig").SequenceEqual(newer), "a newly appeared destination must survive"); + + var missing = Path.Combine(root, "missing-signature.p7s"); + await ExpectIo(() => signer.SignFileAsync(appearingDoc, c, new DocumentSigner.SignOptions + { + MergeWithExisting = true, ExistingSignaturePath = missing, + })); + Assert(!File.Exists(missing), "a missing selected file must not silently redirect publication"); + + var staged = Path.Combine(root, "staged.sig"); + File.WriteAllBytes(staged, original); + await ExpectChanged(() => AtomicFile.WriteAsync(staged, signer.Sign(data, c), beforeCommit: () => + { + File.WriteAllBytes(staged, newer); + DocumentSigner.ThrowIfSignatureChanged(original, File.ReadAllBytes(staged)); + })); + Assert(File.ReadAllBytes(staged).SequenceEqual(newer), "last check must run after staging and before replacement"); + Assert(!Directory.EnumerateFiles(root, ".signservice-*.tmp").Any(), "cancelled publication cleans its staging file"); + Console.WriteLine("publication: one input snapshot, append/replace changes, deletion, appearance and staging guard: OK"); + + foreach (var extension in new[] { ".docx", ".pdf" }) + foreach (var variant in new[] { ".sig", ".p7s", ".p7m", "odd", "direct", "missing-cert", "unrelated" }) + { + var dir = Directory.CreateDirectory(Path.Combine(root, extension[1..] + "-" + variant.Replace('.', '-'))).FullName; + var document = Path.Combine(dir, "document" + extension); + CreateDocument(document); + var source = File.ReadAllBytes(document); + var copy = DocumentStamper.CreateStampedCopy(document, new[] { a }, new PdfStamper.StampOptions(), new DateTime(2025, 1, 1)); + var snapshot = File.ReadAllBytes(copy); + var signaturePath = variant.StartsWith('.') ? copy + variant : Path.Combine(dir, "подпись с другим именем.p7s"); + var signed = variant switch + { + "direct" => DirectSignature(snapshot, a), + "missing-cert" => WithoutCertificate(snapshot, a), + "unrelated" => signer.Sign("different document"u8.ToArray(), a), + _ => signer.Sign(snapshot, a), + }; + File.WriteAllBytes(signaturePath, signed); + if (variant == "unrelated") + { + DocumentStamper.CreateStampedCopy(document, new[] { b }, new PdfStamper.StampOptions(), DateTime.Now); + Assert(!File.ReadAllBytes(copy).SequenceEqual(snapshot), "an unrelated signature must not prevent a fresh copy"); + continue; + } + + ExpectSignedCopy(() => DocumentStamper.CreateStampedCopy(document, new[] { b }, new PdfStamper.StampOptions(), DateTime.Now)); + await ExpectSignedCopyAsync(() => signer.SignFileAsync(document, b, new DocumentSigner.SignOptions { Stamp = true })); + await ExpectSignedCopyAsync(() => signer.SignFileAsync(document, b, new DocumentSigner.SignOptions { Stamp = true, StampSignCopy = true })); + // Direct callers of the format stampers must also be protected at commit time. + ExpectSignedCopy(() => extension == ".pdf" + ? PdfStamper.CreateStampedCopy(document, new[] { b }, new PdfStamper.StampOptions(), DateTime.Now) + : WordStamper.CreateStampedCopy(document, new[] { b }, new PdfStamper.StampOptions(), DateTime.Now)); + Assert(File.ReadAllBytes(copy).SequenceEqual(snapshot) && File.ReadAllBytes(document).SequenceEqual(source), + "guarded stamping must preserve both documents"); + Assert(File.ReadAllBytes(signaturePath).SequenceEqual(signed), "guarded stamping must preserve the original signature"); + Assert(!File.Exists(document + ".sig"), "preflight must stop signing before creating a new signature"); + } + Console.WriteLine("publication: PDF/Word signed copies protected for sig/p7s/p7m, odd names, direct CMS and missing certificates: OK"); + } + + private static byte[] DirectSignature(byte[] data, X509Certificate2 certificate) + { + using var rsa = certificate.GetRSAPrivateKey()!; + var key = DotNetUtilities.GetRsaKeyPair(rsa); + var cert = new X509CertificateParser().ReadCertificate(certificate.RawData); + var generator = new CmsSignedDataGenerator(); + generator.AddSignerInfoGenerator(new SignerInfoGeneratorBuilder().SetDirectSignature(true) + .Build(new Asn1SignatureFactory("SHA256WITHRSA", key.Private), cert)); + generator.AddCertificates(Org.BouncyCastle.Utilities.Collections.CollectionUtilities.CreateStore(new[] { cert })); + return generator.Generate(new CmsProcessableByteArray(data), false).GetEncoded(); + } + + private static byte[] WithoutCertificate(byte[] data, X509Certificate2 certificate) + { + var cms = new SignedCms(new ContentInfo(data), true); + var signer = new CmsSigner(certificate) { IncludeOption = X509IncludeOption.None }; + signer.SignedAttributes.Add(new Pkcs9SigningTime()); + cms.ComputeSignature(signer); + return cms.Encode(); + } + + private static void CreateDocument(string path) + { + if (path.EndsWith(".pdf", StringComparison.Ordinal)) + { + using var pdf = new PdfSharp.Pdf.PdfDocument(); + pdf.AddPage(); + pdf.Save(path); + return; + } + using var document = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document); + document.AddMainDocumentPart().Document = new Document(new Body(new Paragraph(new Run(new Text("Original content"))), new SectionProperties())); + } + + private static X509Certificate2 Certificate(string name) + { + using var key = RSA.Create(2048); + return new CertificateRequest("CN=" + name, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) + .CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddYears(1)); + } + + private static async Task ExpectChanged(Func operation) + { + try { await operation(); } + catch (IOException e) when (e.Message.Contains("изменился")) { return; } + throw new Exception("Concurrent publication must be rejected"); + } + + private static async Task ExpectIo(Func operation) + { + try { await operation(); } + catch (IOException) { return; } + throw new Exception("Missing input must be rejected"); + } + + private static void ExpectSignedCopy(Func operation) + { + try { operation(); } + catch (InvalidOperationException e) when (e.Message.Contains("уже подписана")) { return; } + throw new Exception("Signed copy must be protected"); + } + + private static async Task ExpectSignedCopyAsync(Func operation) + { + try { await operation(); } + catch (InvalidOperationException e) when (e.Message.Contains("уже подписана")) { return; } + throw new Exception("Signing must preserve the signed copy"); + } + + private static void Assert(bool condition, string message) + { + if (!condition) throw new Exception("Publication regression: " + message); + } +}