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
79 changes: 79 additions & 0 deletions src/ClientVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Reflection;
using System.Runtime.InteropServices;

namespace E4A.PostGuard;

/// <summary>
/// Builds and applies the <c>X-POSTGUARD-CLIENT-VERSION</c> header so the PKG
/// and Cryptify servers can attribute requests to this SDK and version. The
/// value format (<c>host,host_version,app,app_version</c>) is shared with the
/// other PostGuard clients (e.g. the Outlook add-in sends
/// <c>Outlook,1.0,pg4ol,…</c>); this SDK sends
/// <c>dotnet,&lt;framework&gt;,pg-dotnet,&lt;version&gt;</c>.
/// </summary>
internal static class ClientVersion
{
internal const string HeaderName = "X-POSTGUARD-CLIENT-VERSION";

private const string App = "pg-dotnet";

/// <summary>The computed <c>host,host_version,app,app_version</c> value (computed once).</summary>
internal static readonly string HeaderValue = BuildHeaderValue();

private static string BuildHeaderValue()
{
var host = "dotnet";
var hostVersion = Sanitize(RuntimeInformation.FrameworkDescription); // e.g. ".NET 8.0.7"
var appVersion = Sanitize(ReadAssemblyVersion());
return $"{host},{hostVersion},{App},{appVersion}";
}

/// <summary>
/// Read this SDK assembly's version. Prefers
/// <see cref="AssemblyInformationalVersionAttribute"/> (carries the full
/// semver from the csproj &lt;Version&gt;, which release-please bumps),
/// stripping any <c>+&lt;gitHash&gt;</c> suffix the SDK may append. Falls
/// back to the 3-part assembly version, then "unknown".
/// </summary>
private static string ReadAssemblyVersion()
{
var asm = typeof(ClientVersion).Assembly;
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(info))
{
var plus = info.IndexOf('+');
return plus >= 0 ? info[..plus] : info;
}
return asm.GetName().Version?.ToString(3) ?? "unknown";
}

/// <summary>
/// The value is comma-delimited and the servers split on ',' expecting
/// exactly four fields — never let a comma (or CR/LF) inside a field break
/// the format or inject a header. Empty/blank collapses to "unknown".
/// </summary>
private static string Sanitize(string? s)
{
if (string.IsNullOrWhiteSpace(s))
{
return "unknown";
}
return s.Replace(',', ' ').Replace('\r', ' ').Replace('\n', ' ').Trim();
}

