From a29ff4ab3106fc61833e5bd8e29c69cac5ed77d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:28:42 +0000 Subject: [PATCH] Send a valid User-Agent when the app name is not an HTTP token [patch] GitHubProvider built Octokit's ProductHeaderValue straight from AppDomain.FriendlyName, so a host built from "My App.csproj" threw FormatException from every call before a request was sent, outside the GitHostingException hierarchy. Replace characters outside the RFC 9110 token set with '-', fall back to "ktsu.GitIntegration" when nothing usable is left, and test it through the fake transport with names the test host itself never has. Fixes ktsu-dev/GitIntegration#195 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016n29aex51pHZeVj2vx831E --- .../Hosting/GitHubProviderTests.cs | 36 +++++++++++++ GitIntegration/GitHubProvider.cs | 50 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/GitIntegration.Test/Hosting/GitHubProviderTests.cs b/GitIntegration.Test/Hosting/GitHubProviderTests.cs index 6d8f97d..6da001a 100644 --- a/GitIntegration.Test/Hosting/GitHubProviderTests.cs +++ b/GitIntegration.Test/Hosting/GitHubProviderTests.cs @@ -144,6 +144,42 @@ public async Task SendsBearerAuthForABearerTokenCredentialAsync() Assert.AreEqual("Bearer eyJ0eXAiOiJKV1Qi", handler.Requests[0].Headers["Authorization"]); } + [TestMethod] + [DataRow("My App", "My-App")] + [DataRow("Tool (x86)", "Tool--x86")] + [DataRow("a/b@c", "a-b-c")] + [DataRow("Café", "Caf")] + [DataRow("testhost", "testhost")] + [DataRow("ktsu.Tool_1", "ktsu.Tool_1")] + public async Task SendsTheRequestWhenTheApplicationNameIsNotAnHttpTokenAsync(string applicationName, string expectedProduct) + { + // The friendly name is the entry assembly's name, so "My App.csproj" yields "My App". Passed + // to ProductHeaderValue as-is, that threw FormatException before any request went out. The + // test host is always "testhost", which is why the name is injected here. + using FakeHttpMessageHandler handler = new FakeHttpMessageHandler() + .Respond(HttpStatusCode.OK, Fixture("github-repositories.json"), ("Content-Type", "application/json")); + GitHubProvider provider = new() + { + Owner = "contoso".As(), + Handler = handler, + ApplicationName = applicationName, + }; + + _ = await provider.GetRepositoriesAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false); + + Assert.HasCount(1, handler.Requests); + // The fake joins a header's values with ", ", and the product is the first of them. + Assert.AreEqual(expectedProduct, handler.Requests[0].Headers["User-Agent"].Split(',')[0]); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("日本")] + public void FallsBackToTheLibraryNameWhenNothingOfTheApplicationNameIsUsable(string? applicationName) => + Assert.AreEqual(GitHubProvider.FallbackProductName, GitHubProvider.ToProductName(applicationName)); + [TestMethod] public async Task EnumeratesRepositoriesForTheOwnerAsync() { diff --git a/GitIntegration/GitHubProvider.cs b/GitIntegration/GitHubProvider.cs index 49f1855..ea14582 100644 --- a/GitIntegration/GitHubProvider.cs +++ b/GitIntegration/GitHubProvider.cs @@ -45,6 +45,23 @@ public sealed class GitHubProvider : GitProvider /// private protected override HttpMessageHandler DefaultHandler => SharedHandler; + /// + /// The product name sent in the User-Agent when the application's own name has no usable + /// characters. + /// + internal const string FallbackProductName = "ktsu.GitIntegration"; + + /// + /// Gets or initializes the application name this provider identifies itself by in the + /// User-Agent GitHub requires. + /// + /// + /// and defaulted to the host's , so + /// a test can drive a name the test host itself never has. Under MSTest the friendly name is + /// testhost, a valid token, which is how a name like My App went unnoticed. + /// + internal string ApplicationName { get; init; } = AppDomain.CurrentDomain.FriendlyName; + /// /// /// , because GitHub's repository-addressed routes are two routes rather @@ -328,7 +345,7 @@ private static long ToOctokitRepositoryId(GitRepositoryAddress repositoryAddress private (GitHubClient Client, IDisposable Transport) CreateClient() { Credentials credentials = ToOctokitCredentials(ResolveCredential()); - ProductHeaderValue product = new(AppDomain.CurrentDomain.FriendlyName); + ProductHeaderValue product = new(ToProductName(ApplicationName)); HttpMessageHandler transport = Handler ?? DefaultHandler; HttpClientAdapter adapter = new(() => new NonOwningHandler(transport)); @@ -338,6 +355,37 @@ private static long ToOctokitRepositoryId(GitRepositoryAddress repositoryAddress return (client, adapter); } + /// + /// Turns an application name into a User-Agent product name, which must be an HTTP token. + /// + /// + /// is the entry assembly's name, so an executable built from + /// My App.csproj is called My App. Passed through unchanged, the space makes + /// throw before any request is sent. + /// Every character outside the RFC 9110 token set, including any non-ASCII letter, becomes + /// -, so the host stays recognisable in GitHub's logs. A name with nothing left once those + /// are stripped falls back to . + /// + /// The application's name, as the host reports it. + /// A name accepts. + internal static string ToProductName(string? applicationName) + { + if (string.IsNullOrEmpty(applicationName)) + { + return FallbackProductName; + } + + char[] product = [.. applicationName.Select(c => IsTokenChar(c) ? c : '-')]; + string name = new string(product).Trim('-'); + return name.Length == 0 ? FallbackProductName : name; + } + + /// Reports whether a character may appear in an RFC 9110 token. + /// The character to test. + /// for an ASCII letter, digit, or one of !#$%&'*+-.^_`|~. + private static bool IsTokenChar(char c) => + char.IsAsciiLetterOrDigit(c) || "!#$%&'*+-.^_`|~".Contains(c, StringComparison.Ordinal); + /// /// A pass-through transport whose disposal stops at itself, so wrapping an /// in it never disposes that handler.