Skip to content

Commit 9548337

Browse files
committed
feat: send X-POSTGUARD-CLIENT-VERSION on every request
Add a ClientVersion helper that reports dotnet,<framework>,pg-dotnet,<version> and inject it on the SDK-owned HttpClient's default headers (covering both PKG and Cryptify). A caller-supplied header (any casing) wins; a bring-your-own HttpClient is never mutated. Version read from AssemblyInformationalVersion (git-hash suffix stripped).
1 parent c33860b commit 9548337

4 files changed

Lines changed: 188 additions & 2 deletions

File tree

src/ClientVersion.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System.Reflection;
2+
using System.Runtime.InteropServices;
3+
4+
namespace E4A.PostGuard;
5+
6+
/// <summary>
7+
/// Builds and applies the <c>X-POSTGUARD-CLIENT-VERSION</c> header so the PKG
8+
/// and Cryptify servers can attribute requests to this SDK and version. The
9+
/// value format (<c>host,host_version,app,app_version</c>) is shared with the
10+
/// other PostGuard clients (e.g. the Outlook add-in sends
11+
/// <c>Outlook,1.0,pg4ol,…</c>); this SDK sends
12+
/// <c>dotnet,&lt;framework&gt;,pg-dotnet,&lt;version&gt;</c>.
13+
/// </summary>
14+
internal static class ClientVersion
15+
{
16+
internal const string HeaderName = "X-POSTGUARD-CLIENT-VERSION";
17+
18+
private const string App = "pg-dotnet";
19+
20+
/// <summary>The computed <c>host,host_version,app,app_version</c> value (computed once).</summary>
21+
internal static readonly string HeaderValue = BuildHeaderValue();
22+
23+
private static string BuildHeaderValue()
24+
{
25+
var host = "dotnet";
26+
var hostVersion = Sanitize(RuntimeInformation.FrameworkDescription); // e.g. ".NET 8.0.7"
27+
var appVersion = Sanitize(ReadAssemblyVersion());
28+
return $"{host},{hostVersion},{App},{appVersion}";
29+
}
30+
31+
/// <summary>
32+
/// Read this SDK assembly's version. Prefers
33+
/// <see cref="AssemblyInformationalVersionAttribute"/> (carries the full
34+
/// semver from the csproj &lt;Version&gt;, which release-please bumps),
35+
/// stripping any <c>+&lt;gitHash&gt;</c> suffix the SDK may append. Falls
36+
/// back to the 3-part assembly version, then "unknown".
37+
/// </summary>
38+
private static string ReadAssemblyVersion()
39+
{
40+
var asm = typeof(ClientVersion).Assembly;
41+
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
42+
if (!string.IsNullOrWhiteSpace(info))
43+
{
44+
var plus = info.IndexOf('+');
45+
return plus >= 0 ? info[..plus] : info;
46+
}
47+
return asm.GetName().Version?.ToString(3) ?? "unknown";
48+
}
49+
50+
/// <summary>
51+
/// The value is comma-delimited and the servers split on ',' expecting
52+
/// exactly four fields — never let a comma (or CR/LF) inside a field break
53+
/// the format or inject a header. Empty/blank collapses to "unknown".
54+
/// </summary>
55+
private static string Sanitize(string? s)
56+
{
57+
if (string.IsNullOrWhiteSpace(s))
58+
{
59+
return "unknown";
60+
}
61+
return s.Replace(',', ' ').Replace('\r', ' ').Replace('\n', ' ').Trim();
62+
}
63+
64+
/// <summary>
65+
/// Add the client-version header to <paramref name="http"/>'s default
66+
/// request headers, unless the caller already supplied that header (any
67+
/// casing) via <paramref name="callerHeaders"/> — in which case the
68+
/// caller's value wins (e.g. an embedding host identifying itself).
69+
/// </summary>
70+
internal static void ApplyTo(HttpClient http, IDictionary<string, string>? callerHeaders)
71+
{
72+
var callerSetIt = callerHeaders is not null &&
73+
callerHeaders.Keys.Any(k => string.Equals(k, HeaderName, StringComparison.OrdinalIgnoreCase));
74+
if (!callerSetIt)
75+
{
76+
http.DefaultRequestHeaders.TryAddWithoutValidation(HeaderName, HeaderValue);
77+
}
78+
}
79+
}

src/PostGuard.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ public PostGuard(PostGuardConfig config)
4242
_http.DefaultRequestHeaders.TryAddWithoutValidation(key, value);
4343
}
4444
}
45+
// Identify this SDK + version to the PKG and Cryptify on every
46+
// request. Skipped when the caller already set the header (they win).
47+
// Only for the SDK-owned client — a caller-supplied HttpClient may
48+
// be shared, so we never mutate its default headers.
49+
ClientVersion.ApplyTo(_http, config.Headers);
4550
_ownsHttp = true;
4651
}
4752
}