/// <summary>
/// Add the client-version header to <paramref name="http"/>'s default
/// request headers, unless the caller already supplied that header (any
/// casing) via <paramref name="callerHeaders"/> — in which case the
/// caller's value wins (e.g. an embedding host identifying itself).
/// </summary>
internal static void ApplyTo(HttpClient http, IDictionary<string, string>? callerHeaders)
{
var callerSetIt = callerHeaders is not null &&
callerHeaders.Keys.Any(k => string.Equals(k, HeaderName, StringComparison.OrdinalIgnoreCase));
if (!callerSetIt)
{
http.DefaultRequestHeaders.TryAddWithoutValidation(HeaderName, HeaderValue);
}
}
}
5 changes: 5 additions & 0 deletions src/PostGuard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public PostGuard(PostGuardConfig config)
_http.DefaultRequestHeaders.TryAddWithoutValidation(key, value);
}
}
// Identify this SDK + version to the PKG and Cryptify on every
// request. Skipped when the caller already set the header (they win).
// Only for the SDK-owned client — a caller-supplied HttpClient may
// be shared, so we never mutate its default headers.
ClientVersion.ApplyTo(_http, config.Headers);
_ownsHttp = true;
}
}
Expand Down
15 changes: 13 additions & 2 deletions src/PostGuardConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ public class PostGuardConfig
{
public required string PkgUrl { get; init; }
public required string CryptifyUrl { get; init; }

/// <summary>
/// Extra headers added to every PKG and Cryptify request (only when the SDK
/// owns the <see cref="HttpClient"/> — see <see cref="HttpClient"/>). The SDK
/// also sends an <c>X-POSTGUARD-CLIENT-VERSION</c> header automatically;
/// supply that key here (any casing) to override it (e.g. an embedding host
/// identifying itself).
/// </summary>
public Dictionary<string, string>? Headers { get; init; }

/// <summary>
Expand All @@ -16,8 +24,11 @@ public class PostGuardConfig
/// <summary>
/// Optional caller-supplied <see cref="System.Net.Http.HttpClient"/>. When
/// set, the SDK reuses this client for all PKG and Cryptify calls and does
/// NOT dispose it — ownership stays with the caller (DI-friendly). When
/// null, <see cref="PostGuard"/> creates and owns a single long-lived client.
/// NOT dispose it — ownership stays with the caller (DI-friendly). The SDK
/// also does NOT mutate its headers, so it will not add
/// <c>X-POSTGUARD-CLIENT-VERSION</c>; set that yourself if you want it. When
/// null, <see cref="PostGuard"/> creates and owns a single long-lived client
/// (and sends <c>X-POSTGUARD-CLIENT-VERSION</c> automatically).
/// </summary>
public HttpClient? HttpClient { get; init; }

Expand Down
91 changes: 91 additions & 0 deletions tests/E4A.PostGuard.Tests/ClientVersionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Net;

namespace E4A.PostGuard.Tests;

public class ClientVersionTests
{
[Fact]
public void HeaderValue_HasFourCommaFields()
{
var fields = ClientVersion.HeaderValue.Split(',');
Assert.Equal(4, fields.Length);
}

[Fact]
public void HeaderValue_HostIsDotnet_AppIsPgDotnet()
{
var fields = ClientVersion.HeaderValue.Split(',');
Assert.Equal("dotnet", fields[0]);
Assert.Equal("pg-dotnet", fields[2]);
}

[Fact]
public void HeaderValue_HostVersionAndAppVersionAreClean()
{
var fields = ClientVersion.HeaderValue.Split(',');
var hostVersion = fields[1];
var appVersion = fields[3];

Assert.False(string.IsNullOrWhiteSpace(hostVersion));
Assert.False(string.IsNullOrWhiteSpace(appVersion));
// app_version must not carry a "+<gitHash>" suffix or a stray comma.
Assert.DoesNotContain('+', appVersion);
Assert.DoesNotContain(',', appVersion);
}

[Fact]
public void ApplyTo_AddsHeader_WhenCallerSuppliedNone()
{
using var http = new HttpClient();
ClientVersion.ApplyTo(http, null);

Assert.True(http.DefaultRequestHeaders.TryGetValues(ClientVersion.HeaderName, out var values));
Assert.Equal(ClientVersion.HeaderValue, Assert.Single(values!));
}

[Fact]
public void ApplyTo_DoesNotAdd_WhenCallerSuppliedExactCase()
{
using var http = new HttpClient();
var headers = new Dictionary<string, string> { [ClientVersion.HeaderName] = "Outlook,1.0,pg4ol,9.9.9" };

ClientVersion.ApplyTo(http, headers);

// ApplyTo runs after the caller's own header loop in PostGuard; here we
// assert it does not add a second value of its own.
Assert.False(http.DefaultRequestHeaders.TryGetValues(ClientVersion.HeaderName, out _));
}

[Fact]
public void ApplyTo_DoesNotAdd_WhenCallerSuppliedDifferentCasing()
{
using var http = new HttpClient();
var headers = new Dictionary<string, string> { ["x-postguard-client-version"] = "Outlook,1.0,pg4ol,9.9.9" };

ClientVersion.ApplyTo(http, headers);

Assert.False(http.DefaultRequestHeaders.TryGetValues(ClientVersion.HeaderName, out _));
}

[Fact]
public void ByoHttpClient_IsNotMutated()
{
// A caller-supplied HttpClient is used as-is; the SDK must not add the
// client-version header to it (it may be shared across endpoints).
var byo = new HttpClient(new CapturingHandler());
using var pg = new PostGuard(new PostGuardConfig
{
PkgUrl = "https://pkg.example.com",
CryptifyUrl = "https://storage.example.com",
HttpClient = byo,
});

Assert.False(byo.DefaultRequestHeaders.TryGetValues(ClientVersion.HeaderName, out _));
}

private sealed class CapturingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}