Skip to content

Commit e7d4c71

Browse files
User agent policy updates (#51788)
* User agent policy updates * remove duplicate * pr fb
1 parent 6cd2643 commit e7d4c71

File tree

6 files changed

+216
-22
lines changed

6 files changed

+216
-22
lines changed

sdk/core/System.ClientModel/api/System.ClientModel.net8.0.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ public partial class UserAgentPolicy : System.ClientModel.Primitives.PipelinePol
451451
public UserAgentPolicy(System.Reflection.Assembly callerAssembly, string? applicationId = null) { }
452452
public string? ApplicationId { get { throw null; } }
453453
public System.Reflection.Assembly Assembly { get { throw null; } }
454-
public static string GenerateUserAgentString(System.Reflection.Assembly callerAssembly, string? applicationId = null) { throw null; }
454+
public string UserAgentValue { get { throw null; } }
455455
public override void Process(System.ClientModel.Primitives.PipelineMessage message, System.Collections.Generic.IReadOnlyList<System.ClientModel.Primitives.PipelinePolicy> pipeline, int currentIndex) { }
456456
public override System.Threading.Tasks.ValueTask ProcessAsync(System.ClientModel.Primitives.PipelineMessage message, System.Collections.Generic.IReadOnlyList<System.ClientModel.Primitives.PipelinePolicy> pipeline, int currentIndex) { throw null; }
457457
}

sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ public partial class UserAgentPolicy : System.ClientModel.Primitives.PipelinePol
449449
public UserAgentPolicy(System.Reflection.Assembly callerAssembly, string? applicationId = null) { }
450450
public string? ApplicationId { get { throw null; } }
451451
public System.Reflection.Assembly Assembly { get { throw null; } }
452-
public static string GenerateUserAgentString(System.Reflection.Assembly callerAssembly, string? applicationId = null) { throw null; }
452+
public string UserAgentValue { get { throw null; } }
453453
public override void Process(System.ClientModel.Primitives.PipelineMessage message, System.Collections.Generic.IReadOnlyList<System.ClientModel.Primitives.PipelinePolicy> pipeline, int currentIndex) { }
454454
public override System.Threading.Tasks.ValueTask ProcessAsync(System.ClientModel.Primitives.PipelineMessage message, System.Collections.Generic.IReadOnlyList<System.ClientModel.Primitives.PipelinePolicy> pipeline, int currentIndex) { throw null; }
455455
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System.Runtime.InteropServices;
5+
6+
namespace System.ClientModel.Internal;
7+
8+
internal class RuntimeInformationWrapper
9+
{
10+
public virtual string FrameworkDescription => RuntimeInformation.FrameworkDescription;
11+
public virtual string OSDescription => RuntimeInformation.OSDescription;
12+
public virtual Architecture OSArchitecture => RuntimeInformation.OSArchitecture;
13+
public virtual Architecture ProcessArchitecture => RuntimeInformation.ProcessArchitecture;
14+
public virtual bool IsOSPlatform(OSPlatform osPlatform) => RuntimeInformation.IsOSPlatform(osPlatform);
15+
}

sdk/core/System.ClientModel/src/Pipeline/UserAgentPolicy.cs

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22
// Licensed under the MIT License.
33

44
using System.ClientModel.Internal;
5-
using System.Collections.Generic;
65
using System.Net;
76
using System.Net.Http.Headers;
87
using System.Reflection;
9-
using System.Runtime.InteropServices;
108
using System.Text;
11-
using System.Threading.Tasks;
129

1310
namespace System.ClientModel.Primitives;
1411

