Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
990 changes: 990 additions & 0 deletions docs/superpowers/plans/2026-06-15-pipeline-prerequisites.md

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

using Dexpace.Sdk.Core.Internal;

namespace Dexpace.Sdk.Core.Configuration;

/// <summary>
/// Top-level configuration options for the Dexpace SDK client.
/// </summary>
/// <remarks>
/// All properties carry sensible defaults; the client is fully usable with <c>new DexpaceClientOptions()</c>.
/// Per-policy sub-options are exposed as nested objects (<see cref="Retry"/>, <see cref="Redirect"/>).
/// </remarks>
public sealed class DexpaceClientOptions
{
private static readonly string s_defaultUserAgent = BuildDefaultUserAgent();

/// <summary>
/// The base address prepended to relative request URLs, or <see langword="null"/> when
/// requests always use absolute URLs.
/// </summary>
public Uri? BaseAddress { get; set; }

/// <summary>
/// The <c>User-Agent</c> header value sent with every request.
/// Defaults to <c>dexpace-dotnet/&lt;assembly-version&gt;</c>.
/// </summary>
public string UserAgent { get; set; } = s_defaultUserAgent;

/// <summary>
/// The wall-clock deadline for an entire operation (all redirect hops and retry attempts
/// combined), or <see langword="null"/> for no overall deadline.
/// </summary>
public TimeSpan? OverallTimeout { get; set; }

/// <summary>
/// The deadline for a single send attempt, or <see langword="null"/> for no per-attempt deadline.
/// </summary>
public TimeSpan? AttemptTimeout { get; set; }

/// <summary>
/// Retry-policy options. Defaults to <see cref="RetryOptions"/> with its built-in defaults.
/// </summary>
public RetryOptions Retry { get; set; } = new();

/// <summary>
/// Redirect-policy options. Defaults to <see cref="RedirectOptions"/> with its built-in defaults.
/// </summary>
public RedirectOptions Redirect { get; set; } = new();

private static string BuildDefaultUserAgent() =>
$"dexpace-dotnet/{SdkVersion.Value}";
}

/// <summary>
/// Options for the retry policy.
/// </summary>
public sealed class RetryOptions
{
/// <summary>
/// The number of retry attempts after the initial send. Defaults to <c>3</c>.
/// Matches the Polly v8 / <c>Microsoft.Extensions.Http.Resilience</c> naming convention.
/// </summary>
public int MaxRetryAttempts { get; set; } = 3;

/// <summary>
/// The base delay for exponential back-off. Defaults to <c>200 ms</c>.
/// </summary>
public TimeSpan BaseDelay { get; set; } = TimeSpan.FromMilliseconds(200);

/// <summary>
/// The maximum back-off delay cap. Defaults to <c>30 s</c>.
/// </summary>
public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(30);

/// <summary>
/// When <see langword="true"/>, the retry policy respects a <c>Retry-After</c> response
/// header. Defaults to <see langword="true"/>.
/// </summary>
public bool HonorRetryAfter { get; set; } = true;

/// <summary>
/// When <see langword="true"/>, the retry policy may retry non-idempotent methods (e.g.
/// <c>POST</c>) if the request body is replayable. Defaults to <see langword="false"/>.
/// </summary>
public bool RetryNonIdempotentWhenReplayable { get; set; }
}

/// <summary>
/// Options for the redirect-following policy.
/// </summary>
public sealed class RedirectOptions
{
/// <summary>
/// The maximum number of redirect hops to follow. Defaults to <c>20</c>,
/// matching browser and <c>HttpClient</c> norms.
/// </summary>
public int MaxRedirects { get; set; } = 20;

/// <summary>
/// When <see langword="true"/>, the policy follows <c>https → http</c> downgrade redirects.
/// Defaults to <see langword="false"/> for security.
/// </summary>
public bool AllowHttpsToHttpDowngrade { get; set; }

/// <summary>
/// When <see langword="true"/>, sensitive headers (e.g. <c>Authorization</c>) are stripped
/// when the redirect crosses an origin boundary. Defaults to <see langword="true"/>.
/// </summary>
public bool StripSensitiveHeadersOnCrossOrigin { get; set; } = true;
}
32 changes: 32 additions & 0 deletions src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// 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 Dexpace.Sdk.Core.Internal;

namespace Dexpace.Sdk.Core.Diagnostics;