src/PostGuardConfig.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ public class PostGuardConfig
44
{
55
public required string PkgUrl { get; init; }
66
public required string CryptifyUrl { get; init; }
7+
8+
/// <summary>
9+
/// Extra headers added to every PKG and Cryptify request (only when the SDK
10+
/// owns the <see cref="HttpClient"/> — see <see cref="HttpClient"/>). The SDK
11+
/// also sends an <c>X-POSTGUARD-CLIENT-VERSION</c> header automatically;
12+
/// supply that key here (any casing) to override it (e.g. an embedding host
13+
/// identifying itself).
14+
/// </summary>
715
public Dictionary<string, string>? Headers { get; init; }
816

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using System.Net;
2+
3+
namespace E4A.PostGuard.Tests;
4+
5+
public class ClientVersionTests
6+
{
7+
[Fact]
8+
public void HeaderValue_HasFourCommaFields()
9+
{
10+
var fields = ClientVersion.HeaderValue.Split(',');
11+
Assert.Equal(4, fields.Length);
12+
}
13+
14+
[Fact]
15+
public void HeaderValue_HostIsDotnet_AppIsPgDotnet()
16+
{
17+
var fields = ClientVersion.HeaderValue.Split(',');
18+
Assert.Equal("dotnet", fields[0]);
19+
Assert.Equal("pg-dotnet", fields[2]);
20+
}
21+
22+
[Fact]
23+
public void HeaderValue_HostVersionAndAppVersionAreClean()
24+
{
25+
var fields = ClientVersion.HeaderValue.Split(',');
26+
var hostVersion = fields[1];
27+
var appVersion = fields[3];
28+
29+
Assert.False(string.IsNullOrWhiteSpace(hostVersion));
30+
Assert.False(string.IsNullOrWhiteSpace(appVersion));
31+
// app_version must not carry a "+<gitHash>" suffix or a stray comma.
32+
Assert.DoesNotContain('+', appVersion);
33+
Assert.DoesNotContain(',', appVersion);
34+
}
35+
36+
[Fact]
37+
public void ApplyTo_AddsHeader_WhenCallerSuppliedNone()
38+
{
39+
using var http = new HttpClient();
40+
ClientVersion.ApplyTo(http, null);
41+
42+
Assert.True(http.DefaultRequestHeaders.TryGetValues(ClientVersion.HeaderName, out var values));
43+
Assert.Equal(ClientVersion.HeaderValue, Assert.Single(values!));
44+
}
45+
46+
[Fact]
47+
public void ApplyTo_DoesNotAdd_WhenCallerSuppliedExactCase()
48+
{
49+
using var http = new HttpClient();
50+
var headers = new Dictionary<string, string> { [ClientVersion.HeaderName] = "Outlook,1.0,pg4ol,9.9.9" };
51+
52+
ClientVersion.ApplyTo(http, headers);
53+
54+
// ApplyTo runs after the caller's own header loop in PostGuard; here we
55+
// assert it does not add a second value of its own.
56+
Assert.False(http.DefaultRequestHeaders.TryGetValues(ClientVersion.HeaderName, out _));
57+
}
58+
59+
[Fact]
60+
public void ApplyTo_DoesNotAdd_WhenCallerSuppliedDifferentCasing()
61+
{
62+
using var http = new HttpClient();
63+
var headers = new Dictionary<string, string> { ["x-postguard-client-version"] = "Outlook,1.0,pg4ol,9.9.9" };
64+
65+
ClientVersion.ApplyTo(http, headers);
66+
67+
Assert.False(http.DefaultRequestHeaders.TryGetValues(ClientVersion.HeaderName, out _));
68+
}
69+
70+
[Fact]
71+
public void ByoHttpClient_IsNotMutated()
72+
{
73+
// A caller-supplied HttpClient is used as-is; the SDK must not add the
74+
// client-version header to it (it may be shared across endpoints).
75+
var byo = new HttpClient(new CapturingHandler());
76+
using var pg = new PostGuard(new PostGuardConfig
77+
{
78+
PkgUrl = "https://pkg.example.com",
79+
CryptifyUrl = "https://storage.example.com",
80+
HttpClient = byo,
81+
});
82+
83+
Assert.False(byo.DefaultRequestHeaders.TryGetValues(ClientVersion.HeaderName, out _));
84+
}
85+
86+
private sealed class CapturingHandler : HttpMessageHandler
87+
{
88+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
89+
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
90+
}
91+
}

0 commit comments

Comments
 (0)