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