@@ -18,7 +15,7 @@ namespace System.ClientModel.Primitives;
1815
public class UserAgentPolicy : PipelinePolicy
1916
{
2017
private const int MaxApplicationIdLength = 24;
21-
private readonly string _defaultHeader;
18+
private readonly string _userAgent;
2219

2320
/// <summary>
2421
/// The package type represented by this <see cref="UserAgentPolicy"/> instance.
@@ -30,6 +27,11 @@ public class UserAgentPolicy : PipelinePolicy
3027
/// </summary>
3128
public string? ApplicationId { get; }
3229

30+
/// <summary>
31+
/// The formatted user agent string that will be added to HTTP requests by this policy.
32+
/// </summary>
33+
public string UserAgentValue => _userAgent;
34+
3335
/// <summary>
3436
/// Initialize an instance of <see cref="UserAgentPolicy"/> by extracting the name and version information from the <see cref="System.Reflection.Assembly"/> associated with the <paramref name="callerAssembly"/>.
3537
/// </summary>
@@ -45,7 +47,7 @@ public UserAgentPolicy(Assembly callerAssembly, string? applicationId = null)
4547

4648
Assembly = callerAssembly;
4749
ApplicationId = applicationId;
48-
_defaultHeader = GenerateUserAgentString(callerAssembly, applicationId);
50+
_userAgent = GenerateUserAgentString(callerAssembly, applicationId, new RuntimeInformationWrapper());
4951
}
5052

5153
/// <summary>
@@ -75,16 +77,18 @@ public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyL
7577

7678
private void AddUserAgentHeader(PipelineMessage message)
7779
{
78-
message.Request.Headers.Add("User-Agent", _defaultHeader);
80+
message.Request.Headers.Add("User-Agent", _userAgent);
7981
}
8082

8183
/// <summary>
82-
/// Generates a user agent string from the provided assembly and optional application ID.
84+
/// Generates a user agent string from the provided assembly and optional application ID using custom runtime information.
85+
/// This method is intended for testing scenarios that need to mock runtime information.
8386
/// </summary>
8487
/// <param name="callerAssembly">The caller assembly to extract name and version information from.</param>
8588
/// <param name="applicationId">An optional application ID to prepend to the user agent string.</param>
89+
/// <param name="runtimeInformation">Custom runtime information for testing scenarios.</param>
8690
/// <returns>A formatted user agent string.</returns>
87-
public static string GenerateUserAgentString(Assembly callerAssembly, string? applicationId = null)
91+
internal static string GenerateUserAgentString(Assembly callerAssembly, string? applicationId, RuntimeInformationWrapper runtimeInformation)
8892
{
8993
AssemblyInformationalVersionAttribute? versionAttribute = callerAssembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
9094
if (versionAttribute == null)
@@ -107,14 +111,14 @@ public static string GenerateUserAgentString(Assembly callerAssembly, string? ap
107111
// can use an encoding, such as the one defined in RFC8187." RFC8187 is targeted at parameter values, almost always filename, so using url encoding here instead, which is
108112
// more widely used. Since user-agent does not usually contain non-ascii, only encode when necessary.
109113
// This was added to support operating systems with non-ascii characters in their release names.
110-
string osDescription;
114+
string osDescription = runtimeInformation.OSDescription;
111115
#if NET8_0_OR_GREATER
112-
osDescription = System.Text.Ascii.IsValid(RuntimeInformation.OSDescription) ? RuntimeInformation.OSDescription : WebUtility.UrlEncode(RuntimeInformation.OSDescription);
116+
osDescription = System.Text.Ascii.IsValid(osDescription) ? osDescription : WebUtility.UrlEncode(osDescription);
113117
#else
114-
osDescription = ContainsNonAscii(RuntimeInformation.OSDescription) ? WebUtility.UrlEncode(RuntimeInformation.OSDescription) : RuntimeInformation.OSDescription;
118+
osDescription = ContainsNonAscii(osDescription) ? WebUtility.UrlEncode(osDescription) : osDescription;
115119
#endif
116120

117-
var platformInformation = EscapeProductInformation($"({RuntimeInformation.FrameworkDescription}; {osDescription})");
121+
var platformInformation = EscapeProductInformation($"({runtimeInformation.FrameworkDescription}; {osDescription})");
118122

119123
return applicationId != null
120124
? $"{applicationId} {assemblyName}/{version} {platformInformation}"
@@ -203,4 +207,4 @@ private static bool ContainsNonAscii(string value)
203207
return false;
204208
#endif
205209
}
206-
}
210+
}

