diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs index 6b43fdf276..724c83012a 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs @@ -7,7 +7,6 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Formatters.Json; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Mvc.Formatters @@ -67,7 +66,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } var httpContext = context.HttpContext; - var inputStream = GetInputStream(httpContext, encoding); + var (inputStream, usesTranscodingStream) = GetInputStream(httpContext, encoding); object model; try @@ -98,9 +97,9 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } finally { - if (inputStream is TranscodingReadStream transcoding) + if (usesTranscodingStream) { - await transcoding.DisposeAsync(); + await inputStream.DisposeAsync(); } } @@ -119,14 +118,15 @@ namespace Microsoft.AspNetCore.Mvc.Formatters } } - private Stream GetInputStream(HttpContext httpContext, Encoding encoding) + private (Stream inputStream, bool usesTranscodingStream) GetInputStream(HttpContext httpContext, Encoding encoding) { if (encoding.CodePage == Encoding.UTF8.CodePage) { - return httpContext.Request.Body; + return (httpContext.Request.Body, false); } - return new TranscodingReadStream(httpContext.Request.Body, encoding); + var inputStream = Encoding.CreateTranscodingStream(httpContext.Request.Body, encoding, Encoding.UTF8, leaveOpen: true); + return (inputStream, true); } private static class Log diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs index 77be650884..adf81cf1f3 100644 --- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs +++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs @@ -2,14 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.IO; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Formatters.Json; namespace Microsoft.AspNetCore.Mvc.Formatters { @@ -73,44 +70,50 @@ namespace Microsoft.AspNetCore.Mvc.Formatters var httpContext = context.HttpContext; - var writeStream = GetWriteStream(httpContext, selectedEncoding); - try - { - // context.ObjectType reflects the declared model type when specified. - // For polymorphic scenarios where the user declares a return type, but returns a derived type, - // we want to serialize all the properties on the derived type. This keeps parity with - // the behavior you get when the user does not declare the return type and with Json.Net at least at the top level. - var objectType = context.Object?.GetType() ?? context.ObjectType ?? typeof(object); - await JsonSerializer.SerializeAsync(writeStream, context.Object, objectType, SerializerOptions); + // context.ObjectType reflects the declared model type when specified. + // For polymorphic scenarios where the user declares a return type, but returns a derived type, + // we want to serialize all the properties on the derived type. This keeps parity with + // the behavior you get when the user does not declare the return type and with Json.Net at least at the top level. + var objectType = context.Object?.GetType() ?? context.ObjectType ?? typeof(object); - // The transcoding streams use Encoders and Decoders that have internal buffers. We need to flush these - // when there is no more data to be written. Stream.FlushAsync isn't suitable since it's - // acceptable to Flush a Stream (multiple times) prior to completion. - if (writeStream is TranscodingWriteStream transcodingStream) - { - await transcodingStream.FinalWriteAsync(CancellationToken.None); - } - await writeStream.FlushAsync(); - } - finally - { - if (writeStream is TranscodingWriteStream transcodingStream) - { - await transcodingStream.DisposeAsync(); - } - } - } - - private Stream GetWriteStream(HttpContext httpContext, Encoding selectedEncoding) - { + var responseStream = httpContext.Response.Body; if (selectedEncoding.CodePage == Encoding.UTF8.CodePage) { - // JsonSerializer does not write a BOM. Therefore we do not have to handle it - // in any special way. - return httpContext.Response.Body; + await JsonSerializer.SerializeAsync(responseStream, context.Object, objectType, SerializerOptions); + await responseStream.FlushAsync(); } + else + { + // JsonSerializer only emits UTF8 encoded output, but we need to write the response in the encoding specified by + // selectedEncoding + var transcodingStream = Encoding.CreateTranscodingStream(httpContext.Response.Body, selectedEncoding, Encoding.UTF8, leaveOpen: true); - return new TranscodingWriteStream(httpContext.Response.Body, selectedEncoding); + ExceptionDispatchInfo exceptionDispatchInfo = null; + try + { + await JsonSerializer.SerializeAsync(transcodingStream, context.Object, objectType, SerializerOptions); + await transcodingStream.FlushAsync(); + } + catch (Exception ex) + { + // TranscodingStream may write to the inner stream as part of it's disposal. + // We do not want this exception "ex" to be eclipsed by any exception encountered during the write. We will stash it and + // explicitly rethrow it during the finally block. + exceptionDispatchInfo = ExceptionDispatchInfo.Capture(ex); + } + finally + { + try + { + await transcodingStream.DisposeAsync(); + } + catch when (exceptionDispatchInfo != null) + { + } + + exceptionDispatchInfo?.Throw(); + } + } } } } diff --git a/src/Mvc/Mvc.Core/src/Formatters/TranscodingReadStream.cs b/src/Mvc/Mvc.Core/src/Formatters/TranscodingReadStream.cs deleted file mode 100644 index 7771093e89..0000000000 --- a/src/Mvc/Mvc.Core/src/Formatters/TranscodingReadStream.cs +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Buffers; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Text.Unicode; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.AspNetCore.Mvc.Formatters.Json -{ - internal sealed class TranscodingReadStream : Stream - { - private static readonly int OverflowBufferSize = Encoding.UTF8.GetMaxByteCount(1); // The most number of bytes used to represent a single UTF char - - internal const int MaxByteBufferSize = 4096; - internal const int MaxCharBufferSize = 3 * MaxByteBufferSize; - - private readonly Stream _stream; - private readonly Decoder _decoder; - - private ArraySegment _byteBuffer; - private ArraySegment _charBuffer; - private ArraySegment _overflowBuffer; - private bool _disposed; - - public TranscodingReadStream(Stream input, Encoding sourceEncoding) - { - _stream = input; - - // The "count" in the buffer is the size of any content from a previous read. - // Initialize them to 0 since nothing has been read so far. - _byteBuffer = new ArraySegment( - ArrayPool.Shared.Rent(MaxByteBufferSize), - 0, - count: 0); - - // Attempt to allocate a char buffer than can tolerate the worst-case scenario for this - // encoding. This would allow the byte -> char conversion to complete in a single call. - // However limit the buffer size to prevent an encoding that has a very poor worst-case scenario. - // The conversion process is tolerant of char buffer that is not large enough to convert all the bytes at once. - var maxCharBufferSize = Math.Min(MaxCharBufferSize, sourceEncoding.GetMaxCharCount(MaxByteBufferSize)); - _charBuffer = new ArraySegment( - ArrayPool.Shared.Rent(maxCharBufferSize), - 0, - count: 0); - - _overflowBuffer = new ArraySegment( - ArrayPool.Shared.Rent(OverflowBufferSize), - 0, - count: 0); - - _decoder = sourceEncoding.GetDecoder(); - } - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - internal int ByteBufferCount => _byteBuffer.Count; - internal int CharBufferCount => _charBuffer.Count; - internal int OverflowCount => _overflowBuffer.Count; - - public override void Flush() - => throw new NotSupportedException(); - - public override int Read(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); - - public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ThrowArgumentOutOfRangeException(buffer, offset, count); - - if (count == 0) - { - return 0; - } - - var readBuffer = new ArraySegment(buffer, offset, count); - - if (_overflowBuffer.Count > 0) - { - var bytesToCopy = Math.Min(count, _overflowBuffer.Count); - _overflowBuffer.Slice(0, bytesToCopy).CopyTo(readBuffer); - - _overflowBuffer = _overflowBuffer.Slice(bytesToCopy); - - // If we have any overflow bytes, avoid complicating the remainder of the code, by returning as - // soon as we copy any content. - return bytesToCopy; - } - - if (_charBuffer.Count == 0) - { - // Only read more content from the input stream if we have exhausted all the buffered chars. - await ReadInputChars(cancellationToken); - } - - var operationStatus = Utf8.FromUtf16(_charBuffer, readBuffer, out var charsRead, out var bytesWritten, isFinalBlock: false); - _charBuffer = _charBuffer.Slice(charsRead); - - switch (operationStatus) - { - case OperationStatus.Done: - return bytesWritten; - - case OperationStatus.DestinationTooSmall: - if (bytesWritten != 0) - { - return bytesWritten; - } - - // Overflow buffer is always empty when we get here and we can use it's full length to write contents to. - Utf8.FromUtf16(_charBuffer, _overflowBuffer.Array, out var overFlowChars, out var overflowBytes, isFinalBlock: false); - - Debug.Assert(overflowBytes > 0 && overFlowChars > 0, "We expect writes to the overflow buffer to always succeed since it is large enough to accommodate at least one char."); - - _charBuffer = _charBuffer.Slice(overFlowChars); - - // readBuffer: [ 0, 0, ], overflowBuffer: [ 7, 13, 34, ] - // Fill up the readBuffer to capacity, so the result looks like so: - // readBuffer: [ 7, 13 ], overflowBuffer: [ 34 ] - Debug.Assert(readBuffer.Count < overflowBytes); - _overflowBuffer.Array.AsSpan(0, readBuffer.Count).CopyTo(readBuffer); - - _overflowBuffer = new ArraySegment( - _overflowBuffer.Array, - readBuffer.Count, - overflowBytes - readBuffer.Count); - - Debug.Assert(_overflowBuffer.Count != 0); - - return readBuffer.Count; - - default: - Debug.Fail("We should never see this"); - throw new InvalidOperationException(); - } - } - - private async Task ReadInputChars(CancellationToken cancellationToken) - { - // If we had left-over bytes from a previous read, move it to the start of the buffer and read content in to - // the segment that follows. - Buffer.BlockCopy( - _byteBuffer.Array, - _byteBuffer.Offset, - _byteBuffer.Array, - 0, - _byteBuffer.Count); - - var readBytes = await _stream.ReadAsync(_byteBuffer.Array.AsMemory(_byteBuffer.Count), cancellationToken); - _byteBuffer = new ArraySegment(_byteBuffer.Array, 0, _byteBuffer.Count + readBytes); - - Debug.Assert(_charBuffer.Count == 0, "We should only expect to read more input chars once all buffered content is read"); - - _decoder.Convert( - _byteBuffer.AsSpan(), - _charBuffer.Array, - flush: readBytes == 0, - out var bytesUsed, - out var charsUsed, - out _); - - _byteBuffer = _byteBuffer.Slice(bytesUsed); - _charBuffer = new ArraySegment(_charBuffer.Array, 0, charsUsed); - } - - private static void ThrowArgumentOutOfRangeException(byte[] buffer, int offset, int count) - { - if (count < 0) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } - - if (offset < 0 || offset >= buffer.Length) - { - throw new ArgumentOutOfRangeException(nameof(offset)); - } - - if (buffer.Length - offset < count) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); - } - - public override void SetLength(long value) - { - throw new NotSupportedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotSupportedException(); - } - - protected override void Dispose(bool disposing) - { - if (!_disposed) - { - _disposed = true; - ArrayPool.Shared.Return(_charBuffer.Array); - ArrayPool.Shared.Return(_byteBuffer.Array); - ArrayPool.Shared.Return(_overflowBuffer.Array); - } - } - } -} diff --git a/src/Mvc/Mvc.Core/src/Formatters/TranscodingWriteStream.cs b/src/Mvc/Mvc.Core/src/Formatters/TranscodingWriteStream.cs deleted file mode 100644 index a5dc39fe9e..0000000000 --- a/src/Mvc/Mvc.Core/src/Formatters/TranscodingWriteStream.cs +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Buffers; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.AspNetCore.Mvc.Formatters.Json -{ - internal sealed class TranscodingWriteStream : Stream - { - internal const int MaxCharBufferSize = 4096; - internal const int MaxByteBufferSize = 4 * MaxCharBufferSize; - private readonly int _maxByteBufferSize; - - private readonly Stream _stream; - private readonly Decoder _decoder; - private readonly Encoder _encoder; - private readonly char[] _charBuffer; - private int _charsDecoded; - private bool _disposed; - - public TranscodingWriteStream(Stream stream, Encoding targetEncoding) - { - _stream = stream; - - _charBuffer = ArrayPool.Shared.Rent(MaxCharBufferSize); - - // Attempt to allocate a byte buffer than can tolerate the worst-case scenario for this - // encoding. This would allow the char -> byte conversion to complete in a single call. - // However limit the buffer size to prevent an encoding that has a very poor worst-case scenario. - _maxByteBufferSize = Math.Min(MaxByteBufferSize, targetEncoding.GetMaxByteCount(MaxCharBufferSize)); - - _decoder = Encoding.UTF8.GetDecoder(); - _encoder = targetEncoding.GetEncoder(); - } - - public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - public override long Position { get; set; } - - public override void Flush() - => throw new NotSupportedException(); - - public override async Task FlushAsync(CancellationToken cancellationToken) - { - await _stream.FlushAsync(cancellationToken); - } - - public override int Read(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); - } - - public override void SetLength(long value) - { - throw new NotSupportedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); - - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ThrowArgumentException(buffer, offset, count); - var bufferSegment = new ArraySegment(buffer, offset, count); - return WriteAsync(bufferSegment, cancellationToken); - } - - private async Task WriteAsync( - ArraySegment bufferSegment, - CancellationToken cancellationToken) - { - var decoderCompleted = false; - while (!decoderCompleted) - { - _decoder.Convert( - bufferSegment, - _charBuffer.AsSpan(_charsDecoded), - flush: false, - out var bytesDecoded, - out var charsDecoded, - out decoderCompleted); - - _charsDecoded += charsDecoded; - bufferSegment = bufferSegment.Slice(bytesDecoded); - - if (!decoderCompleted) - { - await WriteBufferAsync(cancellationToken); - } - } - } - - private async Task WriteBufferAsync(CancellationToken cancellationToken) - { - var encoderCompleted = false; - var charsWritten = 0; - var byteBuffer = ArrayPool.Shared.Rent(_maxByteBufferSize); - - while (!encoderCompleted && charsWritten < _charsDecoded) - { - _encoder.Convert( - _charBuffer.AsSpan(charsWritten, _charsDecoded - charsWritten), - byteBuffer, - flush: false, - out var charsEncoded, - out var bytesUsed, - out encoderCompleted); - - await _stream.WriteAsync(byteBuffer.AsMemory(0, bytesUsed), cancellationToken); - charsWritten += charsEncoded; - } - - ArrayPool.Shared.Return(byteBuffer); - - // At this point, we've written all the buffered chars to the underlying Stream. - _charsDecoded = 0; - } - - private static void ThrowArgumentException(byte[] buffer, int offset, int count) - { - if (count <= 0) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } - - if (offset < 0 || offset >= buffer.Length) - { - throw new ArgumentOutOfRangeException(nameof(offset)); - } - - if (buffer.Length - offset < count) - { - throw new ArgumentOutOfRangeException(nameof(count)); - } - } - - protected override void Dispose(bool disposing) - { - if (!_disposed) - { - _disposed = true; - ArrayPool.Shared.Return(_charBuffer); - } - } - - public async Task FinalWriteAsync(CancellationToken cancellationToken) - { - // First write any buffered content - await WriteBufferAsync(cancellationToken); - - // Now flush the encoder. - var byteBuffer = ArrayPool.Shared.Rent(_maxByteBufferSize); - var encoderCompleted = false; - - while (!encoderCompleted) - { - _encoder.Convert( - Array.Empty(), - byteBuffer, - flush: true, - out _, - out var bytesUsed, - out encoderCompleted); - - await _stream.WriteAsync(byteBuffer.AsMemory(0, bytesUsed), cancellationToken); - } - - ArrayPool.Shared.Return(byteBuffer); - } - } -} diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs index 1a4960ba5d..c5e7185dc0 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs @@ -2,16 +2,12 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.IO; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.AspNetCore.Mvc.Formatters.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -71,50 +67,56 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure Log.JsonResultExecuting(_logger, result.Value); + var value = result.Value; + if (value != null && _asyncEnumerableReaderFactory.TryGetReader(value.GetType(), out var reader)) + { + Log.BufferingAsyncEnumerable(_logger, value); + value = await reader(value); + } + + var objectType = value?.GetType() ?? typeof(object); + // Keep this code in sync with SystemTextJsonOutputFormatter - var writeStream = GetWriteStream(context.HttpContext, resolvedContentTypeEncoding); - try + var responseStream = response.Body; + if (resolvedContentTypeEncoding.CodePage == Encoding.UTF8.CodePage) { - var value = result.Value; - if (value != null && _asyncEnumerableReaderFactory.TryGetReader(value.GetType(), out var reader)) - { - Log.BufferingAsyncEnumerable(_logger, value); - value = await reader(value); - } - - var type = value?.GetType() ?? typeof(object); - await JsonSerializer.SerializeAsync(writeStream, value, type, jsonSerializerOptions); - - // The transcoding streams use Encoders and Decoders that have internal buffers. We need to flush these - // when there is no more data to be written. Stream.FlushAsync isn't suitable since it's - // acceptable to Flush a Stream (multiple times) prior to completion. - if (writeStream is TranscodingWriteStream transcodingStream) - { - await transcodingStream.FinalWriteAsync(CancellationToken.None); - } - await writeStream.FlushAsync(); + await JsonSerializer.SerializeAsync(responseStream, value, objectType, jsonSerializerOptions); + await responseStream.FlushAsync(); } - finally + else { - if (writeStream is TranscodingWriteStream transcodingStream) + // JsonSerializer only emits UTF8 encoded output, but we need to write the response in the encoding specified by + // selectedEncoding + var transcodingStream = Encoding.CreateTranscodingStream(response.Body, resolvedContentTypeEncoding, Encoding.UTF8, leaveOpen: true); + + ExceptionDispatchInfo exceptionDispatchInfo = null; + try { - await transcodingStream.DisposeAsync(); + await JsonSerializer.SerializeAsync(transcodingStream, value, objectType, jsonSerializerOptions); + await transcodingStream.FlushAsync(); + } + catch (Exception ex) + { + // TranscodingStream may write to the inner stream as part of it's disposal. + // We do not want this exception "ex" to be eclipsed by any exception encountered during the write. We will stash it and + // explicitly rethrow it during the finally block. + exceptionDispatchInfo = ExceptionDispatchInfo.Capture(ex); + } + finally + { + try + { + await transcodingStream.DisposeAsync(); + } + catch when (exceptionDispatchInfo != null) + { + } + + exceptionDispatchInfo?.Throw(); } } } - private Stream GetWriteStream(HttpContext httpContext, Encoding selectedEncoding) - { - if (selectedEncoding.CodePage == Encoding.UTF8.CodePage) - { - // JsonSerializer does not write a BOM. Therefore we do not have to handle it - // in any special way. - return httpContext.Response.Body; - } - - return new TranscodingWriteStream(httpContext.Response.Body, selectedEncoding); - } - private JsonSerializerOptions GetSerializerOptions(JsonResult result) { var serializerSettings = result.SerializerSettings; diff --git a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs index c44ddeb77a..83380231e5 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs @@ -110,6 +110,28 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.Equal("abcd", stringValue); } + [Fact] + public async Task JsonFormatterReadsNonUtf8Content() + { + // Arrange + var content = "☀☁☂☃☄★☆☇☈☉☊☋☌☍☎☏☐☑☒☓☚☛☜☝☞☟☠☡☢☣☤☥☦☧☨☩☪☫☬☮☯☰☱☲☳☴☵☶☷☸"; + var formatter = GetInputFormatter(); + + var contentBytes = Encoding.Unicode.GetBytes($"\"{content}\""); + var httpContext = GetHttpContext(contentBytes, "application/json;charset=utf-16"); + + var formatterContext = CreateInputFormatterContext(typeof(string), httpContext); + + // Act + var result = await formatter.ReadAsync(formatterContext); + + // Assert + Assert.False(result.HasError); + var stringValue = Assert.IsType(result.Model); + Assert.Equal(content, stringValue); + Assert.True(httpContext.Request.Body.CanRead, "Verify that the request stream hasn't been disposed"); + } + [Fact] public virtual async Task JsonFormatter_EscapedKeys_Bracket() { diff --git a/src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs b/src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs index 8990b1cb9c..e10e6ca4f5 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs @@ -108,6 +108,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters // Assert var actualContent = encoding.GetString(body.ToArray()); Assert.Equal(expectedContent, actualContent, StringComparer.OrdinalIgnoreCase); + Assert.True(body.CanWrite, "Response body should not be disposed."); } [Fact] diff --git a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs index ca9180f971..7687845593 100644 --- a/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs +++ b/src/Mvc/Mvc.Core/test/Formatters/SystemTextJsonOutputFormatterTest.cs @@ -1,8 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.IO; using System.Text; +using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.Extensions.Primitives; @@ -55,6 +57,30 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.Equal(expectedContent, actualContent); } + [Fact] + public async Task WriteResponseBodyAsync_WithNonUtf8Encoding_FormattingErrorsAreThrown() + { + // Arrange + var formatter = GetOutputFormatter(); + var mediaType = MediaTypeHeaderValue.Parse("application/json; charset=utf-16"); + var encoding = CreateOrGetSupportedEncoding(formatter, "utf-16", isDefaultEncoding: true); + + var body = new MemoryStream(); + var actionContext = GetActionContext(mediaType, body); + + var outputFormatterContext = new OutputFormatterWriteContext( + actionContext.HttpContext, + new TestHttpResponseStreamWriterFactory().CreateWriter, + typeof(Person), + new ThrowingFormatterModel()) + { + ContentType = new StringSegment(mediaType.ToString()), + }; + + // Act & Assert + await Assert.ThrowsAsync(() => formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-16"))); + } + private class Person { public string Name { get; set; } @@ -63,5 +89,24 @@ namespace Microsoft.AspNetCore.Mvc.Formatters public Person Parent { get; set; } } + + [JsonConverter(typeof(ThrowingFormatterPersonConverter))] + private class ThrowingFormatterModel + { + + } + + private class ThrowingFormatterPersonConverter : JsonConverter + { + public override ThrowingFormatterModel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, ThrowingFormatterModel value, JsonSerializerOptions options) + { + throw new TimeZoneNotFoundException(); + } + } } } diff --git a/src/Mvc/Mvc.Core/test/Formatters/TranscodingReadStreamTest.cs b/src/Mvc/Mvc.Core/test/Formatters/TranscodingReadStreamTest.cs deleted file mode 100644 index 6206aeab62..0000000000 --- a/src/Mvc/Mvc.Core/test/Formatters/TranscodingReadStreamTest.cs +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System.IO; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Formatters.Json -{ - public class TranscodingReadStreamTest - { - [Fact] - public async Task ReadAsync_SingleByte() - { - // Arrange - var input = "Hello world"; - var encoding = Encoding.Unicode; - using var stream = new TranscodingReadStream(new MemoryStream(encoding.GetBytes(input)), encoding); - var bytes = new byte[4]; - - // Act - var readBytes = await stream.ReadAsync(bytes, 0, 1); - - // Assert - Assert.Equal(1, readBytes); - Assert.Equal((byte)'H', bytes[0]); - Assert.Equal(0, bytes[1]); - - Assert.Equal(0, stream.ByteBufferCount); - Assert.Equal(10, stream.CharBufferCount); - Assert.Equal(0, stream.OverflowCount); - } - - [Fact] - public async Task ReadAsync_FillsBuffer() - { - // Arrange - var input = "Hello world"; - var encoding = Encoding.Unicode; - using var stream = new TranscodingReadStream(new MemoryStream(encoding.GetBytes(input)), encoding); - var bytes = new byte[3]; - var expected = Encoding.UTF8.GetBytes(input.Substring(0, bytes.Length)); - - // Act - var readBytes = await stream.ReadAsync(bytes, 0, bytes.Length); - - // Assert - Assert.Equal(3, readBytes); - Assert.Equal(expected, bytes); - Assert.Equal(0, stream.ByteBufferCount); - Assert.Equal(8, stream.CharBufferCount); - Assert.Equal(0, stream.OverflowCount); - } - - [Fact] - public async Task ReadAsync_CompletedInSecondIteration() - { - // Arrange - var input = new string('A', 1024 + 10); - var encoding = Encoding.Unicode; - using var stream = new TranscodingReadStream(new MemoryStream(encoding.GetBytes(input)), encoding); - var bytes = new byte[1024]; - var expected = Encoding.UTF8.GetBytes(input.Substring(0, bytes.Length)); - - // Act - var readBytes = await stream.ReadAsync(bytes, 0, bytes.Length); - - // Assert - Assert.Equal(bytes.Length, readBytes); - Assert.Equal(expected, bytes); - Assert.Equal(0, stream.ByteBufferCount); - Assert.Equal(10, stream.CharBufferCount); - Assert.Equal(0, stream.OverflowCount); - - readBytes = await stream.ReadAsync(bytes, 0, bytes.Length); - Assert.Equal(10, readBytes); - Assert.Equal(0, stream.ByteBufferCount); - Assert.Equal(0, stream.CharBufferCount); - Assert.Equal(0, stream.OverflowCount); - } - - [Fact] - public async Task ReadAsync_WithOverflowBuffer() - { - // Arrange - // Test ensures that the overflow buffer works correctly - var input = "☀"; - var encoding = Encoding.Unicode; - using var stream = new TranscodingReadStream(new MemoryStream(encoding.GetBytes(input)), encoding); - var bytes = new byte[1]; - var expected = Encoding.UTF8.GetBytes(input); - - // Act - var readBytes = await stream.ReadAsync(bytes, 0, bytes.Length); - - // Assert - Assert.Equal(1, readBytes); - Assert.Equal(expected[0], bytes[0]); - Assert.Equal(0, stream.ByteBufferCount); - Assert.Equal(0, stream.CharBufferCount); - Assert.Equal(2, stream.OverflowCount); - - bytes = new byte[expected.Length - 1]; - readBytes = await stream.ReadAsync(bytes, 0, bytes.Length); - Assert.Equal(bytes.Length, readBytes); - Assert.Equal(0, stream.ByteBufferCount); - Assert.Equal(0, stream.CharBufferCount); - Assert.Equal(0, stream.OverflowCount); - - readBytes = await stream.ReadAsync(bytes, 0, bytes.Length); - Assert.Equal(0, readBytes); - } - - public static TheoryData ReadAsync_WithOverflowBuffer_AtBoundariesData => new TheoryData - { - new string('a', TranscodingReadStream.MaxCharBufferSize - 1) + "☀", - new string('a', TranscodingReadStream.MaxCharBufferSize - 2) + "☀", - new string('a', TranscodingReadStream.MaxCharBufferSize) + "☀", - }; - - [Theory] - [MemberData(nameof(ReadAsync_WithOverflowBuffer_AtBoundariesData))] - public Task ReadAsync_WithOverflowBuffer_WithBufferSize1(string input) => ReadAsync_WithOverflowBufferAtCharBufferBoundaries(input, bufferSize: 1); - - [Theory] - [MemberData(nameof(ReadAsync_WithOverflowBuffer_AtBoundariesData))] - public Task ReadAsync_WithOverflowBuffer_WithBufferSize2(string input) => ReadAsync_WithOverflowBufferAtCharBufferBoundaries(input, bufferSize: 1); - - private static async Task ReadAsync_WithOverflowBufferAtCharBufferBoundaries(string input, int bufferSize) - { - // Arrange - // Test ensures that the overflow buffer works correctly - var encoding = Encoding.Unicode; - var stream = new TranscodingReadStream(new MemoryStream(encoding.GetBytes(input)), encoding); - var bytes = new byte[1]; - var expected = Encoding.UTF8.GetBytes(input); - - // Act - int read; - var buffer = new byte[bufferSize]; - var actual = new List(); - - while ((read = await stream.ReadAsync(buffer)) != 0) - { - actual.AddRange(buffer); - } - - Assert.Equal(expected, actual); - return stream; - } - - public static TheoryData ReadAsyncInputLatin => - GetLatinTextInput(TranscodingReadStream.MaxCharBufferSize, TranscodingReadStream.MaxByteBufferSize); - - public static TheoryData ReadAsyncInputUnicode => - GetUnicodeText(TranscodingReadStream.MaxCharBufferSize); - - internal static TheoryData GetLatinTextInput(int maxCharBufferSize, int maxByteBufferSize) - { - return new TheoryData - { - "Hello world", - string.Join(string.Empty, Enumerable.Repeat("AB", 9000)), - new string('A', count: maxByteBufferSize), - new string('A', count: maxCharBufferSize), - new string('A', count: maxByteBufferSize + 1), - new string('A', count: maxCharBufferSize + 1), - }; - } - - internal static TheoryData GetUnicodeText(int maxCharBufferSize) - { - return new TheoryData - { - new string('Æ', count: 7), - new string('A', count: maxCharBufferSize - 1) + 'Æ', - "AbĀāĂ㥹ĆŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſAbc", - "Abcஐஒஓஔகஙசஜஞடணதநனபமயரறலளழவஷஸஹ", - "☀☁☂☃☄★☆☇☈☉☊☋☌☍☎☏☐☑☒☓☚☛☜☝☞☟☠☡☢☣☤☥☦☧☨☩☪☫☬☭☮☯☰☱☲☳☴☵☶☷☸", - new string('Æ', count: 64 * 1024), - new string('Æ', count: 64 * 1024 + 1), - "pingüino", - new string('ऄ', count: maxCharBufferSize + 1), // This uses 3 bytes to represent in UTF8 - }; - } - - [Theory] - [MemberData(nameof(ReadAsyncInputLatin))] - [MemberData(nameof(ReadAsyncInputUnicode))] - public Task ReadAsync_Works_WhenInputIs_UTF32(string message) - { - var sourceEncoding = Encoding.UTF32; - return ReadAsyncTest(sourceEncoding, message); - } - - [Theory] - [MemberData(nameof(ReadAsyncInputLatin))] - [MemberData(nameof(ReadAsyncInputUnicode))] - public Task ReadAsync_Works_WhenInputIs_Unicode(string message) - { - var sourceEncoding = Encoding.Unicode; - return ReadAsyncTest(sourceEncoding, message); - } - - [Theory] - [MemberData(nameof(ReadAsyncInputLatin))] - [MemberData(nameof(ReadAsyncInputUnicode))] - public Task ReadAsync_Works_WhenInputIs_UTF7(string message) - { - var sourceEncoding = Encoding.UTF7; - return ReadAsyncTest(sourceEncoding, message); - } - - [Theory] - [MemberData(nameof(ReadAsyncInputLatin))] - public Task ReadAsync_Works_WhenInputIs_WesternEuropeanEncoding(string message) - { - // Arrange - var sourceEncoding = Encoding.GetEncoding(28591); - return ReadAsyncTest(sourceEncoding, message); - } - - [Theory] - [MemberData(nameof(ReadAsyncInputLatin))] - public Task ReadAsync_Works_WhenInputIs_ASCII(string message) - { - // Arrange - var sourceEncoding = Encoding.ASCII; - return ReadAsyncTest(sourceEncoding, message); - } - - private static async Task ReadAsyncTest(Encoding sourceEncoding, string message) - { - var input = $"{{ \"Message\": \"{message}\" }}"; - var stream = new MemoryStream(sourceEncoding.GetBytes(input)); - - var transcodingStream = new TranscodingReadStream(stream, sourceEncoding); - - var model = await JsonSerializer.DeserializeAsync(transcodingStream, typeof(TestModel)); - var testModel = Assert.IsType(model); - - Assert.Equal(message, testModel.Message); - } - - public class TestModel - { - public string Message { get; set; } - } - } -} diff --git a/src/Mvc/Mvc.Core/test/Formatters/TranscodingWriteStreamTest.cs b/src/Mvc/Mvc.Core/test/Formatters/TranscodingWriteStreamTest.cs deleted file mode 100644 index 313a326efd..0000000000 --- a/src/Mvc/Mvc.Core/test/Formatters/TranscodingWriteStreamTest.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.IO; -using System.Text; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Threading.Tasks; -using Xunit; - -namespace Microsoft.AspNetCore.Mvc.Formatters.Json -{ - public class TranscodingWriteStreamTest - { - public static TheoryData WriteAsyncInputLatin => - TranscodingReadStreamTest.GetLatinTextInput(TranscodingWriteStream.MaxCharBufferSize, TranscodingWriteStream.MaxByteBufferSize); - - public static TheoryData WriteAsyncInputUnicode => - TranscodingReadStreamTest.GetUnicodeText(TranscodingWriteStream.MaxCharBufferSize); - - [Theory] - [MemberData(nameof(WriteAsyncInputLatin))] - [MemberData(nameof(WriteAsyncInputUnicode))] - public Task WriteAsync_Works_WhenOutputIs_UTF32(string message) - { - var targetEncoding = Encoding.UTF32; - return WriteAsyncTest(targetEncoding, message); - } - - [Theory] - [MemberData(nameof(WriteAsyncInputLatin))] - [MemberData(nameof(WriteAsyncInputUnicode))] - public Task WriteAsync_Works_WhenOutputIs_Unicode(string message) - { - var targetEncoding = Encoding.Unicode; - return WriteAsyncTest(targetEncoding, message); - } - - [Theory] - [MemberData(nameof(WriteAsyncInputLatin))] - public Task WriteAsync_Works_WhenOutputIs_UTF7(string message) - { - var targetEncoding = Encoding.UTF7; - return WriteAsyncTest(targetEncoding, message); - } - - [Theory] - [MemberData(nameof(WriteAsyncInputLatin))] - public Task WriteAsync_Works_WhenOutputIs_WesternEuropeanEncoding(string message) - { - // Arrange - var targetEncoding = Encoding.GetEncoding(28591); - return WriteAsyncTest(targetEncoding, message); - } - - [Theory] - [MemberData(nameof(WriteAsyncInputLatin))] - public Task WriteAsync_Works_WhenOutputIs_ASCII(string message) - { - // Arrange - var targetEncoding = Encoding.ASCII; - return WriteAsyncTest(targetEncoding, message); - } - - private static async Task WriteAsyncTest(Encoding targetEncoding, string message) - { - var expected = $"{{\"Message\":\"{JavaScriptEncoder.Default.Encode(message)}\"}}"; - - var model = new TestModel { Message = message }; - var stream = new MemoryStream(); - - var transcodingStream = new TranscodingWriteStream(stream, targetEncoding); - await JsonSerializer.SerializeAsync(transcodingStream, model, model.GetType()); - await transcodingStream.FinalWriteAsync(default); - await transcodingStream.FlushAsync(); - - var actual = targetEncoding.GetString(stream.ToArray()); - Assert.Equal(expected, actual, StringComparer.OrdinalIgnoreCase); - } - - private class TestModel - { - public string Message { get; set; } - } - } -} diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/JsonResultExecutorTestBase.cs b/src/Mvc/Mvc.Core/test/Infrastructure/JsonResultExecutorTestBase.cs index 8d0fa3ef84..cb5d3f1897 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/JsonResultExecutorTestBase.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/JsonResultExecutorTestBase.cs @@ -382,7 +382,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure return httpContext; } - private static ActionContext GetActionContext() + protected static ActionContext GetActionContext() { return new ActionContext(GetHttpContext(), new RouteData(), new ActionDescriptor()); } diff --git a/src/Mvc/Mvc.Core/test/Infrastructure/SystemTextJsonResultExecutorTest.cs b/src/Mvc/Mvc.Core/test/Infrastructure/SystemTextJsonResultExecutorTest.cs index 5fd36427de..54a2d99a9d 100644 --- a/src/Mvc/Mvc.Core/test/Infrastructure/SystemTextJsonResultExecutorTest.cs +++ b/src/Mvc/Mvc.Core/test/Infrastructure/SystemTextJsonResultExecutorTest.cs @@ -1,9 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Xunit; namespace Microsoft.AspNetCore.Mvc.Infrastructure { @@ -17,9 +22,44 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure Options.Create(new MvcOptions())); } + [Fact] + public async Task WriteResponseBodyAsync_WithNonUtf8Encoding_FormattingErrorsAreThrown() + { + // Arrange + var context = GetActionContext(); + + var result = new JsonResult(new ThrowingFormatterModel()) + { + ContentType = "application/json; charset=utf-16", + }; + var executor = CreateExecutor(); + + // Act & Assert + await Assert.ThrowsAsync(() => executor.ExecuteAsync(context, result)); + } + protected override object GetIndentedSettings() { return new JsonSerializerOptions { WriteIndented = true }; } + + [JsonConverter(typeof(ThrowingFormatterPersonConverter))] + private class ThrowingFormatterModel + { + + } + + private class ThrowingFormatterPersonConverter : JsonConverter + { + public override ThrowingFormatterModel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, ThrowingFormatterModel value, JsonSerializerOptions options) + { + throw new TimeZoneNotFoundException(); + } + } } }