From 5c19b659f7e8ef64a86a5d6cec70194499ec1fed Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 18:42:49 +0300 Subject: [PATCH 1/7] docs: add implementation plan for pipeline prerequisites Bite-sized TDD plan for slices 2-3: DexpaceClientOptions POCOs, the Dexpace.Sdk ActivitySource/Meter, UrlRedactor, and PipelineContext. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-15-pipeline-prerequisites.md | 990 ++++++++++++++++++ 1 file changed, 990 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-pipeline-prerequisites.md diff --git a/docs/superpowers/plans/2026-06-15-pipeline-prerequisites.md b/docs/superpowers/plans/2026-06-15-pipeline-prerequisites.md new file mode 100644 index 0000000..8975add --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-pipeline-prerequisites.md @@ -0,0 +1,990 @@ +# Pipeline Prerequisites Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add four small building blocks (`DexpaceClientOptions`, `DexpaceDiagnostics`, `UrlRedactor`, `PipelineContext`) to `Dexpace.Sdk.Core` so the pipeline and policy slices have their configuration/observability/context foundation. + +**Architecture:** Each new type lives in its own file under a new namespace folder (`Configuration/`, `Diagnostics/`, `Pipeline/`). All types are plain BCL — no new runtime packages needed for options or context; `System.Diagnostics.DiagnosticSource` (ActivitySource + Meter) ships in-box on net8/net10 BCL, so no NuGet package is required either. Four independent tasks, one commit each, driven by TDD. + +**Tech Stack:** C# `latest`, net8.0 / net10.0 multi-target, xUnit, `System.Diagnostics.ActivitySource`, `System.Diagnostics.Metrics.Meter`. + +--- + +## Environment notes (critical) + +- Build: `dotnet build /Users/omar/dexpace/dotnet-sdk -c Release` — 0 warnings required (TreatWarningsAsErrors; CS1591 doc gate; EnforceCodeStyleInBuild). +- Test: `dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj` + **Never** `dotnet test` bare — net8 runtime absent on this machine. +- Every new `.cs` file must start with the two-line MIT header: + ```csharp + // Copyright (c) 2026 dexpace and Omar Aljarrah. + // Licensed under the MIT License. See LICENSE in the repository root for details. + ``` +- Every `public` member needs a `///` XML doc comment. +- File-scoped namespaces (`namespace Foo;`), not block-scoped. +- Central Package Management: version goes in `Directory.Packages.props`; `PackageReference` carries no `Version`. + +--- + +## File map + +| New file | Responsibility | +|---|---| +| `src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs` | Root options POCO + `RetryOptions` + `RedirectOptions` | +| `tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs` | Task A tests | +| `src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs` | Static `ActivitySource` + `Meter` | +| `tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs` | Task B tests | +| `src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs` | Userinfo + sensitive-query-param redaction | +| `tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs` | Task C tests | +| `src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs` | Per-call mutable pipeline state | +| `tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs` | Task D tests | + +--- + +## Task A: DexpaceClientOptions POCOs + +**Files:** +- Create: `src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs` +- Create: `tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Configuration; + +public class DexpaceClientOptionsTests +{ + [Fact] + public void DexpaceClientOptions_Defaults_AreCorrect() + { + var opts = new DexpaceClientOptions(); + + Assert.Null(opts.BaseAddress); + Assert.NotNull(opts.UserAgent); + Assert.StartsWith("dexpace-dotnet/", opts.UserAgent); + Assert.Null(opts.OverallTimeout); + Assert.Null(opts.AttemptTimeout); + Assert.NotNull(opts.Retry); + Assert.NotNull(opts.Redirect); + } + + [Fact] + public void RetryOptions_Defaults_AreCorrect() + { + var retry = new RetryOptions(); + + Assert.Equal(3, retry.MaxRetryAttempts); + Assert.Equal(TimeSpan.FromMilliseconds(200), retry.BaseDelay); + Assert.Equal(TimeSpan.FromSeconds(30), retry.MaxDelay); + Assert.True(retry.HonorRetryAfter); + Assert.False(retry.RetryNonIdempotentWhenReplayable); + } + + [Fact] + public void RedirectOptions_Defaults_AreCorrect() + { + var redirect = new RedirectOptions(); + + Assert.Equal(20, redirect.MaxRedirects); + Assert.False(redirect.AllowHttpsToHttpDowngrade); + Assert.True(redirect.StripSensitiveHeadersOnCrossOrigin); + } + + [Fact] + public void DexpaceClientOptions_RetryAndRedirect_AreNonNullOnFreshInstance() + { + var opts = new DexpaceClientOptions(); + + // Property bag objects must be initialized — not null — so callers can do + // opts.Retry.MaxRetryAttempts = 5 without a null ref. + Assert.NotNull(opts.Retry); + Assert.NotNull(opts.Redirect); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: build error — `Dexpace.Sdk.Core.Configuration` namespace not found. + +- [ ] **Step 3: Create the implementation** + +Create `src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; + +namespace Dexpace.Sdk.Core.Configuration; + +/// +/// Top-level configuration options for the Dexpace SDK client. +/// +/// +/// All properties carry sensible defaults; the client is fully usable with new DexpaceClientOptions(). +/// Per-policy sub-options are exposed as nested objects (, ). +/// +public sealed class DexpaceClientOptions +{ + private static readonly string s_defaultUserAgent = BuildDefaultUserAgent(); + + /// + /// The base address prepended to relative request URLs, or when + /// requests always use absolute URLs. + /// + public Uri? BaseAddress { get; set; } + + /// + /// The User-Agent header value sent with every request. + /// Defaults to dexpace-dotnet/<assembly-version>. + /// + public string UserAgent { get; set; } = s_defaultUserAgent; + + /// + /// The wall-clock deadline for an entire operation (all redirect hops and retry attempts + /// combined), or for no overall deadline. + /// + public TimeSpan? OverallTimeout { get; set; } + + /// + /// The deadline for a single send attempt, or for no per-attempt deadline. + /// + public TimeSpan? AttemptTimeout { get; set; } + + /// + /// Retry-policy options. Defaults to with its built-in defaults. + /// + public RetryOptions Retry { get; set; } = new(); + + /// + /// Redirect-policy options. Defaults to with its built-in defaults. + /// + public RedirectOptions Redirect { get; set; } = new(); + + private static string BuildDefaultUserAgent() + { + var version = typeof(DexpaceClientOptions).Assembly + .GetCustomAttribute() + ?.InformationalVersion + ?? typeof(DexpaceClientOptions).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1"). + var plusIndex = version.IndexOf('+', StringComparison.Ordinal); + if (plusIndex >= 0) + { + version = version[..plusIndex]; + } + + return $"dexpace-dotnet/{version}"; + } +} + +/// +/// Options for the retry policy. +/// +public sealed class RetryOptions +{ + /// + /// The number of retry attempts after the initial send. Defaults to 3. + /// Matches the Polly v8 / Microsoft.Extensions.Http.Resilience naming convention. + /// + public int MaxRetryAttempts { get; set; } = 3; + + /// + /// The base delay for exponential back-off. Defaults to 200 ms. + /// + public TimeSpan BaseDelay { get; set; } = TimeSpan.FromMilliseconds(200); + + /// + /// The maximum back-off delay cap. Defaults to 30 s. + /// + public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// When , the retry policy respects a Retry-After response + /// header. Defaults to . + /// + public bool HonorRetryAfter { get; set; } = true; + + /// + /// When , the retry policy may retry non-idempotent methods (e.g. + /// POST) if the request body is replayable. Defaults to . + /// + public bool RetryNonIdempotentWhenReplayable { get; set; } +} + +/// +/// Options for the redirect-following policy. +/// +public sealed class RedirectOptions +{ + /// + /// The maximum number of redirect hops to follow. Defaults to 20, + /// matching browser and HttpClient norms. + /// + public int MaxRedirects { get; set; } = 20; + + /// + /// When , the policy follows https → http downgrade redirects. + /// Defaults to for security. + /// + public bool AllowHttpsToHttpDowngrade { get; set; } + + /// + /// When , sensitive headers (e.g. Authorization) are stripped + /// when the redirect crosses an origin boundary. Defaults to . + /// + public bool StripSensitiveHeadersOnCrossOrigin { get; set; } = true; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: all prior tests still pass, plus 4 new ones — total 34 passed, 0 failed. + +- [ ] **Step 5: Verify full build is clean (0 warnings)** + +```bash +dotnet build /Users/omar/dexpace/dotnet-sdk -c Release +``` + +Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. + +- [ ] **Step 6: Commit** + +```bash +git -C /Users/omar/dexpace/dotnet-sdk add \ + src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs \ + tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs +git -C /Users/omar/dexpace/dotnet-sdk commit -m "feat: add DexpaceClientOptions and per-policy option types" +``` + +--- + +## Task B: Diagnostics ActivitySource and Meter + +**Files:** +- Create: `src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs` +- Create: `tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs` + +**Note on package:** `ActivitySource` lives in `System.Diagnostics` and `Meter` in `System.Diagnostics.Metrics`; both ship in the BCL on net8+ via `System.Diagnostics.DiagnosticSource`. Try building first — if the types are missing, add `System.Diagnostics.DiagnosticSource` to `Directory.Packages.props` and a `PackageReference` (no `Version`) in the Core `.csproj`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Diagnostics; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Diagnostics; + +public class DexpaceDiagnosticsTests +{ + [Fact] + public void ActivitySource_HasCorrectName() + { + Assert.Equal("Dexpace.Sdk", DexpaceDiagnostics.ActivitySource.Name); + } + + [Fact] + public void Meter_HasCorrectName() + { + Assert.Equal("Dexpace.Sdk", DexpaceDiagnostics.Meter.Name); + } + + [Fact] + public void ActivitySource_VersionIsNonEmpty() + { + Assert.False(string.IsNullOrEmpty(DexpaceDiagnostics.ActivitySource.Version)); + } + + [Fact] + public void Meter_VersionIsNonEmpty() + { + Assert.False(string.IsNullOrEmpty(DexpaceDiagnostics.Meter.Version)); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: build error — `DexpaceDiagnostics` not found. + +- [ ] **Step 3: Create the implementation** + +Create `src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; + +namespace Dexpace.Sdk.Core.Diagnostics; + +/// +/// Central home for the SDK's OpenTelemetry instrumentation objects. +/// +/// +/// Consumers wire up collection by subscribing to (tracing) and +/// (metrics) via their chosen OTel SDK — no SDK-internal configuration needed. +/// When no listener is active, returns +/// and the hot path allocates nothing for tracing. +/// +public static class DexpaceDiagnostics +{ + private static readonly string s_version = BuildVersion(); + + /// + /// The used by the SDK for distributed tracing. + /// Name: "Dexpace.Sdk". + /// + public static readonly ActivitySource ActivitySource = new("Dexpace.Sdk", s_version); + + /// + /// The used by the SDK for metrics. + /// Name: "Dexpace.Sdk". + /// + public static readonly Meter Meter = new("Dexpace.Sdk", s_version); + + private static string BuildVersion() + { + var version = typeof(DexpaceDiagnostics).Assembly + .GetCustomAttribute() + ?.InformationalVersion + ?? typeof(DexpaceDiagnostics).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1"). + var plusIndex = version.IndexOf('+', StringComparison.Ordinal); + return plusIndex >= 0 ? version[..plusIndex] : version; + } +} +``` + +- [ ] **Step 4: Attempt build — check if BCL types are available** + +```bash +dotnet build /Users/omar/dexpace/dotnet-sdk -c Release +``` + +If build succeeds with 0 warnings, proceed to Step 5. + +If build fails with `CS0234`/`CS0246` errors about `ActivitySource` or `Meter` not found: +1. Add to `Directory.Packages.props` inside the existing ``: + ```xml + + ``` +2. Add to `src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj` inside an ``: + ```xml + + ``` +3. Run `dotnet restore /Users/omar/dexpace/dotnet-sdk` then re-run the build. + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: 4 new tests pass, total 38 (or 34 + 4 if Task A was committed), 0 failed. + +- [ ] **Step 6: Commit** + +```bash +git -C /Users/omar/dexpace/dotnet-sdk add \ + src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs \ + tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs +# Include package changes if they were needed: +# git -C /Users/omar/dexpace/dotnet-sdk add Directory.Packages.props src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj +git -C /Users/omar/dexpace/dotnet-sdk commit -m "feat: add Dexpace diagnostics ActivitySource and Meter" +``` + +--- + +## Task C: UrlRedactor + +**Files:** +- Create: `src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs` +- Create: `tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Diagnostics; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Diagnostics; + +public class UrlRedactorTests +{ + // Use the default-set instance for most tests. + private static readonly UrlRedactor DefaultRedactor = new(); + + [Fact] + public void Redact_UserInfo_IsStripped() + { + var uri = new Uri("https://user:secret@api.example.com/path"); + var result = DefaultRedactor.Redact(uri); + + Assert.DoesNotContain("user", result); + Assert.DoesNotContain("secret", result); + Assert.Contains("api.example.com", result); + } + + [Fact] + public void Redact_SensitiveQueryParam_ValueIsReplaced() + { + var uri = new Uri("https://api.example.com/v1/items?access_token=super-secret&page=2"); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("access_token=REDACTED", result); + Assert.Contains("page=2", result); + Assert.DoesNotContain("super-secret", result); + } + + [Fact] + public void Redact_NonSensitiveQueryParam_IsPreserved() + { + var uri = new Uri("https://api.example.com/search?q=hello&lang=en"); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("q=hello", result); + Assert.Contains("lang=en", result); + } + + [Fact] + public void Redact_SensitiveParamCheck_IsCaseInsensitive() + { + var uri = new Uri("https://api.example.com/v1?API_KEY=abc123"); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("API_KEY=REDACTED", result); + Assert.DoesNotContain("abc123", result); + } + + [Fact] + public void Redact_NoQueryString_ReturnsSafeUrl() + { + var uri = new Uri("https://api.example.com/v1/resource"); + var result = DefaultRedactor.Redact(uri); + + Assert.Equal("https://api.example.com/v1/resource", result); + } + + [Fact] + public void Redact_CustomSensitiveParams_AreRedacted() + { + var redactor = new UrlRedactor(["x-custom-secret"]); + var uri = new Uri("https://api.example.com/?x-custom-secret=mysecret&other=value"); + var result = redactor.Redact(uri); + + Assert.Contains("x-custom-secret=REDACTED", result); + Assert.Contains("other=value", result); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: build error — `UrlRedactor` not found. + +- [ ] **Step 3: Create the implementation** + +Create `src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using System.Web; + +namespace Dexpace.Sdk.Core.Diagnostics; + +/// +/// Produces a log-safe string form of a by stripping userinfo and +/// replacing the values of known-sensitive query parameters with REDACTED. +/// +/// +/// Sensitive parameter names are matched case-insensitively. The default set covers the most +/// common credential-bearing parameters; callers may supply a custom set instead. +/// Non-sensitive parameters and all path/host/scheme components are preserved verbatim. +/// +public sealed class UrlRedactor +{ + /// + /// Default set of query parameter names whose values are redacted. + /// + public static readonly IReadOnlyCollection DefaultSensitiveParams = + [ + "access_token", + "token", + "code", + "sig", + "signature", + "api_key", + "apikey", + "password", + ]; + + private readonly HashSet _sensitiveParams; + + /// + /// Initializes a using . + /// + public UrlRedactor() + : this(DefaultSensitiveParams) + { + } + + /// + /// Initializes a with a caller-supplied set of sensitive + /// parameter names (case-insensitive). + /// + /// + /// The query parameter names whose values should be replaced with REDACTED. + /// + public UrlRedactor(IEnumerable sensitiveParams) + { + ArgumentNullException.ThrowIfNull(sensitiveParams); + _sensitiveParams = new HashSet(sensitiveParams, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Returns a log-safe representation of : userinfo is always stripped; + /// sensitive query parameter values are replaced with REDACTED. + /// + /// The URI to redact. + /// A safe string representation. + public string Redact(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + + var query = uri.Query; + + // Build the base URL without userinfo and without the query string. + var builder = new UriBuilder(uri) + { + UserName = string.Empty, + Password = string.Empty, + Query = string.Empty, + }; + var baseUrl = builder.Uri.GetLeftPart(UriPartial.Path); + + if (string.IsNullOrEmpty(query)) + { + return baseUrl; + } + + // Parse, redact, and re-serialize the query string. + var parsed = HttpUtility.ParseQueryString(query); + var sb = new StringBuilder(); + + foreach (string? key in parsed) + { + if (key is null) + { + continue; + } + + if (sb.Length > 0) + { + sb.Append('&'); + } + + var value = _sensitiveParams.Contains(key) ? "REDACTED" : parsed[key]; + sb.Append(Uri.EscapeDataString(key)); + sb.Append('='); + sb.Append(value is null ? string.Empty : Uri.EscapeDataString(value)); + } + + return sb.Length == 0 ? baseUrl : $"{baseUrl}?{sb}"; + } +} +``` + +- [ ] **Step 4: Attempt build — `System.Web` availability check** + +`HttpUtility.ParseQueryString` lives in `System.Web`. On net8+ it is in-box. Build to verify: + +```bash +dotnet build /Users/omar/dexpace/dotnet-sdk -c Release +``` + +If build fails with `CS0234` on `System.Web`, replace the `HttpUtility.ParseQueryString` approach with the manual query-string parser below (drop `using System.Web;`, use this helper instead): + +```csharp +// Drop-in replacement for HttpUtility.ParseQueryString — no System.Web needed. +private static IEnumerable<(string Key, string Value)> ParseQueryParams(string query) +{ + var span = query.AsSpan().TrimStart('?'); + foreach (var segment in span.Split('&')) + { + var part = span[segment]; + var eq = part.IndexOf('='); + if (eq < 0) continue; + yield return ( + Uri.UnescapeDataString(part[..eq].ToString()), + Uri.UnescapeDataString(part[(eq + 1)..].ToString())); + } +} +``` + +And replace the `ParseQueryString`/`foreach` block in `Redact` with: + +```csharp +var sb = new StringBuilder(); +foreach (var (key, value) in ParseQueryParams(query)) +{ + if (sb.Length > 0) sb.Append('&'); + var redactedValue = _sensitiveParams.Contains(key) ? "REDACTED" : value; + sb.Append(Uri.EscapeDataString(key)); + sb.Append('='); + sb.Append(Uri.EscapeDataString(redactedValue)); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: 6 new tests pass, 0 failed, total grows by 6. + +- [ ] **Step 6: Commit** + +```bash +git -C /Users/omar/dexpace/dotnet-sdk add \ + src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs \ + tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs +git -C /Users/omar/dexpace/dotnet-sdk commit -m "feat: add UrlRedactor for log-safe URLs" +``` + +--- + +## Task D: PipelineContext + +**Files:** +- Create: `src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs` +- Create: `tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +public class PipelineContextTests +{ + private static Request MakeRequest() => + Request.Get("https://api.example.com/v1/resource"); + + [Fact] + public void Constructor_StoresRequest() + { + var request = MakeRequest(); + var options = new DexpaceClientOptions(); + var ctx = new PipelineContext(request, options); + + Assert.Same(request, ctx.Request); + } + + [Fact] + public void Constructor_StoresOptions() + { + var options = new DexpaceClientOptions { UserAgent = "test-agent/1.0" }; + var ctx = new PipelineContext(MakeRequest(), options); + + Assert.Same(options, ctx.Options); + } + + [Fact] + public void Constructor_StoresCancellationToken() + { + using var cts = new CancellationTokenSource(); + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions(), cts.Token); + + Assert.Equal(cts.Token, ctx.CancellationToken); + } + + [Fact] + public void Constructor_DefaultCancellationToken_IsDefault() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + + Assert.Equal(CancellationToken.None, ctx.CancellationToken); + } + + [Fact] + public void AttemptNumber_DefaultsToZero() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + + Assert.Equal(0, ctx.AttemptNumber); + } + + [Fact] + public void Response_DefaultsToNull() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + + Assert.Null(ctx.Response); + } + + [Fact] + public void Activity_DefaultsToNull() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + + Assert.Null(ctx.Activity); + } + + [Fact] + public void PropertyBag_RoundTrips_TypedValue() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + ctx.SetProperty("idempotency-key", "idem-abc123"); + var retrieved = ctx.GetProperty("idempotency-key"); + + Assert.Equal("idem-abc123", retrieved); + } + + [Fact] + public void PropertyBag_MissingKey_ReturnsDefault() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + var result = ctx.GetProperty("nonexistent"); + + Assert.Equal(0, result); + } + + [Fact] + public void PropertyBag_MissingKeyForReferenceType_ReturnsNull() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + var result = ctx.GetProperty("nonexistent"); + + Assert.Null(result); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: build error — `PipelineContext` not found. + +- [ ] **Step 3: Create the implementation** + +Create `src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Diagnostics; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Carries all per-call mutable state as it flows through the pipeline delegate chain. +/// +/// +/// One instance is created per client call and passed to every policy in the chain. Policies +/// read and replace (for redirect/auth rewriting), stash cross-policy +/// coordination data in the property bag (see / +/// ), and observe once the transport has +/// responded. +/// +public sealed class PipelineContext +{ + private Dictionary? _properties; + + /// + /// Initializes a new for a single client call. + /// + /// The initial request. Policies may replace it during the call. + /// A snapshot of the client options for this call. + /// + /// An optional cancellation token that can abort the call. + /// + public PipelineContext( + Request request, + DexpaceClientOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + + Request = request; + Options = options; + CancellationToken = cancellationToken; + } + + /// + /// The current request. Policies (e.g. redirect, auth) may replace this during the call. + /// + public Request Request { get; set; } + + /// + /// The response from the transport, or before the transport responds. + /// Set by the pipeline after the outermost transport send completes. + /// + public Response? Response { get; internal set; } + + /// + /// The active SDK tracing span, or when no + /// listener is registered (near-zero overhead when unobserved). + /// + public Activity? Activity { get; internal set; } + + /// + /// A snapshot of the client options that applies to this call. + /// + public DexpaceClientOptions Options { get; } + + /// + /// A token that can cancel the in-flight operation. + /// + public CancellationToken CancellationToken { get; } + + /// + /// The zero-based retry attempt counter. 0 on the initial send; + /// incremented by the retry policy before each subsequent attempt. + /// + public int AttemptNumber { get; internal set; } + + /// + /// Retrieves a typed value from the cross-policy property bag. + /// + /// The expected type of the stored value. + /// The key used when the value was stored. + /// + /// The stored value cast to , or + /// if the key is absent or the stored value cannot be cast. + /// + public T? GetProperty(string key) + { + if (_properties is null || !_properties.TryGetValue(key, out var value)) + { + return default; + } + + return value is T typed ? typed : default; + } + + /// + /// Stores a typed value in the cross-policy property bag. + /// Overwrites any existing value for . + /// + /// The type of the value to store. + /// A key that identifies the value within this call. + /// The value to store. + public void SetProperty(string key, T value) + { + _properties ??= new Dictionary(StringComparer.Ordinal); + _properties[key] = value; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: 10 new tests pass, 0 failed. + +- [ ] **Step 5: Final build verification (0 warnings)** + +```bash +dotnet build /Users/omar/dexpace/dotnet-sdk -c Release +``` + +Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. + +- [ ] **Step 6: Commit** + +```bash +git -C /Users/omar/dexpace/dotnet-sdk add \ + src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs \ + tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs +git -C /Users/omar/dexpace/dotnet-sdk commit -m "feat: add PipelineContext for per-call pipeline state" +``` + +--- + +## Final verification + +- [ ] **Run full build one last time** + +```bash +dotnet build /Users/omar/dexpace/dotnet-sdk -c Release +``` + +Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. + +- [ ] **Run the full Core test suite on net10.0** + +```bash +dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +``` + +Expected: all prior tests pass, plus ~24 new ones (4+4+6+10). 0 failures. From 7e0867dfcb936c0f0b9452049a4c6381bb349b5b Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 18:45:41 +0300 Subject: [PATCH 2/7] feat: add DexpaceClientOptions and per-policy option types Co-Authored-By: Claude Opus 4.8 --- .../Configuration/DexpaceClientOptions.cs | 127 ++++++++++++++++++ .../DexpaceClientOptionsTests.cs | 57 ++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs diff --git a/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs b/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs new file mode 100644 index 0000000..f81527a --- /dev/null +++ b/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; + +namespace Dexpace.Sdk.Core.Configuration; + +/// +/// Top-level configuration options for the Dexpace SDK client. +/// +/// +/// All properties carry sensible defaults; the client is fully usable with new DexpaceClientOptions(). +/// Per-policy sub-options are exposed as nested objects (, ). +/// +public sealed class DexpaceClientOptions +{ + private static readonly string s_defaultUserAgent = BuildDefaultUserAgent(); + + /// + /// The base address prepended to relative request URLs, or when + /// requests always use absolute URLs. + /// + public Uri? BaseAddress { get; set; } + + /// + /// The User-Agent header value sent with every request. + /// Defaults to dexpace-dotnet/<assembly-version>. + /// + public string UserAgent { get; set; } = s_defaultUserAgent; + + /// + /// The wall-clock deadline for an entire operation (all redirect hops and retry attempts + /// combined), or for no overall deadline. + /// + public TimeSpan? OverallTimeout { get; set; } + + /// + /// The deadline for a single send attempt, or for no per-attempt deadline. + /// + public TimeSpan? AttemptTimeout { get; set; } + + /// + /// Retry-policy options. Defaults to with its built-in defaults. + /// + public RetryOptions Retry { get; set; } = new(); + + /// + /// Redirect-policy options. Defaults to with its built-in defaults. + /// + public RedirectOptions Redirect { get; set; } = new(); + + private static string BuildDefaultUserAgent() + { + var version = typeof(DexpaceClientOptions).Assembly + .GetCustomAttribute() + ?.InformationalVersion + ?? typeof(DexpaceClientOptions).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1"). + var plusIndex = version.IndexOf('+', StringComparison.Ordinal); + if (plusIndex >= 0) + { + version = version[..plusIndex]; + } + + return $"dexpace-dotnet/{version}"; + } +} + +/// +/// Options for the retry policy. +/// +public sealed class RetryOptions +{ + /// + /// The number of retry attempts after the initial send. Defaults to 3. + /// Matches the Polly v8 / Microsoft.Extensions.Http.Resilience naming convention. + /// + public int MaxRetryAttempts { get; set; } = 3; + + /// + /// The base delay for exponential back-off. Defaults to 200 ms. + /// + public TimeSpan BaseDelay { get; set; } = TimeSpan.FromMilliseconds(200); + + /// + /// The maximum back-off delay cap. Defaults to 30 s. + /// + public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// When , the retry policy respects a Retry-After response + /// header. Defaults to . + /// + public bool HonorRetryAfter { get; set; } = true; + + /// + /// When , the retry policy may retry non-idempotent methods (e.g. + /// POST) if the request body is replayable. Defaults to . + /// + public bool RetryNonIdempotentWhenReplayable { get; set; } +} + +/// +/// Options for the redirect-following policy. +/// +public sealed class RedirectOptions +{ + /// + /// The maximum number of redirect hops to follow. Defaults to 20, + /// matching browser and HttpClient norms. + /// + public int MaxRedirects { get; set; } = 20; + + /// + /// When , the policy follows https → http downgrade redirects. + /// Defaults to for security. + /// + public bool AllowHttpsToHttpDowngrade { get; set; } + + /// + /// When , sensitive headers (e.g. Authorization) are stripped + /// when the redirect crosses an origin boundary. Defaults to . + /// + public bool StripSensitiveHeadersOnCrossOrigin { get; set; } = true; +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs b/tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs new file mode 100644 index 0000000..3c7a9b4 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Configuration; + +public class DexpaceClientOptionsTests +{ + [Fact] + public void DexpaceClientOptions_Defaults_AreCorrect() + { + var opts = new DexpaceClientOptions(); + + Assert.Null(opts.BaseAddress); + Assert.NotNull(opts.UserAgent); + Assert.StartsWith("dexpace-dotnet/", opts.UserAgent); + Assert.Null(opts.OverallTimeout); + Assert.Null(opts.AttemptTimeout); + Assert.NotNull(opts.Retry); + Assert.NotNull(opts.Redirect); + } + + [Fact] + public void RetryOptions_Defaults_AreCorrect() + { + var retry = new RetryOptions(); + + Assert.Equal(3, retry.MaxRetryAttempts); + Assert.Equal(TimeSpan.FromMilliseconds(200), retry.BaseDelay); + Assert.Equal(TimeSpan.FromSeconds(30), retry.MaxDelay); + Assert.True(retry.HonorRetryAfter); + Assert.False(retry.RetryNonIdempotentWhenReplayable); + } + + [Fact] + public void RedirectOptions_Defaults_AreCorrect() + { + var redirect = new RedirectOptions(); + + Assert.Equal(20, redirect.MaxRedirects); + Assert.False(redirect.AllowHttpsToHttpDowngrade); + Assert.True(redirect.StripSensitiveHeadersOnCrossOrigin); + } + + [Fact] + public void DexpaceClientOptions_RetryAndRedirect_AreNonNullOnFreshInstance() + { + var opts = new DexpaceClientOptions(); + + // Property bag objects must be initialized — not null — so callers can do + // opts.Retry.MaxRetryAttempts = 5 without a null ref. + Assert.NotNull(opts.Retry); + Assert.NotNull(opts.Redirect); + } +} From e41d74514f24668f45cc60d181c16285a9f56c62 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 18:47:11 +0300 Subject: [PATCH 3/7] feat: add Dexpace diagnostics ActivitySource and Meter Co-Authored-By: Claude Opus 4.8 --- .../Diagnostics/DexpaceDiagnostics.cs | 47 +++++++++++++++++++ .../Diagnostics/DexpaceDiagnosticsTests.cs | 34 ++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs diff --git a/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs b/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs new file mode 100644 index 0000000..991fc1b --- /dev/null +++ b/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; + +namespace Dexpace.Sdk.Core.Diagnostics; + +/// +/// Central home for the SDK's OpenTelemetry instrumentation objects. +/// +/// +/// Consumers wire up collection by subscribing to (tracing) and +/// (metrics) via their chosen OTel SDK — no SDK-internal configuration needed. +/// When no listener is active, ActivitySource.StartActivity returns +/// and the hot path allocates nothing for tracing. +/// +public static class DexpaceDiagnostics +{ + private static readonly string s_version = BuildVersion(); + + /// + /// The used by the SDK for distributed tracing. + /// Name: "Dexpace.Sdk". + /// + public static readonly ActivitySource ActivitySource = new("Dexpace.Sdk", s_version); + + /// + /// The used by the SDK for metrics. + /// Name: "Dexpace.Sdk". + /// + public static readonly Meter Meter = new("Dexpace.Sdk", s_version); + + private static string BuildVersion() + { + var version = typeof(DexpaceDiagnostics).Assembly + .GetCustomAttribute() + ?.InformationalVersion + ?? typeof(DexpaceDiagnostics).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1"). + var plusIndex = version.IndexOf('+', StringComparison.Ordinal); + return plusIndex >= 0 ? version[..plusIndex] : version; + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs new file mode 100644 index 0000000..4357ceb --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Diagnostics; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Diagnostics; + +public class DexpaceDiagnosticsTests +{ + [Fact] + public void ActivitySource_HasCorrectName() + { + Assert.Equal("Dexpace.Sdk", DexpaceDiagnostics.ActivitySource.Name); + } + + [Fact] + public void Meter_HasCorrectName() + { + Assert.Equal("Dexpace.Sdk", DexpaceDiagnostics.Meter.Name); + } + + [Fact] + public void ActivitySource_VersionIsNonEmpty() + { + Assert.False(string.IsNullOrEmpty(DexpaceDiagnostics.ActivitySource.Version)); + } + + [Fact] + public void Meter_VersionIsNonEmpty() + { + Assert.False(string.IsNullOrEmpty(DexpaceDiagnostics.Meter.Version)); + } +} From d47fa8d68e369cee595b5a4c035a15ec7d18aa1e Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 18:50:05 +0300 Subject: [PATCH 4/7] feat: add UrlRedactor for log-safe URLs Co-Authored-By: Claude Opus 4.8 --- .../Diagnostics/UrlRedactor.cs | 117 ++++++++++++++++++ .../Diagnostics/UrlRedactorTests.cs | 75 +++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs diff --git a/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs b/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs new file mode 100644 index 0000000..0c402c1 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs @@ -0,0 +1,117 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; + +namespace Dexpace.Sdk.Core.Diagnostics; + +/// +/// Produces a log-safe string form of a by stripping userinfo and +/// replacing the values of known-sensitive query parameters with REDACTED. +/// +/// +/// Sensitive parameter names are matched case-insensitively. The default set covers the most +/// common credential-bearing parameters; callers may supply a custom set instead. +/// Non-sensitive parameters and all path/host/scheme components are preserved verbatim. +/// +public sealed class UrlRedactor +{ + /// + /// Default set of query parameter names whose values are redacted. + /// + public static readonly IReadOnlyCollection DefaultSensitiveParams = + [ + "access_token", + "token", + "code", + "sig", + "signature", + "api_key", + "apikey", + "password", + ]; + + private readonly HashSet _sensitiveParams; + + /// + /// Initializes a using . + /// + public UrlRedactor() + : this(DefaultSensitiveParams) + { + } + + /// + /// Initializes a with a caller-supplied set of sensitive + /// parameter names (case-insensitive). + /// + /// + /// The query parameter names whose values should be replaced with REDACTED. + /// + public UrlRedactor(IEnumerable sensitiveParams) + { + ArgumentNullException.ThrowIfNull(sensitiveParams); + _sensitiveParams = new HashSet(sensitiveParams, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Returns a log-safe representation of : userinfo is always stripped; + /// sensitive query parameter values are replaced with REDACTED. + /// + /// The URI to redact. + /// A safe string representation. + public string Redact(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + + var query = uri.Query; + + // Build the base URL without userinfo and without the query string. + var builder = new UriBuilder(uri) + { + UserName = string.Empty, + Password = string.Empty, + Query = string.Empty, + }; + var baseUrl = builder.Uri.GetLeftPart(UriPartial.Path); + + if (string.IsNullOrEmpty(query)) + { + return baseUrl; + } + + // Parse, redact, and re-serialize the query string without System.Web dependency. + var sb = new StringBuilder(); + foreach (var (key, value) in ParseQueryParams(query)) + { + if (sb.Length > 0) + { + sb.Append('&'); + } + + var redactedValue = _sensitiveParams.Contains(key) ? "REDACTED" : value; + sb.Append(Uri.EscapeDataString(key)); + sb.Append('='); + sb.Append(Uri.EscapeDataString(redactedValue)); + } + + return sb.Length == 0 ? baseUrl : $"{baseUrl}?{sb}"; + } + + private static IEnumerable<(string Key, string Value)> ParseQueryParams(string query) + { + var raw = query.TrimStart('?'); + foreach (var part in raw.Split('&')) + { + var eq = part.IndexOf('=', StringComparison.Ordinal); + if (eq < 0) + { + continue; + } + + yield return ( + Uri.UnescapeDataString(part[..eq]), + Uri.UnescapeDataString(part[(eq + 1)..])); + } + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs new file mode 100644 index 0000000..f67a25a --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Diagnostics; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Diagnostics; + +public class UrlRedactorTests +{ + // Use the default-set instance for most tests. + private static readonly UrlRedactor DefaultRedactor = new(); + + [Fact] + public void Redact_UserInfo_IsStripped() + { + var uri = new Uri("https://user:secret@api.example.com/path"); + var result = DefaultRedactor.Redact(uri); + + Assert.DoesNotContain("user", result); + Assert.DoesNotContain("secret", result); + Assert.Contains("api.example.com", result); + } + + [Fact] + public void Redact_SensitiveQueryParam_ValueIsReplaced() + { + var uri = new Uri("https://api.example.com/v1/items?access_token=super-secret&page=2"); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("access_token=REDACTED", result); + Assert.Contains("page=2", result); + Assert.DoesNotContain("super-secret", result); + } + + [Fact] + public void Redact_NonSensitiveQueryParam_IsPreserved() + { + var uri = new Uri("https://api.example.com/search?q=hello&lang=en"); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("q=hello", result); + Assert.Contains("lang=en", result); + } + + [Fact] + public void Redact_SensitiveParamCheck_IsCaseInsensitive() + { + var uri = new Uri("https://api.example.com/v1?API_KEY=abc123"); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("API_KEY=REDACTED", result); + Assert.DoesNotContain("abc123", result); + } + + [Fact] + public void Redact_NoQueryString_ReturnsSafeUrl() + { + var uri = new Uri("https://api.example.com/v1/resource"); + var result = DefaultRedactor.Redact(uri); + + Assert.Equal("https://api.example.com/v1/resource", result); + } + + [Fact] + public void Redact_CustomSensitiveParams_AreRedacted() + { + var redactor = new UrlRedactor(["x-custom-secret"]); + var uri = new Uri("https://api.example.com/?x-custom-secret=mysecret&other=value"); + var result = redactor.Redact(uri); + + Assert.Contains("x-custom-secret=REDACTED", result); + Assert.Contains("other=value", result); + } +} From 152ee666d78eae96ccf2d6e839f86d58a0683f41 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 18:51:14 +0300 Subject: [PATCH 5/7] feat: add PipelineContext for per-call pipeline state Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/PipelineContext.cs | 110 ++++++++++++++++++ .../Pipeline/PipelineContextTests.cs | 104 +++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs new file mode 100644 index 0000000..0c2e496 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Diagnostics; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Carries all per-call mutable state as it flows through the pipeline delegate chain. +/// +/// +/// One instance is created per client call and passed to every policy in the chain. Policies +/// read and replace (for redirect/auth rewriting), stash cross-policy +/// coordination data in the property bag (see / +/// ), and observe once the transport has +/// responded. +/// +public sealed class PipelineContext +{ + private Dictionary? _properties; + + /// + /// Initializes a new for a single client call. + /// + /// The initial request. Policies may replace it during the call. + /// A snapshot of the client options for this call. + /// + /// An optional cancellation token that can abort the call. + /// + public PipelineContext( + Request request, + DexpaceClientOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + + Request = request; + Options = options; + CancellationToken = cancellationToken; + } + + /// + /// The current request. Policies (e.g. redirect, auth) may replace this during the call. + /// + public Request Request { get; set; } + + /// + /// The response from the transport, or before the transport responds. + /// Set by the pipeline after the outermost transport send completes. + /// + public Response? Response { get; internal set; } + + /// + /// The active SDK tracing span, or when no + /// listener is registered (near-zero overhead when unobserved). + /// + public Activity? Activity { get; internal set; } + + /// + /// A snapshot of the client options that applies to this call. + /// + public DexpaceClientOptions Options { get; } + + /// + /// A token that can cancel the in-flight operation. + /// + public CancellationToken CancellationToken { get; } + + /// + /// The zero-based retry attempt counter. 0 on the initial send; + /// incremented by the retry policy before each subsequent attempt. + /// + public int AttemptNumber { get; internal set; } + + /// + /// Retrieves a typed value from the cross-policy property bag. + /// + /// The expected type of the stored value. + /// The key used when the value was stored. + /// + /// The stored value cast to , or + /// if the key is absent or the stored value cannot be cast. + /// + public T? GetProperty(string key) + { + if (_properties is null || !_properties.TryGetValue(key, out var value)) + { + return default; + } + + return value is T typed ? typed : default; + } + + /// + /// Stores a typed value in the cross-policy property bag. + /// Overwrites any existing value for . + /// + /// The type of the value to store. + /// A key that identifies the value within this call. + /// The value to store. + public void SetProperty(string key, T value) + { + _properties ??= new Dictionary(StringComparer.Ordinal); + _properties[key] = value; + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs new file mode 100644 index 0000000..2ac0680 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +public class PipelineContextTests +{ + private static Request MakeRequest() => + Request.Get("https://api.example.com/v1/resource"); + + [Fact] + public void Constructor_StoresRequest() + { + var request = MakeRequest(); + var options = new DexpaceClientOptions(); + var ctx = new PipelineContext(request, options); + + Assert.Same(request, ctx.Request); + } + + [Fact] + public void Constructor_StoresOptions() + { + var options = new DexpaceClientOptions { UserAgent = "test-agent/1.0" }; + var ctx = new PipelineContext(MakeRequest(), options); + + Assert.Same(options, ctx.Options); + } + + [Fact] + public void Constructor_StoresCancellationToken() + { + using var cts = new CancellationTokenSource(); + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions(), cts.Token); + + Assert.Equal(cts.Token, ctx.CancellationToken); + } + + [Fact] + public void Constructor_DefaultCancellationToken_IsDefault() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + + Assert.Equal(CancellationToken.None, ctx.CancellationToken); + } + + [Fact] + public void AttemptNumber_DefaultsToZero() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + + Assert.Equal(0, ctx.AttemptNumber); + } + + [Fact] + public void Response_DefaultsToNull() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + + Assert.Null(ctx.Response); + } + + [Fact] + public void Activity_DefaultsToNull() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + + Assert.Null(ctx.Activity); + } + + [Fact] + public void PropertyBag_RoundTrips_TypedValue() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + ctx.SetProperty("idempotency-key", "idem-abc123"); + var retrieved = ctx.GetProperty("idempotency-key"); + + Assert.Equal("idem-abc123", retrieved); + } + + [Fact] + public void PropertyBag_MissingKey_ReturnsDefault() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + var result = ctx.GetProperty("nonexistent"); + + Assert.Equal(0, result); + } + + [Fact] + public void PropertyBag_MissingKeyForReferenceType_ReturnsNull() + { + var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions()); + var result = ctx.GetProperty("nonexistent"); + + Assert.Null(result); + } +} From aba8135b6eb3d3eecb509438a11be1e93253929c Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 18:57:07 +0300 Subject: [PATCH 6/7] fix: harden UrlRedactor for relative URLs and document its redaction boundary Co-Authored-By: Claude Sonnet 4.6 --- .../Diagnostics/UrlRedactor.cs | 82 +++++++++++++++-- .../Diagnostics/UrlRedactorTests.cs | 87 +++++++++++++++++++ 2 files changed, 164 insertions(+), 5 deletions(-) diff --git a/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs b/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs index 0c402c1..a67edd5 100644 --- a/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs +++ b/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs @@ -10,9 +10,31 @@ namespace Dexpace.Sdk.Core.Diagnostics; /// replacing the values of known-sensitive query parameters with REDACTED. /// /// +/// /// Sensitive parameter names are matched case-insensitively. The default set covers the most /// common credential-bearing parameters; callers may supply a custom set instead. -/// Non-sensitive parameters and all path/host/scheme components are preserved verbatim. +/// +/// +/// Redaction boundary: +/// +/// +/// Userinfo (the user:password@ segment of an authority) is always +/// removed. +/// +/// +/// Sensitive query-parameter values are replaced with REDACTED; +/// names and non-sensitive parameters are preserved verbatim. +/// +/// +/// Fragment (the #… portion) is always dropped — fragments are +/// client-side only and carry no information relevant to logging. +/// +/// +/// Path segments are preserved verbatim. Callers must not embed secrets +/// inside the URL path; this class does not inspect or redact path components. +/// +/// +/// /// public sealed class UrlRedactor { @@ -56,14 +78,27 @@ public UrlRedactor(IEnumerable sensitiveParams) /// /// Returns a log-safe representation of : userinfo is always stripped; - /// sensitive query parameter values are replaced with REDACTED. + /// sensitive query parameter values are replaced with REDACTED; the fragment is + /// dropped. For non-absolute URIs the method operates on + /// and never throws. /// - /// The URI to redact. + /// The URI to redact. May be relative or absolute. /// A safe string representation. public string Redact(Uri uri) { ArgumentNullException.ThrowIfNull(uri); + if (uri.IsAbsoluteUri) + { + return RedactAbsolute(uri); + } + + return RedactRelative(uri.OriginalString); + } + + // Handles fully-qualified URIs where Uri properties are safe to access. + private string RedactAbsolute(Uri uri) + { var query = uri.Query; // Build the base URL without userinfo and without the query string. @@ -72,6 +107,7 @@ public string Redact(Uri uri) UserName = string.Empty, Password = string.Empty, Query = string.Empty, + Fragment = string.Empty, }; var baseUrl = builder.Uri.GetLeftPart(UriPartial.Path); @@ -80,7 +116,40 @@ public string Redact(Uri uri) return baseUrl; } - // Parse, redact, and re-serialize the query string without System.Web dependency. + var redactedQuery = RedactQuery(query); + return redactedQuery.Length == 0 ? baseUrl : $"{baseUrl}?{redactedQuery}"; + } + + // Handles relative URI references by operating on the raw string directly. + // Relative references have no userinfo, so only fragment dropping and query redaction apply. + private string RedactRelative(string originalString) + { + // Drop the fragment first (everything from the first '#'). + var fragmentIndex = originalString.IndexOf('#', StringComparison.Ordinal); + var withoutFragment = fragmentIndex >= 0 + ? originalString[..fragmentIndex] + : originalString; + + // Split path from query on the first '?'. + var queryIndex = withoutFragment.IndexOf('?', StringComparison.Ordinal); + if (queryIndex < 0) + { + // No query string — return path as-is (fragment already dropped). + return withoutFragment; + } + + var path = withoutFragment[..queryIndex]; + var query = withoutFragment[queryIndex..]; // includes the leading '?' + + var redactedQuery = RedactQuery(query); + return redactedQuery.Length == 0 ? path : $"{path}?{redactedQuery}"; + } + + // Redacts sensitive parameter values in a raw query string (with or without a leading '?'). + // Returns the redacted query string without the leading '?', or an empty string if there are + // no key=value pairs after redaction. + private string RedactQuery(string query) + { var sb = new StringBuilder(); foreach (var (key, value) in ParseQueryParams(query)) { @@ -95,7 +164,7 @@ public string Redact(Uri uri) sb.Append(Uri.EscapeDataString(redactedValue)); } - return sb.Length == 0 ? baseUrl : $"{baseUrl}?{sb}"; + return sb.ToString(); } private static IEnumerable<(string Key, string Value)> ParseQueryParams(string query) @@ -104,6 +173,9 @@ public string Redact(Uri uri) foreach (var part in raw.Split('&')) { var eq = part.IndexOf('=', StringComparison.Ordinal); + + // Valueless params (e.g. "?flag") are intentionally skipped — a bare key + // carries no value that could leak a secret. if (eq < 0) { continue; diff --git a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs index f67a25a..c8e349a 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs @@ -72,4 +72,91 @@ public void Redact_CustomSensitiveParams_AreRedacted() Assert.Contains("x-custom-secret=REDACTED", result); Assert.Contains("other=value", result); } + + // ── Edge-case tests added by code-review hardening ──────────────────────── + + [Fact] + public void Redact_RelativeUri_SensitiveQueryParamIsRedacted_NoException() + { + // A relative URI carrying a sensitive query param must not throw and must not leak. + var uri = new Uri("/v1/items?token=super-secret&page=2", UriKind.Relative); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("token=REDACTED", result); + Assert.DoesNotContain("super-secret", result); + Assert.Contains("page=2", result); + Assert.Contains("/v1/items", result); + } + + [Fact] + public void Redact_Fragment_IsDropped() + { + // Fragments should be stripped from absolute URIs. + var uri = new Uri("https://api.example.com/path?q=hello#section"); + var result = DefaultRedactor.Redact(uri); + + Assert.DoesNotContain("#section", result); + Assert.Contains("q=hello", result); + } + + [Fact] + public void Redact_RelativeUri_Fragment_IsDropped() + { + // Fragments should be stripped from relative URIs too. + var uri = new Uri("/path?q=hello#section", UriKind.Relative); + var result = DefaultRedactor.Redact(uri); + + Assert.DoesNotContain("#section", result); + Assert.Contains("q=hello", result); + } + + [Fact] + public void Redact_ValuelessParam_DoesNotCrash_AndSensitiveParamIsStillRedacted() + { + // "?flag" has no '=' so it is skipped (no value to leak); token must still be redacted. + var uri = new Uri("https://api.example.com/v1?flag&token=x"); + var result = DefaultRedactor.Redact(uri); + + // The bare flag is dropped (no value — safe to omit). + Assert.DoesNotContain("flag=", result); + // The sensitive token value must not appear. + Assert.Contains("token=REDACTED", result); + Assert.DoesNotContain("=x", result); + } + + [Fact] + public void Redact_RepeatedSensitiveParam_BothValuesAreRedacted() + { + // Both occurrences of a repeated sensitive param must be redacted. + var uri = new Uri("https://api.example.com/v1?token=A&token=B"); + var result = DefaultRedactor.Redact(uri); + + Assert.DoesNotContain("=A", result); + Assert.DoesNotContain("=B", result); + // Both occurrences of the key should appear, both redacted. + Assert.Equal(2, result.Split("token=REDACTED").Length - 1); + } + + [Fact] + public void Redact_PercentEncodedSensitiveValue_IsRedacted() + { + // A percent-encoded sensitive value must still be caught and redacted. + var uri = new Uri("https://api.example.com/v1?token=my%2Fsecret%3Dvalue"); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("token=REDACTED", result); + Assert.DoesNotContain("secret", result); + } + + [Fact] + public void Redact_PathSegmentThatLooksSecret_IsPreservedVerbatim() + { + // Path components are NOT inspected — secrets in the path are the caller's responsibility. + // This test documents the boundary: path is preserved, no redaction is applied there. + var uri = new Uri("https://api.example.com/token/super-secret-value?page=1"); + var result = DefaultRedactor.Redact(uri); + + Assert.Contains("/token/super-secret-value", result); + Assert.Contains("page=1", result); + } } From 42fbad6071ff2578fb56a9f810d5c97c09a52bb3 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 18:58:23 +0300 Subject: [PATCH 7/7] refactor: share the SDK version helper between options and diagnostics Co-Authored-By: Claude Sonnet 4.6 --- .../Configuration/DexpaceClientOptions.cs | 21 ++---------- .../Diagnostics/DexpaceDiagnostics.cs | 21 ++---------- src/Dexpace.Sdk.Core/Internal/SdkVersion.cs | 33 +++++++++++++++++++ 3 files changed, 39 insertions(+), 36 deletions(-) create mode 100644 src/Dexpace.Sdk.Core/Internal/SdkVersion.cs diff --git a/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs b/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs index f81527a..1e9a977 100644 --- a/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs +++ b/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. -using System.Reflection; +using Dexpace.Sdk.Core.Internal; namespace Dexpace.Sdk.Core.Configuration; @@ -49,23 +49,8 @@ public sealed class DexpaceClientOptions /// public RedirectOptions Redirect { get; set; } = new(); - private static string BuildDefaultUserAgent() - { - var version = typeof(DexpaceClientOptions).Assembly - .GetCustomAttribute() - ?.InformationalVersion - ?? typeof(DexpaceClientOptions).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - - // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1"). - var plusIndex = version.IndexOf('+', StringComparison.Ordinal); - if (plusIndex >= 0) - { - version = version[..plusIndex]; - } - - return $"dexpace-dotnet/{version}"; - } + private static string BuildDefaultUserAgent() => + $"dexpace-dotnet/{SdkVersion.Value}"; } /// diff --git a/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs b/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs index 991fc1b..426416e 100644 --- a/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs +++ b/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs @@ -3,7 +3,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; -using System.Reflection; +using Dexpace.Sdk.Core.Internal; namespace Dexpace.Sdk.Core.Diagnostics; @@ -18,30 +18,15 @@ namespace Dexpace.Sdk.Core.Diagnostics; /// public static class DexpaceDiagnostics { - private static readonly string s_version = BuildVersion(); - /// /// The used by the SDK for distributed tracing. /// Name: "Dexpace.Sdk". /// - public static readonly ActivitySource ActivitySource = new("Dexpace.Sdk", s_version); + public static readonly ActivitySource ActivitySource = new("Dexpace.Sdk", SdkVersion.Value); /// /// The used by the SDK for metrics. /// Name: "Dexpace.Sdk". /// - public static readonly Meter Meter = new("Dexpace.Sdk", s_version); - - private static string BuildVersion() - { - var version = typeof(DexpaceDiagnostics).Assembly - .GetCustomAttribute() - ?.InformationalVersion - ?? typeof(DexpaceDiagnostics).Assembly.GetName().Version?.ToString() - ?? "0.0.0"; - - // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1"). - var plusIndex = version.IndexOf('+', StringComparison.Ordinal); - return plusIndex >= 0 ? version[..plusIndex] : version; - } + public static readonly Meter Meter = new("Dexpace.Sdk", SdkVersion.Value); } diff --git a/src/Dexpace.Sdk.Core/Internal/SdkVersion.cs b/src/Dexpace.Sdk.Core/Internal/SdkVersion.cs new file mode 100644 index 0000000..361617f --- /dev/null +++ b/src/Dexpace.Sdk.Core/Internal/SdkVersion.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; + +namespace Dexpace.Sdk.Core.Internal; + +/// +/// Shared helper that resolves the Core assembly's informational version at startup, with the +/// +build suffix stripped. +/// +internal static class SdkVersion +{ + /// + /// The Core assembly version string (e.g. "0.0.1-alpha.1"), with any git commit hash + /// suffix (e.g. +abc123) removed. Falls back to the assembly's Version + /// property, and ultimately to "0.0.0" if neither attribute is present. + /// + internal static readonly string Value = BuildVersion(); + + private static string BuildVersion() + { + var version = typeof(SdkVersion).Assembly + .GetCustomAttribute() + ?.InformationalVersion + ?? typeof(SdkVersion).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1"). + var plusIndex = version.IndexOf('+', StringComparison.Ordinal); + return plusIndex >= 0 ? version[..plusIndex] : version; + } +}