sdk/core/System.ClientModel/tests/Pipeline/UserAgentPolicyTests.cs

Lines changed: 158 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.ClientModel.Primitives;
88
using System.Collections.Generic;
99
using System.Reflection;
10+
using System.Runtime.InteropServices;
1011
using System.Threading.Tasks;
1112

1213
namespace System.ClientModel.Tests.Pipeline;
@@ -181,11 +182,13 @@ public void GenerateUserAgentString_ProducesValidUserAgent()
181182
Assembly assembly = Assembly.GetExecutingAssembly();
182183

183184
// Test without application ID
184-
string userAgent = UserAgentPolicy.GenerateUserAgentString(assembly);
185-
Assert.IsNotNull(userAgent);
186-
Assert.IsNotEmpty(userAgent);
185+
var policy = new UserAgentPolicy(assembly);
186+
Assert.IsNotNull(policy);
187+
Assert.IsNull(policy.ApplicationId);
188+
Assert.AreEqual(assembly, policy.Assembly);
187189

188190
// Should contain assembly name and version
191+
var userAgent = policy.UserAgentValue;
189192
string assemblyName = assembly.GetName().Name!;
190193
Assert.That(userAgent, Does.Contain(assemblyName));
191194

@@ -200,9 +203,11 @@ public void GenerateUserAgentString_WithApplicationId_ProducesValidUserAgent()
200203
Assembly assembly = Assembly.GetExecutingAssembly();
201204
string applicationId = "TestApp/1.0";
202205

203-
string userAgent = UserAgentPolicy.GenerateUserAgentString(assembly, applicationId);
204-
Assert.IsNotNull(userAgent);
205-
Assert.IsNotEmpty(userAgent);
206+
var policy = new UserAgentPolicy(assembly, applicationId);
207+
Assert.IsNotNull(policy);
208+
Assert.AreEqual(applicationId, policy.ApplicationId);
209+
Assert.AreEqual(assembly, policy.Assembly);
210+
var userAgent = policy.UserAgentValue;
206211

