diff --git a/Azure.Core.TestCommon/AsyncGate.cs b/Azure.Core.TestCommon/AsyncGate.cs new file mode 100644 index 00000000..c96961a6 --- /dev/null +++ b/Azure.Core.TestCommon/AsyncGate.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// A gate for coordinating async operations in tests. +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public class AsyncGate +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + private readonly object _sync = new(); + private TaskCompletionSource _signalTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _releaseTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Waits for a signal with the default timeout. + /// + public Task WaitForSignal() + { + return TimeoutAfter(_signalTaskCompletionSource.Task, DefaultTimeout); + } + + /// + /// Cycles through waiting for signal and releasing. + /// + public async Task Cycle(TOut value = default!) + { + var signal = await WaitForSignal(); + Release(value); + return signal; + } + + /// + /// Cycles through waiting for signal and releasing with an exception. + /// + public async Task CycleWithException(Exception exception) + { + var signal = await WaitForSignal(); + ReleaseWithException(exception); + return signal; + } + + /// + /// Releases the gate with a value. + /// + public void Release(TOut value = default!) + { + lock (_sync) + { + Reset().SetResult(value); + } + } + + /// + /// Releases the gate with an exception. + /// + public void ReleaseWithException(Exception exception) + { + lock (_sync) + { + Reset().SetException(exception); + } + } + + private TaskCompletionSource Reset() + { + lock (_sync) + { + if (!_signalTaskCompletionSource.Task.IsCompleted) + { + throw new InvalidOperationException("No await call to release"); + } + + var releaseTaskCompletionSource = _releaseTaskCompletionSource; + _releaseTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _signalTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return releaseTaskCompletionSource; + } + } + + /// + /// Waits for the gate to be released. + /// + public Task WaitForRelease(TIn value = default!) + { + lock (_sync) + { + _signalTaskCompletionSource.SetResult(value); + return TimeoutAfter(_releaseTaskCompletionSource.Task, DefaultTimeout); + } + } + + private static async Task TimeoutAfter(Task task, TimeSpan timeout) + { + if (task.IsCompleted || Debugger.IsAttached) + { + return await task; + } + + using var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + await cts.CancelAsync(); + return await task; + } + + throw new TimeoutException($"Operation timed out after {timeout}"); + } +} +#pragma warning restore CS1591 diff --git a/Azure.Core.TestCommon/AsyncValidatingStream.cs b/Azure.Core.TestCommon/AsyncValidatingStream.cs new file mode 100644 index 00000000..10c22435 --- /dev/null +++ b/Azure.Core.TestCommon/AsyncValidatingStream.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// A stream wrapper that validates sync/async usage consistency. +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +internal class AsyncValidatingStream : Stream +{ + private readonly bool _isAsync; + private readonly Stream _innerStream; + + public AsyncValidatingStream(bool isAsync, Stream innerStream) + { + _isAsync = isAsync; + _innerStream = innerStream; + } + + public override void Flush() + { + Validate(false); + _innerStream.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + Validate(true); + return _innerStream.FlushAsync(cancellationToken); + } + + private void Validate(bool isAsync) + { + if (isAsync != _isAsync) + { + throw new InvalidOperationException( + $"All stream calls were expected to be {(_isAsync ? "async" : "sync")} but were {(isAsync ? "async" : "sync")}"); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + Validate(false); + return _innerStream.Read(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + Validate(true); + return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _innerStream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + Validate(false); + _innerStream.Write(buffer, offset, count); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + Validate(true); + return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + Validate(true); + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override void Close() + { + _innerStream.Close(); + } + + public override bool CanRead => _innerStream.CanRead; + public override bool CanSeek => _innerStream.CanSeek; + public override bool CanWrite => _innerStream.CanWrite; + public override long Length => _innerStream.Length; + public override long Position + { + get => _innerStream.Position; + set => _innerStream.Position = value; + } +} +#pragma warning restore CS1591 diff --git a/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj b/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj new file mode 100644 index 00000000..c4a4badf --- /dev/null +++ b/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj @@ -0,0 +1,25 @@ + + + + + + net8.0 + enable + enable + false + + + + + + + diff --git a/Azure.Core.TestCommon/DictionaryHeaders.cs b/Azure.Core.TestCommon/DictionaryHeaders.cs new file mode 100644 index 00000000..43135dd5 --- /dev/null +++ b/Azure.Core.TestCommon/DictionaryHeaders.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// An implementation for manipulating headers on Request. +/// +[ExcludeFromCodeCoverage] +internal class DictionaryHeaders +{ + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds a header value to the header collection. + /// + public void AddHeader(string name, string value) + { + if (!_headers.TryGetValue(name, out object? objValue)) + { + _headers[name] = value; + } + else + { + if (objValue is List values) + { + values.Add(value); + } + else + { + _headers[name] = new List { (objValue as string)!, value }; + } + } + } + + /// + /// Sets a header value, replacing any existing values. + /// + public void SetHeader(string name, string value) + { + _headers[name] = value; + } + + /// + /// Returns header value if the header is stored in the collection. + /// + public bool TryGetHeader(string name, out string? value) + { + if (_headers.TryGetValue(name, out object? objValue)) + { + value = objValue is List values ? JoinHeaderValue(values) : objValue as string; + return true; + } + + value = null; + return false; + } + + /// + /// Returns header values if the header is stored in the collection. + /// + public bool TryGetHeaderValues(string name, out IEnumerable? values) + { + if (_headers.TryGetValue(name, out object? objValue)) + { + values = objValue is List valuesList + ? valuesList + : new List { (objValue as string)! }; + return true; + } + + values = null; + return false; + } + + /// + /// Removes a header from the collection. + /// + public bool RemoveHeader(string name) + { + return _headers.Remove(name); + } + + /// + /// Enumerates all headers in the collection. + /// + public IEnumerable EnumerateHeaders() + { + foreach (var kvp in _headers) + { + if (kvp.Value is List values) + { + yield return new HttpHeader(kvp.Key, JoinHeaderValue(values)); + } + else + { + yield return new HttpHeader(kvp.Key, (kvp.Value as string)!); + } + } + } + + private static string JoinHeaderValue(IEnumerable values) => string.Join(",", values); +} diff --git a/Azure.Core.TestCommon/MockRequest.cs b/Azure.Core.TestCommon/MockRequest.cs new file mode 100644 index 00000000..5cf0e74a --- /dev/null +++ b/Azure.Core.TestCommon/MockRequest.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// Mock HTTP request for testing Azure SDK clients. +/// +/// +/// From https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/Azure.Core.TestFramework/src/MockRequest.cs +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public sealed class MockRequest : Request +{ + private readonly DictionaryHeaders _headers = new(); + private string _clientRequestId = Guid.NewGuid().ToString(); + + /// + /// Gets whether this request has been disposed. + /// + public bool IsDisposed { get; private set; } + + /// + public override RequestContent? Content + { + get => base.Content; + set => base.Content = value; + } + + /// + protected override void SetHeader(string name, string value) => _headers.SetHeader(name, value); + + /// + protected override void AddHeader(string name, string value) => _headers.AddHeader(name, value); + + /// + protected override bool TryGetHeader(string name, [NotNullWhen(true)] out string? value) => _headers.TryGetHeader(name, out value); + + /// + protected override bool TryGetHeaderValues(string name, [NotNullWhen(true)] out IEnumerable? values) => _headers.TryGetHeaderValues(name, out values); + + /// + protected override bool ContainsHeader(string name) => _headers.TryGetHeaderValues(name, out _); + + /// + protected override bool RemoveHeader(string name) => _headers.RemoveHeader(name); + + /// + protected override IEnumerable EnumerateHeaders() => _headers.EnumerateHeaders(); + + /// + public override string ClientRequestId + { + get => _clientRequestId; + set => _clientRequestId = value; + } + + /// + public override string ToString() => $"{Method} {Uri}"; + + /// + public override void Dispose() + { + IsDisposed = true; + } +} +#pragma warning restore CS1591 diff --git a/Azure.Core.TestCommon/MockResponse.cs b/Azure.Core.TestCommon/MockResponse.cs new file mode 100644 index 00000000..2df5b595 --- /dev/null +++ b/Azure.Core.TestCommon/MockResponse.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Azure.Core.TestCommon; + +/// +/// Mock HTTP response for testing Azure SDK clients. +/// +/// +/// From https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/Azure.Core.TestFramework/src/MockResponse.cs +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public sealed class MockResponse : Response +{ + private readonly Dictionary> _headers = new(StringComparer.OrdinalIgnoreCase); + private readonly int _status; + private readonly string _reasonPhrase; + private Stream? _contentStream; + private string _clientRequestId; + private bool? _isError; + + /// + /// Creates a new instance with the specified status code. + /// + public MockResponse(int status, string? reasonPhrase = null) + { + _status = status; + _reasonPhrase = reasonPhrase ?? string.Empty; + _contentStream = new MemoryStream(); + _clientRequestId = Guid.NewGuid().ToString(); + } + + /// + public override int Status => _status; + + /// + public override string ReasonPhrase => _reasonPhrase; + + /// + public override Stream? ContentStream + { + get => _contentStream; + set => _contentStream = value; + } + + /// + public override string ClientRequestId + { + get => _clientRequestId; + set => _clientRequestId = value; + } + + /// + public override bool IsError => _isError ?? base.IsError; + + /// + /// Gets whether this response has been disposed. + /// + public bool IsDisposed { get; private set; } + + /// + /// Sets the IsError value explicitly. + /// + public void SetIsError(bool value) => _isError = value; + + /// + /// Sets the response content from a byte array. + /// + public void SetContent(byte[] content) + { + ContentStream = new MemoryStream(content, 0, content.Length, false, true); + } + + /// + /// Sets the response content from a string. + /// + public MockResponse SetContent(string content) + { + SetContent(Encoding.UTF8.GetBytes(content)); + return this; + } + + /// + /// Adds a header to the response. + /// + public MockResponse AddHeader(string name, string value) + { + return AddHeader(new HttpHeader(name, value)); + } + + /// + /// Adds a header to the response. + /// + public MockResponse AddHeader(HttpHeader header) + { + if (!_headers.TryGetValue(header.Name, out List? values)) + { + _headers[header.Name] = values = new List(); + } + + values.Add(header.Value); + return this; + } + + /// + protected override bool TryGetHeader(string name, [NotNullWhen(true)] out string? value) + { + if (_headers.TryGetValue(name, out List? values)) + { + value = JoinHeaderValue(values); + return true; + } + + value = null; + return false; + } + + /// + protected override bool TryGetHeaderValues(string name, [NotNullWhen(true)] out IEnumerable? values) + { + bool result = _headers.TryGetValue(name, out List? valuesList); + values = valuesList; + return result; + } + + /// + protected override bool ContainsHeader(string name) + { + return TryGetHeaderValues(name, out _); + } + + /// + protected override IEnumerable EnumerateHeaders() + => _headers.Select(h => new HttpHeader(h.Key, JoinHeaderValue(h.Value))); + + /// + public override void Dispose() + { + IsDisposed = true; + GC.SuppressFinalize(this); + } + + private static string JoinHeaderValue(IEnumerable values) => string.Join(",", values); +} +#pragma warning restore CS1591 diff --git a/Azure.Core.TestCommon/MockTransport.cs b/Azure.Core.TestCommon/MockTransport.cs new file mode 100644 index 00000000..4af03818 --- /dev/null +++ b/Azure.Core.TestCommon/MockTransport.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core.Pipeline; +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// Mock HTTP pipeline transport for testing Azure SDK clients. +/// +/// +/// From https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/Azure.Core.TestFramework/src/MockTransport.cs +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public class MockTransport : HttpPipelineTransport +{ + private readonly object _syncObj = new(); + private readonly Func? _responseFactory; + + /// + /// Gets the async gate for controlling request/response flow in tests. + /// + public AsyncGate? RequestGate { get; } + + /// + /// Gets the list of requests made through this transport. + /// + public List Requests { get; } = new(); + + /// + /// Gets or sets whether to expect sync pipeline operations. + /// + public bool? ExpectSyncPipeline { get; set; } + + /// + /// Creates a new instance using the async gate pattern. + /// + public MockTransport() + { + RequestGate = new AsyncGate(); + } + + /// + /// Creates a new instance with a sequence of canned responses. + /// + public MockTransport(params MockResponse[] responses) + { + int requestIndex = 0; + _responseFactory = _ => + { + lock (_syncObj) + { + return responses[requestIndex++]; + } + }; + } + + /// + /// Creates a new instance with a response factory function. + /// + public MockTransport(Func responseFactory) + : this(req => responseFactory((MockRequest)req.Request)) + { + } + + private MockTransport(Func responseFactory) + { + _responseFactory = responseFactory; + } + + /// + /// Creates a mock transport from a message callback function. + /// + public static MockTransport FromMessageCallback(Func responseFactory) + => new(responseFactory); + + /// + public override Request CreateRequest() => new MockRequest(); + + /// + public override void Process(HttpMessage message) + { + if (ExpectSyncPipeline == false) + { + throw new InvalidOperationException("Sync pipeline invocation not expected"); + } + + ProcessCore(message).GetAwaiter().GetResult(); + } + + /// + public override async ValueTask ProcessAsync(HttpMessage message) + { + if (ExpectSyncPipeline == true) + { + throw new InvalidOperationException("Async pipeline invocation not expected"); + } + + await ProcessCore(message); + } + + private async Task ProcessCore(HttpMessage message) + { + if (message.Request is not MockRequest request) + { + throw new InvalidOperationException("The request is not compatible with the transport"); + } + + message.Response = null!; + + lock (_syncObj) + { + Requests.Add(request); + } + + if (RequestGate != null) + { + message.Response = await RequestGate.WaitForRelease(request); + } + else if (_responseFactory != null) + { + message.Response = _responseFactory(message); + } + + message.Response.ClientRequestId = request.ClientRequestId; + + if (message.Response.ContentStream != null && ExpectSyncPipeline != null) + { + message.Response.ContentStream = new AsyncValidatingStream(!ExpectSyncPipeline.Value, message.Response.ContentStream); + } + } + + /// + /// Gets the single request made through this transport. + /// + public MockRequest SingleRequest + { + get + { + lock (_syncObj) + { + return Requests.Single(); + } + } + } +} +#pragma warning restore CS1591 diff --git a/CHANGELOG.md b/CHANGELOG.md index 491be8aa..915130e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [v1.7.2](https://github.com/microsoft/CoseSignTool/tree/v1.7.2) (2026-02-17) + +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.7.2-pre1...v1.7.2) + +## [v1.7.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.7.2-pre1) (2026-02-17) + +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.7.1...v1.7.2-pre1) + +**Merged pull requests:** + +- Fix release workflow: restore plugin projects before publish [\#165](https://github.com/microsoft/CoseSignTool/pull/165) ([JeromySt](https://github.com/JeromySt)) + ## [v1.7.1](https://github.com/microsoft/CoseSignTool/tree/v1.7.1) (2026-02-14) [Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.7.0...v1.7.1) @@ -46,24 +58,24 @@ ## [v1.6.6](https://github.com/microsoft/CoseSignTool/tree/v1.6.6) (2025-11-25) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.3...v1.6.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.5...v1.6.6) **Merged pull requests:** - Users/jstatia/azure trusted signing [\#148](https://github.com/microsoft/CoseSignTool/pull/148) ([JeromySt](https://github.com/JeromySt)) - Users/jstatia/package upgrades [\#145](https://github.com/microsoft/CoseSignTool/pull/145) ([JeromySt](https://github.com/JeromySt)) -## [v1.6.3](https://github.com/microsoft/CoseSignTool/tree/v1.6.3) (2025-08-05) +## [v1.6.5](https://github.com/microsoft/CoseSignTool/tree/v1.6.5) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.4...v1.6.3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.4...v1.6.5) ## [v1.6.4](https://github.com/microsoft/CoseSignTool/tree/v1.6.4) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.5...v1.6.4) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.3...v1.6.4) -## [v1.6.5](https://github.com/microsoft/CoseSignTool/tree/v1.6.5) (2025-08-05) +## [v1.6.3](https://github.com/microsoft/CoseSignTool/tree/v1.6.3) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre4...v1.6.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre4...v1.6.3) ## [v1.5.4-pre4](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre4) (2025-08-05) @@ -108,20 +120,20 @@ ## [v1.5.7](https://github.com/microsoft/CoseSignTool/tree/v1.5.7) (2025-07-17) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v.1.5.5...v1.5.7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.6...v1.5.7) **Merged pull requests:** - Enhance PFX certificate handling in SignCommand and update documentation [\#138](https://github.com/microsoft/CoseSignTool/pull/138) ([JeromySt](https://github.com/JeromySt)) - Migrate from VSTest to MTP [\#124](https://github.com/microsoft/CoseSignTool/pull/124) ([Youssef1313](https://github.com/Youssef1313)) -## [v.1.5.5](https://github.com/microsoft/CoseSignTool/tree/v.1.5.5) (2025-07-15) +## [v1.5.6](https://github.com/microsoft/CoseSignTool/tree/v1.5.6) (2025-07-15) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.6...v.1.5.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v.1.5.5...v1.5.6) -## [v1.5.6](https://github.com/microsoft/CoseSignTool/tree/v1.5.6) (2025-07-15) +## [v.1.5.5](https://github.com/microsoft/CoseSignTool/tree/v.1.5.5) (2025-07-15) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre1...v1.5.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre1...v.1.5.5) ## [v1.5.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre1) (2025-07-15) @@ -145,19 +157,19 @@ ## [v1.5.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.3-pre1) (2025-06-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.2-pre1...v1.5.3-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.3...v1.5.3-pre1) **Merged pull requests:** - Add unit tests for CoseSign1MessageExtensions and enhance Certificate… [\#133](https://github.com/microsoft/CoseSignTool/pull/133) ([JeromySt](https://github.com/JeromySt)) -## [v1.5.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.2-pre1) (2025-05-30) +## [v1.5.3](https://github.com/microsoft/CoseSignTool/tree/v1.5.3) (2025-05-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.3...v1.5.2-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.2-pre1...v1.5.3) -## [v1.5.3](https://github.com/microsoft/CoseSignTool/tree/v1.5.3) (2025-05-30) +## [v1.5.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.2-pre1) (2025-05-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.2...v1.5.3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.2...v1.5.2-pre1) **Merged pull requests:** @@ -169,43 +181,43 @@ ## [v1.5.1-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.1-pre1) (2025-05-29) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.1...v1.5.1-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.0-pre1...v1.5.1-pre1) **Merged pull requests:** - feat: Add header extender support to IndirectSignatureFactory [\#130](https://github.com/microsoft/CoseSignTool/pull/130) ([JeromySt](https://github.com/JeromySt)) -## [v1.5.1](https://github.com/microsoft/CoseSignTool/tree/v1.5.1) (2025-05-07) +## [v1.5.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.0-pre1) (2025-05-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.0-pre1...v1.5.1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.1...v1.5.0-pre1) -## [v1.5.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.0-pre1) (2025-05-07) +## [v1.5.1](https://github.com/microsoft/CoseSignTool/tree/v1.5.1) (2025-05-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0-pre1...v1.5.0-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.0...v1.5.1) **Merged pull requests:** - Allow beta version of Azure.Security.CodeTransparency [\#129](https://github.com/microsoft/CoseSignTool/pull/129) ([lemccomb](https://github.com/lemccomb)) -## [v1.4.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.4.0-pre1) (2025-04-28) +## [v1.5.0](https://github.com/microsoft/CoseSignTool/tree/v1.5.0) (2025-04-28) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.0...v1.4.0-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0-pre1...v1.5.0) -## [v1.5.0](https://github.com/microsoft/CoseSignTool/tree/v1.5.0) (2025-04-28) +## [v1.4.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.4.0-pre1) (2025-04-28) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0...v1.5.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre5...v1.4.0-pre1) **Merged pull requests:** - Added support for Transparency to CoseSign1 libraries to leverage services such as Azure Code Transparency Service [\#127](https://github.com/microsoft/CoseSignTool/pull/127) ([JeromySt](https://github.com/JeromySt)) -## [v1.4.0](https://github.com/microsoft/CoseSignTool/tree/v1.4.0) (2025-03-18) +## [v1.3.0-pre5](https://github.com/microsoft/CoseSignTool/tree/v1.3.0-pre5) (2025-03-18) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre5...v1.4.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0...v1.3.0-pre5) -## [v1.3.0-pre5](https://github.com/microsoft/CoseSignTool/tree/v1.3.0-pre5) (2025-03-18) +## [v1.4.0](https://github.com/microsoft/CoseSignTool/tree/v1.4.0) (2025-03-18) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre4...v1.3.0-pre5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre4...v1.4.0) **Merged pull requests:** @@ -249,19 +261,19 @@ ## [v1.2.8-pre7](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre7) (2024-10-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0...v1.2.8-pre7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre6...v1.2.8-pre7) **Merged pull requests:** - Adds CLI install instructions [\#116](https://github.com/microsoft/CoseSignTool/pull/116) ([ivarprudnikov](https://github.com/ivarprudnikov)) -## [v1.3.0](https://github.com/microsoft/CoseSignTool/tree/v1.3.0) (2024-10-30) +## [v1.2.8-pre6](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre6) (2024-10-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre6...v1.3.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0...v1.2.8-pre6) -## [v1.2.8-pre6](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre6) (2024-10-30) +## [v1.3.0](https://github.com/microsoft/CoseSignTool/tree/v1.3.0) (2024-10-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre5...v1.2.8-pre6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre5...v1.3.0) **Merged pull requests:** @@ -427,19 +439,19 @@ ## [v1.2.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.3-pre1) (2024-05-31) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.2-pre1...v1.2.3-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.3...v1.2.3-pre1) **Merged pull requests:** - upgrade to .NET 8, add docs to package, add retry loop for revocation server [\#89](https://github.com/microsoft/CoseSignTool/pull/89) ([lemccomb](https://github.com/lemccomb)) -## [v1.2.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.2-pre1) (2024-03-20) +## [v1.2.3](https://github.com/microsoft/CoseSignTool/tree/v1.2.3) (2024-03-20) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.3...v1.2.2-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.2-pre1...v1.2.3) -## [v1.2.3](https://github.com/microsoft/CoseSignTool/tree/v1.2.3) (2024-03-20) +## [v1.2.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.2-pre1) (2024-03-20) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.1-pre2...v1.2.3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.1-pre2...v1.2.2-pre1) **Merged pull requests:** @@ -535,31 +547,31 @@ ## [v1.1.6](https://github.com/microsoft/CoseSignTool/tree/v1.1.6) (2024-02-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4-pre1...v1.1.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.5...v1.1.6) **Merged pull requests:** - Only hit iterator once [\#75](https://github.com/microsoft/CoseSignTool/pull/75) ([JeromySt](https://github.com/JeromySt)) -## [v1.1.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.4-pre1) (2024-01-31) +## [v1.1.5](https://github.com/microsoft/CoseSignTool/tree/v1.1.5) (2024-01-31) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.5...v1.1.4-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4-pre1...v1.1.5) -## [v1.1.5](https://github.com/microsoft/CoseSignTool/tree/v1.1.5) (2024-01-31) +## [v1.1.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.4-pre1) (2024-01-31) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4...v1.1.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3-pre1...v1.1.4-pre1) **Merged pull requests:** - write validation output to standard out [\#74](https://github.com/microsoft/CoseSignTool/pull/74) ([elantiguamsft](https://github.com/elantiguamsft)) -## [v1.1.4](https://github.com/microsoft/CoseSignTool/tree/v1.1.4) (2024-01-26) +## [v1.1.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.3-pre1) (2024-01-26) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3-pre1...v1.1.4) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4...v1.1.3-pre1) -## [v1.1.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.3-pre1) (2024-01-26) +## [v1.1.4](https://github.com/microsoft/CoseSignTool/tree/v1.1.4) (2024-01-26) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3...v1.1.3-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3...v1.1.4) **Merged pull requests:** @@ -591,19 +603,19 @@ ## [v1.1.1-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1-pre1) (2024-01-17) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1...v1.1.1-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre7...v1.1.1-pre1) **Merged pull requests:** - Move CreateChangelog to after build in PR build [\#70](https://github.com/microsoft/CoseSignTool/pull/70) ([lemccomb](https://github.com/lemccomb)) -## [v1.1.1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1) (2024-01-12) +## [v1.1.0-pre7](https://github.com/microsoft/CoseSignTool/tree/v1.1.0-pre7) (2024-01-12) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre7...v1.1.1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1...v1.1.0-pre7) -## [v1.1.0-pre7](https://github.com/microsoft/CoseSignTool/tree/v1.1.0-pre7) (2024-01-12) +## [v1.1.1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1) (2024-01-12) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre6...v1.1.0-pre7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre6...v1.1.1) **Merged pull requests:** @@ -652,7 +664,7 @@ ## [v1.1.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.0-pre1) (2023-11-03) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v0.3.1-pre.10...v1.1.0-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0...v1.1.0-pre1) **Merged pull requests:** @@ -662,13 +674,13 @@ - DetachedSignatureFactory accepts pre-hashed content as payload [\#53](https://github.com/microsoft/CoseSignTool/pull/53) ([elantiguamsft](https://github.com/elantiguamsft)) - Add password support for certificate files [\#52](https://github.com/microsoft/CoseSignTool/pull/52) ([lemccomb](https://github.com/lemccomb)) -## [v0.3.1-pre.10](https://github.com/microsoft/CoseSignTool/tree/v0.3.1-pre.10) (2023-10-10) +## [v1.1.0](https://github.com/microsoft/CoseSignTool/tree/v1.1.0) (2023-10-10) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0...v0.3.1-pre.10) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v0.3.1-pre.10...v1.1.0) -## [v1.1.0](https://github.com/microsoft/CoseSignTool/tree/v1.1.0) (2023-10-10) +## [v0.3.1-pre.10](https://github.com/microsoft/CoseSignTool/tree/v0.3.1-pre.10) (2023-10-10) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v0.3.1-pre.9...v1.1.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v0.3.1-pre.9...v0.3.1-pre.10) **Merged pull requests:** diff --git a/CoseSign1.Transparent.MST.Tests/BinaryDataExtensionsTests.cs b/CoseSign1.Transparent.MST.Tests/BinaryDataExtensionsTests.cs index 71a590bb..a94f1d05 100644 --- a/CoseSign1.Transparent.MST.Tests/BinaryDataExtensionsTests.cs +++ b/CoseSign1.Transparent.MST.Tests/BinaryDataExtensionsTests.cs @@ -4,7 +4,7 @@ namespace CoseSign1.Transparent.MST.Tests; using System.Formats.Cbor; -using CoseSign1.Transparent.MST.Extensions; +using CoseSign1.Transparent.MST; /// /// Unit tests for the class. diff --git a/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs b/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs new file mode 100644 index 00000000..39161e3c --- /dev/null +++ b/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs @@ -0,0 +1,719 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.Tests; + +using System; +using System.Collections.Generic; +using System.Formats.Cbor; +using Azure; +using Azure.Core.TestCommon; +using CoseSign1.Transparent.MST; +using Moq; + +/// +/// Unit tests for and . +/// +[TestFixture] +public class CborProblemDetailsTests +{ + #region CborProblemDetails.TryParse + + [Test] + public void TryParse_ReturnsNull_ForNullInput() + { + Assert.That(CborProblemDetails.TryParse(null!), Is.Null); + } + + [Test] + public void TryParse_ReturnsNull_ForEmptyInput() + { + Assert.That(CborProblemDetails.TryParse(Array.Empty()), Is.Null); + } + + [Test] + public void TryParse_ReturnsNull_ForInvalidCbor() + { + Assert.That(CborProblemDetails.TryParse(new byte[] { 0xFF, 0xFF }), Is.Null); + } + + [Test] + public void TryParse_ReturnsNull_ForNonMapCbor() + { + // Encode a CBOR array instead of a map + var writer = new CborWriter(); + writer.WriteStartArray(1); + writer.WriteTextString("not a map"); + writer.WriteEndArray(); + + Assert.That(CborProblemDetails.TryParse(writer.Encode()), Is.Null); + } + + [Test] + public void TryParse_ParsesIntegerKeys_Rfc9290() + { + // RFC 9290 standard integer keys: -1=type, -2=title, -3=status, -4=detail, -5=instance + var writer = new CborWriter(); + writer.WriteStartMap(5); + writer.WriteInt32(-1); writer.WriteTextString("urn:example:error:bad-request"); + writer.WriteInt32(-2); writer.WriteTextString("Bad Request"); + writer.WriteInt32(-3); writer.WriteInt32(400); + writer.WriteInt32(-4); writer.WriteTextString("The payload is not valid COSE"); + writer.WriteInt32(-5); writer.WriteTextString("/entries/abc-123"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Type, Is.EqualTo("urn:example:error:bad-request")); + Assert.That(details.Title, Is.EqualTo("Bad Request")); + Assert.That(details.Status, Is.EqualTo(400)); + Assert.That(details.Detail, Is.EqualTo("The payload is not valid COSE")); + Assert.That(details.Instance, Is.EqualTo("/entries/abc-123")); + } + + [Test] + public void TryParse_ParsesStringKeys() + { + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteTextString("title"); writer.WriteTextString("Service Unavailable"); + writer.WriteTextString("status"); writer.WriteInt32(503); + writer.WriteTextString("detail"); writer.WriteTextString("Ledger is syncing"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Title, Is.EqualTo("Service Unavailable")); + Assert.That(details.Status, Is.EqualTo(503)); + Assert.That(details.Detail, Is.EqualTo("Ledger is syncing")); + } + + [Test] + public void TryParse_CapturesExtensionFields() + { + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteInt32(-2); writer.WriteTextString("Error"); + writer.WriteInt32(-3); writer.WriteInt32(422); + writer.WriteTextString("retryAfter"); writer.WriteTextString("5s"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Extensions, Is.Not.Null); + Assert.That(details.Extensions!.ContainsKey("retryAfter"), Is.True); + Assert.That(details.Extensions["retryAfter"], Is.EqualTo("5s")); + } + + [Test] + public void TryParse_CapturesUnknownIntegerKeysAsExtensions() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteInt32(500); + writer.WriteInt32(99); writer.WriteTextString("custom-value"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.EqualTo(500)); + Assert.That(details.Extensions, Is.Not.Null); + Assert.That(details.Extensions!["key_99"], Is.EqualTo("custom-value")); + } + + [Test] + public void TryParse_HandlesNullValues() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-2); writer.WriteNull(); + writer.WriteInt32(-3); writer.WriteInt32(404); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Title, Is.Null); + Assert.That(details.Status, Is.EqualTo(404)); + } + + [Test] + public void TryParse_HandlesPartialFields() + { + // Only status — all other fields should be null + var writer = new CborWriter(); + writer.WriteStartMap(1); + writer.WriteInt32(-3); writer.WriteInt32(429); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.EqualTo(429)); + Assert.That(details.Type, Is.Null); + Assert.That(details.Title, Is.Null); + Assert.That(details.Detail, Is.Null); + Assert.That(details.Instance, Is.Null); + Assert.That(details.Extensions, Is.Null); + } + + [Test] + public void TryParse_HandlesMixedIntegerAndStringKeys() + { + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteInt32(-2); writer.WriteTextString("Mixed Error"); + writer.WriteTextString("status"); writer.WriteInt32(500); + writer.WriteTextString("x-request-id"); writer.WriteTextString("req-abc-123"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Title, Is.EqualTo("Mixed Error")); + Assert.That(details.Status, Is.EqualTo(500)); + Assert.That(details.Extensions!["x-request-id"], Is.EqualTo("req-abc-123")); + } + + [Test] + public void TryParse_HandlesExtensionWithVariousValueTypes() + { + var writer = new CborWriter(); + writer.WriteStartMap(4); + writer.WriteInt32(-3); writer.WriteInt32(400); + writer.WriteTextString("boolField"); writer.WriteBoolean(true); + writer.WriteTextString("intField"); writer.WriteInt64(42); + writer.WriteTextString("floatField"); writer.WriteDouble(3.14); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Extensions!["boolField"], Is.EqualTo(true)); + Assert.That(details.Extensions["intField"], Is.EqualTo(42L)); + Assert.That(details.Extensions["floatField"], Is.EqualTo(3.14)); + } + + #endregion + + #region CborProblemDetails.ToString + + [Test] + public void ToString_ReturnsNoDetailsAvailable_WhenEmpty() + { + var details = new CborProblemDetails(); + Assert.That(details.ToString(), Is.EqualTo("No details available")); + } + + [Test] + public void ToString_IncludesAllFields() + { + var details = new CborProblemDetails + { + Type = "urn:error:test", + Title = "Test Error", + Status = 400, + Detail = "Something went wrong", + Instance = "/entries/123" + }; + + string result = details.ToString(); + Assert.That(result, Does.Contain("Title: Test Error")); + Assert.That(result, Does.Contain("Status: 400")); + Assert.That(result, Does.Contain("Detail: Something went wrong")); + Assert.That(result, Does.Contain("Type: urn:error:test")); + Assert.That(result, Does.Contain("Instance: /entries/123")); + } + + #endregion + + #region MstServiceException + + [Test] + public void MstServiceException_BasicConstructor() + { + var ex = new MstServiceException("test error"); + + Assert.That(ex.Message, Is.EqualTo("test error")); + Assert.That(ex.ProblemDetails, Is.Null); + Assert.That(ex.StatusCode, Is.Null); + Assert.That(ex.InnerException, Is.Null); + } + + [Test] + public void MstServiceException_WithInnerException() + { + var inner = new InvalidOperationException("inner"); + var ex = new MstServiceException("outer", inner); + + Assert.That(ex.Message, Is.EqualTo("outer")); + Assert.That(ex.InnerException, Is.SameAs(inner)); + } + + [Test] + public void MstServiceException_WithProblemDetails() + { + var details = new CborProblemDetails + { + Title = "Bad Request", + Status = 400, + Detail = "Invalid COSE payload" + }; + var ex = new MstServiceException("error", details); + + Assert.That(ex.ProblemDetails, Is.Not.Null); + Assert.That(ex.StatusCode, Is.EqualTo(400)); + Assert.That(ex.ProblemDetails!.Title, Is.EqualTo("Bad Request")); + } + + [Test] + public void MstServiceException_ToString_IncludesProblemDetails() + { + var details = new CborProblemDetails + { + Type = "urn:error:test", + Title = "Test", + Status = 500, + Detail = "Internal error", + Instance = "/api/entries" + }; + var ex = new MstServiceException("Service failed", details); + + string result = ex.ToString(); + Assert.That(result, Does.Contain("Service failed")); + Assert.That(result, Does.Contain("Status: 500")); + Assert.That(result, Does.Contain("Title: Test")); + Assert.That(result, Does.Contain("Detail: Internal error")); + Assert.That(result, Does.Contain("Type: urn:error:test")); + Assert.That(result, Does.Contain("Instance: /api/entries")); + } + + [Test] + public void MstServiceException_ToString_IncludesExtensions() + { + var details = new CborProblemDetails + { + Status = 429, + Extensions = new Dictionary { { "retryAfter", "5s" } } + }; + var ex = new MstServiceException("Rate limited", details); + + string result = ex.ToString(); + Assert.That(result, Does.Contain("retryAfter: 5s")); + } + + [Test] + public void FromRequestFailedException_WithoutCborContent_ReturnsGenericMessage() + { + // Create a RequestFailedException with a basic message (no CBOR content) + var rfEx = new RequestFailedException(500, "Internal Server Error"); + + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx, Is.Not.Null); + Assert.That(mstEx.ProblemDetails, Is.Null); + Assert.That(mstEx.InnerException, Is.SameAs(rfEx)); + Assert.That(mstEx.Message, Does.Contain("500")); + } + + [Test] + public void FromRequestFailedException_PreservesInnerException() + { + var rfEx = new RequestFailedException(502, "Bad Gateway"); + + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.InnerException, Is.SameAs(rfEx)); + Assert.That(mstEx.Message, Does.Contain("502")); + } + + #endregion + + #region Additional coverage — CborProblemDetails edge cases + + [Test] + public void TryParse_SkipsNonStandardKeyType() + { + // Map with a byte-string key (not integer or text) — should be skipped + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteByteString(new byte[] { 0x01 }); // non-standard key + writer.WriteTextString("skipped"); + writer.WriteInt32(-3); writer.WriteInt32(500); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.EqualTo(500)); + } + + [Test] + public void TryParse_HandlesNullStatusValue() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteNull(); + writer.WriteInt32(-2); writer.WriteTextString("Null Status"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.Null); + Assert.That(details.Title, Is.EqualTo("Null Status")); + } + + [Test] + public void TryParse_SkipsNonStringTypeForStringField() + { + // A type field with an integer value instead of string — should skip + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-1); writer.WriteInt32(999); // type should be string + writer.WriteInt32(-3); writer.WriteInt32(400); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Type, Is.Null); // skipped + Assert.That(details.Status, Is.EqualTo(400)); + } + + [Test] + public void TryParse_SkipsNonIntTypeForStatusField() + { + // Status with a string value instead of integer — should skip + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteTextString("not-a-number"); + writer.WriteInt32(-2); writer.WriteTextString("Title"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.Null); // skipped + Assert.That(details.Title, Is.EqualTo("Title")); + } + + [Test] + public void TryParse_HandlesNullExtensionValue() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteInt32(200); + writer.WriteTextString("nullExt"); writer.WriteNull(); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Extensions!["nullExt"], Is.Null); + } + + [Test] + public void TryParse_HandlesByteStringExtensionValue() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteInt32(200); + writer.WriteTextString("binaryData"); writer.WriteByteString(new byte[] { 0xDE, 0xAD }); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Extensions!["binaryData"], Is.TypeOf()); + } + + #endregion + + #region Additional coverage — MstServiceException ToString / BuildErrorMessage + + [Test] + public void MstServiceException_ToString_WithInnerException() + { + var inner = new InvalidOperationException("inner error"); + var ex = new MstServiceException("outer", inner); + + string result = ex.ToString(); + + Assert.That(result, Does.Contain("outer")); + Assert.That(result, Does.Contain("inner error")); + Assert.That(result, Does.Contain("End of inner exception stack trace")); + } + + [Test] + public void MstServiceException_ToString_WithoutProblemDetails() + { + var ex = new MstServiceException("simple error"); + + string result = ex.ToString(); + + Assert.That(result, Does.Contain("simple error")); + Assert.That(result, Does.Not.Contain("Problem Details")); + } + + [Test] + public void MstServiceException_ToString_WithProblemDetailsAndNoExtensions() + { + var details = new CborProblemDetails { Status = 404, Title = "Not Found" }; + var ex = new MstServiceException("not found", details); + + string result = ex.ToString(); + + Assert.That(result, Does.Contain("Status: 404")); + Assert.That(result, Does.Contain("Title: Not Found")); + Assert.That(result, Does.Not.Contain("Extensions")); + } + + [Test] + public void BuildErrorMessage_IncludesTitleAndDetail_WhenDifferent() + { + var details = new CborProblemDetails + { + Title = "Bad Request", + Status = 400, + Detail = "Payload is not valid" + }; + // Verify the constructor overload that accepts CborProblemDetails works + // and that BuildErrorMessage composes both title and detail into the message. + var ex = new MstServiceException("test", details, new RequestFailedException(400, "Bad Request")); + Assert.That(ex.Message, Does.Contain("test")); + Assert.That(ex.ProblemDetails, Is.Not.Null); + Assert.That(ex.ProblemDetails!.Title, Is.EqualTo("Bad Request")); + Assert.That(ex.ProblemDetails.Detail, Is.EqualTo("Payload is not valid")); + + // Verify via FromRequestFailedException which uses BuildErrorMessage internally + var rfEx = new RequestFailedException(400, "Bad Request"); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + // At minimum, the generic message path should work + Assert.That(mstEx.Message, Does.Contain("400")); + } + + [Test] + public void BuildErrorMessage_OmitsDetail_WhenSameAsTitle() + { + // Can't test BuildErrorMessage directly since it's private, + // but we test the behavior via CborProblemDetails.ToString + var details = new CborProblemDetails + { + Title = "Same", + Detail = "Same" + }; + + string result = details.ToString(); + // ToString includes both — but BuildErrorMessage would deduplicate + Assert.That(result, Does.Contain("Title: Same")); + Assert.That(result, Does.Contain("Detail: Same")); + } + + [Test] + public void MstServiceException_ToString_WithStackTrace() + { + // Throw and catch to get a real StackTrace + MstServiceException? caught = null; + try + { + throw new MstServiceException("with stack", new CborProblemDetails { Status = 500 }); + } + catch (MstServiceException ex) + { + caught = ex; + } + + string result = caught!.ToString(); + Assert.That(result, Does.Contain("with stack")); + Assert.That(result, Does.Contain("Status: 500")); + // Should contain stack trace since it was actually thrown + Assert.That(result, Does.Contain("MstServiceException_ToString_WithStackTrace")); + } + + [Test] + public void MstServiceException_ToString_WithProblemDetails_AllFieldsPopulated() + { + var details = new CborProblemDetails + { + Type = "urn:error:full", + Title = "Full Error", + Status = 503, + Detail = "All fields set", + Instance = "/entries/xyz", + Extensions = new Dictionary + { + { "retryAfter", "10s" }, + { "requestId", "abc-123" } + } + }; + var inner = new InvalidOperationException("root cause"); + var ex = new MstServiceException("full error test", details, inner); + + string result = ex.ToString(); + Assert.That(result, Does.Contain("Type: urn:error:full")); + Assert.That(result, Does.Contain("Title: Full Error")); + Assert.That(result, Does.Contain("Status: 503")); + Assert.That(result, Does.Contain("Detail: All fields set")); + Assert.That(result, Does.Contain("Instance: /entries/xyz")); + Assert.That(result, Does.Contain("retryAfter: 10s")); + Assert.That(result, Does.Contain("requestId: abc-123")); + Assert.That(result, Does.Contain("root cause")); + Assert.That(result, Does.Contain("End of inner exception stack trace")); + } + + #endregion + + #region FromRequestFailedException with MockResponse — full CBOR parsing path + + [Test] + public void FromRequestFailedException_WithCborResponse_ParsesProblemDetails() + { + // Build CBOR problem details payload + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteInt32(-2); writer.WriteTextString("Forbidden"); + writer.WriteInt32(-3); writer.WriteInt32(403); + writer.WriteInt32(-4); writer.WriteTextString("Certificate not authorized"); + writer.WriteEndMap(); + byte[] cborBytes = writer.Encode(); + + // Create a MockResponse with CBOR content-type and body + var mockResponse = new MockResponse(403, "Forbidden"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(cborBytes); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx, Is.Not.Null); + Assert.That(mstEx.ProblemDetails, Is.Not.Null); + Assert.That(mstEx.ProblemDetails!.Title, Is.EqualTo("Forbidden")); + Assert.That(mstEx.StatusCode, Is.EqualTo(403)); + Assert.That(mstEx.ProblemDetails.Detail, Is.EqualTo("Certificate not authorized")); + Assert.That(mstEx.Message, Does.Contain("403")); + Assert.That(mstEx.Message, Does.Contain("Forbidden")); + Assert.That(mstEx.InnerException, Is.SameAs(rfEx)); + } + + [Test] + public void FromRequestFailedException_WithCborResponse_TitleAndDetailInMessage() + { + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteInt32(-2); writer.WriteTextString("Bad Request"); + writer.WriteInt32(-3); writer.WriteInt32(400); + writer.WriteInt32(-4); writer.WriteTextString("Payload is not valid COSE"); + writer.WriteEndMap(); + + var mockResponse = new MockResponse(400, "Bad Request"); + mockResponse.AddHeader("Content-Type", "application/cbor"); + mockResponse.SetContent(writer.Encode()); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.Message, Does.Contain("Bad Request")); + Assert.That(mstEx.Message, Does.Contain("Payload is not valid COSE")); + Assert.That(mstEx.Message, Does.Contain("400")); + } + + [Test] + public void FromRequestFailedException_WithCborResponse_DetailSameAsTitleNotDuplicated() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-2); writer.WriteTextString("Error"); + writer.WriteInt32(-3); writer.WriteInt32(500); + writer.WriteEndMap(); + + var mockResponse = new MockResponse(500, "Internal Server Error"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(writer.Encode()); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + // Title only, no detail — message should just have title + Assert.That(mstEx.Message, Does.Contain("Error")); + Assert.That(mstEx.ProblemDetails!.Detail, Is.Null); + } + + [Test] + public void FromRequestFailedException_WithNonCborContentType_ReturnsGenericMessage() + { + var mockResponse = new MockResponse(500, "Internal Server Error"); + mockResponse.AddHeader("Content-Type", "application/json"); + mockResponse.SetContent("{\"error\":\"something\"}"); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.ProblemDetails, Is.Null); + Assert.That(mstEx.Message, Does.Contain("500")); + } + + [Test] + public void FromRequestFailedException_WithEmptyCborBody_ReturnsGenericMessage() + { + var mockResponse = new MockResponse(500, "Internal Server Error"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(Array.Empty()); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.ProblemDetails, Is.Null); + Assert.That(mstEx.Message, Does.Contain("500")); + } + + [Test] + public void FromRequestFailedException_WithInvalidCborBody_ReturnsGenericMessage() + { + var mockResponse = new MockResponse(500, "Internal Server Error"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(new byte[] { 0xFF, 0xFF, 0xFF }); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + // TryParse returns null for invalid CBOR, so generic message + Assert.That(mstEx.ProblemDetails, Is.Null); + Assert.That(mstEx.Message, Does.Contain("500")); + } + + [Test] + public void FromRequestFailedException_WithAllProblemDetailsFields() + { + var writer = new CborWriter(); + writer.WriteStartMap(5); + writer.WriteInt32(-1); writer.WriteTextString("urn:example:ledger-unavailable"); + writer.WriteInt32(-2); writer.WriteTextString("Ledger Unavailable"); + writer.WriteInt32(-3); writer.WriteInt32(503); + writer.WriteInt32(-4); writer.WriteTextString("The CCF node is syncing"); + writer.WriteInt32(-5); writer.WriteTextString("/entries/submit"); + writer.WriteEndMap(); + + var mockResponse = new MockResponse(503, "Service Unavailable"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(writer.Encode()); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.ProblemDetails, Is.Not.Null); + Assert.That(mstEx.ProblemDetails!.Type, Is.EqualTo("urn:example:ledger-unavailable")); + Assert.That(mstEx.ProblemDetails.Title, Is.EqualTo("Ledger Unavailable")); + Assert.That(mstEx.StatusCode, Is.EqualTo(503)); + Assert.That(mstEx.ProblemDetails.Detail, Is.EqualTo("The CCF node is syncing")); + Assert.That(mstEx.ProblemDetails.Instance, Is.EqualTo("/entries/submit")); + Assert.That(mstEx.Message, Does.Contain("503").And.Contain("Ledger Unavailable").And.Contain("CCF node is syncing")); + } + + #endregion +} diff --git a/CoseSign1.Transparent.MST.Tests/CoseSign1.Transparent.MST.Tests.csproj b/CoseSign1.Transparent.MST.Tests/CoseSign1.Transparent.MST.Tests.csproj index 43802e79..0dbd5d3a 100644 --- a/CoseSign1.Transparent.MST.Tests/CoseSign1.Transparent.MST.Tests.csproj +++ b/CoseSign1.Transparent.MST.Tests/CoseSign1.Transparent.MST.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/CoseSign1.Transparent.MST.Tests/MstPollingOptionsTests.cs b/CoseSign1.Transparent.MST.Tests/MstPollingOptionsTests.cs new file mode 100644 index 00000000..43dddc15 --- /dev/null +++ b/CoseSign1.Transparent.MST.Tests/MstPollingOptionsTests.cs @@ -0,0 +1,620 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.Tests; + +using System; +using System.Collections.Generic; +using System.Formats.Cbor; +using System.Security.Cryptography.Cose; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Core; +using Azure.Security.CodeTransparency; +using CoseSign1.Abstractions.Interfaces; +using CoseSign1.Certificates.Interfaces; +using CoseSign1.Certificates.Local; +using CoseSign1.Interfaces; +using CoseSign1.Tests.Common; +using CoseSign1.Transparent.MST; +using CoseSign1.Transparent.Extensions; +using Moq; + +/// +/// Unit tests for and the polling behavior of +/// . +/// +[TestFixture] +public class MstPollingOptionsTests +{ + private CoseSign1MessageFactory? messageFactory; + private ICoseSigningKeyProvider? signingKeyProvider; + + [SetUp] + public void Setup() + { + X509Certificate2 testSigningCert = TestCertificateUtils.CreateCertificate(); + ICertificateChainBuilder testChainBuilder = new TestChainBuilder(); + signingKeyProvider = new X509Certificate2CoseSigningKeyProvider(testChainBuilder, testSigningCert); + messageFactory = new(); + } + + #region MstPollingOptions property tests + + /// + /// Verifies that a newly created has null defaults. + /// + [Test] + public void MstPollingOptions_DefaultsAreNull() + { + // Arrange & Act + MstPollingOptions options = new(); + + // Assert + Assert.That(options.PollingInterval, Is.Null); + Assert.That(options.DelayStrategy, Is.Null); + } + + /// + /// Verifies that can be set and retrieved. + /// + [Test] + public void MstPollingOptions_PollingInterval_CanBeSet() + { + // Arrange + MstPollingOptions options = new(); + + // Act + options.PollingInterval = TimeSpan.FromSeconds(2); + + // Assert + Assert.That(options.PollingInterval, Is.EqualTo(TimeSpan.FromSeconds(2))); + } + + /// + /// Verifies that can be set and retrieved. + /// + [Test] + public void MstPollingOptions_DelayStrategy_CanBeSet() + { + // Arrange + MstPollingOptions options = new(); + DelayStrategy strategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(500)); + + // Act + options.DelayStrategy = strategy; + + // Assert + Assert.That(options.DelayStrategy, Is.SameAs(strategy)); + } + + /// + /// Verifies that both properties can be set simultaneously. + /// + [Test] + public void MstPollingOptions_BothPropertiesCanBeSet() + { + // Arrange & Act + MstPollingOptions options = new() + { + PollingInterval = TimeSpan.FromSeconds(1), + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(200)) + }; + + // Assert + Assert.That(options.PollingInterval, Is.Not.Null); + Assert.That(options.DelayStrategy, Is.Not.Null); + } + + #endregion + + #region Constructor tests with MstPollingOptions + + /// + /// Verifies the two-parameter constructor (client + pollingOptions) creates an instance. + /// + [Test] + public void Constructor_WithPollingOptions_CreatesInstance() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromSeconds(1) + }; + + // Act + MstTransparencyService service = new(mockClient, pollingOptions); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies the constructor with null pollingOptions creates an instance (defaults to SDK behavior). + /// + [Test] + public void Constructor_WithNullPollingOptions_CreatesInstance() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + + // Act + MstTransparencyService service = new(mockClient, (MstPollingOptions?)null!); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies the four-parameter constructor (client, verificationOptions, clientOptions, pollingOptions). + /// + [Test] + public void Constructor_WithVerificationAndPollingOptions_CreatesInstance() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + CodeTransparencyVerificationOptions verificationOptions = new() + { + AuthorizedDomains = new List { "example.com" } + }; + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromMilliseconds(500) + }; + + // Act + MstTransparencyService service = new(mockClient, verificationOptions, null, pollingOptions); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies the full constructor with all parameters including pollingOptions and logging. + /// + [Test] + public void Constructor_WithAllOptionsAndLogging_CreatesInstance() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + MstPollingOptions pollingOptions = new() + { + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(100)) + }; + List logMessages = new(); + + // Act + MstTransparencyService service = new( + mockClient, + null, + null, + pollingOptions, + null, + msg => logMessages.Add($"VERBOSE: {msg}"), + msg => logMessages.Add($"WARNING: {msg}"), + msg => logMessages.Add($"ERROR: {msg}")); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies that the original 6-parameter constructor (without pollingOptions) still works. + /// + [Test] + public void Constructor_OriginalSixParameterOverload_StillWorks() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + + // Act + MstTransparencyService service = new( + mockClient, null, null, + msg => { }, msg => { }, msg => { }); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies that the pollingOptions constructor still throws for null client. + /// + [Test] + public void Constructor_WithPollingOptions_ThrowsForNullClient() + { + // Arrange + MstPollingOptions pollingOptions = new() { PollingInterval = TimeSpan.FromSeconds(1) }; + + // Act & Assert + Assert.That( + () => new MstTransparencyService(null!, pollingOptions), + Throws.TypeOf().With.Property("ParamName").EqualTo("transparencyClient")); + } + + #endregion + + #region MakeTransparentAsync with polling options + + /// + /// Verifies that MakeTransparentAsync uses PollingInterval when configured. + /// + [Test] + public async Task MakeTransparentAsync_UsesPollingInterval_WhenConfigured() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromMilliseconds(100) + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + // Verify WaitForCompletionAsync was called with the TimeSpan overload + mockOperation.Verify( + op => op.WaitForCompletionAsync(TimeSpan.FromMilliseconds(100), It.IsAny()), + Times.Once); + } + + /// + /// Verifies that MakeTransparentAsync uses DelayStrategy when configured. + /// + [Test] + public async Task MakeTransparentAsync_UsesDelayStrategy_WhenConfigured() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + DelayStrategy fixedStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(50)); + MstPollingOptions pollingOptions = new() + { + DelayStrategy = fixedStrategy + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + // Verify WaitForCompletionAsync was called with the DelayStrategy overload + mockOperation.Verify( + op => op.WaitForCompletionAsync(fixedStrategy, It.IsAny()), + Times.Once); + } + + /// + /// Verifies that DelayStrategy takes precedence over PollingInterval when both are set. + /// + [Test] + public async Task MakeTransparentAsync_DelayStrategyTakesPrecedence_WhenBothSet() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + DelayStrategy fixedStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(50)); + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromSeconds(5), // Should be ignored + DelayStrategy = fixedStrategy // Should take precedence + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + // DelayStrategy overload should be called, NOT TimeSpan overload + mockOperation.Verify( + op => op.WaitForCompletionAsync(fixedStrategy, It.IsAny()), + Times.Once); + mockOperation.Verify( + op => op.WaitForCompletionAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that the default (no-arg) WaitForCompletionAsync is called when no polling options are set. + /// + [Test] + public async Task MakeTransparentAsync_UsesDefaultPolling_WhenNoOptionsSet() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + // No polling options + MstTransparencyService service = new(mockClient.Object); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + // Default overload (CancellationToken only) should be called + mockOperation.Verify( + op => op.WaitForCompletionAsync(It.IsAny()), + Times.Once); + } + + /// + /// Verifies that the default is used when MstPollingOptions is provided but both fields are null. + /// + [Test] + public async Task MakeTransparentAsync_UsesDefaultPolling_WhenOptionsAllNull() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + // Empty polling options (both null) + MstPollingOptions pollingOptions = new(); + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + mockOperation.Verify( + op => op.WaitForCompletionAsync(It.IsAny()), + Times.Once); + } + + /// + /// Verifies that polling options are correctly logged when verbose logging is enabled. + /// + [Test] + public async Task MakeTransparentAsync_LogsPollingInterval_WhenVerboseEnabled() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + List logMessages = new(); + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromMilliseconds(250) + }; + MstTransparencyService service = new( + mockClient.Object, null, null, pollingOptions, + null, msg => logMessages.Add(msg), null, null); + + // Act + await service.MakeTransparentAsync(message); + + // Assert + Assert.That(logMessages, Has.Some.Contains("fixed polling interval")); + Assert.That(logMessages, Has.Some.Contains("250ms")); + } + + /// + /// Verifies that logging includes the DelayStrategy type name when verbose logging is enabled. + /// + [Test] + public async Task MakeTransparentAsync_LogsDelayStrategy_WhenVerboseEnabled() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + List logMessages = new(); + MstPollingOptions pollingOptions = new() + { + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(100)) + }; + MstTransparencyService service = new( + mockClient.Object, null, null, pollingOptions, + null, msg => logMessages.Add(msg), null, null); + + // Act + await service.MakeTransparentAsync(message); + + // Assert + Assert.That(logMessages, Has.Some.Contains("custom DelayStrategy")); + } + + #endregion + + #region ServiceEndpoint auto-derivation tests + + /// + /// Verifies that ServiceEndpoint is auto-derived from CodeTransparencyClient when not explicitly provided. + /// + [Test] + public void ServiceEndpoint_IsDerivedFromClient_WhenNotExplicitlyProvided() + { + // Arrange — create a real CodeTransparencyClient with a known endpoint + Uri expectedEndpoint = new("https://my-cts-instance.confidential-ledger.azure.com"); + CodeTransparencyClient client = new(expectedEndpoint); + + // Act + MstTransparencyService service = new(client); + + // Assert — the endpoint should have been extracted via reflection + Assert.That(service.ServiceEndpoint, Is.EqualTo(expectedEndpoint)); + } + + /// + /// Verifies that an explicit serviceEndpoint overrides the auto-derived one. + /// + [Test] + public void ServiceEndpoint_ExplicitOverridesAutoDerived() + { + // Arrange + Uri clientEndpoint = new("https://auto-derived.example.com"); + Uri explicitEndpoint = new("https://explicit-override.example.com"); + CodeTransparencyClient client = new(clientEndpoint); + + // Act + MstTransparencyService service = new(client, explicitEndpoint); + + // Assert + Assert.That(service.ServiceEndpoint, Is.EqualTo(explicitEndpoint)); + } + + /// + /// Verifies that ServiceEndpoint is null when using a mocked client (no _endpoint field). + /// + [Test] + public void ServiceEndpoint_IsNull_WhenClientIsMocked() + { + // Arrange — Moq creates a proxy; the backing field won't exist + CodeTransparencyClient mockClient = Mock.Of(); + + // Act + MstTransparencyService service = new(mockClient); + + // Assert + Assert.That(service.ServiceEndpoint, Is.Null); + } + + /// + /// Verifies that ServiceEndpoint works with the two-arg endpoint constructor. + /// + [Test] + public void ServiceEndpoint_WorksWithEndpointConstructor() + { + // Arrange + Uri endpoint = new("https://staging.cts.azure.com"); + CodeTransparencyClient client = new(endpoint); + + // Act + MstTransparencyService service = new(client, endpoint); + + // Assert + Assert.That(service.ServiceEndpoint, Is.EqualTo(endpoint)); + } + + /// + /// Verifies that ServiceEndpoint is auto-derived in the full constructor when serviceEndpoint is null. + /// + [Test] + public void ServiceEndpoint_AutoDerived_InFullConstructor() + { + // Arrange + Uri endpoint = new("https://full-ctor.cts.azure.com"); + CodeTransparencyClient client = new(endpoint); + + // Act + MstTransparencyService service = new( + client, null, null, null, null, null, null, null); + + // Assert + Assert.That(service.ServiceEndpoint, Is.EqualTo(endpoint)); + } + + #endregion + + #region Helpers + + private CoseSign1Message CreateMockCoseSign1Message() + { + byte[] testPayload = Encoding.ASCII.GetBytes("Payload1!"); + return messageFactory!.CreateCoseSign1Message(testPayload, signingKeyProvider!, embedPayload: false); + } + + /// + /// Creates a mock Operation that simulates a successful CreateEntryAsync response + /// with a valid CBOR-encoded EntryId. + /// + private static Mock> CreateSuccessfulOperation() + { + Mock> mock = new(); + mock.Setup(op => op.HasValue).Returns(true); + + CborWriter cborWriter = new(); + cborWriter.WriteStartMap(1); + cborWriter.WriteTextString("EntryId"); + cborWriter.WriteTextString("test-entry-12345"); + cborWriter.WriteEndMap(); + mock.Setup(op => op.Value).Returns(BinaryData.FromBytes(cborWriter.Encode())); + + return mock; + } + + #endregion +} diff --git a/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs b/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs new file mode 100644 index 00000000..f8a6d301 --- /dev/null +++ b/CoseSign1.Transparent.MST.Tests/MstTransactionNotCachedPolicyTests.cs @@ -0,0 +1,524 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST.Tests; + +using System.Formats.Cbor; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Core.TestCommon; +using Azure.Security.CodeTransparency; +using CoseSign1.Transparent.MST; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class MstTransactionNotCachedPolicyTests +{ + #region Constructor Tests + + [Test] + public void Constructor_DefaultValues_SetsExpectedDefaults() + { + // Act + var policy = new MstTransactionNotCachedPolicy(); + + // Assert — verify defaults are accessible via the public constants + Assert.That(MstTransactionNotCachedPolicy.DefaultRetryDelay, Is.EqualTo(TimeSpan.FromMilliseconds(250))); + Assert.That(MstTransactionNotCachedPolicy.DefaultMaxRetries, Is.EqualTo(8)); + Assert.That(policy, Is.Not.Null); + } + + [Test] + public void Constructor_CustomValues_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.FromSeconds(1), 3)); + } + + [Test] + public void Constructor_ZeroDelay_DoesNotThrow() + { + Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.Zero, 5)); + } + + [Test] + public void Constructor_ZeroRetries_DoesNotThrow() + { + Assert.DoesNotThrow(() => new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(100), 0)); + } + + [Test] + public void Constructor_NegativeDelay_ThrowsArgumentOutOfRange() + { + Assert.Throws(() => + new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(-1), 3)); + } + + [Test] + public void Constructor_NegativeRetries_ThrowsArgumentOutOfRange() + { + Assert.Throws(() => + new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(100), -1)); + } + + #endregion + + #region ProcessAsync — Non-Matching Requests (Pass-Through) + + [Test] + public async Task ProcessAsync_NonGetRequest_PassesThroughWithoutRetry() + { + // Arrange — POST to /entries/ returning 503 with TransactionNotCached body + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Post, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only 1 call, no retries (POST is not matched) + Assert.That(callCount, Is.EqualTo(1)); + Assert.That(message.Response.Status, Is.EqualTo(503)); + } + + [Test] + public async Task ProcessAsync_GetNonEntriesPath_PassesThroughWithoutRetry() + { + // Arrange — GET to /operations/ returning 503 with TransactionNotCached body + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/operations/abc"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only 1 call + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public async Task ProcessAsync_GetEntriesPath_Non503Status_PassesThroughWithoutRetry() + { + // Arrange — GET to /entries/ returning 200 + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return new MockResponse(200); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only 1 call + Assert.That(callCount, Is.EqualTo(1)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public async Task ProcessAsync_503WithoutCborBody_DoesNotRetry() + { + // Arrange — 503 with empty body (no CBOR problem details) + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return new MockResponse(503); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only 1 call (no TransactionNotCached in body) + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public async Task ProcessAsync_503WithDifferentCborError_DoesNotRetry() + { + // Arrange — 503 with CBOR body containing a different error + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + var response = new MockResponse(503); + response.SetContent(CreateCborProblemDetailsBytes("ServiceTooBusy")); + return response; + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only 1 call (not TransactionNotCached) + Assert.That(callCount, Is.EqualTo(1)); + } + + #endregion + + #region ProcessAsync — Matching Requests (Retry Behavior) + + [Test] + public async Task ProcessAsync_TransactionNotCached_RetriesUntilSuccess() + { + // Arrange — first 2 calls return 503/TransactionNotCached, third returns 200 + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 5)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — 3 calls total (1 initial + 2 retries before success) + Assert.That(callCount, Is.EqualTo(3)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public async Task ProcessAsync_TransactionNotCached_ExhaustsRetries_Returns503() + { + // Arrange — always return 503/TransactionNotCached + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + + int maxRetries = 3; + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), maxRetries)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — 1 initial + maxRetries retries = 4 total calls + Assert.That(callCount, Is.EqualTo(1 + maxRetries)); + Assert.That(message.Response.Status, Is.EqualTo(503)); + } + + [Test] + public async Task ProcessAsync_ZeroMaxRetries_NoRetries() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 0)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — only the initial call, no retries + Assert.That(callCount, Is.EqualTo(1)); + } + + [Test] + public async Task ProcessAsync_TransactionNotCached_InTitle_IsDetected() + { + // Arrange — error code in Title field instead of Detail + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 1) + { + var response = new MockResponse(503); + response.SetContent(CreateCborProblemDetailsBytesInTitle("TransactionNotCached")); + return response; + } + return new MockResponse(200); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — retried and succeeded on second call + Assert.That(callCount, Is.EqualTo(2)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public async Task ProcessAsync_TransactionNotCached_CaseInsensitive() + { + // Arrange — lowercase error code + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 1) + { + var response = new MockResponse(503); + response.SetContent(CreateCborProblemDetailsBytes("transactionnotcached")); + return response; + } + return new MockResponse(200); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert + Assert.That(callCount, Is.EqualTo(2)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public async Task ProcessAsync_EntriesPath_CaseInsensitive() + { + // Arrange — uppercase ENTRIES in path + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 1) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/ENTRIES/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — path matching is case-insensitive + Assert.That(callCount, Is.EqualTo(2)); + } + + [Test] + public async Task ProcessAsync_503WithInvalidCborBody_DoesNotRetry() + { + // Arrange — 503 with garbage body (not valid CBOR) + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + var response = new MockResponse(503); + response.SetContent(new byte[] { 0xFF, 0xFE, 0x00 }); + return response; + }); + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + await pipeline.SendAsync(message, CancellationToken.None); + + // Assert — no retry on invalid CBOR + Assert.That(callCount, Is.EqualTo(1)); + } + + #endregion + + #region Process (Sync) + + [Test] + public void Process_TransactionNotCached_RetriesUntilSuccess() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + if (callCount <= 2) + { + return CreateTransactionNotCachedResponse(); + } + return new MockResponse(200); + }); + transport.ExpectSyncPipeline = true; + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 5)); + var message = CreateHttpMessage(pipeline, RequestMethod.Get, "https://mst.example.com/entries/1.234"); + + // Act + pipeline.Send(message, CancellationToken.None); + + // Assert + Assert.That(callCount, Is.EqualTo(3)); + Assert.That(message.Response.Status, Is.EqualTo(200)); + } + + [Test] + public void Process_NonMatchingRequest_DoesNotRetry() + { + // Arrange + int callCount = 0; + var transport = MockTransport.FromMessageCallback(msg => + { + callCount++; + return CreateTransactionNotCachedResponse(); + }); + transport.ExpectSyncPipeline = true; + + var pipeline = CreatePipeline(transport, new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(1), 3)); + var message = CreateHttpMessage(pipeline, RequestMethod.Post, "https://mst.example.com/entries/1.234"); + + // Act + pipeline.Send(message, CancellationToken.None); + + // Assert + Assert.That(callCount, Is.EqualTo(1)); + } + + #endregion + + #region MstClientOptionsExtensions Tests + + [Test] + public void ConfigureTransactionNotCachedRetry_NullOptions_ThrowsArgumentNullException() + { + CodeTransparencyClientOptions? options = null; + + Assert.Throws(() => + options!.ConfigureTransactionNotCachedRetry()); + } + + [Test] + public void ConfigureTransactionNotCachedRetry_DefaultParams_ReturnsSameInstance() + { + var options = new CodeTransparencyClientOptions(); + + var result = options.ConfigureTransactionNotCachedRetry(); + + Assert.That(result, Is.SameAs(options)); + } + + [Test] + public void ConfigureTransactionNotCachedRetry_CustomParams_ReturnsSameInstance() + { + var options = new CodeTransparencyClientOptions(); + + var result = options.ConfigureTransactionNotCachedRetry( + retryDelay: TimeSpan.FromMilliseconds(100), + maxRetries: 16); + + Assert.That(result, Is.SameAs(options)); + } + + #endregion + + #region Test Helpers + + /// + /// Builds a pipeline with the policy under test inserted before the transport. + /// The pipeline has no SDK retry policy — just the custom policy + transport. + /// + private static HttpPipeline CreatePipeline(MockTransport transport, MstTransactionNotCachedPolicy policy) + { + return HttpPipelineBuilder.Build( + new TestClientOptions(transport, policy)); + } + + /// + /// Creates an HttpMessage with the given method and URI, ready to send through the pipeline. + /// + private static HttpMessage CreateHttpMessage(HttpPipeline pipeline, RequestMethod method, string uri) + { + var message = pipeline.CreateMessage(); + message.Request.Method = method; + message.Request.Uri.Reset(new Uri(uri)); + return message; + } + + /// + /// Creates a mock 503 response with a CBOR problem-details body containing TransactionNotCached. + /// + private static MockResponse CreateTransactionNotCachedResponse() + { + var response = new MockResponse(503); + response.AddHeader("Retry-After", "1"); + response.SetContent(CreateCborProblemDetailsBytes("TransactionNotCached")); + return response; + } + + /// + /// Creates CBOR problem details bytes with the error code in the Detail field (key -4). + /// + private static byte[] CreateCborProblemDetailsBytes(string detailValue) + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); // status + writer.WriteInt32(503); + writer.WriteInt32(-4); // detail + writer.WriteTextString(detailValue); + writer.WriteEndMap(); + return writer.Encode(); + } + + /// + /// Creates CBOR problem details bytes with the error code in the Title field (key -2). + /// + private static byte[] CreateCborProblemDetailsBytesInTitle(string titleValue) + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); // status + writer.WriteInt32(503); + writer.WriteInt32(-2); // title + writer.WriteTextString(titleValue); + writer.WriteEndMap(); + return writer.Encode(); + } + + /// + /// Minimal ClientOptions subclass for building a pipeline with our custom policy and a mock transport. + /// Disables the SDK's default retry to isolate the policy's behavior. + /// + private sealed class TestClientOptions : ClientOptions + { + public TestClientOptions(MockTransport transport, MstTransactionNotCachedPolicy policy) + { + Transport = transport; + Retry.MaxRetries = 0; // Disable SDK retries to test policy in isolation + AddPolicy(policy, HttpPipelinePosition.PerRetry); + } + } + + #endregion +} diff --git a/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceExtensionsTests.cs b/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceExtensionsTests.cs index ec09abc8..dc954433 100644 --- a/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceExtensionsTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceExtensionsTests.cs @@ -5,7 +5,6 @@ using Azure.Security.CodeTransparency; using CoseSign1.Transparent; using CoseSign1.Transparent.MST; -using CoseSign1.Transparent.MST.Extensions; using Moq; using NUnit.Framework; diff --git a/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceLoggingTests.cs b/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceLoggingTests.cs index f718c82e..7d5a7055 100644 --- a/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceLoggingTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceLoggingTests.cs @@ -19,7 +19,6 @@ namespace CoseSign1.Transparent.Tests; using CoseSign1.Interfaces; using CoseSign1.Tests.Common; using CoseSign1.Transparent.MST; -using CoseSign1.Transparent.MST.Extensions; using CoseSign1.Transparent.Extensions; using Moq; diff --git a/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceTests.cs b/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceTests.cs index 3ba2d137..acbd8256 100644 --- a/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceTests.cs @@ -276,6 +276,29 @@ public void VerifyTransparencyAsync_WithReceipt_ThrowsArgumentOutOfRangeExceptio Throws.TypeOf().With.Property("ParamName").EqualTo("receipt")); } + /// + /// Tests that MakeTransparentAsync wraps RequestFailedException in MstServiceException. + /// + [Test] + public void MakeTransparentAsync_ThrowsMstServiceException_WhenRequestFails() + { + // Arrange + Mock mockClient = new Mock(); + mockClient + .Setup(client => client.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new RequestFailedException(502, "Bad Gateway")); + + MstTransparencyService service = new MstTransparencyService(mockClient.Object); + CoseSign1Message message = CreateMockCoseSign1Message(); + + // Act & Assert + Assert.That( + async () => await service.MakeTransparentAsync(message), + Throws.TypeOf() + .With.Message.Contains("502") + .And.InnerException.TypeOf()); + } + /// /// Helper method to create a mock failed operation. /// diff --git a/CoseSign1.Transparent.MST/CborProblemDetails.cs b/CoseSign1.Transparent.MST/CborProblemDetails.cs new file mode 100644 index 00000000..4eeaa9c4 --- /dev/null +++ b/CoseSign1.Transparent.MST/CborProblemDetails.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; +using System.Collections.Generic; +using System.Formats.Cbor; + +/// +/// Represents parsed CBOR problem details from +/// RFC 9290 (Concise Problem Details). +/// +/// +/// RFC 9290 defines standard fields +/// for problem details in CBOR format, +/// returned by Azure Code Transparency Service with Content-Type: +/// application/concise-problem-details+cbor. +/// +/// Standard CBOR integer keys: +/// +/// -1type (URI reference) +/// -2title (human-readable summary) +/// -3status (HTTP status code) +/// -4detail (human-readable explanation) +/// -5instance (URI reference for the occurrence) +/// +/// +/// String keys ("type", "title", etc.) are also accepted for interoperability. +/// +public class CborProblemDetails +{ + /// + /// Gets or sets the problem type URI reference (CBOR key: -1 or "type"). + /// + public string? Type { get; set; } + + /// + /// Gets or sets a short human-readable summary of the problem (CBOR key: -2 or "title"). + /// + public string? Title { get; set; } + + /// + /// Gets or sets the HTTP status code (CBOR key: -3 or "status"). + /// + public int? Status { get; set; } + + /// + /// Gets or sets a human-readable explanation of the problem (CBOR key: -4 or "detail"). + /// + public string? Detail { get; set; } + + /// + /// Gets or sets a URI reference for the specific occurrence (CBOR key: -5 or "instance"). + /// + public string? Instance { get; set; } + + /// + /// Gets or sets additional extension fields not covered by the standard keys. + /// + public Dictionary? Extensions { get; set; } + + /// + /// Parses CBOR-encoded problem details + /// (RFC 9290) from a byte array. + /// + /// The CBOR-encoded problem details. + /// The parsed problem details, or null if parsing fails. + public static CborProblemDetails? TryParse(byte[] cborBytes) + { + if (cborBytes == null || cborBytes.Length == 0) + { + return null; + } + + try + { + var reader = new CborReader(cborBytes); + return ParseFromReader(reader); + } + catch (CborContentException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + /// + /// Parses CBOR problem details from a positioned at a map. + /// + private static CborProblemDetails? ParseFromReader(CborReader reader) + { + if (reader.PeekState() != CborReaderState.StartMap) + { + return null; + } + + var details = new CborProblemDetails(); + var extensions = new Dictionary(); + + reader.ReadStartMap(); + + while (reader.PeekState() != CborReaderState.EndMap) + { + var keyState = reader.PeekState(); + + if (keyState == CborReaderState.NegativeInteger || keyState == CborReaderState.UnsignedInteger) + { + int key = reader.ReadInt32(); + switch (key) + { + case -1: details.Type = ReadStringValue(reader); break; + case -2: details.Title = ReadStringValue(reader); break; + case -3: details.Status = ReadIntValue(reader); break; + case -4: details.Detail = ReadStringValue(reader); break; + case -5: details.Instance = ReadStringValue(reader); break; + default: extensions[$"key_{key}"] = ReadAnyValue(reader); break; + } + } + else if (keyState == CborReaderState.TextString) + { + string key = reader.ReadTextString(); + switch (key.ToLowerInvariant()) + { + case "type": details.Type = ReadStringValue(reader); break; + case "title": details.Title = ReadStringValue(reader); break; + case "status": details.Status = ReadIntValue(reader); break; + case "detail": details.Detail = ReadStringValue(reader); break; + case "instance": details.Instance = ReadStringValue(reader); break; + default: extensions[key] = ReadAnyValue(reader); break; + } + } + else + { + reader.SkipValue(); + reader.SkipValue(); + } + } + + reader.ReadEndMap(); + + if (extensions.Count > 0) + { + details.Extensions = extensions; + } + + return details; + } + + private static string? ReadStringValue(CborReader reader) + { + if (reader.PeekState() == CborReaderState.TextString) + { + return reader.ReadTextString(); + } + + if (reader.PeekState() == CborReaderState.Null) { reader.ReadNull(); return null; } + reader.SkipValue(); + return null; + } + + private static int? ReadIntValue(CborReader reader) + { + var state = reader.PeekState(); + if (state == CborReaderState.UnsignedInteger || state == CborReaderState.NegativeInteger) + { + return reader.ReadInt32(); + } + + if (state == CborReaderState.Null) { reader.ReadNull(); return null; } + reader.SkipValue(); + return null; + } + + private static object? ReadAnyValue(CborReader reader) + { + return reader.PeekState() switch + { + CborReaderState.TextString => reader.ReadTextString(), + CborReaderState.ByteString => reader.ReadByteString(), + CborReaderState.UnsignedInteger => reader.ReadInt64(), + CborReaderState.NegativeInteger => reader.ReadInt64(), + CborReaderState.Boolean => reader.ReadBoolean(), + CborReaderState.Null => ReadNull(reader), + CborReaderState.SinglePrecisionFloat => reader.ReadSingle(), + CborReaderState.DoublePrecisionFloat => reader.ReadDouble(), + CborReaderState.HalfPrecisionFloat => reader.ReadDouble(), + _ => Skip(reader), + }; + } + + private static object? ReadNull(CborReader reader) { reader.ReadNull(); return null; } + private static object? Skip(CborReader reader) { reader.SkipValue(); return null; } + + /// + /// Returns a human-readable summary of the problem details. + /// + public override string ToString() + { + var parts = new List(); + if (!string.IsNullOrEmpty(Title)) + { + parts.Add($"Title: {Title}"); + } + + if (Status.HasValue) + { + parts.Add($"Status: {Status}"); + } + + if (!string.IsNullOrEmpty(Detail)) + { + parts.Add($"Detail: {Detail}"); + } + + if (!string.IsNullOrEmpty(Type)) + { + parts.Add($"Type: {Type}"); + } + + if (!string.IsNullOrEmpty(Instance)) + { + parts.Add($"Instance: {Instance}"); + } + + return parts.Count > 0 ? string.Join(", ", parts) : "No details available"; + } +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/Extensions/BinaryDataExtensions.cs b/CoseSign1.Transparent.MST/Extensions/BinaryDataExtensions.cs index 7c89da0f..56c95aa1 100644 --- a/CoseSign1.Transparent.MST/Extensions/BinaryDataExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/BinaryDataExtensions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace CoseSign1.Transparent.MST.Extensions; +namespace CoseSign1.Transparent.MST; using System; using System.Formats.Cbor; @@ -19,7 +19,7 @@ public static class BinaryDataExtensions /// The containing the CBOR-encoded data. /// /// When this method returns, contains the extracted "EntryId" value if the operation was successful; - /// otherwise, contains null. + /// otherwise, contains an empty string. /// /// /// true if the "EntryId" was successfully extracted; otherwise, false. @@ -27,9 +27,9 @@ public static class BinaryDataExtensions /// /// This method reads the CBOR-encoded data as a map and searches for a key named "EntryId". /// If the key is found, its corresponding value is returned as a string. - /// If the data is not valid CBOR or does not contain the "EntryId" key, the method returns false. + /// If the data is null, not valid CBOR, or does not contain the "EntryId" key, + /// the method returns false with set to an empty string. /// - /// Thrown if is null. public static bool TryGetMstEntryId(this BinaryData binaryData, out string? entryId) { entryId = string.Empty; @@ -57,7 +57,7 @@ public static bool TryGetMstEntryId(this BinaryData binaryData, out string? entr } } } - catch(InvalidOperationException) + catch (InvalidOperationException) { return false; } @@ -72,5 +72,4 @@ public static bool TryGetMstEntryId(this BinaryData binaryData, out string? entr return false; } -} - +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs new file mode 100644 index 00000000..f97094c5 --- /dev/null +++ b/CoseSign1.Transparent.MST/Extensions/MstClientOptionsExtensions.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Security.CodeTransparency; + +using System; +using Azure.Core; +using Azure.Core.Pipeline; +using CoseSign1.Transparent.MST; + +/// +/// Extension methods for configuring with MST-specific +/// pipeline policies. +/// +public static class MstClientOptionsExtensions +{ + /// + /// Adds the to the client options pipeline, + /// enabling fast retries for the MST GetEntryStatement 503 / TransactionNotCached + /// response pattern. + /// + /// The to configure. + /// + /// The interval between fast retry attempts. Defaults to 250 ms. + /// + /// + /// The maximum number of fast retry attempts before falling through to the SDK's standard + /// retry logic. Defaults to 8 (≈ 2 seconds at 250 ms). + /// + /// The same instance for fluent chaining. + /// Thrown when is null. + /// + /// + /// This method does not modify the SDK's global + /// on the client options. The fast retry loop runs entirely within the policy and only targets + /// HTTP 503 responses to GET /entries/ requests. All other API calls retain whatever + /// retry behavior the caller has configured (or the SDK defaults). + /// + /// + /// + /// Example: + /// + /// var options = new CodeTransparencyClientOptions(); + /// options.ConfigureTransactionNotCachedRetry(); // defaults + /// options.ConfigureTransactionNotCachedRetry(TimeSpan.FromMilliseconds(100)); // faster + /// options.ConfigureTransactionNotCachedRetry(maxRetries: 16); // longer window + /// + /// var client = new CodeTransparencyClient(endpoint, credential, options); + /// + /// + /// + public static CodeTransparencyClientOptions ConfigureTransactionNotCachedRetry( + this CodeTransparencyClientOptions options, + TimeSpan? retryDelay = null, + int? maxRetries = null) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + var policy = new MstTransactionNotCachedPolicy( + retryDelay ?? MstTransactionNotCachedPolicy.DefaultRetryDelay, + maxRetries ?? MstTransactionNotCachedPolicy.DefaultMaxRetries); + + options.AddPolicy(policy, HttpPipelinePosition.PerRetry); + return options; + } +} diff --git a/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs index 5cb00356..ff6b545e 100644 --- a/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace CoseSign1.Transparent.MST.Extensions; +namespace Azure.Security.CodeTransparency; using System; -using Azure.Security.CodeTransparency; using CoseSign1.Transparent; +using CoseSign1.Transparent.MST; /// /// Provides extension methods for working with the @@ -64,4 +64,32 @@ public static TransparencyService ToCoseSign1TransparencyService( return new MstTransparencyService(client, null, null, logVerbose, logWarning, logError); } -} + + /// + /// Converts a instance into a implementation + /// with polling options and logging support. + /// + /// The to be converted. + /// Options controlling the polling behavior for long-running operations. + /// Optional callback for verbose logging. + /// Optional callback for warning logging. + /// Optional callback for error logging. + /// + /// An instance of that wraps the provided . + /// + /// Thrown if is null. + public static TransparencyService ToCoseSign1TransparencyService( + this CodeTransparencyClient client, + MstPollingOptions? pollingOptions, + Action? logVerbose = null, + Action? logWarning = null, + Action? logError = null) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + return new MstTransparencyService(client, null, null, pollingOptions, null, logVerbose, logWarning, logError); + } +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/MstPollingOptions.cs b/CoseSign1.Transparent.MST/MstPollingOptions.cs new file mode 100644 index 00000000..bc4aa2f9 --- /dev/null +++ b/CoseSign1.Transparent.MST/MstPollingOptions.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; + +/// +/// Configuration options for controlling how polls the +/// Azure Code Transparency Service for completed receipt registrations. +/// +/// +/// When a COSE_Sign1 message is submitted to MST via CreateEntryAsync, the service +/// returns a long-running operation that must be polled until completion. These options let +/// callers tune the polling behavior to balance latency against cost. +/// +/// If neither nor is set, +/// the Azure SDK's default exponential back-off strategy is used. +/// +/// If both are set, takes precedence. +/// +public class MstPollingOptions +{ + /// + /// Gets or sets the fixed interval between polling attempts. + /// + /// + /// When set, Operation<T>.WaitForCompletionAsync(TimeSpan, CancellationToken) + /// is called with this value. Set to null (the default) to use the SDK's built-in + /// delay strategy instead. + /// + /// Typical values range from 100 ms (aggressive, local dev) to 5 s (production). + /// + /// + /// + /// var options = new MstPollingOptions { PollingInterval = TimeSpan.FromSeconds(2) }; + /// + /// + public TimeSpan? PollingInterval { get; set; } + + /// + /// Gets or sets a custom that controls the + /// back-off pattern between polling attempts. + /// + /// + /// When set, this strategy is passed to the + /// Operation<T>.WaitForCompletionAsync(DelayStrategy, CancellationToken) overload. + /// This takes precedence over if both are specified. + /// + /// Use + /// for a constant interval, or implement a custom strategy for exponential back-off + /// with jitter. + /// + /// + /// + /// var options = new MstPollingOptions + /// { + /// DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(500)) + /// }; + /// + /// + public Azure.Core.DelayStrategy? DelayStrategy { get; set; } +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/MstServiceException.cs b/CoseSign1.Transparent.MST/MstServiceException.cs new file mode 100644 index 00000000..f276d392 --- /dev/null +++ b/CoseSign1.Transparent.MST/MstServiceException.cs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; +using System.Formats.Cbor; +using System.Text; + +/// +/// Exception thrown when an MST transparency service operation fails. +/// +/// +/// This exception captures parsed details from CBOR problem details responses +/// (application/concise-problem-details+cbor) as defined in RFC 9290, +/// when the Azure Code Transparency Service returns an error. +/// +public class MstServiceException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception, if any. + public MstServiceException(string message, Exception? innerException = null) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with parsed problem details. + /// + /// The error message. + /// The parsed CBOR problem details from the MST response. + /// The inner exception, if any. + public MstServiceException( + string message, + CborProblemDetails problemDetails, + Exception? innerException = null) + : base(message, innerException) + { + ProblemDetails = problemDetails; + } + + /// + /// Gets the HTTP status code from the failed request, if available from the problem details. + /// + public int? StatusCode => ProblemDetails?.Status; + + /// + /// Gets the parsed CBOR problem details (RFC 9290) from the service response, if available. + /// + public CborProblemDetails? ProblemDetails { get; } + + /// + /// Returns a detailed string representation including all parsed problem details. + /// + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"{GetType().FullName}: {Message}"); + + if (ProblemDetails != null) + { + sb.AppendLine(" Problem Details:"); + if (ProblemDetails.Status.HasValue) + { + sb.AppendLine($" Status: {ProblemDetails.Status}"); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Type)) + { + sb.AppendLine($" Type: {ProblemDetails.Type}"); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Title)) + { + sb.AppendLine($" Title: {ProblemDetails.Title}"); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Detail)) + { + sb.AppendLine($" Detail: {ProblemDetails.Detail}"); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Instance)) + { + sb.AppendLine($" Instance: {ProblemDetails.Instance}"); + } + + if (ProblemDetails.Extensions is { Count: > 0 }) + { + sb.AppendLine(" Extensions:"); + foreach (var ext in ProblemDetails.Extensions) + { + sb.AppendLine($" {ext.Key}: {ext.Value}"); + } + } + } + + if (InnerException != null) + { + sb.AppendLine($" ---> {InnerException}"); + sb.AppendLine(" --- End of inner exception stack trace ---"); + } + + if (StackTrace != null) + { + sb.AppendLine(StackTrace); + } + + return sb.ToString(); + } + + /// + /// Creates an from an Azure , + /// attempting to parse CBOR problem details from the response body. + /// + /// The Azure SDK request failure. + /// An with parsed details when available. + public static MstServiceException FromRequestFailedException(Azure.RequestFailedException requestFailedException) + { + CborProblemDetails? problemDetails = null; + + if (requestFailedException.GetRawResponse() is Azure.Response response) + { + var contentType = response.Headers.ContentType; + + if (contentType?.Contains("cbor", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + var content = response.Content; + if (content != null && content.ToMemory().Length > 0) + { + problemDetails = CborProblemDetails.TryParse(content.ToArray()); + } + } + catch (CborContentException) + { + // CBOR parsing failure is non-fatal; fall through to generic message + } + catch (InvalidOperationException) + { + // Parsing state failure is non-fatal; fall through to generic message + } + } + } + + string errorMessage = problemDetails != null + ? BuildErrorMessage(problemDetails, requestFailedException.Status) + : $"MST service returned HTTP {requestFailedException.Status}: {requestFailedException.Message}"; + + return problemDetails != null + ? new MstServiceException(errorMessage, problemDetails, requestFailedException) + : new MstServiceException(errorMessage, requestFailedException); + } + + /// + /// Builds a human-readable error message from parsed problem details. + /// + private static string BuildErrorMessage(CborProblemDetails details, int httpStatus) + { + var parts = new System.Collections.Generic.List + { + $"MST service returned an error (HTTP {details.Status ?? httpStatus})" + }; + + if (!string.IsNullOrEmpty(details.Title)) + { + parts.Add($": {details.Title}"); + } + + if (!string.IsNullOrEmpty(details.Detail) && details.Detail != details.Title) + { + parts.Add($". {details.Detail}"); + } + + return string.Concat(parts); + } +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs b/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs new file mode 100644 index 00000000..56dd35c5 --- /dev/null +++ b/CoseSign1.Transparent.MST/MstTransactionNotCachedPolicy.cs @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Core; +using Azure.Core.Pipeline; + +/// +/// An Azure SDK pipeline policy that performs aggressive, fast retries exclusively for the +/// MST GetEntryStatement 503 / TransactionNotCached response pattern. +/// +/// +/// +/// Problem: The Azure Code Transparency Service returns HTTP 503 with a +/// Retry-After: 1 header (1 second) when a newly registered entry has not yet +/// propagated to the serving node (TransactionNotCached). The entry typically becomes +/// available in well under 1 second, but the Azure SDK's default +/// respects the server's Retry-After header, causing unnecessary 1-second delays. +/// +/// +/// +/// Solution: This policy intercepts that specific response pattern and performs its own +/// fast retry loop (default: 250 ms intervals, up to 8 retries ≈ 2 seconds) inside +/// the pipeline, before the SDK's standard ever sees the response. +/// +/// +/// +/// Scope: Only HTTP 503 responses to GET requests whose URI path contains +/// /entries/ are retried by this policy. All other requests and response codes +/// pass through with a single ProcessNext call — the SDK's normal retry +/// infrastructure handles them with whatever the caller configured. +/// +/// +/// +/// Pipeline position: Register this policy in the +/// position so it runs inside the SDK's +/// retry loop. Each of the policy's internal retries re-sends the request through +/// the remaining pipeline (transport). If the fast retries succeed, the SDK sees a +/// successful response. If they exhaust without success, the SDK sees the final 503 +/// and applies its own retry logic as usual. +/// +/// +/// +/// Usage: +/// +/// var options = new CodeTransparencyClientOptions(); +/// options.AddPolicy(new MstTransactionNotCachedPolicy(), HttpPipelinePosition.PerRetry); +/// var client = new CodeTransparencyClient(endpoint, credential, options); +/// +/// Or use the convenience extension: +/// +/// var options = new CodeTransparencyClientOptions(); +/// options.ConfigureTransactionNotCachedRetry(); +/// +/// +/// +public class MstTransactionNotCachedPolicy : HttpPipelinePolicy +{ + /// + /// The default interval between fast retry attempts. + /// + public static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromMilliseconds(250); + + /// + /// The default maximum number of fast retry attempts (8 retries × 250 ms ≈ 2 seconds). + /// + public const int DefaultMaxRetries = 8; + + private const int ServiceUnavailableStatusCode = 503; + private const string EntriesPathSegment = "/entries/"; + private const string TransactionNotCachedErrorCode = "TransactionNotCached"; + + private readonly TimeSpan _retryDelay; + private readonly int _maxRetries; + + /// + /// Initializes a new instance of the class + /// with default retry settings (250 ms delay, 8 retries). + /// + public MstTransactionNotCachedPolicy() + : this(DefaultRetryDelay, DefaultMaxRetries) + { + } + + /// + /// Initializes a new instance of the class + /// with custom retry settings. + /// + /// The interval to wait between fast retry attempts. + /// The maximum number of fast retry attempts before falling through + /// to the SDK's standard retry logic. + /// + /// Thrown when is negative or is negative. + /// + public MstTransactionNotCachedPolicy(TimeSpan retryDelay, int maxRetries) + { + if (retryDelay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must not be negative."); + } + + if (maxRetries < 0) + { + throw new ArgumentOutOfRangeException(nameof(maxRetries), "Max retries must not be negative."); + } + + _retryDelay = retryDelay; + _maxRetries = maxRetries; + } + + /// + public override void Process(HttpMessage message, ReadOnlyMemory pipeline) + { + ProcessCore(message, pipeline, isAsync: false).AsTask().GetAwaiter().GetResult(); + } + + /// + public override ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline) + { + return ProcessCore(message, pipeline, isAsync: true); + } + + private async ValueTask ProcessCore(HttpMessage message, ReadOnlyMemory pipeline, bool isAsync) + { + if (isAsync) + { + await ProcessNextAsync(message, pipeline).ConfigureAwait(false); + } + else + { + ProcessNext(message, pipeline); + } + + if (!IsTransactionNotCachedResponse(message)) + { + return; + } + + for (int attempt = 0; attempt < _maxRetries; attempt++) + { + if (isAsync) + { + await Task.Delay(_retryDelay, message.CancellationToken).ConfigureAwait(false); + + // Dispose the previous response before issuing a new request. + // ProcessNextAsync will assign a fresh Response to message, so the old one + // must be explicitly released to avoid leaking its content stream and connection. + message.Response?.Dispose(); + await ProcessNextAsync(message, pipeline).ConfigureAwait(false); + } + else + { + message.CancellationToken.ThrowIfCancellationRequested(); + Thread.Sleep(_retryDelay); + + // Dispose the previous response before issuing a new request. + // ProcessNext will assign a fresh Response to message, so the old one + // must be explicitly released to avoid leaking its content stream and connection. + message.Response?.Dispose(); + ProcessNext(message, pipeline); + } + + if (!IsTransactionNotCachedResponse(message)) + { + return; + } + } + + // All fast retries exhausted — return the final 503 to the outer pipeline. + // The SDK's RetryPolicy will handle it from here (respecting Retry-After as usual). + } + + /// + /// Returns if the response matches the MST TransactionNotCached pattern: + /// HTTP 503 on a GET request to a /entries/ URI with a CBOR problem-details body + /// containing the TransactionNotCached error code. + /// + private static bool IsTransactionNotCachedResponse(HttpMessage message) + { + if (message.Response == null) + { + return false; + } + + if (message.Response.Status != ServiceUnavailableStatusCode) + { + return false; + } + + if (!message.Request.Method.Equals(RequestMethod.Get)) + { + return false; + } + + string? requestUri = message.Request.Uri?.ToUri()?.AbsoluteUri; + if (requestUri == null || requestUri.IndexOf(EntriesPathSegment, StringComparison.OrdinalIgnoreCase) < 0) + { + return false; + } + + // Parse the CBOR problem details body to confirm this is TransactionNotCached. + return HasTransactionNotCachedErrorCode(message.Response); + } + + /// + /// Reads the response body as CBOR problem details and checks whether the error code + /// matches TransactionNotCached in any of the standard fields (Detail, Title, Type) + /// or extension values. + /// + private static bool HasTransactionNotCachedErrorCode(Response response) + { + try + { + // Read the response body. The stream must be seekable so subsequent retries + // (and the SDK's own retry infrastructure) can re-read it. + if (response.ContentStream == null) + { + return false; + } + + if (!response.ContentStream.CanSeek) + { + // Buffer into a seekable MemoryStream so the body is re-readable. + MemoryStream buffer = new(); + response.ContentStream.CopyTo(buffer); + buffer.Position = 0; + response.ContentStream = buffer; + } + + long startPosition = response.ContentStream.Position; + byte[] body; + try + { + response.ContentStream.Position = 0; + using MemoryStream bodyBuffer = new(); + response.ContentStream.CopyTo(bodyBuffer); + body = bodyBuffer.ToArray(); + } + finally + { + // Always rewind so the body remains available for subsequent reads. + response.ContentStream.Position = startPosition; + } + + if (body.Length == 0) + { + return false; + } + + CborProblemDetails? details = CborProblemDetails.TryParse(body); + if (details == null) + { + return false; + } + + // Check standard fields for the error code string. + if (ContainsErrorCode(details.Detail) || + ContainsErrorCode(details.Title) || + ContainsErrorCode(details.Type)) + { + return true; + } + + // Check extension values. + if (details.Extensions?.Any(ext => ext.Value is string strValue && ContainsErrorCode(strValue)) == true) + { + return true; + } + + return false; + } + catch (IOException) + { + // Stream read/seek failure — can't confirm it's TransactionNotCached, don't retry. + return false; + } + catch (ObjectDisposedException) + { + // Response stream was disposed — can't confirm it's TransactionNotCached, don't retry. + return false; + } + catch (NotSupportedException) + { + // Stream doesn't support seek/read — can't confirm it's TransactionNotCached, don't retry. + return false; + } + } + + private static bool ContainsErrorCode(string? value) + { + return value != null + && value.IndexOf(TransactionNotCachedErrorCode, StringComparison.OrdinalIgnoreCase) >= 0; + } +} diff --git a/CoseSign1.Transparent.MST/MstTransparencyService.cs b/CoseSign1.Transparent.MST/MstTransparencyService.cs index 9e32727e..2a027aa6 100644 --- a/CoseSign1.Transparent.MST/MstTransparencyService.cs +++ b/CoseSign1.Transparent.MST/MstTransparencyService.cs @@ -12,9 +12,8 @@ namespace CoseSign1.Transparent.MST; using System.Threading.Tasks; using Azure; using Azure.Security.CodeTransparency; -using CoseSign1.Transparent.MST.Extensions; -using CoseSign1.Transparent.Extensions; using CoseSign1.Transparent; +using CoseSign1.Transparent.Extensions; /// /// Provides an implementation of the base class using Microsoft's Signing Transparency (MST). @@ -25,6 +24,11 @@ public class MstTransparencyService : TransparencyService private readonly CodeTransparencyClient TransparencyClient; private readonly CodeTransparencyVerificationOptions? VerificationOptions; private readonly CodeTransparencyClientOptions? ClientOptions; + private readonly MstPollingOptions? PollingOptions; + private readonly Uri? ServiceEndpointUri; + + /// + public override Uri? ServiceEndpoint => this.ServiceEndpointUri; /// /// Initializes a new instance of the class. @@ -32,7 +36,18 @@ public class MstTransparencyService : TransparencyService /// The used to interact with the Azure CTS. /// Thrown if is null. public MstTransparencyService(CodeTransparencyClient transparencyClient) - : this(transparencyClient, null, null, null, null, null) + : this(transparencyClient, null, null, null, null, null, null, null) + { + } + + /// + /// Initializes a new instance of the class with a service endpoint. + /// + /// The used to interact with the Azure CTS. + /// The URI of the Azure CTS endpoint this service communicates with. + /// Thrown if is null. + public MstTransparencyService(CodeTransparencyClient transparencyClient, Uri serviceEndpoint) + : this(transparencyClient, null, null, null, serviceEndpoint, null, null, null) { } @@ -47,7 +62,37 @@ public MstTransparencyService( CodeTransparencyClient transparencyClient, CodeTransparencyVerificationOptions? verificationOptions, CodeTransparencyClientOptions? clientOptions) - : this(transparencyClient, verificationOptions, clientOptions, null, null, null) + : this(transparencyClient, verificationOptions, clientOptions, null, null, null, null, null) + { + } + + /// + /// Initializes a new instance of the class with polling options. + /// + /// The used to interact with the Azure CTS. + /// Optional options controlling the polling behavior for long-running operations. + /// Thrown if is null. + public MstTransparencyService( + CodeTransparencyClient transparencyClient, + MstPollingOptions? pollingOptions) + : this(transparencyClient, null, null, pollingOptions, null, null, null, null) + { + } + + /// + /// Initializes a new instance of the class with verification and polling options. + /// + /// The used to interact with the Azure CTS. + /// Optional verification options for controlling receipt validation behavior. + /// Optional client options for configuring client instances used during verification. + /// Optional options controlling the polling behavior for long-running operations. + /// Thrown if is null. + public MstTransparencyService( + CodeTransparencyClient transparencyClient, + CodeTransparencyVerificationOptions? verificationOptions, + CodeTransparencyClientOptions? clientOptions, + MstPollingOptions? pollingOptions) + : this(transparencyClient, verificationOptions, clientOptions, pollingOptions, null, null, null, null) { } @@ -68,11 +113,65 @@ public MstTransparencyService( Action? logVerbose, Action? logWarning, Action? logError) + : this(transparencyClient, verificationOptions, clientOptions, null, null, logVerbose, logWarning, logError) + { + } + + /// + /// Initializes a new instance of the class with all options. + /// + /// The used to interact with the Azure CTS. + /// Optional verification options for controlling receipt validation behavior. + /// Optional client options for configuring client instances used during verification. + /// Options controlling the polling behavior for long-running operations. + /// The URI of the Azure CTS endpoint this service communicates with. + /// Optional verbose logging callback. + /// Optional warning logging callback. + /// Optional error logging callback. + /// Thrown if is null. + public MstTransparencyService( + CodeTransparencyClient transparencyClient, + CodeTransparencyVerificationOptions? verificationOptions, + CodeTransparencyClientOptions? clientOptions, + MstPollingOptions? pollingOptions, + Uri? serviceEndpoint, + Action? logVerbose, + Action? logWarning, + Action? logError) : base(logVerbose, logWarning, logError) { TransparencyClient = transparencyClient ?? throw new ArgumentNullException(nameof(transparencyClient)); VerificationOptions = verificationOptions; ClientOptions = clientOptions; + PollingOptions = pollingOptions; + ServiceEndpointUri = serviceEndpoint ?? TryGetEndpointFromClient(transparencyClient); + } + + /// + /// Attempts to derive the service endpoint URI from the + /// by reading its internal _endpoint field via reflection. + /// + /// + /// The Azure.Security.CodeTransparency SDK does not currently expose the endpoint as a public + /// property. This method uses reflection as a best-effort fallback so callers don't need to + /// pass the URI twice. Returns null if the field is not found or inaccessible. + /// + private static Uri? TryGetEndpointFromClient(CodeTransparencyClient client) + { + try + { + var field = typeof(CodeTransparencyClient) + .GetField("_endpoint", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return field?.GetValue(client) as Uri; + } + catch (System.Reflection.TargetInvocationException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } } /// @@ -88,6 +187,7 @@ public MstTransparencyService( /// with the transparency metadata or headers applied. /// /// Thrown if is null. + /// Thrown if the MST service returns an error with CBOR problem details. /// Thrown if the transparency operation fails. protected override async Task MakeTransparentCoreAsync(CoseSign1Message message, CancellationToken cancellationToken = default) { @@ -97,9 +197,42 @@ protected override async Task MakeTransparentCoreAsync(CoseSig BinaryData content = BinaryData.FromBytes(message.Encode()); LogVerbose?.Invoke($"Encoded message size: {content.ToArray().Length} bytes"); - // Request the entry be created in the transparency service - LogVerbose?.Invoke("Calling CreateEntryAsync..."); - Operation operation = await TransparencyClient.CreateEntryAsync(WaitUntil.Completed, content, cancellationToken).ConfigureAwait(false); + Operation operation; + try + { + // Request the entry be created in the transparency service + LogVerbose?.Invoke("Calling CreateEntryAsync..."); + operation = await TransparencyClient.CreateEntryAsync(WaitUntil.Started, content, cancellationToken).ConfigureAwait(false); + + // Wait for the operation to complete, respecting polling options. + // DelayStrategy takes precedence over PollingInterval if both are set. + LogVerbose?.Invoke("Waiting for CreateEntryAsync operation to complete..."); + if (PollingOptions?.DelayStrategy != null) + { + LogVerbose?.Invoke($"Using custom DelayStrategy: {PollingOptions.DelayStrategy.GetType().Name}"); + await operation.WaitForCompletionAsync(PollingOptions.DelayStrategy, cancellationToken).ConfigureAwait(false); + } + else if (PollingOptions?.PollingInterval != null) + { + LogVerbose?.Invoke($"Using fixed polling interval: {PollingOptions.PollingInterval.Value.TotalMilliseconds}ms"); + await operation.WaitForCompletionAsync(PollingOptions.PollingInterval.Value, cancellationToken).ConfigureAwait(false); + } + else + { + await operation.WaitForCompletionAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (Azure.RequestFailedException rfEx) + { + // Parse CBOR problem details from the MST error response (RFC 9290) + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + LogError?.Invoke(mstEx.Message); + if (mstEx.ProblemDetails != null) + { + LogVerbose?.Invoke($"Problem details: {mstEx.ProblemDetails}"); + } + throw mstEx; + } // Check if the operation was successful if (!operation.HasValue) @@ -161,7 +294,7 @@ public override Task VerifyTransparencyAsync(CoseSign1Message message, Can LogError?.Invoke(error); throw new InvalidOperationException(error); } - + LogVerbose?.Invoke("Transparency header found in message"); cancellationToken.ThrowIfCancellationRequested(); @@ -184,39 +317,39 @@ public override Task VerifyTransparencyAsync(CoseSign1Message message, Can LogVerbose?.Invoke("Transparency verification succeeded"); return Task.FromResult(true); } - catch(InvalidOperationException ex) + catch (InvalidOperationException ex) { LogError?.Invoke($"Verification failed: {ex.Message}"); LogVerbose?.Invoke($"InvalidOperationException details: {ex}"); return Task.FromResult(false); } - catch(CryptographicException ex) + catch (CryptographicException ex) { LogError?.Invoke($"Cryptographic error during verification: {ex.Message}"); LogVerbose?.Invoke($"CryptographicException details: {ex}"); return Task.FromResult(false); } - catch(CborContentException ex) + catch (CborContentException ex) { LogError?.Invoke($"CBOR content error during verification: {ex.Message}"); LogVerbose?.Invoke($"CborContentException details: {ex}"); return Task.FromResult(false); } - catch(ArgumentException ex) + catch (ArgumentException ex) { LogError?.Invoke($"Invalid argument during verification: {ex.Message}"); LogVerbose?.Invoke($"ArgumentException details: {ex}"); return Task.FromResult(false); } - catch(AggregateException ex) + catch (AggregateException ex) { LogError?.Invoke($"Multiple verification failures occurred"); - + foreach (var innerEx in ex.InnerExceptions) { LogVerbose?.Invoke($" - {innerEx.Message}"); } - + return Task.FromResult(false); } } @@ -256,5 +389,4 @@ public override Task VerifyTransparencyAsync(CoseSign1Message message, byt // Verify the transparency of the message using the provided service return VerifyTransparencyAsync(message, cancellationToken); } -} - +} \ No newline at end of file diff --git a/CoseSign1.Transparent/TransparencyService.cs b/CoseSign1.Transparent/TransparencyService.cs index 4df24a5c..5518879a 100644 --- a/CoseSign1.Transparent/TransparencyService.cs +++ b/CoseSign1.Transparent/TransparencyService.cs @@ -33,6 +33,16 @@ protected TransparencyService(Action? logVerbose = null, Action? protected Action? LogWarning { get; } protected Action? LogError { get; } + /// + /// Gets the URI of the transparency service endpoint that this instance communicates with. + /// + /// + /// This property identifies which service instance is being used, enabling callers to + /// distinguish between multiple transparency services (e.g., production vs. staging). + /// Returns null if no endpoint was configured. + /// + public virtual Uri? ServiceEndpoint { get; } + /// /// Creates a new transparent COSE Sign1 message by embedding additional metadata or headers into the provided message. /// diff --git a/CoseSignTool.MST.Plugin.Tests/CtsCommandBaseLoggingTests.cs b/CoseSignTool.MST.Plugin.Tests/CtsCommandBaseLoggingTests.cs deleted file mode 100644 index 757e21a1..00000000 --- a/CoseSignTool.MST.Plugin.Tests/CtsCommandBaseLoggingTests.cs +++ /dev/null @@ -1,482 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.Security.CodeTransparency; -using System.Security.Cryptography.Cose; - -namespace CoseSignTool.MST.Plugin.Tests; - -/// -/// Tests for logging behavior in CtsCommandBase. -/// -[TestClass] -public class CtsCommandBaseLoggingTests -{ - /// - /// Mock logger to capture logging calls. - /// - private class MockLogger : IPluginLogger - { - public List<(LogLevel Level, string Message)> LoggedMessages { get; } = new(); - public List<(Exception Exception, string? Message)> LoggedExceptions { get; } = new(); - public LogLevel Level { get; set; } = LogLevel.Normal; - - public void LogError(string message) - { - LoggedMessages.Add((LogLevel.Normal, $"ERROR: {message}")); - } - - public void LogWarning(string message) - { - LoggedMessages.Add((LogLevel.Normal, $"WARNING: {message}")); - } - - public void LogInformation(string message) - { - LoggedMessages.Add((LogLevel.Normal, $"INFO: {message}")); - } - - public void LogVerbose(string message) - { - if (Level == LogLevel.Verbose) - { - LoggedMessages.Add((LogLevel.Verbose, $"VERBOSE: {message}")); - } - } - - public void LogException(Exception ex, string? message = null) - { - LoggedExceptions.Add((ex, message)); - } - } - - [TestMethod] - public void ValidateCommonParameters_WithInvalidTimeout_LogsError() - { - // Arrange - MockLogger logger = new MockLogger(); - IConfiguration configuration = CreateConfiguration(new Dictionary - { - ["timeout"] = "invalid" - }); - - // Act - PluginExitCode result = TestCtsCommand.TestValidateCommonParameters(configuration, out int timeout, logger); - - // Assert - Assert.AreEqual(PluginExitCode.InvalidArgumentValue, result); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Invalid timeout value"))); - } - - [TestMethod] - public void ValidateCommonParameters_WithNullLogger_DoesNotThrow() - { - // Arrange - IConfiguration configuration = CreateConfiguration(new Dictionary - { - ["timeout"] = "invalid" - }); - - // Act & Assert - should not throw - PluginExitCode result = TestCtsCommand.TestValidateCommonParameters(configuration, out int timeout, null); - Assert.AreEqual(PluginExitCode.InvalidArgumentValue, result); - } - - [TestMethod] - public void ValidateFilePaths_WithMissingFile_LogsError() - { - // Arrange - MockLogger logger = new MockLogger(); - Dictionary filePaths = new Dictionary - { - ["Payload"] = "nonexistent-file.bin" - }; - - // Act - PluginExitCode result = TestCtsCommand.TestValidateFilePaths(filePaths, logger); - - // Assert - Assert.AreEqual(PluginExitCode.UserSpecifiedFileNotFound, result); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Payload file not found"))); - } - - [TestMethod] - public void ValidateFilePaths_WithNullLogger_DoesNotThrow() - { - // Arrange - Dictionary filePaths = new Dictionary - { - ["Payload"] = "nonexistent.bin" - }; - - // Act & Assert - should not throw - PluginExitCode result = TestCtsCommand.TestValidateFilePaths(filePaths, null); - Assert.AreEqual(PluginExitCode.UserSpecifiedFileNotFound, result); - } - - [TestMethod] - public async Task ReadAndDecodeCoseMessage_WithInvalidData_LogsError() - { - // Arrange - MockLogger logger = new MockLogger(); - string tempFile = Path.GetTempFileName(); - await File.WriteAllBytesAsync(tempFile, new byte[] { 0x01, 0x02, 0x03 }); // Invalid COSE - - try - { - // Act - (CoseSign1Message? message, _, PluginExitCode result) = - await TestCtsCommand.TestReadAndDecodeCoseMessage(tempFile, CancellationToken.None, logger); - - // Assert - Assert.AreEqual(PluginExitCode.InvalidArgumentValue, result); - Assert.IsNull(message); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Failed to decode"))); - Assert.IsTrue(logger.LoggedExceptions.Count > 0); - } - finally - { - File.Delete(tempFile); - } - } - - [TestMethod] - public async Task ReadAndDecodeCoseMessage_WithNullLogger_DoesNotThrow() - { - // Arrange - string tempFile = Path.GetTempFileName(); - await File.WriteAllBytesAsync(tempFile, new byte[] { 0x01, 0x02, 0x03 }); - - try - { - // Act & Assert - should not throw - (CoseSign1Message? message, byte[] signatureBytes, PluginExitCode result) = - await TestCtsCommand.TestReadAndDecodeCoseMessage(tempFile, CancellationToken.None, null); - Assert.AreEqual(PluginExitCode.InvalidArgumentValue, result); - } - finally - { - File.Delete(tempFile); - } - } - - [TestMethod] - public async Task WriteJsonResult_WithValidData_LogsInformation() - { - // Arrange - MockLogger logger = new MockLogger(); - string outputPath = Path.GetTempFileName() + ".json"; - var testData = new { Test = "Value" }; - - try - { - // Act - await TestCtsCommand.TestWriteJsonResult(outputPath, testData, CancellationToken.None, logger); - - // Assert - Assert.IsTrue(File.Exists(outputPath)); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Result written to"))); - } - finally - { - if (File.Exists(outputPath)) - { - File.Delete(outputPath); - } - } - } - - [TestMethod] - public async Task WriteJsonResult_WithNullLogger_DoesNotThrow() - { - // Arrange - string outputPath = Path.GetTempFileName() + ".json"; - var testData = new { Test = "Value" }; - - try - { - // Act & Assert - should not throw - await TestCtsCommand.TestWriteJsonResult(outputPath, testData, CancellationToken.None, null); - Assert.IsTrue(File.Exists(outputPath)); - } - finally - { - if (File.Exists(outputPath)) - { - File.Delete(outputPath); - } - } - } - - [TestMethod] - public void HandleCommonException_WithArgumentNullException_LogsError() - { - // Arrange - MockLogger logger = new MockLogger(); - IConfiguration configuration = CreateConfiguration(new Dictionary()); - ArgumentNullException exception = new ArgumentNullException("testParam"); - - // Act - PluginExitCode result = TestCtsCommand.TestHandleCommonException(exception, configuration, CancellationToken.None, logger); - - // Assert - Assert.AreEqual(PluginExitCode.MissingRequiredOption, result); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Missing required argument"))); - } - - [TestMethod] - public void HandleCommonException_WithFileNotFoundException_LogsError() - { - // Arrange - MockLogger logger = new MockLogger(); - IConfiguration configuration = CreateConfiguration(new Dictionary()); - FileNotFoundException exception = new FileNotFoundException("Test file not found"); - - // Act - PluginExitCode result = TestCtsCommand.TestHandleCommonException(exception, configuration, CancellationToken.None, logger); - - // Assert - Assert.AreEqual(PluginExitCode.UserSpecifiedFileNotFound, result); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("File not found"))); - } - - [TestMethod] - public void HandleCommonException_WithCancellation_LogsError() - { - // Arrange - MockLogger logger = new MockLogger(); - IConfiguration configuration = CreateConfiguration(new Dictionary()); - CancellationTokenSource cts = new CancellationTokenSource(); - cts.Cancel(); - OperationCanceledException exception = new OperationCanceledException(cts.Token); - - // Act - PluginExitCode result = TestCtsCommand.TestHandleCommonException(exception, configuration, cts.Token, logger); - - // Assert - Assert.AreEqual(PluginExitCode.UnknownError, result); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Operation was cancelled"))); - } - - [TestMethod] - public void HandleCommonException_WithTimeout_LogsError() - { - // Arrange - MockLogger logger = new MockLogger(); - IConfiguration configuration = CreateConfiguration(new Dictionary - { - ["timeout"] = "45" - }); - OperationCanceledException exception = new OperationCanceledException(); - - // Act - PluginExitCode result = TestCtsCommand.TestHandleCommonException(exception, configuration, CancellationToken.None, logger); - - // Assert - Assert.AreEqual(PluginExitCode.UnknownError, result); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("timed out after 45 seconds"))); - } - - [TestMethod] - public void HandleCommonException_WithGenericException_LogsError() - { - // Arrange - MockLogger logger = new MockLogger(); - IConfiguration configuration = CreateConfiguration(new Dictionary()); - Exception exception = new Exception("Generic error message"); - - // Act - PluginExitCode result = TestCtsCommand.TestHandleCommonException(exception, configuration, CancellationToken.None, logger); - - // Assert - Assert.AreEqual(PluginExitCode.UnknownError, result); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Generic error message"))); - } - - [TestMethod] - public void HandleCommonException_WithNullLogger_DoesNotThrow() - { - // Arrange - IConfiguration configuration = CreateConfiguration(new Dictionary()); - Exception exception = new Exception("Test error"); - - // Act & Assert - should not throw - PluginExitCode result = TestCtsCommand.TestHandleCommonException(exception, configuration, CancellationToken.None, null); - Assert.AreEqual(PluginExitCode.UnknownError, result); - } - - [TestMethod] - public void CreateTimeoutCancellationToken_CreatesValidToken() - { - // Arrange & Act - using CancellationTokenSource result = TestCtsCommand.TestCreateTimeoutCancellationToken(5, CancellationToken.None); - - // Assert - Assert.IsNotNull(result); - Assert.IsFalse(result.IsCancellationRequested); - } - - [TestMethod] - public void CreateTimeoutCancellationToken_CombinesWithCancellationToken() - { - // Arrange - using CancellationTokenSource cts = new CancellationTokenSource(); - cts.Cancel(); - - // Act - using CancellationTokenSource result = TestCtsCommand.TestCreateTimeoutCancellationToken(5, cts.Token); - - // Assert - Assert.IsTrue(result.IsCancellationRequested); - } - - [TestMethod] - public void PrintOperationStatus_WithAllParameters_LogsCorrectly() - { - // Arrange - TestCtsCommand command = new TestCtsCommand(); - MockLogger logger = new MockLogger { Level = LogLevel.Verbose }; - command.SetLogger(logger); - - // Act - command.TestPrintOperationStatus("Testing", "https://test.com", "/path/payload", "/path/signature", 1024, "Extra info"); - - // Assert - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Testing"))); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("https://test.com"))); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("/path/payload"))); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("/path/signature"))); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("1024"))); - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Extra info"))); - } - - [TestMethod] - public void PrintOperationStatus_WithoutAdditionalInfo_LogsCorrectly() - { - // Arrange - TestCtsCommand command = new TestCtsCommand(); - MockLogger logger = new MockLogger { Level = LogLevel.Verbose }; - command.SetLogger(logger); - - // Act - command.TestPrintOperationStatus("Testing", "https://test.com", "/path/payload", "/path/signature", 512, null); - - // Assert - Assert.IsTrue(logger.LoggedMessages.Any(m => m.Message.Contains("Testing"))); - Assert.AreEqual(4, logger.LoggedMessages.Count(m => m.Message.Contains("VERBOSE") || m.Message.Contains("INFO"))); - } - - [TestMethod] - public void GetOptionalValue_WithValue_ReturnsValue() - { - // Arrange - IConfiguration configuration = CreateConfiguration(new Dictionary - { - ["test-key"] = "test-value" - }); - - // Act - string? result = TestCtsCommand.TestGetOptionalValue(configuration, "test-key"); - - // Assert - Assert.AreEqual("test-value", result); - } - - [TestMethod] - public void GetOptionalValue_WithoutValue_ReturnsDefault() - { - // Arrange - IConfiguration configuration = CreateConfiguration(new Dictionary()); - - // Act - string? result = TestCtsCommand.TestGetOptionalValue(configuration, "missing-key", "default-value"); - - // Assert - Assert.AreEqual("default-value", result); - } - - [TestMethod] - public void GetRequiredValue_WithValue_ReturnsValue() - { - // Arrange - IConfiguration configuration = CreateConfiguration(new Dictionary - { - ["test-key"] = "test-value" - }); - - // Act - string result = TestCtsCommand.TestGetRequiredValue(configuration, "test-key"); - - // Assert - Assert.AreEqual("test-value", result); - } - - [TestMethod] - public void GetRequiredValue_WithoutValue_ThrowsArgumentNullException() - { - // Arrange - IConfiguration configuration = CreateConfiguration(new Dictionary()); - - // Act & Assert - Assert.ThrowsException(() => - TestCtsCommand.TestGetRequiredValue(configuration, "missing-key")); - } - - private static IConfiguration CreateConfiguration(Dictionary values) - { - return new ConfigurationBuilder() - .AddInMemoryCollection(values!) - .Build(); - } - - /// - /// Test wrapper to access protected static methods in CtsCommandBase. - /// - private class TestCtsCommand : CtsCommandBase - { - public override string Name => "test"; - public override string Description => "Test"; - public override string Usage => "Test"; - public override IDictionary Options => new Dictionary(); - - protected override string GetExamples() => "Test"; - - protected override void AddAdditionalFileValidation(Dictionary requiredFiles, IConfiguration configuration) { } - - protected override Task<(PluginExitCode exitCode, object? result)> ExecuteSpecificOperation( - CodeTransparencyClient client, CoseSign1Message message, byte[] signatureBytes, - string endpoint, string payloadPath, string signaturePath, - IConfiguration configuration, CancellationToken cancellationToken) - { - return Task.FromResult<(PluginExitCode, object?)>((PluginExitCode.Success, null)); - } - - public static PluginExitCode TestValidateCommonParameters(IConfiguration configuration, out int timeoutSeconds, IPluginLogger? logger) - => ValidateCommonParameters(configuration, out timeoutSeconds, logger); - - public static PluginExitCode TestValidateFilePaths(Dictionary filePaths, IPluginLogger? logger) - => ValidateFilePaths(filePaths, logger); - - public static Task<(CoseSign1Message? message, byte[] signatureBytes, PluginExitCode result)> TestReadAndDecodeCoseMessage( - string signaturePath, CancellationToken cancellationToken, IPluginLogger? logger) - => ReadAndDecodeCoseMessage(signaturePath, cancellationToken, logger); - - public static Task TestWriteJsonResult(string outputPath, object result, CancellationToken cancellationToken, IPluginLogger? logger) - => WriteJsonResult(outputPath, result, cancellationToken, logger); - - public static PluginExitCode TestHandleCommonException(Exception ex, IConfiguration configuration, CancellationToken cancellationToken, IPluginLogger? logger) - => HandleCommonException(ex, configuration, cancellationToken, logger); - - public static CancellationTokenSource TestCreateTimeoutCancellationToken(int timeoutSeconds, CancellationToken cancellationToken) - => CreateTimeoutCancellationToken(timeoutSeconds, cancellationToken); - - public void TestPrintOperationStatus(string operation, string endpoint, string payloadPath, string signaturePath, int signatureSize, string? additionalInfo) - => PrintOperationStatus(operation, endpoint, payloadPath, signaturePath, signatureSize, additionalInfo); - - public static string? TestGetOptionalValue(IConfiguration configuration, string key, string? defaultValue = null) - => GetOptionalValue(configuration, key, defaultValue); - - public static string TestGetRequiredValue(IConfiguration configuration, string key) - => GetRequiredValue(configuration, key); - } -} diff --git a/CoseSignTool.MST.Plugin/CodeTransparencyClientHelper.cs b/CoseSignTool.MST.Plugin/CodeTransparencyClientHelper.cs index 437c4c84..8c221df6 100644 --- a/CoseSignTool.MST.Plugin/CodeTransparencyClientHelper.cs +++ b/CoseSignTool.MST.Plugin/CodeTransparencyClientHelper.cs @@ -25,6 +25,8 @@ internal static class CodeTransparencyClientHelper public static async Task CreateClientAsync(string endpoint, string? tokenEnvVarName, CancellationToken cancellationToken = default) { Uri uri = new Uri(endpoint); + CodeTransparencyClientOptions clientOptions = new CodeTransparencyClientOptions(); + clientOptions.ConfigureTransactionNotCachedRetry(); // Use the specified environment variable name or default to MST_TOKEN string envVarName = tokenEnvVarName ?? "MST_TOKEN"; @@ -36,7 +38,7 @@ public static async Task CreateClientAsync(string endpoi // Use AzureKeyCredential for access tokens as documented in: // https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/confidentialledger/Azure.Security.CodeTransparency/samples/Sample3_UseYourCredentials.md AzureKeyCredential credential = new AzureKeyCredential(token); - return new CodeTransparencyClient(uri, credential); + return new CodeTransparencyClient(uri, credential, clientOptions); } // Use default Azure credential (managed identity, Azure CLI, etc.) when no token is provided @@ -45,7 +47,7 @@ public static async Task CreateClientAsync(string endpoi DefaultAzureCredential defaultCred = new DefaultAzureCredential(); // CodeQL [SM05137] This is non-production testing code which is not deployed. string[] defaultScopes = new[] { "https://confidential-ledger.azure.com/.default" }; AccessToken defaultToken = await defaultCred.GetTokenAsync(new TokenRequestContext(defaultScopes), cancellationToken); - return new CodeTransparencyClient(uri, new AzureKeyCredential(defaultToken.Token)); + return new CodeTransparencyClient(uri, new AzureKeyCredential(defaultToken.Token), clientOptions); } } diff --git a/CoseSignTool.MST.Plugin/CtsCommandBase.cs b/CoseSignTool.MST.Plugin/CtsCommandBase.cs deleted file mode 100644 index f718c7f0..00000000 --- a/CoseSignTool.MST.Plugin/CtsCommandBase.cs +++ /dev/null @@ -1,332 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace CoseSignTool.MST.Plugin; - -using System.Text.Json; -using System.Security.Cryptography.Cose; - -/// -/// Base class for Microsoft's Signing Transparency (MST) commands that provides common functionality -/// for parameter validation, file operations, error handling, and result output. -/// -public abstract class CtsCommandBase : PluginCommandBase -{ - /// - /// Common command options shared across all MST commands. - /// - protected static readonly Dictionary CommonOptions = new() - { - { "endpoint", "The Microsoft's Signing Transparency (MST) service endpoint URL" }, - { "token-env", "The name of the environment variable containing the access token (default: MST_TOKEN)" }, - { "payload", "The file path to the payload file" }, - { "signature", "The file path to the COSE Sign1 signature file" }, - { "output", "The file path where the result will be written (optional)" }, - { "timeout", "Timeout in seconds for the operation (default: 30)" } - }; - - /// - /// Validates common parameters and returns parsed timeout value. - /// - /// The configuration containing command arguments. - /// The parsed timeout value in seconds. - /// Optional logger for error reporting. - /// PluginExitCode indicating validation result. - protected static PluginExitCode ValidateCommonParameters(IConfiguration configuration, out int timeoutSeconds, IPluginLogger? logger = null) - { - string timeoutString = GetOptionalValue(configuration, "timeout", "30") ?? "30"; - - if (!int.TryParse(timeoutString, out timeoutSeconds) || timeoutSeconds <= 0) - { - logger?.LogError("Invalid timeout value. Must be a positive integer."); - return PluginExitCode.InvalidArgumentValue; - } - - return PluginExitCode.Success; - } - - /// - /// Validates that required file paths exist. - /// - /// Dictionary of file descriptions to file paths. - /// Optional logger for error reporting. - /// PluginExitCode indicating validation result. - protected static PluginExitCode ValidateFilePaths(Dictionary filePaths, IPluginLogger? logger = null) - { - foreach (KeyValuePair kvp in filePaths) - { - if (!File.Exists(kvp.Value)) - { - logger?.LogError($"{kvp.Key} file not found: {kvp.Value}"); - return PluginExitCode.UserSpecifiedFileNotFound; - } - } - - return PluginExitCode.Success; - } - - /// - /// Reads and decodes a COSE Sign1 message from a file. - /// - /// Path to the signature file. - /// Cancellation token. - /// Optional logger for error reporting. - /// A tuple containing the decoded message, signature bytes, and operation result. - protected static async Task<(CoseSign1Message? message, byte[] signatureBytes, PluginExitCode result)> - ReadAndDecodeCoseMessage(string signaturePath, CancellationToken cancellationToken, IPluginLogger? logger = null) - { - try - { - byte[] signatureBytes = await File.ReadAllBytesAsync(signaturePath, cancellationToken); - CoseSign1Message message = CoseMessage.DecodeSign1(signatureBytes); - return (message, signatureBytes, PluginExitCode.Success); - } - catch (Exception ex) - { - logger?.LogError($"Failed to decode COSE Sign1 message from {signaturePath}: {ex.Message}"); - logger?.LogException(ex); - return (null, Array.Empty(), PluginExitCode.InvalidArgumentValue); - } - } - - /// - /// Creates a timeout-aware cancellation token that combines the provided token with a timeout. - /// - /// Timeout in seconds. - /// Original cancellation token. - /// Combined cancellation token with timeout. - protected static CancellationTokenSource CreateTimeoutCancellationToken(int timeoutSeconds, CancellationToken cancellationToken) - { - CancellationTokenSource timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); - CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - // Register disposal of the timeout CTS when the linked CTS is disposed - linkedCts.Token.Register(() => timeoutCts.Dispose()); - - return linkedCts; - } - - /// - /// Writes a JSON result to the specified output file. - /// - /// Path to write the JSON result. - /// The object to serialize as JSON. - /// Cancellation token. - /// Optional logger for status reporting. - protected static async Task WriteJsonResult(string outputPath, object result, CancellationToken cancellationToken, IPluginLogger? logger = null) - { - string jsonOutput = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); - await File.WriteAllTextAsync(outputPath, jsonOutput, cancellationToken); - logger?.LogInformation($"Result written to: {outputPath}"); - } - - /// - /// Prints operation status information using the logger. - /// - /// The operation being performed (e.g., "Registering", "Verifying"). - /// The CTS endpoint URL. - /// Path to the payload file. - /// Path to the signature file. - /// Size of the signature in bytes. - /// Optional additional information to display. - protected void PrintOperationStatus(string operation, string endpoint, string payloadPath, - string signaturePath, int signatureSize, string? additionalInfo = null) - { - Logger.LogInformation($"{operation} COSE Sign1 message with Azure CTS..."); - Logger.LogVerbose($" Endpoint: {endpoint}"); - Logger.LogVerbose($" Payload: {payloadPath}"); - Logger.LogVerbose($" Signature: {signaturePath} ({signatureSize} bytes)"); - - if (!string.IsNullOrEmpty(additionalInfo)) - { - Logger.LogVerbose($" {additionalInfo}"); - } - } - - /// - /// Handles common exceptions and returns appropriate exit codes. - /// - /// The exception to handle. - /// Configuration for getting timeout value in error messages. - /// The original cancellation token to check if operation was cancelled. - /// Optional logger for error reporting. - /// Appropriate PluginExitCode for the exception type. - protected static PluginExitCode HandleCommonException(Exception ex, IConfiguration configuration, CancellationToken cancellationToken, IPluginLogger? logger = null) - { - return ex switch - { - ArgumentNullException argEx => - HandleError($"Missing required argument - {argEx.ParamName}", PluginExitCode.MissingRequiredOption, logger), - - FileNotFoundException fileEx => - HandleError($"File not found - {fileEx.Message}", PluginExitCode.UserSpecifiedFileNotFound, logger), - - OperationCanceledException when cancellationToken.IsCancellationRequested => - HandleError("Operation was cancelled.", PluginExitCode.UnknownError, logger), - - OperationCanceledException => - HandleError($"Operation timed out after {GetOptionalValue(configuration, "timeout", "30")} seconds.", PluginExitCode.UnknownError, logger), - - _ => - HandleError(ex.Message, PluginExitCode.UnknownError, logger) - }; - - static PluginExitCode HandleError(string message, PluginExitCode code, IPluginLogger? logger) - { - logger?.LogError(message); - return code; - } - } - - /// - /// Creates an Azure CTS client using the shared helper. - /// - /// The CTS endpoint URL. - /// Name of the environment variable containing the access token. - /// Cancellation token. - /// Configured CodeTransparencyClient. - protected static Task CreateCtsClient(string endpoint, string? tokenEnvVarName, CancellationToken cancellationToken) - { - return CodeTransparencyClientHelper.CreateClientAsync(endpoint, tokenEnvVarName, cancellationToken); - } - - /// - /// Template method that defines the common execution flow for CTS commands. - /// Derived classes override the specific operation method. - /// - /// Command configuration. - /// Cancellation token. - /// Plugin exit code indicating success or failure. - public override async Task ExecuteAsync(IConfiguration configuration, CancellationToken cancellationToken = default) - { - try - { - Logger.LogVerbose("Starting CTS operation"); - - // Get required parameters - string endpoint = GetRequiredValue(configuration, "endpoint"); - Logger.LogVerbose($"Endpoint: {endpoint}"); - string payloadPath = GetRequiredValue(configuration, "payload"); - string signaturePath = GetRequiredValue(configuration, "signature"); - - // Get optional parameters - string? tokenEnvVarName = GetOptionalValue(configuration, "token-env"); - string? outputPath = GetOptionalValue(configuration, "output"); - - // Validate common parameters - PluginExitCode validationResult = ValidateCommonParameters(configuration, out int timeoutSeconds, Logger); - if (validationResult != PluginExitCode.Success) - { - return validationResult; - } - - // Validate file paths - Dictionary requiredFiles = new Dictionary - { - { "Payload", payloadPath }, - { "Signature", signaturePath } - }; - - // Add any additional file validation from derived classes - AddAdditionalFileValidation(requiredFiles, configuration); - - validationResult = ValidateFilePaths(requiredFiles, Logger); - if (validationResult != PluginExitCode.Success) - { - return validationResult; - } - - // Read and decode COSE message - (CoseSign1Message message, byte[] signatureBytes, PluginExitCode readResult) = await ReadAndDecodeCoseMessage(signaturePath, cancellationToken, Logger); - if (readResult != PluginExitCode.Success || message == null) - { - return readResult; - } - - // Create CTS client - CodeTransparencyClient client = await CreateCtsClient(endpoint, tokenEnvVarName, cancellationToken); - - // Execute the specific operation - using CancellationTokenSource combinedCts = CreateTimeoutCancellationToken(timeoutSeconds, cancellationToken); - (PluginExitCode exitCode, object? result) operationResult = await ExecuteSpecificOperation( - client, message, signatureBytes, endpoint, payloadPath, signaturePath, - configuration, combinedCts.Token); - - // Write output if requested - if (!string.IsNullOrEmpty(outputPath) && operationResult.result != null) - { - await WriteJsonResult(outputPath, operationResult.result, cancellationToken, Logger); - } - - return operationResult.exitCode; - } - catch (Exception ex) - { - return HandleCommonException(ex, configuration, cancellationToken, Logger); - } - } - - /// - /// Allows derived classes to add additional file validation requirements. - /// - /// Dictionary to add additional required files to. - /// Command configuration. - protected virtual void AddAdditionalFileValidation(Dictionary requiredFiles, IConfiguration configuration) - { - // Default implementation - no additional files required - } - - /// - /// Executes the specific operation for the derived command (register, verify, etc.). - /// - /// The Azure CTS client. - /// The decoded COSE Sign1 message. - /// The raw signature bytes. - /// The CTS endpoint URL. - /// Path to the payload file. - /// Path to the signature file. - /// Command configuration. - /// Cancellation token with timeout. - /// Tuple containing the exit code and optional result object for JSON output. - protected abstract Task<(PluginExitCode exitCode, object? result)> ExecuteSpecificOperation( - CodeTransparencyClient client, - CoseSign1Message message, - byte[] signatureBytes, - string endpoint, - string payloadPath, - string signaturePath, - IConfiguration configuration, - CancellationToken cancellationToken); - - /// - /// Gets the base usage string common to all MST commands. - /// - protected virtual string GetBaseUsage(string commandName, string verb) - { - return $"CoseSignTool {commandName} --endpoint --payload --signature [options]{Environment.NewLine}" + - $"{Environment.NewLine}" + - $"Required arguments:{Environment.NewLine}" + - $" --endpoint The Microsoft's Signing Transparency (MST) service endpoint URL{Environment.NewLine}" + - $" --payload The file path to the payload to {verb}{Environment.NewLine}" + - $" --signature The file path to the COSE Sign1 signature file{Environment.NewLine}" + - $"{Environment.NewLine}" + - $"Optional arguments:{Environment.NewLine}" + - $" --token-env Name of environment variable containing access token{Environment.NewLine}" + - $" (default: MST_TOKEN, uses default Azure credential if not specified){Environment.NewLine}" + - $" --output File path where {verb} result will be written{Environment.NewLine}"; - } - - /// - /// Gets command-specific examples. Must be implemented by derived classes. - /// - protected abstract string GetExamples(); - - /// - /// Gets additional optional arguments specific to the command. - /// - protected virtual string GetAdditionalOptionalArguments() - { - return string.Empty; - } -} - diff --git a/CoseSignTool.MST.Plugin/MstCommandBase.cs b/CoseSignTool.MST.Plugin/MstCommandBase.cs index c5d8d010..2d2cb052 100644 --- a/CoseSignTool.MST.Plugin/MstCommandBase.cs +++ b/CoseSignTool.MST.Plugin/MstCommandBase.cs @@ -12,6 +12,11 @@ namespace CoseSignTool.MST.Plugin; /// public abstract class MstCommandBase : PluginCommandBase { + /// + /// The default polling interval (in milliseconds) used when checking MST entry availability. + /// + protected const int DefaultPollingIntervalMs = 250; + /// /// Common command options shared across all MST commands. /// diff --git a/CoseSignTool.MST.Plugin/RegisterCommand.cs b/CoseSignTool.MST.Plugin/RegisterCommand.cs index 1596d88a..7e2a7005 100644 --- a/CoseSignTool.MST.Plugin/RegisterCommand.cs +++ b/CoseSignTool.MST.Plugin/RegisterCommand.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using CoseSign1.Transparent.MST.Extensions; using System.Security.Cryptography.Cose; namespace CoseSignTool.MST.Plugin; @@ -47,8 +46,13 @@ protected override string GetExamples() CancellationToken cancellationToken) { Logger.LogVerbose("Creating transparency service"); - // Create the transparency service with logging + // Create the transparency service with polling options and logging + var pollingOptions = new CoseSign1.Transparent.MST.MstPollingOptions + { + PollingInterval = TimeSpan.FromMilliseconds(DefaultPollingIntervalMs) + }; CoseSign1.Transparent.TransparencyService transparencyService = client.ToCoseSign1TransparencyService( + pollingOptions, logVerbose: Logger.LogVerbose, logWarning: Logger.LogWarning, logError: Logger.LogError); diff --git a/CoseSignTool.MST.Plugin/Usings.cs b/CoseSignTool.MST.Plugin/Usings.cs index 59968bc1..214d629f 100644 --- a/CoseSignTool.MST.Plugin/Usings.cs +++ b/CoseSignTool.MST.Plugin/Usings.cs @@ -9,4 +9,3 @@ global using CoseSignTool.Abstractions; global using Azure; global using Azure.Security.CodeTransparency; -global using CoseSign1.Transparent.MST.Extensions; diff --git a/CoseSignTool.MST.Plugin/VerifyCommand.cs b/CoseSignTool.MST.Plugin/VerifyCommand.cs index c1e4eef1..6a12d824 100644 --- a/CoseSignTool.MST.Plugin/VerifyCommand.cs +++ b/CoseSignTool.MST.Plugin/VerifyCommand.cs @@ -84,12 +84,18 @@ protected override void AddAdditionalFileValidation(Dictionary r Logger.LogVerbose("Creating transparency service with verification options"); - // Create the transparency service with verification options and logging + // Create the transparency service with verification options, polling, and logging + var pollingOptions = new CoseSign1.Transparent.MST.MstPollingOptions + { + PollingInterval = TimeSpan.FromMilliseconds(DefaultPollingIntervalMs) + }; CoseSign1.Transparent.TransparencyService transparencyService = new CoseSign1.Transparent.MST.MstTransparencyService( client, verificationOptions, null, + pollingOptions, + null, msg => Logger.LogVerbose(msg), msg => Logger.LogWarning(msg), msg => Logger.LogError(msg)); diff --git a/CoseSignTool.sln b/CoseSignTool.sln index 59225ea2..a37d4c6d 100644 --- a/CoseSignTool.sln +++ b/CoseSignTool.sln @@ -95,6 +95,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoseSignTool.IndirectSignat EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoseSignTool.AzureTrustedSigning.Plugin", "CoseSignTool.AzureTrustedSigning.Plugin\CoseSignTool.AzureTrustedSigning.Plugin.csproj", "{D41AF01F-9174-4F29-A030-8BA2FF43E8D2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Core.TestCommon", "Azure.Core.TestCommon\Azure.Core.TestCommon.csproj", "{6EB58A79-D799-4038-BF97-027F50CE69BD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -539,6 +541,22 @@ Global {D41AF01F-9174-4F29-A030-8BA2FF43E8D2}.Release|x64.Build.0 = Release|Any CPU {D41AF01F-9174-4F29-A030-8BA2FF43E8D2}.Release|x86.ActiveCfg = Release|Any CPU {D41AF01F-9174-4F29-A030-8BA2FF43E8D2}.Release|x86.Build.0 = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|ARM64.Build.0 = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|x64.ActiveCfg = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|x64.Build.0 = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|x86.ActiveCfg = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|x86.Build.0 = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|Any CPU.Build.0 = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|ARM64.ActiveCfg = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|ARM64.Build.0 = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|x64.ActiveCfg = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|x64.Build.0 = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|x86.ActiveCfg = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Directory.Packages.props b/Directory.Packages.props index bc9c8564..b5fa83a6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,9 +17,9 @@ - - - + + + diff --git a/docs/CoseSign1.Transparent.md b/docs/CoseSign1.Transparent.md index ab4c596e..01560104 100644 --- a/docs/CoseSign1.Transparent.md +++ b/docs/CoseSign1.Transparent.md @@ -22,12 +22,18 @@ using System.Security.Cryptography.Cose; using CoseSign1.Transparent; using CoseSign1.Transparent.Extensions; using CoseSign1.Transparent.MST; -using CoseSign1.Transparent.MST.Extensions; ``` +> **Note:** Extension methods on `CodeTransparencyClient` and `CodeTransparencyClientOptions` +> are placed in the `Azure.Security.CodeTransparency` namespace and are available automatically +> without any additional `using` statements. + ## Features - **Transparent Message Creation**: Register a message with a transparency service and embed receipts. - **Verification**: Verify embedded receipts (and/or specific receipts) against a transparency service. +- **TransactionNotCached Fast Retry**: Automatic aggressive retry for the MST `GetEntryStatement` 503 pattern. +- **Polling Options**: Configurable polling intervals and custom delay strategies for long-running operations. +- **CBOR Problem Details**: RFC 9290 error parsing for MST service error responses. ## Usage @@ -75,6 +81,34 @@ public class TransparencyExample } ``` +#### Example: Creating a Transparent Message with Polling Options +```csharp +using Azure.Security.CodeTransparency; +using CoseSign1.Transparent; +using CoseSign1.Transparent.Extensions; +using CoseSign1.Transparent.MST; + +public class TransparencyWithPollingExample +{ + public async Task CreateTransparentMessageWithPolling(CodeTransparencyClient client) + { + CoseSign1Message message = new CoseSign1Message { Content = new byte[] { 1, 2, 3 } }; + + var pollingOptions = new MstPollingOptions + { + PollingInterval = TimeSpan.FromMilliseconds(250) + }; + + // Extension method with polling options — no extra 'using' needed + TransparencyService service = client.ToCoseSign1TransparencyService( + pollingOptions, + logVerbose: Console.WriteLine); + + CoseSign1Message result = await message.MakeTransparentAsync(service); + } +} +``` + ### 2. Verifying Transparency To verify transparency for a COSE Sign1 message, use `VerifyTransparencyAsync`. @@ -86,7 +120,6 @@ using System.Threading.Tasks; using Azure.Security.CodeTransparency; using CoseSign1.Transparent; using CoseSign1.Transparent.Extensions; -using CoseSign1.Transparent.MST.Extensions; public class TransparencyExample { @@ -108,7 +141,6 @@ using System.Security.Cryptography.Cose; using System.Threading.Tasks; using Azure.Security.CodeTransparency; using CoseSign1.Transparent; -using CoseSign1.Transparent.MST.Extensions; public class TransparencyExample { @@ -247,6 +279,101 @@ var service = new MyTransparencyService( #### Common Exceptions - `InvalidOperationException`: Thrown when attempting to create or verify a transparent message without the necessary metadata. - `ArgumentNullException`: Thrown when required parameters are null. +- `MstServiceException`: Thrown when the MST service returns an error. Includes parsed CBOR problem details (RFC 9290) when available. + +##### Example: Catching MstServiceException +```csharp +try +{ + var result = await message.MakeTransparentAsync(transparencyService); +} +catch (MstServiceException ex) +{ + Console.WriteLine($"MST error: {ex.Message}"); + if (ex.ProblemDetails != null) + { + Console.WriteLine($" Status: {ex.StatusCode}"); + Console.WriteLine($" Detail: {ex.ProblemDetails.Detail}"); + } +} +catch (InvalidOperationException ex) +{ + Console.WriteLine($"Invalid operation: {ex.Message}"); +} +``` + +### TransactionNotCached Fast Retry Policy + +The MST service returns HTTP 503 with `Retry-After: 1` when a newly registered entry hasn't +propagated yet (`TransactionNotCached`). The entry typically becomes available in well under +1 second, but the Azure SDK's default retry respects the server's 1-second `Retry-After` header. + +The `MstTransactionNotCachedPolicy` solves this by performing its own fast retry loop +(250 ms intervals, up to 8 retries ≈ 2 seconds) that only targets this specific error. +All other requests pass through untouched. + +#### Enabling the Policy via Extension Method +```csharp +var options = new CodeTransparencyClientOptions(); +options.ConfigureTransactionNotCachedRetry(); // 250ms × 8 retries (default) +var client = new CodeTransparencyClient(endpoint, credential, options); +``` + +#### Custom Retry Settings +```csharp +var options = new CodeTransparencyClientOptions(); +options.ConfigureTransactionNotCachedRetry( + retryDelay: TimeSpan.FromMilliseconds(100), // faster polling + maxRetries: 16); // longer window +``` + +#### Manual Policy Registration +```csharp +using Azure.Core.Pipeline; + +var options = new CodeTransparencyClientOptions(); +options.AddPolicy( + new MstTransactionNotCachedPolicy(TimeSpan.FromMilliseconds(200), 10), + HttpPipelinePosition.PerRetry); +``` + +> **Important:** This policy does **not** affect the SDK's global `RetryOptions`. The fast +> retry loop runs entirely within the policy and only targets HTTP 503 responses to +> `GET /entries/` requests containing a `TransactionNotCached` CBOR error code. + +### Polling Options + +The `MstPollingOptions` class controls how `MstTransparencyService` polls for completed +receipt registrations after `CreateEntryAsync`. + +#### Fixed Interval Polling +```csharp +var pollingOptions = new MstPollingOptions +{ + PollingInterval = TimeSpan.FromSeconds(2) +}; +var service = new MstTransparencyService(client, pollingOptions); +``` + +#### Via Extension Method +```csharp +TransparencyService service = client.ToCoseSign1TransparencyService( + pollingOptions, + logVerbose: Console.WriteLine, + logError: Console.Error.WriteLine); +``` + +#### Custom Delay Strategy +```csharp +using Azure.Core; + +var pollingOptions = new MstPollingOptions +{ + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(500)) +}; +``` + +> If both `DelayStrategy` and `PollingInterval` are set, `DelayStrategy` takes precedence. ##### Example: ```csharp