From b41b3b1b319e59e00d3f7b8a8f807d03b99292ae Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:53:00 -0400 Subject: [PATCH] fix(esg): honor the declared response charset in ReadBodyBoundedAsync with UTF-8 fallback (plan R2-06 T4) --- .../ExternalSystemClient.cs | 34 +++++++++++- .../ExternalSystemClientTests.cs | 54 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs index c70920ce..850ed1f9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs @@ -466,6 +466,12 @@ public class ExternalSystemClient : IExternalSystemClient /// into store-and-forward would defeat the cap. Cancellation /// () is allowed to propagate so the /// caller's ordered timeout/cancellation catch filters classify it. + /// + /// N4: the accumulated bytes are decoded using the response's declared charset + /// (Content-Type: …; charset=…) via — parity + /// with the pre-bounded-read ReadAsStringAsync. A null, empty, or + /// unrecognized charset falls back to UTF-8. + /// /// private static async Task ReadBodyBoundedAsync( HttpContent content, string systemName, long maxBytes, CancellationToken token) @@ -498,7 +504,33 @@ public class ExternalSystemClient : IExternalSystemClient } } - return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + // N4: honor the response's declared charset (parity with the pre-bounded-read + // ReadAsStringAsync). Unknown/invalid charset → UTF-8 fallback: mojibake beats + // failing the call over a cosmetic header. + return ResolveEncoding(content.Headers.ContentType?.CharSet) + .GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + } + + /// + /// Resolves the declared response charset to an , tolerating + /// the quoted form some servers emit (charset="iso-8859-1"). Null, empty, + /// or unrecognized values fall back to UTF-8. + /// + private static Encoding ResolveEncoding(string? charset) + { + if (string.IsNullOrWhiteSpace(charset)) + { + return Encoding.UTF8; + } + + try + { + return Encoding.GetEncoding(charset.Trim().Trim('"')); + } + catch (ArgumentException) + { + return Encoding.UTF8; + } } private static string Truncate(string value, int maxChars) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index fcbfe7c2..206c592f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using System.Text; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -1377,4 +1378,57 @@ public class ExternalSystemClientTests Assert.False(delivered); } + + [Fact] + public async Task Call_Latin1Charset_DecodesUsingDeclaredCharset() + { + // Arch-review R2 N4: the bounded read must honor the declared charset — + // UTF-8-decoding an iso-8859-1 body turns every non-ASCII byte into U+FFFD. + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; + var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; + StubResolution(system, method); + var handler = new ByteBodyHandler( + HttpStatusCode.OK, + Encoding.Latin1.GetBytes("température 25°C"), + "text/plain; charset=iso-8859-1"); + _httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + var result = await client.CallAsync("TestAPI", "getData"); + + Assert.True(result.Success); + Assert.Contains("température 25°C", result.ResponseJson); + } + + [Fact] + public async Task Call_UnknownCharset_FallsBackToUtf8() + { + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; + var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; + StubResolution(system, method); + var handler = new ByteBodyHandler( + HttpStatusCode.OK, Encoding.UTF8.GetBytes("ok"), "text/plain; charset=not-a-charset"); + _httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + var result = await client.CallAsync("TestAPI", "getData"); + + Assert.True(result.Success); + Assert.Contains("ok", result.ResponseJson); + } + + /// Test helper: returns a fixed byte body with an explicit Content-Type. + private sealed class ByteBodyHandler( + HttpStatusCode statusCode, byte[] body, string contentType) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(statusCode) { Content = new ByteArrayContent(body) }; + response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + return Task.FromResult(response); + } + } }