207212
// Should start with application ID
208213
Assert.That(userAgent, Does.StartWith(applicationId));
@@ -211,4 +216,150 @@ public void GenerateUserAgentString_WithApplicationId_ProducesValidUserAgent()
211216
string assemblyName = assembly.GetName().Name!;
212217
Assert.That(userAgent, Does.Contain(assemblyName));
213218
}
214-
}
219+
220+
[Test]
221+
[TestCase("ValidParens (2023-)", "ValidParens (2023-)")]
222+
[TestCase("(ValidParens (2023-))", "(ValidParens (2023-))")]
223+
[TestCase("ProperlyEscapedParens \\(2023-\\)", "ProperlyEscapedParens \\(2023-\\)")]
224+
[TestCase("UnescapedOnlyParens (2023-)", "UnescapedOnlyParens (2023-)")]
225+
[TestCase("UnmatchedOpenParen (2023-", "UnmatchedOpenParen \\(2023-")]
226+
[TestCase("UnEscapedParenWithValidParens (()", "UnEscapedParenWithValidParens \\(\\(\\)")]
227+
[TestCase("UnEscapedInvalidParen (", "UnEscapedInvalidParen \\(")]
228+
[TestCase("UnEscapedParenWithValidParens2 ())", "UnEscapedParenWithValidParens2 \\(\\)\\)")]
229+
[TestCase("InvalidParen )", "InvalidParen \\)")]
230+
[TestCase("(InvalidParen ", "\\(InvalidParen ")]
231+
[TestCase("UnescapedParenInText MyO)SDescription ", "UnescapedParenInText MyO\\)SDescription ")]
232+
[TestCase("UnescapedParenInText MyO(SDescription ", "UnescapedParenInText MyO\\(SDescription ")]
233+
public void ValidatesProperParenthesisMatching(string input, string output)
234+
{
235+
var mockRuntimeInformation = new MockRuntimeInformation
236+
{
237+
OSDescriptionMock = input,
238+
FrameworkDescriptionMock = RuntimeInformation.FrameworkDescription
239+
};
240+
var assembly = Assembly.GetExecutingAssembly();
241+
AssemblyInformationalVersionAttribute? versionAttribute = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
242+
string version = versionAttribute!.InformationalVersion;
243+
int hashSeparator = version.IndexOf('+');
244+
if (hashSeparator != -1)
245+
{
246+
version = version.Substring(0, hashSeparator);
247+
}
248+
249+
string userAgent = UserAgentPolicy.GenerateUserAgentString(assembly, null, mockRuntimeInformation);
250+
string assemblyName = assembly.GetName().Name!;
251+
252+
Assert.AreEqual(
253+
$"{assemblyName}/{version} ({mockRuntimeInformation.FrameworkDescription}; {output})",
254+
userAgent);
255+
}
256+
257+
[Test]
258+
[TestCase("Win64; x64", "Win64; x64")]
259+
[TestCase("Intel Mac OS X 10_15_7", "Intel Mac OS X 10_15_7")]
260+
[TestCase("Android 10; SM-G973F", "Android 10; SM-G973F")]
261+
[TestCase("Win64; x64; Xbox; Xbox One", "Win64; x64; Xbox; Xbox One")]
262+
public void AsciiDoesNotEncode(string input, string output)
263+
{
264+
var mockRuntimeInformation = new MockRuntimeInformation
265+
{
266+
OSDescriptionMock = input,
267+
FrameworkDescriptionMock = RuntimeInformation.FrameworkDescription
268+
};
269+
var assembly = Assembly.GetExecutingAssembly();
270+
AssemblyInformationalVersionAttribute? versionAttribute = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
271+
string version = versionAttribute!.InformationalVersion;
272+
int hashSeparator = version.IndexOf('+');
273+
if (hashSeparator != -1)
274+
{
275+
version = version.Substring(0, hashSeparator);
276+
}
277+
278+
string userAgent = UserAgentPolicy.GenerateUserAgentString(assembly, null, mockRuntimeInformation);
279+
string assemblyName = assembly.GetName().Name!;
280+
281+
Assert.AreEqual(
282+
$"{assemblyName}/{version} ({mockRuntimeInformation.FrameworkDescription}; {output})",
283+
userAgent);
284+
}
285+
286+
[Test]
287+
[TestCase("»-Browser¢sample", "%C2%BB-Browser%C2%A2sample")]
288+
[TestCase("NixOS 24.11 (Vicuña)", "NixOS+24.11+(Vicu%C3%B1a)")]
289+
public void NonAsciiCharactersAreUrlEncoded(string input, string output)
290+
{
291+
var mockRuntimeInformation = new MockRuntimeInformation
292+
{
293+
OSDescriptionMock = input,
294+
FrameworkDescriptionMock = RuntimeInformation.FrameworkDescription
295+
};
296+
var assembly = Assembly.GetExecutingAssembly();
297+
AssemblyInformationalVersionAttribute? versionAttribute = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
298+
string version = versionAttribute!.InformationalVersion;
299+
int hashSeparator = version.IndexOf('+');
300+
if (hashSeparator != -1)
301+
{
302+
version = version.Substring(0, hashSeparator);
303+
}
304+
305+
string userAgent = UserAgentPolicy.GenerateUserAgentString(assembly, null, mockRuntimeInformation);
306+
string assemblyName = assembly.GetName().Name!;
307+
308+
Assert.AreEqual(
309+
$"{assemblyName}/{version} ({mockRuntimeInformation.FrameworkDescription}; {output})",
310+
userAgent);
311+
}
312+
313+
[Test]
314+
public void GenerateUserAgentString_WithCustomRuntimeInfo_ProducesValidUserAgent()
315+
{
316+
var assembly = Assembly.GetExecutingAssembly();
317+
var mockRuntimeInfo = new MockRuntimeInformation
318+
{
319+
OSDescriptionMock = "Test OS",
320+
FrameworkDescriptionMock = "Test Framework"
321+
};
322+
323+
string userAgent = UserAgentPolicy.GenerateUserAgentString(assembly, null, mockRuntimeInfo);
324+
325+
// Get expected values
326+
string assemblyName = assembly.GetName().Name!;
327+
AssemblyInformationalVersionAttribute? versionAttribute = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
328+
string version = versionAttribute!.InformationalVersion;
329+
int hashSeparator = version.IndexOf('+');
330+
if (hashSeparator != -1)
331+
{
332+
version = version.Substring(0, hashSeparator);
333+
}
334+
335+
string expectedUserAgent = $"{assemblyName}/{version} ({mockRuntimeInfo.FrameworkDescriptionMock}; {mockRuntimeInfo.OSDescriptionMock})";
336+
Assert.AreEqual(expectedUserAgent, userAgent);
337+
}
338+
339+
[Test]
340+
public void GenerateUserAgentString_WithCustomRuntimeInfoAndApplicationId_ProducesValidUserAgent()
341+
{
342+
var assembly = Assembly.GetExecutingAssembly();
343+
string applicationId = "TestApp/1.0";
344+
var mockRuntimeInfo = new MockRuntimeInformation
345+
{
346+
OSDescriptionMock = "Test OS",
347+
FrameworkDescriptionMock = "Test Framework"
348+
};
349+
350+
string userAgent = UserAgentPolicy.GenerateUserAgentString(assembly, applicationId, mockRuntimeInfo);
351+
352+
// Get expected values
353+
string assemblyName = assembly.GetName().Name!;
354+
AssemblyInformationalVersionAttribute? versionAttribute = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
355+
string version = versionAttribute!.InformationalVersion;
356+
int hashSeparator = version.IndexOf('+');
357+
if (hashSeparator != -1)
358+
{
359+
version = version.Substring(0, hashSeparator);
360+
}
361+
362+
string expectedUserAgent = $"{applicationId} {assemblyName}/{version} ({mockRuntimeInfo.FrameworkDescriptionMock}; {mockRuntimeInfo.OSDescriptionMock})";
363+
Assert.AreEqual(expectedUserAgent, userAgent);
364+
}
365+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.ClientModel.Internal;
6+
using System.Runtime.InteropServices;
7+
8+
namespace ClientModel.Tests.Mocks
9+
{
10+
internal class MockRuntimeInformation : RuntimeInformationWrapper
11+
{
12+
public string? FrameworkDescriptionMock { get; set; }
13+
public string? OSDescriptionMock { get; set; }
14+
public Architecture OSArchitectureMock { get; set; }
15+
public Architecture ProcessArchitectureMock { get; set; }
16+
public Func<OSPlatform, bool>? IsOSPlatformMock { get; set; }
17+
18+
public override string OSDescription => OSDescriptionMock ?? base.OSDescription;
19+
public override string FrameworkDescription => FrameworkDescriptionMock ?? base.FrameworkDescription;
20+
public override Architecture OSArchitecture => OSArchitectureMock;
21+
public override Architecture ProcessArchitecture => ProcessArchitectureMock;
22+
public override bool IsOSPlatform(OSPlatform osPlatform) => IsOSPlatformMock?.Invoke(osPlatform) ?? base.IsOSPlatform(osPlatform);
23+
}
24+
}

0 commit comments

Comments
 (0)