/// <summary>
/// Central home for the SDK's OpenTelemetry instrumentation objects.
/// </summary>
/// <remarks>
/// Consumers wire up collection by subscribing to <see cref="ActivitySource"/> (tracing) and
/// <see cref="Meter"/> (metrics) via their chosen OTel SDK — no SDK-internal configuration needed.
/// When no listener is active, <c>ActivitySource.StartActivity</c> returns
/// <see langword="null"/> and the hot path allocates nothing for tracing.
/// </remarks>
public static class DexpaceDiagnostics
{
/// <summary>
/// The <see cref="System.Diagnostics.ActivitySource"/> used by the SDK for distributed tracing.
/// Name: <c>"Dexpace.Sdk"</c>.
/// </summary>
public static readonly ActivitySource ActivitySource = new("Dexpace.Sdk", SdkVersion.Value);

/// <summary>
/// The <see cref="System.Diagnostics.Metrics.Meter"/> used by the SDK for metrics.
/// Name: <c>"Dexpace.Sdk"</c>.
/// </summary>
public static readonly Meter Meter = new("Dexpace.Sdk", SdkVersion.Value);
}
189 changes: 189 additions & 0 deletions src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// 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;

/// <summary>
/// Produces a log-safe string form of a <see cref="Uri"/> by stripping userinfo and
/// replacing the values of known-sensitive query parameters with <c>REDACTED</c>.
/// </summary>
/// <remarks>
/// <para>
/// Sensitive parameter names are matched case-insensitively. The default set covers the most
/// common credential-bearing parameters; callers may supply a custom set instead.
/// </para>
/// <para>
/// <strong>Redaction boundary:</strong>
/// <list type="bullet">
/// <item><description>
/// <strong>Userinfo</strong> (the <c>user:password@</c> segment of an authority) is always
/// removed.
/// </description></item>
/// <item><description>
/// <strong>Sensitive query-parameter values</strong> are replaced with <c>REDACTED</c>;
/// names and non-sensitive parameters are preserved verbatim.
/// </description></item>
/// <item><description>
/// <strong>Fragment</strong> (the <c>#…</c> portion) is always dropped — fragments are
/// client-side only and carry no information relevant to logging.
/// </description></item>
/// <item><description>
/// <strong>Path segments are preserved verbatim.</strong> Callers must not embed secrets
/// inside the URL path; this class does not inspect or redact path components.
/// </description></item>
/// </list>
/// </para>
/// </remarks>
public sealed class UrlRedactor
{
/// <summary>
/// Default set of query parameter names whose values are redacted.
/// </summary>
public static readonly IReadOnlyCollection<string> DefaultSensitiveParams =
[
"access_token",
"token",
"code",
"sig",
"signature",
"api_key",
"apikey",
"password",
];

private readonly HashSet<string> _sensitiveParams;

/// <summary>
/// Initializes a <see cref="UrlRedactor"/> using <see cref="DefaultSensitiveParams"/>.
/// </summary>
public UrlRedactor()
: this(DefaultSensitiveParams)
{
}

/// <summary>
/// Initializes a <see cref="UrlRedactor"/> with a caller-supplied set of sensitive
/// parameter names (case-insensitive).
/// </summary>
/// <param name="sensitiveParams">
/// The query parameter names whose values should be replaced with <c>REDACTED</c>.
/// </param>
public UrlRedactor(IEnumerable<string> sensitiveParams)
{
ArgumentNullException.ThrowIfNull(sensitiveParams);
_sensitiveParams = new HashSet<string>(sensitiveParams, StringComparer.OrdinalIgnoreCase);
}

/// <summary>
/// Returns a log-safe representation of <paramref name="uri"/>: userinfo is always stripped;
/// sensitive query parameter values are replaced with <c>REDACTED</c>; the fragment is
/// dropped. For non-absolute URIs the method operates on <see cref="Uri.OriginalString"/>
/// and never throws.
/// </summary>
/// <param name="uri">The URI to redact. May be relative or absolute.</param>
/// <returns>A safe string representation.</returns>
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.
var builder = new UriBuilder(uri)
{
UserName = string.Empty,
Password = string.Empty,
Query = string.Empty,
Fragment = string.Empty,
};
var baseUrl = builder.Uri.GetLeftPart(UriPartial.Path);

if (string.IsNullOrEmpty(query))
{
return baseUrl;
}

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))
{
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.ToString();
}

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);

// Valueless params (e.g. "?flag") are intentionally skipped — a bare key
// carries no value that could leak a secret.
if (eq < 0)
{
continue;
}

yield return (
Uri.UnescapeDataString(part[..eq]),
Uri.UnescapeDataString(part[(eq + 1)..]));
}
}
}
33 changes: 33 additions & 0 deletions src/Dexpace.Sdk.Core/Internal/SdkVersion.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Shared helper that resolves the Core assembly's informational version at startup, with the
/// <c>+build</c> suffix stripped.
/// </summary>
internal static class SdkVersion
{
/// <summary>
/// The Core assembly version string (e.g. <c>"0.0.1-alpha.1"</c>), with any git commit hash
/// suffix (e.g. <c>+abc123</c>) removed. Falls back to the assembly's <c>Version</c>
/// property, and ultimately to <c>"0.0.0"</c> if neither attribute is present.
/// </summary>
internal static readonly string Value = BuildVersion();

private static string BuildVersion()
{
var version = typeof(SdkVersion).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.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;
}
}
Loading
Loading