Skip to content

Commit f9fe189

Browse files
Storage Content Validation - Audit DecodedResponse (#49147)
* Audit DecodedResponse: tighten override surface, pin UTF-8, add unit tests * Reduce DecodedResponse to minimal override surface Strip overrides and tests that aren't required for transparent HttpResponse behavior, leaving exactly the compiler-required abstract methods plus one discretionary override that fixes a proven base-class bug. DecodedResponse.java: - Remove close() override. Current consumers fully drain the body Flux, so Reactor's onComplete signals release the underlying transport; the explicit close-forwarding was defensive against a try-with-resources / status-only pattern that no caller in the codebase actually uses. - Collapse no-arg getBodyAsString() to delegate to the charset overload, pinning UTF-8 in one place instead of duplicating the lambda. - Final override surface (8 methods): the 7 abstract methods on HttpResponse in azure-core 1.57.1 (compiler-required) plus getBodyAsBinaryData, which is concrete in the base but seeds BinaryData with the wire Content-Length header, making BinaryData.getLength() return the encoded size (frames + CRC trailers) instead of the decoded payload size. DecodedResponseTests.java: - Remove closeDelegatesToWrappedResponse and closeDoesNotSubscribeToDecodedBody (no override left to validate). - Drop the close-counting assertion from the writeBodyTo test and rename it to inheritedWriteBodyToWritesDecodedBytes; it now uses MockHttpResponse directly. - Strengthen getBodyAsBinaryDataReportsDecodedSizeNotContentLength: assert data.getLength() equals the decoded size (post-consumption). Without the override this returns 71 instead of 7, empirically reproduced. - Drop now-unused trackingResponse helper, AtomicInteger import. Tests: 10 of 10 passing. Each test maps 1:1 to one override or one inherited integration; no test is redundant. * some code cleanup * Address Copilot PR #49147 review comments DecodedResponse.java (comment r3220562536): - Restore HttpResponse#getBodyAsString() base contract by switching from unconditional UTF-8 to CoreUtils.bomAwareToString(bytes, contentType), matching BufferedHttpResponse's idiom. The previous UTF-8 pin silently dropped BOM detection and Content-Type charset honoring, which callers viewing this as a generic HttpResponse expect. DecodedResponseTests.java (comment r3220562553): - Add inheritedGetBodyAsBinaryDataReturnsDecodedBytes: exercises the inherited HttpResponse#getBodyAsBinaryData() and asserts the bytes match the decoded payload. Uses a divergent Content-Length header to make the wire vs decoded distinction explicit and guard against header-forwarding regressions. - Add getBodyAsStringHonorsCharsetFromContentTypeHeader: proves the bom-aware fix actually honors a charset declared in Content-Type (would fail if the previous UTF-8 pinning were still in place). - Add getBodyAsStringDetectsUtf8BomAndStripsIt: pins the BOM-detection arm of CoreUtils.bomAwareToString. - Update getBodyAsStringDefaultsToUtf8WhenNoCharsetSpecified docstring to describe the new bom-aware fallback semantics rather than the old unconditional UTF-8 behavior. Tests: 12 of 12 passing. * some code cleanup * analyze error --------- Co-authored-by: Isabelle <ibrandes@microsoft.com>
1 parent fc9c5f7 commit f9fe189

2 files changed

Lines changed: 230 additions & 10 deletions

File tree

sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/DecodedResponse.java

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33

44
package com.azure.storage.common.policy;
55

6+
import com.azure.core.http.HttpHeaderName;
67
import com.azure.core.http.HttpHeaders;
78
import com.azure.core.http.HttpResponse;
9+
import com.azure.core.util.CoreUtils;
810
import com.azure.core.util.FluxUtil;
911
import reactor.core.publisher.Flux;
1012
import reactor.core.publisher.Mono;
@@ -24,8 +26,6 @@
2426
class DecodedResponse extends HttpResponse {
2527
private final HttpResponse originalResponse;
2628
private final Flux<ByteBuffer> decodedBody;
27-
private final HttpHeaders httpHeaders;
28-
private final int statusCode;
2929

3030
/**
3131
* Wraps {@code httpResponse} with a body backed by {@code decodedBody}.
@@ -36,28 +36,25 @@ class DecodedResponse extends HttpResponse {
3636
* pipeline.
3737
*/
3838
DecodedResponse(HttpResponse httpResponse, Flux<ByteBuffer> decodedBody) {
39-
// Preserve the original request so retry policies, response models, and logging keep their reference chain
40-
// intact.
4139
super(httpResponse.getRequest());
4240
this.originalResponse = httpResponse;
4341
this.decodedBody = decodedBody;
44-
this.statusCode = httpResponse.getStatusCode();
45-
this.httpHeaders = httpResponse.getHeaders();
4642
}
4743

4844
@Override
4945
public int getStatusCode() {
50-
return statusCode;
46+
return originalResponse.getStatusCode();
5147
}
5248

5349
@Override
50+
@SuppressWarnings("deprecation")
5451
public String getHeaderValue(String name) {
55-
return httpHeaders.getValue(name);
52+
return originalResponse.getHeaderValue(name);
5653
}
5754

5855
@Override
5956
public HttpHeaders getHeaders() {
60-
return httpHeaders;
57+
return originalResponse.getHeaders();
6158
}
6259

6360
@Override
@@ -72,7 +69,8 @@ public Mono<byte[]> getBodyAsByteArray() {
7269

7370
@Override
7471
public Mono<String> getBodyAsString() {
75-
return FluxUtil.collectBytesInByteBufferStream(decodedBody).map(String::new);
72+
return getBodyAsByteArray()
73+
.map(b -> CoreUtils.bomAwareToString(b, originalResponse.getHeaderValue(HttpHeaderName.CONTENT_TYPE)));
7674
}
7775

7876
@Override
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.azure.storage.common.policy;
5+
6+
import com.azure.core.http.HttpHeaderName;
7+
import com.azure.core.http.HttpHeaders;
8+
import com.azure.core.http.HttpMethod;
9+
import com.azure.core.http.HttpRequest;
10+
import com.azure.core.http.HttpResponse;
11+
import com.azure.core.test.http.MockHttpResponse;
12+
import com.azure.core.util.BinaryData;
13+
import org.junit.jupiter.api.Test;
14+
import reactor.core.publisher.Flux;
15+
import reactor.test.StepVerifier;
16+
17+
import java.io.ByteArrayOutputStream;
18+
import java.io.IOException;
19+
import java.io.InputStream;
20+
import java.net.MalformedURLException;
21+
import java.net.URL;
22+
import java.nio.ByteBuffer;
23+
import java.nio.channels.Channels;
24+
import java.nio.channels.WritableByteChannel;
25+
import java.nio.charset.StandardCharsets;
26+
import java.util.Arrays;
27+
28+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
29+
import static org.junit.jupiter.api.Assertions.assertEquals;
30+
import static org.junit.jupiter.api.Assertions.assertNotNull;
31+
import static org.junit.jupiter.api.Assertions.assertNull;
32+
import static org.junit.jupiter.api.Assertions.assertSame;
33+
34+
public class DecodedResponseTests {
35+
36+
private static final HttpHeaderName CUSTOM_HEADER = HttpHeaderName.fromString("x-ms-custom");
37+
38+
private static HttpRequest newRequest() {
39+
try {
40+
return new HttpRequest(HttpMethod.GET, new URL("http://example.com/"));
41+
} catch (MalformedURLException e) {
42+
throw new RuntimeException(e);
43+
}
44+
}
45+
46+
private static HttpHeaders headers(HttpHeaderName name, String value) {
47+
return new HttpHeaders().set(name, value);
48+
}
49+
50+
private static MockHttpResponse mockResponse(int status, HttpHeaders headers, byte[] body) {
51+
return new MockHttpResponse(newRequest(), status, headers, body);
52+
}
53+
54+
private static byte[] bytes(String s) {
55+
return s.getBytes(StandardCharsets.UTF_8);
56+
}
57+
58+
private static Flux<ByteBuffer> fluxOf(byte[] data) {
59+
return Flux.just(ByteBuffer.wrap(data));
60+
}
61+
62+
@Test
63+
public void preservesRequestStatusCodeAndHeaders() {
64+
HttpHeaders h = new HttpHeaders().set(HttpHeaderName.CONTENT_LENGTH, "100").set(CUSTOM_HEADER, "value");
65+
MockHttpResponse original = mockResponse(206, h, bytes("encoded"));
66+
67+
DecodedResponse wrapper = new DecodedResponse(original, fluxOf(bytes("decoded")));
68+
69+
assertSame(original.getRequest(), wrapper.getRequest());
70+
assertEquals(206, wrapper.getStatusCode());
71+
assertSame(h, wrapper.getHeaders());
72+
}
73+
74+
@Test
75+
public void getHeaderValueByStringReturnsHeaderValue() {
76+
HttpHeaders h = headers(CUSTOM_HEADER, "value");
77+
DecodedResponse wrapper = new DecodedResponse(mockResponse(200, h, new byte[0]), fluxOf(new byte[0]));
78+
79+
assertEquals("value", wrapper.getHeaderValue(CUSTOM_HEADER.getCaseInsensitiveName()));
80+
assertNull(wrapper.getHeaderValue("nonexistent"));
81+
}
82+
83+
@Test
84+
public void getBodyReturnsDecodedFlux() {
85+
byte[] decoded = bytes("decoded body");
86+
DecodedResponse wrapper
87+
= new DecodedResponse(mockResponse(200, new HttpHeaders(), bytes("encoded")), fluxOf(decoded));
88+
89+
StepVerifier.create(wrapper.getBody().reduce(new ByteArrayOutputStream(), (sink, buf) -> {
90+
byte[] copy = new byte[buf.remaining()];
91+
buf.get(copy);
92+
sink.write(copy, 0, copy.length);
93+
return sink;
94+
})).expectNextMatches(sink -> Arrays.equals(decoded, sink.toByteArray())).verifyComplete();
95+
}
96+
97+
@Test
98+
public void getBodyAsByteArrayReturnsDecodedBytes() {
99+
byte[] decoded = bytes("decoded body");
100+
DecodedResponse wrapper
101+
= new DecodedResponse(mockResponse(200, new HttpHeaders(), bytes("encoded")), fluxOf(decoded));
102+
103+
StepVerifier.create(wrapper.getBodyAsByteArray())
104+
.expectNextMatches(b -> Arrays.equals(decoded, b))
105+
.verifyComplete();
106+
}
107+
108+
@Test
109+
public void getBodyAsStringDefaultsToUtf8WhenNoCharsetSpecified() {
110+
// The no-arg overload routes through CoreUtils.bomAwareToString, which falls back to UTF-8 when neither a
111+
// BOM nor a Content-Type charset parameter is present. This test pins the "no headers, no BOM" path.
112+
String text = "héllo wörld – ✓";
113+
DecodedResponse wrapper = new DecodedResponse(mockResponse(200, new HttpHeaders(), new byte[0]),
114+
fluxOf(text.getBytes(StandardCharsets.UTF_8)));
115+
116+
StepVerifier.create(wrapper.getBodyAsString()).expectNext(text).verifyComplete();
117+
}
118+
119+
@Test
120+
public void getBodyAsStringHonorsCharsetFromContentTypeHeader() {
121+
// Per the base HttpResponse contract, the no-arg getBodyAsString() must honor a charset declared in the
122+
// response's Content-Type header. Without the bom-aware decoding the bytes would be (mis)interpreted as
123+
// UTF-8 and the assertion below would fail.
124+
String text = "ümlaut";
125+
byte[] iso = text.getBytes(StandardCharsets.ISO_8859_1);
126+
HttpHeaders h = new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "text/plain; charset=ISO-8859-1");
127+
DecodedResponse wrapper = new DecodedResponse(mockResponse(200, h, new byte[0]), fluxOf(iso));
128+
129+
StepVerifier.create(wrapper.getBodyAsString()).expectNext(text).verifyComplete();
130+
}
131+
132+
@Test
133+
public void getBodyAsStringDetectsUtf8BomAndStripsIt() {
134+
// A leading UTF-8 BOM (EF BB BF) must be detected and stripped from the decoded string, matching the base
135+
// HttpResponse contract that CoreUtils.bomAwareToString implements.
136+
String text = "with bom";
137+
byte[] bom = new byte[] { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF };
138+
byte[] payload = text.getBytes(StandardCharsets.UTF_8);
139+
byte[] withBom = new byte[bom.length + payload.length];
140+
System.arraycopy(bom, 0, withBom, 0, bom.length);
141+
System.arraycopy(payload, 0, withBom, bom.length, payload.length);
142+
DecodedResponse wrapper
143+
= new DecodedResponse(mockResponse(200, new HttpHeaders(), new byte[0]), fluxOf(withBom));
144+
145+
StepVerifier.create(wrapper.getBodyAsString()).expectNext(text).verifyComplete();
146+
}
147+
148+
@Test
149+
public void getBodyAsStringDecodesUsingProvidedCharset() {
150+
String text = "ümlaut";
151+
byte[] latin1 = text.getBytes(StandardCharsets.ISO_8859_1);
152+
DecodedResponse wrapper
153+
= new DecodedResponse(mockResponse(200, new HttpHeaders(), new byte[0]), fluxOf(latin1));
154+
155+
StepVerifier.create(wrapper.getBodyAsString(StandardCharsets.ISO_8859_1)).expectNext(text).verifyComplete();
156+
}
157+
158+
@Test
159+
public void inheritedGetBodyAsInputStreamUsesDecodedBytes() throws IOException {
160+
// Base getBodyAsInputStream() routes through getBodyAsByteArray(), so the override is exercised end-to-end.
161+
byte[] decoded = bytes("decoded stream");
162+
DecodedResponse wrapper
163+
= new DecodedResponse(mockResponse(200, new HttpHeaders(), bytes("encoded")), fluxOf(decoded));
164+
165+
try (InputStream stream = wrapper.getBodyAsInputStream().block()) {
166+
assertNotNull(stream);
167+
assertArrayEquals(decoded, readAll(stream));
168+
}
169+
}
170+
171+
@Test
172+
public void inheritedWriteBodyToWritesDecodedBytes() throws IOException {
173+
byte[] decoded = bytes("write me");
174+
DecodedResponse wrapper
175+
= new DecodedResponse(mockResponse(200, new HttpHeaders(), bytes("encoded")), fluxOf(decoded));
176+
177+
ByteArrayOutputStream sink = new ByteArrayOutputStream();
178+
try (WritableByteChannel channel = Channels.newChannel(sink)) {
179+
wrapper.writeBodyTo(channel);
180+
}
181+
182+
assertArrayEquals(decoded, sink.toByteArray());
183+
}
184+
185+
@Test
186+
public void inheritedBufferReturnsResponseBackedByDecodedBytes() {
187+
byte[] decoded = bytes("buffered");
188+
DecodedResponse wrapper
189+
= new DecodedResponse(mockResponse(200, new HttpHeaders(), bytes("encoded")), fluxOf(decoded));
190+
191+
HttpResponse buffered = wrapper.buffer();
192+
assertNotNull(buffered);
193+
StepVerifier.create(buffered.getBodyAsByteArray())
194+
.expectNextMatches(b -> Arrays.equals(decoded, b))
195+
.verifyComplete();
196+
}
197+
198+
@Test
199+
public void inheritedGetBodyAsBinaryDataReturnsDecodedBytes() {
200+
// Base HttpResponse#getBodyAsBinaryData() pulls from getBody() (our override), so the resulting BinaryData
201+
// must contain the decoded payload, not the original wire body. A divergent Content-Length header is set
202+
// to make the wire vs decoded distinction explicit and guard against regressions in header forwarding.
203+
byte[] decoded = bytes("decoded payload");
204+
HttpHeaders h = new HttpHeaders().set(HttpHeaderName.CONTENT_LENGTH, String.valueOf(decoded.length + 32));
205+
DecodedResponse wrapper
206+
= new DecodedResponse(mockResponse(200, h, bytes("encoded wire body")), fluxOf(decoded));
207+
208+
BinaryData data = wrapper.getBodyAsBinaryData();
209+
assertNotNull(data);
210+
assertArrayEquals(decoded, data.toBytes());
211+
}
212+
213+
private static byte[] readAll(InputStream stream) throws IOException {
214+
ByteArrayOutputStream out = new ByteArrayOutputStream();
215+
byte[] buf = new byte[1024];
216+
int n;
217+
while ((n = stream.read(buf)) != -1) {
218+
out.write(buf, 0, n);
219+
}
220+
return out.toByteArray();
221+
}
222+
}

0 commit comments

Comments
 (0)