From 43d9faa2bf466e7ee52d2d512ee9c28959762062 Mon Sep 17 00:00:00 2001 From: David Burg Date: Mon, 8 Jun 2026 21:31:32 -0700 Subject: [PATCH 01/12] Add ConnectorTriggerPayload helper for reading trigger callbacks (#190) Adds a framework-agnostic SDK helper to go from a raw Connector Namespace trigger callback (string or Stream) to a typed TriggerCallbackPayload or decoded binary file bytes, removing ~100 lines of per-function boilerplate. - Read / ReadAsync: metadata triggers (e.g. OnNewFilesV2), case-insensitive so camelCase wire fields bind instead of yielding all-null items. - TryReadBinaryContent / ReadBinaryContentAsync: binary-content triggers (e.g. OnNewFileV2) whose body is a base64 string. - TriggerCallbackBodyConverter now throws an actionable error when a binary-content (string) body is bound to a metadata payload type, pointing to the binary helpers. Adds 11 unit tests; full suite green (862 tests). Updates CHANGELOG and docs/triggers.md. Fixes #190 --- CHANGELOG.md | 8 + docs/triggers.md | 27 +++ .../ConnectorTriggerPayload.cs | 180 ++++++++++++++++ .../TriggerCallbackPayload.cs | 13 ++ .../ConnectorTriggerPayloadTests.cs | 193 ++++++++++++++++++ 5 files changed, 421 insertions(+) create mode 100644 src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs create mode 100644 tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c835e96..b08b159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the same content into release_notes.md for NuGet packaging. Do NOT put HTML comments in release_notes.md — it is packed verbatim into . --> +### Added + +- **`ConnectorTriggerPayload` helper to read trigger callbacks** — turns a raw Connector Namespace trigger callback (`string` or `Stream`) into a typed payload or decoded file bytes without per-function boilerplate. `Read` / `ReadAsync` deserialize metadata triggers (e.g. OneDrive `OnNewFilesV2`) with case-insensitive property matching, so camelCase wire fields bind correctly instead of silently yielding all-`null` items. `TryReadBinaryContent` / `ReadBinaryContentAsync` decode binary-content triggers (e.g. OneDrive `OnNewFileV2`), whose body is a base64 string. ([#190](https://github.com/Azure/Connectors-NET-SDK/issues/190)) + +### Changed + +- **`TriggerCallbackBodyConverter` now throws an actionable error for binary-content bodies** — when a binary-content trigger callback (a JSON string body, e.g. OneDrive `OnNewFileV2`) is deserialized into a metadata payload type, the error now explains the cause and points to `ConnectorTriggerPayload.TryReadBinaryContent` / `ReadBinaryContentAsync` instead of failing with a generic token-mismatch message. ([#190](https://github.com/Azure/Connectors-NET-SDK/issues/190)) + ## [0.12.0-preview.1] - 2026-06-02 ### Breaking Changes diff --git a/docs/triggers.md b/docs/triggers.md index 8692ca6..82d4994 100644 --- a/docs/triggers.md +++ b/docs/triggers.md @@ -107,6 +107,33 @@ Use the typed class directly to deserialize callbacks: var payload = JsonSerializer.Deserialize(callbackJson); ``` +### Reading callbacks with `ConnectorTriggerPayload` + +`ConnectorTriggerPayload` (in the `Azure.Connectors.Sdk` namespace) removes the boilerplate of reading a callback body — bounded read, case-insensitive deserialization, and binary-vs-metadata discrimination — so a trigger handler can go straight from the request body to a typed payload: + +```csharp +// Metadata triggers (e.g. OnNewFilesV2) — string or Stream overloads +var payload = await ConnectorTriggerPayload + .ReadAsync(request.Body, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + +foreach (var file in payload?.Body?.Value ?? Array.Empty()) +{ + // file.Id, file.Name, file.Path, file.Size ... +} +``` + +Property matching is **case-insensitive**, so callbacks whose wire fields are camelCase bind correctly instead of silently producing all-`null` items. For **binary-content** triggers (see below), use `TryReadBinaryContent` / `ReadBinaryContentAsync`, which decode the base64 `{"body":""}` shape into file bytes: + +```csharp +// Binary-content triggers (e.g. OnNewFileV2) +byte[]? fileBytes = await ConnectorTriggerPayload + .ReadBinaryContentAsync(request.Body, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); +``` + +If a binary-content (string) body is read into a metadata payload type, deserialization throws an actionable `JsonException` that points to the binary-content helpers. + ## Trigger Operation Constants Each connector exposes a `{Connector}TriggerOperations` static class with the operation name strings: diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs new file mode 100644 index 0000000..80d2e38 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -0,0 +1,180 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Connectors.Sdk.Serialization; + +namespace Azure.Connectors.Sdk; + +/// +/// Helpers that turn a raw Connector Namespace trigger callback (an HTTP body +/// delivered as a or ) into a typed +/// or into the decoded file bytes of a +/// binary-content trigger. +/// +/// +/// +/// Connector Namespace delivers two distinct trigger callback shapes for file connectors: +/// +/// +/// +/// Metadata (properties only), e.g. OnNewFilesV2 +/// +/// The body is an object envelope {"body":{"value":[{...item...}]}}. +/// Read it with / +/// . +/// +/// +/// +/// Binary content, e.g. OnNewFileV2 +/// +/// The body is a base64-encoded string {"body":"<base64>"}. +/// Read it with / +/// . +/// +/// +/// +/// +/// All metadata reads use case-insensitive property matching, so payloads whose wire +/// fields are camelCase deserialize correctly instead of silently yielding all- +/// items. +/// +/// +public static class ConnectorTriggerPayload +{ + /// + /// Gets the used to read trigger callback payloads. + /// Property matching is case-insensitive so camelCase wire fields bind correctly. + /// + public static JsonSerializerOptions SerializerOptions => ConnectorJsonSerializer.Options; + + /// + /// Reads a metadata trigger callback (for example OneDrive OnNewFilesV2) into its + /// typed payload. The expected wire shape is {"body":{"value":[{...item...}]}}. + /// + /// + /// The connector-specific payload type, a subclass of + /// (for example OneDriveForBusinessOnNewFilesTriggerPayload). + /// + /// The raw JSON callback body. + /// The deserialized payload, or when is JSON null. + /// is . + /// + /// The body was a base64 string (a binary-content trigger such as OnNewFileV2) rather than + /// a metadata object; read it with instead. + /// + public static TPayload? Read(string json) + where TPayload : class + { + ArgumentNullException.ThrowIfNull(json); + + return JsonSerializer.Deserialize(json, ConnectorTriggerPayload.SerializerOptions); + } + + /// + /// Reads a metadata trigger callback (for example OneDrive OnNewFilesV2) from a stream into + /// its typed payload. The expected wire shape is {"body":{"value":[{...item...}]}}. + /// + /// + /// The connector-specific payload type, a subclass of + /// (for example OneDriveForBusinessOnNewFilesTriggerPayload). + /// + /// The callback body stream (for example HttpRequestData.Body). + /// The cancellation token. + /// The deserialized payload, or when the body is JSON null. + /// is . + /// + /// The body was a base64 string (a binary-content trigger such as OnNewFileV2) rather than + /// a metadata object; read it with instead. + /// + public static async ValueTask ReadAsync( + Stream body, + CancellationToken cancellationToken = default) + where TPayload : class + { + ArgumentNullException.ThrowIfNull(body); + + return await ConnectorJsonSerializer + .DeserializeAsync(body, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + } + + /// + /// Attempts to read a binary-content trigger callback (for example OneDrive OnNewFileV2), + /// whose wire shape is {"body":"<base64>"}, into the decoded file bytes. + /// + /// The raw JSON callback body. + /// + /// When this method returns , the decoded file bytes (empty when the body + /// string was empty). When it returns , an empty array. + /// + /// + /// when the callback carried a base64 string body and was decoded; + /// when the body was not a JSON string (for example a metadata callback) + /// or was not valid base64. + /// + /// is . + public static bool TryReadBinaryContent(string json, out byte[] content) + { + ArgumentNullException.ThrowIfNull(json); + + content = Array.Empty(); + + using JsonDocument document = JsonDocument.Parse(json); + if (!document.RootElement.TryGetProperty("body", out JsonElement bodyElement) || + bodyElement.ValueKind != JsonValueKind.String) + { + return false; + } + + // The base64 string may arrive wrapped in extra quotes from the Logic Apps + // expression engine; strip them before decoding. + string base64Content = (bodyElement.GetString() ?? string.Empty).Trim('"'); + + if (base64Content.Length == 0) + { + return true; + } + + var buffer = new byte[((base64Content.Length + 3) / 4) * 3]; + if (!Convert.TryFromBase64String(base64Content, buffer, out int decodedByteCount)) + { + return false; + } + + content = buffer.AsSpan(0, decodedByteCount).ToArray(); + return true; + } + + /// + /// Reads a binary-content trigger callback (for example OneDrive OnNewFileV2) from a stream, + /// whose wire shape is {"body":"<base64>"}, into the decoded file bytes. + /// + /// The callback body stream (for example HttpRequestData.Body). + /// The cancellation token. + /// + /// The decoded file bytes, or when the body was not a JSON string body + /// (for example a metadata callback) or was not valid base64. + /// + /// is . + public static async ValueTask ReadBinaryContentAsync( + Stream body, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(body); + + using var reader = new StreamReader(body); + string json = await reader + .ReadToEndAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + return ConnectorTriggerPayload.TryReadBinaryContent(json, out byte[] content) + ? content + : null; + } +} diff --git a/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs b/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs index 929fa92..4ea7e18 100644 --- a/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs +++ b/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs @@ -89,6 +89,19 @@ internal sealed class TriggerCallbackBodyConverter : JsonConverter was a JSON string, not an object. " + + "This is a binary-content trigger callback (for example OneDrive 'OnNewFileV2'), whose body is a base64-encoded file. " + + $"Read it with {nameof(ConnectorTriggerPayload)}.{nameof(ConnectorTriggerPayload.TryReadBinaryContent)} or " + + $"{nameof(ConnectorTriggerPayload)}.{nameof(ConnectorTriggerPayload.ReadBinaryContentAsync)} instead of deserializing to a metadata payload type."); + } + if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException($"Expected StartObject or Null for TriggerCallbackBody<{typeof(T).Name}>, got {reader.TokenType}."); diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs new file mode 100644 index 0000000..a4d8573 --- /dev/null +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -0,0 +1,193 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.Connectors.Sdk.OneDriveForBusiness.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Connectors.Sdk.Tests +{ + /// + /// Tests for — reading metadata and binary-content + /// trigger callbacks into typed payloads and file bytes. + /// + [TestClass] + public class ConnectorTriggerPayloadTests + { + private const string MetadataPascalCasePayload = """ + {"body":{"value":[{"Id":"01ABC","Name":"report.docx","Path":"/Documents/report.docx","Size":1234,"IsFolder":false}]}} + """; + + private const string MetadataCamelCasePayload = """ + {"body":{"value":[{"id":"01ABC","name":"report.docx","path":"/Documents/report.docx","size":1234,"isFolder":false}]}} + """; + + [TestMethod] + public void Read_MetadataPascalCase_PopulatesItem() + { + // Arrange & Act + var payload = ConnectorTriggerPayload.Read( + ConnectorTriggerPayloadTests.MetadataPascalCasePayload); + + // Assert + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("01ABC", payload.Body.Value[0].Id); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + + [TestMethod] + public void Read_MetadataCamelCase_PopulatesItemCaseInsensitively() + { + // Arrange & Act + var payload = ConnectorTriggerPayload.Read( + ConnectorTriggerPayloadTests.MetadataCamelCasePayload); + + // Assert — camelCase wire fields must still bind (regression guard against all-null items). + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("01ABC", payload.Body.Value[0].Id); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + + [TestMethod] + public async Task ReadAsync_MetadataStream_PopulatesItem() + { + // Arrange + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); + + // Act + var payload = await ConnectorTriggerPayload + .ReadAsync(stream); + + // Assert + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + + [TestMethod] + public void Read_NullJson_Throws() + { + // Arrange, Act & Assert + Assert.ThrowsExactly( + () => ConnectorTriggerPayload.Read(null!)); + } + + [TestMethod] + public void Read_BinaryStringBody_ThrowsActionableJsonException() + { + // Arrange — a binary-content trigger delivers {"body":""}. + string base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("hello")); + string payload = $$"""{"body":"{{base64}}"}"""; + + // Act + var exception = Assert.ThrowsExactly( + () => ConnectorTriggerPayload.Read(payload)); + + // Assert — the message must steer the developer to the binary-content helper. + StringAssert.Contains(exception.Message, nameof(ConnectorTriggerPayload.TryReadBinaryContent)); + StringAssert.Contains(exception.Message, "binary-content trigger"); + } + + [TestMethod] + public void TryReadBinaryContent_Base64StringBody_DecodesBytes() + { + // Arrange + byte[] expected = Encoding.UTF8.GetBytes("hello from trigger test"); + string base64 = Convert.ToBase64String(expected); + string payload = $$"""{"body":"{{base64}}"}"""; + + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsTrue(result); + CollectionAssert.AreEqual(expected, content); + } + + [TestMethod] + public void TryReadBinaryContent_EmptyStringBody_ReturnsTrueWithEmptyContent() + { + // Arrange + const string payload = """{"body":""}"""; + + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsTrue(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + public void TryReadBinaryContent_MetadataObjectBody_ReturnsFalse() + { + // Arrange — a metadata callback has an object body, not a string. + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent( + ConnectorTriggerPayloadTests.MetadataPascalCasePayload, + out byte[] content); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + public void TryReadBinaryContent_NonBase64StringBody_ReturnsFalse() + { + // Arrange — a string body that is not valid base64. + const string payload = """{"body":"not valid base64 !!!"}"""; + + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_Base64Stream_DecodesBytes() + { + // Arrange + byte[] expected = Encoding.UTF8.GetBytes("binary trigger bytes"); + string base64 = Convert.ToBase64String(expected); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes($$"""{"body":"{{base64}}"}""")); + + // Act + byte[]? content = await ConnectorTriggerPayload.ReadBinaryContentAsync(stream); + + // Assert + Assert.IsNotNull(content); + CollectionAssert.AreEqual(expected, content); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_MetadataStream_ReturnsNull() + { + // Arrange + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); + + // Act + byte[]? content = await ConnectorTriggerPayload.ReadBinaryContentAsync(stream); + + // Assert — an object body is not binary content. + Assert.IsNull(content); + } + } +} From 50003ed085b8a63150aea22f17abae62f7d90021 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 16:40:45 -0700 Subject: [PATCH 02/12] Address PR #191 review feedback - TryReadBinaryContent now returns false (not throws) on malformed JSON (Try* contract). - ReadBinaryContentAsync/ReadAsync read the caller-owned stream without closing it, decode UTF-8 explicitly, and enforce a generous overridable body-size limit (DefaultMaxBodySizeBytes = 100 MB). - Extracted wire property names 'body'/'value' to shared TriggerCallbackPropertyNames constants. - Tests: added ConfigureAwait(continueOnCapturedContext: false) on awaits; new tests for malformed JSON, size-limit enforcement, and stream-not-closed. - Docs/CHANGELOG updated to reflect the size limit and stream non-ownership. Co-authored-by: Dobby --- CHANGELOG.md | 2 +- docs/triggers.md | 6 +- .../ConnectorTriggerPayload.cs | 151 ++++++++++++++---- .../TriggerCallbackPayload.cs | 8 +- .../TriggerCallbackPropertyNames.cs | 23 +++ .../ConnectorTriggerPayloadTests.cs | 48 +++++- 6 files changed, 194 insertions(+), 44 deletions(-) create mode 100644 src/Azure.Connectors.Sdk/TriggerCallbackPropertyNames.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b08b159..2bd5062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`ConnectorTriggerPayload` helper to read trigger callbacks** — turns a raw Connector Namespace trigger callback (`string` or `Stream`) into a typed payload or decoded file bytes without per-function boilerplate. `Read` / `ReadAsync` deserialize metadata triggers (e.g. OneDrive `OnNewFilesV2`) with case-insensitive property matching, so camelCase wire fields bind correctly instead of silently yielding all-`null` items. `TryReadBinaryContent` / `ReadBinaryContentAsync` decode binary-content triggers (e.g. OneDrive `OnNewFileV2`), whose body is a base64 string. ([#190](https://github.com/Azure/Connectors-NET-SDK/issues/190)) +- **`ConnectorTriggerPayload` helper to read trigger callbacks** — turns a raw Connector Namespace trigger callback (`string` or `Stream`) into a typed payload or decoded file bytes without per-function boilerplate. `Read` / `ReadAsync` deserialize metadata triggers (e.g. OneDrive `OnNewFilesV2`) with case-insensitive property matching, so camelCase wire fields bind correctly instead of silently yielding all-`null` items. `TryReadBinaryContent` / `ReadBinaryContentAsync` decode binary-content triggers (e.g. OneDrive `OnNewFileV2`), whose body is a base64 string. The `Stream` overloads read the caller-owned stream without closing it and enforce a generous, overridable body-size limit (`DefaultMaxBodySizeBytes`, 100 MB); `TryReadBinaryContent` returns `false` (rather than throwing) on malformed JSON. ([#190](https://github.com/Azure/Connectors-NET-SDK/issues/190)) ### Changed diff --git a/docs/triggers.md b/docs/triggers.md index 82d4994..1833228 100644 --- a/docs/triggers.md +++ b/docs/triggers.md @@ -114,7 +114,7 @@ var payload = JsonSerializer.Deserialize(call ```csharp // Metadata triggers (e.g. OnNewFilesV2) — string or Stream overloads var payload = await ConnectorTriggerPayload - .ReadAsync(request.Body, cancellationToken) + .ReadAsync(request.Body, cancellationToken: cancellationToken) .ConfigureAwait(continueOnCapturedContext: false); foreach (var file in payload?.Body?.Value ?? Array.Empty()) @@ -123,12 +123,12 @@ foreach (var file in payload?.Body?.Value ?? Array.Empty()) } ``` -Property matching is **case-insensitive**, so callbacks whose wire fields are camelCase bind correctly instead of silently producing all-`null` items. For **binary-content** triggers (see below), use `TryReadBinaryContent` / `ReadBinaryContentAsync`, which decode the base64 `{"body":""}` shape into file bytes: +Property matching is **case-insensitive**, so callbacks whose wire fields are camelCase bind correctly instead of silently producing all-`null` items. The stream overloads read the caller-owned stream without closing it and enforce a generous body-size limit (`ConnectorTriggerPayload.DefaultMaxBodySizeBytes`, 100 MB, overridable per call). For **binary-content** triggers (see below), use `TryReadBinaryContent` / `ReadBinaryContentAsync`, which decode the base64 `{"body":""}` shape into file bytes: ```csharp // Binary-content triggers (e.g. OnNewFileV2) byte[]? fileBytes = await ConnectorTriggerPayload - .ReadBinaryContentAsync(request.Body, cancellationToken) + .ReadBinaryContentAsync(request.Body, cancellationToken: cancellationToken) .ConfigureAwait(continueOnCapturedContext: false); ``` diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index 80d2e38..b67fd30 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -3,7 +3,9 @@ //------------------------------------------------------------ using System; +using System.Buffers; using System.IO; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -27,7 +29,7 @@ namespace Azure.Connectors.Sdk; /// /// The body is an object envelope {"body":{"value":[{...item...}]}}. /// Read it with / -/// . +/// . /// /// /// @@ -35,7 +37,7 @@ namespace Azure.Connectors.Sdk; /// /// The body is a base64-encoded string {"body":"<base64>"}. /// Read it with / -/// . +/// . /// /// /// @@ -47,6 +49,14 @@ namespace Azure.Connectors.Sdk; /// public static class ConnectorTriggerPayload { + /// + /// The default maximum trigger callback body size, in bytes, enforced by the stream-based + /// readers (100 MB). This is a generous ceiling that guards against unbounded buffering of a + /// hostile or malformed stream while comfortably accommodating large binary-content callbacks. + /// Override it per call with the maxBodySizeBytes parameter. + /// + public const long DefaultMaxBodySizeBytes = 100L * 1024 * 1024; + /// /// Gets the used to read trigger callback payloads. /// Property matching is case-insensitive so camelCase wire fields bind correctly. @@ -84,24 +94,33 @@ public static class ConnectorTriggerPayload /// The connector-specific payload type, a subclass of /// (for example OneDriveForBusinessOnNewFilesTriggerPayload). /// - /// The callback body stream (for example HttpRequestData.Body). + /// The callback body stream (for example HttpRequestData.Body). The stream is read but not disposed; the caller retains ownership. + /// + /// The maximum number of bytes to read from before failing. + /// Defaults to . + /// /// The cancellation token. /// The deserialized payload, or when the body is JSON null. /// is . + /// is not greater than zero. + /// exceeded . /// /// The body was a base64 string (a binary-content trigger such as OnNewFileV2) rather than - /// a metadata object; read it with instead. + /// a metadata object; read it with instead. /// public static async ValueTask ReadAsync( Stream body, + long maxBodySizeBytes = ConnectorTriggerPayload.DefaultMaxBodySizeBytes, CancellationToken cancellationToken = default) where TPayload : class { ArgumentNullException.ThrowIfNull(body); - return await ConnectorJsonSerializer - .DeserializeAsync(body, cancellationToken) + byte[] utf8Json = await ConnectorTriggerPayload + .ReadBoundedAsync(body, maxBodySizeBytes, cancellationToken) .ConfigureAwait(continueOnCapturedContext: false); + + return JsonSerializer.Deserialize(utf8Json, ConnectorTriggerPayload.SerializerOptions); } /// @@ -115,8 +134,8 @@ public static class ConnectorTriggerPayload /// /// /// when the callback carried a base64 string body and was decoded; - /// when the body was not a JSON string (for example a metadata callback) - /// or was not valid base64. + /// when was not valid JSON, the body was not a + /// JSON string (for example a metadata callback), or the string was not valid base64. /// /// is . public static bool TryReadBinaryContent(string json, out byte[] content) @@ -125,56 +144,130 @@ public static bool TryReadBinaryContent(string json, out byte[] content) content = Array.Empty(); - using JsonDocument document = JsonDocument.Parse(json); - if (!document.RootElement.TryGetProperty("body", out JsonElement bodyElement) || - bodyElement.ValueKind != JsonValueKind.String) + JsonDocument document; + try { - return false; + document = JsonDocument.Parse(json); } - - // The base64 string may arrive wrapped in extra quotes from the Logic Apps - // expression engine; strip them before decoding. - string base64Content = (bodyElement.GetString() ?? string.Empty).Trim('"'); - - if (base64Content.Length == 0) + catch (JsonException) { - return true; + // This is a Try* API: malformed JSON is a "could not read" outcome, not an exception. + return false; } - var buffer = new byte[((base64Content.Length + 3) / 4) * 3]; - if (!Convert.TryFromBase64String(base64Content, buffer, out int decodedByteCount)) + using (document) { - return false; - } + if (!document.RootElement.TryGetProperty(TriggerCallbackPropertyNames.Body, out JsonElement bodyElement) || + bodyElement.ValueKind != JsonValueKind.String) + { + return false; + } + + // The base64 string may arrive wrapped in extra quotes from the Logic Apps + // expression engine; strip them before decoding. + string base64Content = (bodyElement.GetString() ?? string.Empty).Trim('"'); + + if (base64Content.Length == 0) + { + return true; + } - content = buffer.AsSpan(0, decodedByteCount).ToArray(); - return true; + var buffer = new byte[((base64Content.Length + 3) / 4) * 3]; + if (!Convert.TryFromBase64String(base64Content, buffer, out int decodedByteCount)) + { + return false; + } + + content = buffer.AsSpan(0, decodedByteCount).ToArray(); + return true; + } } /// /// Reads a binary-content trigger callback (for example OneDrive OnNewFileV2) from a stream, /// whose wire shape is {"body":"<base64>"}, into the decoded file bytes. /// - /// The callback body stream (for example HttpRequestData.Body). + /// The callback body stream (for example HttpRequestData.Body). The stream is read but not disposed; the caller retains ownership. + /// + /// The maximum number of bytes to read from before failing. + /// Defaults to . + /// /// The cancellation token. /// /// The decoded file bytes, or when the body was not a JSON string body /// (for example a metadata callback) or was not valid base64. /// /// is . + /// is not greater than zero. + /// exceeded . public static async ValueTask ReadBinaryContentAsync( Stream body, + long maxBodySizeBytes = ConnectorTriggerPayload.DefaultMaxBodySizeBytes, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(body); - using var reader = new StreamReader(body); - string json = await reader - .ReadToEndAsync(cancellationToken) + byte[] utf8Json = await ConnectorTriggerPayload + .ReadBoundedAsync(body, maxBodySizeBytes, cancellationToken) .ConfigureAwait(continueOnCapturedContext: false); + // JSON is UTF-8 by default; decode explicitly rather than relying on a StreamReader + // (which would also take ownership of and close the caller's stream). + string json = Encoding.UTF8.GetString(utf8Json); + return ConnectorTriggerPayload.TryReadBinaryContent(json, out byte[] content) ? content : null; } + + /// + /// Reads the caller-owned stream into a byte array, enforcing + /// . The stream is read but never disposed. + /// + /// The stream to read. + /// The maximum number of bytes to read before failing. + /// The cancellation token. + /// The bytes read from the stream. + /// is not greater than zero. + /// exceeded . + private static async ValueTask ReadBoundedAsync( + Stream body, + long maxBodySizeBytes, + CancellationToken cancellationToken) + { + if (maxBodySizeBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(maxBodySizeBytes), + maxBodySizeBytes, + "The maximum body size must be greater than zero."); + } + + using var buffer = new MemoryStream(); + byte[] chunk = ArrayPool.Shared.Rent(81920); + try + { + long totalBytesRead = 0; + int bytesRead; + while ((bytesRead = await body + .ReadAsync(chunk.AsMemory(0, chunk.Length), cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false)) > 0) + { + totalBytesRead += bytesRead; + if (totalBytesRead > maxBodySizeBytes) + { + throw new InvalidOperationException( + $"The trigger callback body exceeded the maximum allowed size of {maxBodySizeBytes} bytes."); + } + + buffer.Write(chunk, 0, bytesRead); + } + } + finally + { + ArrayPool.Shared.Return(chunk); + } + + return buffer.ToArray(); + } } diff --git a/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs b/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs index 4ea7e18..be5bf72 100644 --- a/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs +++ b/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs @@ -26,7 +26,7 @@ public class TriggerCallbackPayload /// /// The body envelope containing the trigger items. /// - [JsonPropertyName("body")] + [JsonPropertyName(TriggerCallbackPropertyNames.Body)] public TriggerCallbackBody? Body { get; init; } } @@ -46,7 +46,7 @@ public class TriggerCallbackBody /// "value": null property (or a null body), so consumers should null-check before /// iterating. /// - [JsonPropertyName("value")] + [JsonPropertyName(TriggerCallbackPropertyNames.Value)] public IReadOnlyList? Value { get; internal set; } } @@ -132,7 +132,7 @@ internal sealed class TriggerCallbackBodyConverter : JsonConverter +/// Wire property names shared by the Connector Namespace trigger callback envelope +/// ( / ) and the +/// readers. +/// +internal static class TriggerCallbackPropertyNames +{ + /// + /// The outer envelope property carrying the trigger body ({"body": ...}). + /// + internal const string Body = "body"; + + /// + /// The inner body property carrying the batch item array ({"value": [...]}). + /// + internal const string Value = "value"; +} diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs index a4d8573..0ac5c3b 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -68,7 +68,8 @@ public async Task ReadAsync_MetadataStream_PopulatesItem() // Act var payload = await ConnectorTriggerPayload - .ReadAsync(stream); + .ReadAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); // Assert Assert.IsNotNull(payload); @@ -161,19 +162,50 @@ public void TryReadBinaryContent_NonBase64StringBody_ReturnsFalse() } [TestMethod] - public async Task ReadBinaryContentAsync_Base64Stream_DecodesBytes() + public void TryReadBinaryContent_MalformedJson_ReturnsFalse() + { + // Arrange — a Try* API must not throw on invalid JSON. + const string payload = "this is not json {"; + + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + public async Task ReadAsync_BodyExceedsLimit_ThrowsInvalidOperation() + { + // Arrange — a payload larger than the (tiny) configured limit. + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); + + // Act & Assert + await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync(stream, maxBodySizeBytes: 8) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_DoesNotCloseCallerStream() { // Arrange - byte[] expected = Encoding.UTF8.GetBytes("binary trigger bytes"); + byte[] expected = Encoding.UTF8.GetBytes("keep me open"); string base64 = Convert.ToBase64String(expected); using var stream = new MemoryStream(Encoding.UTF8.GetBytes($$"""{"body":"{{base64}}"}""")); // Act - byte[]? content = await ConnectorTriggerPayload.ReadBinaryContentAsync(stream); + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); - // Assert + // Assert — the helper must not take ownership of the caller's stream. Assert.IsNotNull(content); - CollectionAssert.AreEqual(expected, content); + Assert.IsTrue(stream.CanRead, "The caller-owned stream must remain open after reading."); } [TestMethod] @@ -184,7 +216,9 @@ public async Task ReadBinaryContentAsync_MetadataStream_ReturnsNull() Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); // Act - byte[]? content = await ConnectorTriggerPayload.ReadBinaryContentAsync(stream); + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); // Assert — an object body is not binary content. Assert.IsNull(content); From 6e57be28a7d03fe2226af54e8ae552f99a83d914 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 16:48:27 -0700 Subject: [PATCH 03/12] Address PR #191 review feedback: clear rented buffer on return Return the ArrayPool buffer with clearArray:true so residual trigger callback content (including base64 file bytes) cannot be observed by a subsequent renter in the same process. Co-authored-by: Dobby --- src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index b67fd30..7c89861 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -265,7 +265,10 @@ private static async ValueTask ReadBoundedAsync( } finally { - ArrayPool.Shared.Return(chunk); + // Clear the rented buffer on return: it can hold trigger callback content + // (including base64 file bytes), and a subsequent renter in the same process + // must not be able to observe residual data. + ArrayPool.Shared.Return(chunk, clearArray: true); } return buffer.ToArray(); From 65174f5c723507501cc9dde384e37014994677ff Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 16:54:00 -0700 Subject: [PATCH 04/12] Address PR #191 review feedback: assert decoded bytes in stream-not-closed test Co-authored-by: Dobby --- tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs index 0ac5c3b..9da2db9 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -205,6 +205,7 @@ public async Task ReadBinaryContentAsync_DoesNotCloseCallerStream() // Assert — the helper must not take ownership of the caller's stream. Assert.IsNotNull(content); + CollectionAssert.AreEqual(expected, content); Assert.IsTrue(stream.CanRead, "The caller-owned stream must remain open after reading."); } From 0e8168a8187df336e53f1e3530528f9082ad43e4 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 17:02:34 -0700 Subject: [PATCH 05/12] Address PR #191 review feedback: cap each read to the size limit ReadBoundedAsync now requests at most (remaining allowance + 1) bytes per ReadAsync, so a single read cannot pull far past maxBodySizeBytes before the limit is enforced. Adds a MaxReadTrackingStream-based test asserting no over-read. Co-authored-by: Dobby --- .../ConnectorTriggerPayload.cs | 19 ++++- .../ConnectorTriggerPayloadTests.cs | 76 +++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index 7c89861..edb6b6b 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -248,11 +248,22 @@ private static async ValueTask ReadBoundedAsync( try { long totalBytesRead = 0; - int bytesRead; - while ((bytesRead = await body - .ReadAsync(chunk.AsMemory(0, chunk.Length), cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false)) > 0) + while (true) { + // Never request more than one byte past the remaining allowance, so a single + // ReadAsync cannot pull far beyond maxBodySizeBytes before the limit is checked. + // The extra byte lets us detect an over-limit body without reading the whole stream. + long remainingAllowance = (maxBodySizeBytes - totalBytesRead) + 1; + int requestSize = (int)Math.Min(chunk.Length, remainingAllowance); + + int bytesRead = await body + .ReadAsync(chunk.AsMemory(0, requestSize), cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + if (bytesRead <= 0) + { + break; + } + totalBytesRead += bytesRead; if (totalBytesRead > maxBodySizeBytes) { diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs index 9da2db9..16661f3 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -6,6 +6,7 @@ using System.IO; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Azure.Connectors.Sdk.OneDriveForBusiness.Models; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -190,6 +191,31 @@ await Assert.ThrowsExactlyAsync( .ConfigureAwait(continueOnCapturedContext: false); } + [TestMethod] + public async Task ReadAsync_OverLimitBody_DoesNotReadEntireStream() + { + // Arrange — a stream that records the largest single read it is asked for, so we can + // prove the reader never pulls far past the configured limit even when the chunk + // would otherwise be much larger. + byte[] payload = Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload); + using var inner = new MemoryStream(payload); + using var tracking = new MaxReadTrackingStream(inner); + const long limit = 8; + + // Act & Assert + await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync(tracking, maxBodySizeBytes: limit) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // A single read must never be asked for more than the limit plus the one-byte + // overflow probe, regardless of the (much larger) internal chunk size. + Assert.IsTrue( + tracking.MaxRequestedCount <= limit + 1, + $"Expected no single read larger than {limit + 1} bytes, but observed {tracking.MaxRequestedCount}."); + } + [TestMethod] public async Task ReadBinaryContentAsync_DoesNotCloseCallerStream() { @@ -225,4 +251,54 @@ public async Task ReadBinaryContentAsync_MetadataStream_ReturnsNull() Assert.IsNull(content); } } + + /// + /// A pass-through that records the largest single read request it receives, + /// used to verify the reader caps each read to the configured maximum body size. + /// + internal sealed class MaxReadTrackingStream : Stream + { + private readonly Stream _inner; + + public MaxReadTrackingStream(Stream inner) + { + this._inner = inner; + } + + public int MaxRequestedCount { get; private set; } + + public override bool CanRead => this._inner.CanRead; + + public override bool CanSeek => this._inner.CanSeek; + + public override bool CanWrite => this._inner.CanWrite; + + public override long Length => this._inner.Length; + + public override long Position + { + get => this._inner.Position; + set => this._inner.Position = value; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + this.MaxRequestedCount = Math.Max(this.MaxRequestedCount, buffer.Length); + return this._inner.ReadAsync(buffer, cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + this.MaxRequestedCount = Math.Max(this.MaxRequestedCount, count); + return this._inner.Read(buffer, offset, count); + } + + public override void Flush() => this._inner.Flush(); + + public override long Seek(long offset, SeekOrigin origin) => this._inner.Seek(offset, origin); + + public override void SetLength(long value) => this._inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => this._inner.Write(buffer, offset, count); + } } From ab53f3f604019427dbe4a8c4307e9065c412cd29 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 17:11:47 -0700 Subject: [PATCH 06/12] Address PR #191 review feedback: avoid double allocation; one type per file - TryReadBinaryContent decodes directly via Convert.FromBase64String (single right-sized array) inside try/catch, halving peak memory for large binary bodies while keeping the Try* contract. - Moved MaxReadTrackingStream test helper to its own file (one top-level type per file). Co-authored-by: Dobby --- .../ConnectorTriggerPayload.cs | 16 +++-- .../ConnectorTriggerPayloadTests.cs | 50 --------------- .../MaxReadTrackingStream.cs | 61 +++++++++++++++++++ 3 files changed, 72 insertions(+), 55 deletions(-) create mode 100644 tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index edb6b6b..f95cded 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -172,14 +172,20 @@ public static bool TryReadBinaryContent(string json, out byte[] content) return true; } - var buffer = new byte[((base64Content.Length + 3) / 4) * 3]; - if (!Convert.TryFromBase64String(base64Content, buffer, out int decodedByteCount)) + // Decode directly into a single right-sized array. Avoids the doubled peak memory + // of renting an oversized buffer and then copying into a right-sized array, which + // matters for large binary trigger bodies. The try/catch keeps the Try* contract: + // invalid base64 returns false rather than throwing. + try { + content = Convert.FromBase64String(base64Content); + return true; + } + catch (FormatException) + { + content = Array.Empty(); return false; } - - content = buffer.AsSpan(0, decodedByteCount).ToArray(); - return true; } } diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs index 16661f3..aefa5b5 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -251,54 +251,4 @@ public async Task ReadBinaryContentAsync_MetadataStream_ReturnsNull() Assert.IsNull(content); } } - - /// - /// A pass-through that records the largest single read request it receives, - /// used to verify the reader caps each read to the configured maximum body size. - /// - internal sealed class MaxReadTrackingStream : Stream - { - private readonly Stream _inner; - - public MaxReadTrackingStream(Stream inner) - { - this._inner = inner; - } - - public int MaxRequestedCount { get; private set; } - - public override bool CanRead => this._inner.CanRead; - - public override bool CanSeek => this._inner.CanSeek; - - public override bool CanWrite => this._inner.CanWrite; - - public override long Length => this._inner.Length; - - public override long Position - { - get => this._inner.Position; - set => this._inner.Position = value; - } - - public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - { - this.MaxRequestedCount = Math.Max(this.MaxRequestedCount, buffer.Length); - return this._inner.ReadAsync(buffer, cancellationToken); - } - - public override int Read(byte[] buffer, int offset, int count) - { - this.MaxRequestedCount = Math.Max(this.MaxRequestedCount, count); - return this._inner.Read(buffer, offset, count); - } - - public override void Flush() => this._inner.Flush(); - - public override long Seek(long offset, SeekOrigin origin) => this._inner.Seek(offset, origin); - - public override void SetLength(long value) => this._inner.SetLength(value); - - public override void Write(byte[] buffer, int offset, int count) => this._inner.Write(buffer, offset, count); - } } diff --git a/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs b/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs new file mode 100644 index 0000000..e137bb5 --- /dev/null +++ b/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs @@ -0,0 +1,61 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Connectors.Sdk.Tests +{ + /// + /// A pass-through that records the largest single read request it receives, + /// used to verify the reader caps each read to the configured maximum body size. + /// + internal sealed class MaxReadTrackingStream : Stream + { + private readonly Stream _inner; + + public MaxReadTrackingStream(Stream inner) + { + this._inner = inner; + } + + public int MaxRequestedCount { get; private set; } + + public override bool CanRead => this._inner.CanRead; + + public override bool CanSeek => this._inner.CanSeek; + + public override bool CanWrite => this._inner.CanWrite; + + public override long Length => this._inner.Length; + + public override long Position + { + get => this._inner.Position; + set => this._inner.Position = value; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + this.MaxRequestedCount = Math.Max(this.MaxRequestedCount, buffer.Length); + return this._inner.ReadAsync(buffer, cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + this.MaxRequestedCount = Math.Max(this.MaxRequestedCount, count); + return this._inner.Read(buffer, offset, count); + } + + public override void Flush() => this._inner.Flush(); + + public override long Seek(long offset, SeekOrigin origin) => this._inner.Seek(offset, origin); + + public override void SetLength(long value) => this._inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => this._inner.Write(buffer, offset, count); + } +} From 0f97ef0132f252bebce241c6b835e171d8cfe7d2 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 17:19:36 -0700 Subject: [PATCH 07/12] Address PR #191 feedback: doc discoverability, non-object root guard, dispose inner stream - README: added a Triggers component table and docs/triggers.md link so ConnectorTriggerPayload is discoverable from the repo docs (addresses reviewer's documentation request). - TryReadBinaryContent guards root ValueKind != Object before TryGetProperty, so non-object JSON (null/array/string/number) returns false instead of throwing. - MaxReadTrackingStream now disposes the wrapped inner stream. Co-authored-by: Dobby --- README.md | 10 ++++++++++ .../ConnectorTriggerPayload.cs | 6 +++++- .../ConnectorTriggerPayloadTests.cs | 16 ++++++++++++++++ .../MaxReadTrackingStream.cs | 10 ++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e000267..aed5ffb 100644 --- a/README.md +++ b/README.md @@ -151,11 +151,21 @@ Authentication uses Azure.Core `TokenCredential` directly — any credential fro | `ConnectorJsonSerializer` | JSON serialization with connector conventions | | `JsonConverters` | Custom converters for connector types | +### Triggers + +| Component | Description | +|-----------|-------------| +| `TriggerCallbackPayload` / `TriggerCallbackBody` | Typed envelope for connector trigger callbacks; normalizes batch and single-item shapes into `Body.Value` | +| `ConnectorTriggerPayload` | Helpers to read a raw trigger callback (`string`/`Stream`) into a typed payload (`Read`/`ReadAsync`) or decoded binary file bytes (`TryReadBinaryContent`/`ReadBinaryContentAsync`), with case-insensitive matching and a bounded read | + +See [docs/triggers.md](docs/triggers.md) for the trigger architecture, payload shapes, and end-to-end usage. + ## Documentation - [docs/concepts.md](docs/concepts.md) - Key concepts, terminology, and architecture - [GENERATION.md](GENERATION.md) - How to generate connector code - [docs/connection-setup.md](docs/connection-setup.md) - Setting up connections for local testing +- [docs/triggers.md](docs/triggers.md) - Trigger architecture, typed payloads, and the `ConnectorTriggerPayload` reader - [ROADMAP.md](ROADMAP.md) - Connector generation progress and lessons learned - [Azure/Connectors-NET-Samples](https://github.com/Azure/Connectors-NET-Samples) - Full working samples (Azure Functions, triggers, etc.) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index f95cded..f211499 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -157,7 +157,11 @@ public static bool TryReadBinaryContent(string json, out byte[] content) using (document) { - if (!document.RootElement.TryGetProperty(TriggerCallbackPropertyNames.Body, out JsonElement bodyElement) || + // TryGetProperty throws InvalidOperationException when the root is not an object + // (for example a JSON null, array, or string). Guard the kind first so a non-object + // body is a "could not read" outcome rather than an exception, honouring the Try* contract. + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty(TriggerCallbackPropertyNames.Body, out JsonElement bodyElement) || bodyElement.ValueKind != JsonValueKind.String) { return false; diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs index aefa5b5..b6ea8e0 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -176,6 +176,22 @@ public void TryReadBinaryContent_MalformedJson_ReturnsFalse() Assert.AreEqual(0, content.Length); } + [TestMethod] + [DataRow("null")] + [DataRow("[]")] + [DataRow("\"a string root\"")] + [DataRow("42")] + public void TryReadBinaryContent_NonObjectRoot_ReturnsFalse(string payload) + { + // Arrange — valid JSON whose root is not an object must not throw (Try* contract). + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(0, content.Length); + } + [TestMethod] public async Task ReadAsync_BodyExceedsLimit_ThrowsInvalidOperation() { diff --git a/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs b/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs index e137bb5..ec9b7ff 100644 --- a/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs +++ b/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs @@ -57,5 +57,15 @@ public override int Read(byte[] buffer, int offset, int count) public override void SetLength(long value) => this._inner.SetLength(value); public override void Write(byte[] buffer, int offset, int count) => this._inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this._inner.Dispose(); + } + + base.Dispose(disposing); + } } } From 744f49bc39d224f2fba8af1db94144b2999380b5 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 17:26:07 -0700 Subject: [PATCH 08/12] Address PR #191 review feedback: use definition list in XML doc Switch the ConnectorTriggerPayload remarks list from type=bullet to type=definition since its items use term/description pairs. Co-authored-by: Dobby --- src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index f211499..3f636e8 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -23,7 +23,7 @@ namespace Azure.Connectors.Sdk; /// /// Connector Namespace delivers two distinct trigger callback shapes for file connectors: /// -/// +/// /// /// Metadata (properties only), e.g. OnNewFilesV2 /// From beda96c6fb91ecb37869701e0cbc06226c644f71 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 17:31:51 -0700 Subject: [PATCH 09/12] Address PR #191 review feedback: avoid overflow in read-cap arithmetic ReadBoundedAsync no longer computes (maxBodySizeBytes - totalBytesRead) + 1 unconditionally; it only adds the +1 overflow probe in the branch where the remaining allowance is smaller than the chunk, so a very large maxBodySizeBytes (e.g. long.MaxValue) cannot overflow and produce a negative request size. Adds ReadAsync_VeryLargeLimit_DoesNotOverflow. Co-authored-by: Dobby --- .../ConnectorTriggerPayload.cs | 9 ++++++-- .../ConnectorTriggerPayloadTests.cs | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index 3f636e8..1a5445a 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -263,8 +263,13 @@ private static async ValueTask ReadBoundedAsync( // Never request more than one byte past the remaining allowance, so a single // ReadAsync cannot pull far beyond maxBodySizeBytes before the limit is checked. // The extra byte lets us detect an over-limit body without reading the whole stream. - long remainingAllowance = (maxBodySizeBytes - totalBytesRead) + 1; - int requestSize = (int)Math.Min(chunk.Length, remainingAllowance); + // Compare against the chunk length WITHOUT adding the probe first, so a very large + // maxBodySizeBytes (e.g. long.MaxValue) cannot overflow the long arithmetic; only + // add the +1 probe in the branch where the remaining allowance is the smaller bound. + long remainingAllowance = maxBodySizeBytes - totalBytesRead; + int requestSize = remainingAllowance >= chunk.Length + ? chunk.Length + : (int)Math.Min(chunk.Length, remainingAllowance + 1); int bytesRead = await body .ReadAsync(chunk.AsMemory(0, requestSize), cancellationToken) diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs index b6ea8e0..98bcdd1 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -232,6 +232,27 @@ await Assert.ThrowsExactlyAsync( $"Expected no single read larger than {limit + 1} bytes, but observed {tracking.MaxRequestedCount}."); } + [TestMethod] + public async Task ReadAsync_VeryLargeLimit_DoesNotOverflow() + { + // Arrange — long.MaxValue effectively disables the limit. The internal read-cap + // arithmetic must not overflow (which would make the request size negative). + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); + + // Act + var payload = await ConnectorTriggerPayload + .ReadAsync(stream, maxBodySizeBytes: long.MaxValue) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + [TestMethod] public async Task ReadBinaryContentAsync_DoesNotCloseCallerStream() { From 0801dcd09b4d7958e5cc4b6556fbe84ffce832e1 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 17:38:52 -0700 Subject: [PATCH 10/12] Address PR #191 review feedback: name the read chunk size constant Extract the 81920 read-buffer literal to a named ReadChunkSizeBytes constant (80 KB, matching Stream.CopyTo's default chunk). Co-authored-by: Dobby --- src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index 1a5445a..b0de971 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -57,6 +57,13 @@ public static class ConnectorTriggerPayload /// public const long DefaultMaxBodySizeBytes = 100L * 1024 * 1024; + /// + /// The buffer size, in bytes, rented from the shared for each + /// stream read. 80 KB matches the default chunk size, + /// balancing read throughput against per-call memory. + /// + private const int ReadChunkSizeBytes = 81920; + /// /// Gets the used to read trigger callback payloads. /// Property matching is case-insensitive so camelCase wire fields bind correctly. @@ -254,7 +261,7 @@ private static async ValueTask ReadBoundedAsync( } using var buffer = new MemoryStream(); - byte[] chunk = ArrayPool.Shared.Rent(81920); + byte[] chunk = ArrayPool.Shared.Rent(ConnectorTriggerPayload.ReadChunkSizeBytes); try { long totalBytesRead = 0; From b50c06027809abaa6505072f4a19b24a37278744 Mon Sep 17 00:00:00 2001 From: David Burg Date: Tue, 9 Jun 2026 17:46:45 -0700 Subject: [PATCH 11/12] Address PR #191 review feedback: parse binary callback from UTF-8 bytes ReadBinaryContentAsync now parses JsonDocument directly from the UTF-8 byte buffer instead of decoding to a UTF-16 string first, avoiding a large intermediate allocation for big binary bodies. Extracted the shared base64-body decode into TryDecodeBinaryBody, used by both TryReadBinaryContent (string) and ReadBinaryContentAsync (bytes). Co-authored-by: Dobby --- .../ConnectorTriggerPayload.cs | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index b0de971..5c0b144 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -5,7 +5,6 @@ using System; using System.Buffers; using System.IO; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -164,39 +163,59 @@ public static bool TryReadBinaryContent(string json, out byte[] content) using (document) { - // TryGetProperty throws InvalidOperationException when the root is not an object - // (for example a JSON null, array, or string). Guard the kind first so a non-object - // body is a "could not read" outcome rather than an exception, honouring the Try* contract. - if (document.RootElement.ValueKind != JsonValueKind.Object || - !document.RootElement.TryGetProperty(TriggerCallbackPropertyNames.Body, out JsonElement bodyElement) || - bodyElement.ValueKind != JsonValueKind.String) - { - return false; - } + return ConnectorTriggerPayload.TryDecodeBinaryBody(document, out content); + } + } - // The base64 string may arrive wrapped in extra quotes from the Logic Apps - // expression engine; strip them before decoding. - string base64Content = (bodyElement.GetString() ?? string.Empty).Trim('"'); + /// + /// Decodes the base64 body string of a parsed binary-content trigger callback into bytes. + /// + /// The parsed callback document. + /// + /// When this method returns , the decoded file bytes (empty when the body + /// string was empty). When it returns , an empty array. + /// + /// + /// when the document carried a base64 string body and was decoded; + /// when the root was not an object, the body was not a JSON string, or + /// the string was not valid base64. + /// + private static bool TryDecodeBinaryBody(JsonDocument document, out byte[] content) + { + content = Array.Empty(); - if (base64Content.Length == 0) - { - return true; - } + // TryGetProperty throws InvalidOperationException when the root is not an object + // (for example a JSON null, array, or string). Guard the kind first so a non-object + // body is a "could not read" outcome rather than an exception, honouring the Try* contract. + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty(TriggerCallbackPropertyNames.Body, out JsonElement bodyElement) || + bodyElement.ValueKind != JsonValueKind.String) + { + return false; + } - // Decode directly into a single right-sized array. Avoids the doubled peak memory - // of renting an oversized buffer and then copying into a right-sized array, which - // matters for large binary trigger bodies. The try/catch keeps the Try* contract: - // invalid base64 returns false rather than throwing. - try - { - content = Convert.FromBase64String(base64Content); - return true; - } - catch (FormatException) - { - content = Array.Empty(); - return false; - } + // The base64 string may arrive wrapped in extra quotes from the Logic Apps + // expression engine; strip them before decoding. + string base64Content = (bodyElement.GetString() ?? string.Empty).Trim('"'); + + if (base64Content.Length == 0) + { + return true; + } + + // Decode directly into a single right-sized array. Avoids the doubled peak memory + // of renting an oversized buffer and then copying into a right-sized array, which + // matters for large binary trigger bodies. The try/catch keeps the Try* contract: + // invalid base64 returns false rather than throwing. + try + { + content = Convert.FromBase64String(base64Content); + return true; + } + catch (FormatException) + { + content = Array.Empty(); + return false; } } @@ -228,13 +247,24 @@ public static bool TryReadBinaryContent(string json, out byte[] content) .ReadBoundedAsync(body, maxBodySizeBytes, cancellationToken) .ConfigureAwait(continueOnCapturedContext: false); - // JSON is UTF-8 by default; decode explicitly rather than relying on a StreamReader - // (which would also take ownership of and close the caller's stream). - string json = Encoding.UTF8.GetString(utf8Json); + // Parse straight from the UTF-8 bytes (JSON's native encoding) rather than first + // decoding to a UTF-16 string, avoiding a large intermediate allocation for big bodies. + JsonDocument document; + try + { + document = JsonDocument.Parse(utf8Json); + } + catch (JsonException) + { + return null; + } - return ConnectorTriggerPayload.TryReadBinaryContent(json, out byte[] content) - ? content - : null; + using (document) + { + return ConnectorTriggerPayload.TryDecodeBinaryBody(document, out byte[] content) + ? content + : null; + } } /// From cd5b5f7da155853d17a0fc1c13bae10158f912c2 Mon Sep 17 00:00:00 2001 From: David Burg Date: Thu, 11 Jun 2026 16:41:23 -0700 Subject: [PATCH 12/12] ReadAsync: stream via BoundedStream; add ReadBinaryContentAsync failure tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #191 review feedback: 1. ReadAsync now uses a BoundedStream wrapper + JsonSerializer.DeserializeAsync for true streaming deserialization, avoiding the full-body buffer copy for metadata triggers. BoundedStream caps each read and enforces the size guard without buffering. 2. ReadBinaryContentAsync stream tests now cover malformed JSON, invalid base64, non-object root, and the extra-quoted base64 case — matching TryReadBinaryContent string coverage. --- .../ConnectorTriggerPayload.cs | 123 +++++++++++++++++- .../ConnectorTriggerPayloadTests.cs | 69 ++++++++++ 2 files changed, 188 insertions(+), 4 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index 5c0b144..33262b8 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -122,11 +122,18 @@ public static class ConnectorTriggerPayload { ArgumentNullException.ThrowIfNull(body); - byte[] utf8Json = await ConnectorTriggerPayload - .ReadBoundedAsync(body, maxBodySizeBytes, cancellationToken) - .ConfigureAwait(continueOnCapturedContext: false); + if (maxBodySizeBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(maxBodySizeBytes), + maxBodySizeBytes, + "The maximum body size must be greater than zero."); + } - return JsonSerializer.Deserialize(utf8Json, ConnectorTriggerPayload.SerializerOptions); + var bounded = new BoundedStream(body, maxBodySizeBytes); + return await JsonSerializer + .DeserializeAsync(bounded, ConnectorTriggerPayload.SerializerOptions, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); } /// @@ -336,4 +343,112 @@ private static async ValueTask ReadBoundedAsync( return buffer.ToArray(); } + + /// + /// A read-only wrapper that enforces a byte limit on the underlying stream without + /// buffering the entire content. Each read is capped to at most the remaining + /// allowance plus one byte (to detect over-limit bodies), and + /// is thrown when the limit is exceeded. + /// The inner stream is never closed or disposed; the caller retains ownership. + /// + private sealed class BoundedStream : Stream + { + private readonly Stream _inner; + private readonly long _maxBytes; + private long _totalBytesRead; + + public BoundedStream(Stream inner, long maxBytes) + { + this._inner = inner; + this._maxBytes = maxBytes; + } + + 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(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + int cappedCount = this.CapRequestSize(count); + int bytesRead = this._inner.Read(buffer, offset, cappedCount); + this.EnforceBound(bytesRead); + return bytesRead; + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + int cappedLength = this.CapRequestSize(buffer.Length); + int bytesRead = await this._inner + .ReadAsync(buffer.Slice(0, cappedLength), cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + this.EnforceBound(bytesRead); + return bytesRead; + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + int cappedCount = this.CapRequestSize(count); + int bytesRead = await this._inner + .ReadAsync(buffer, offset, cappedCount, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + this.EnforceBound(bytesRead); + return bytesRead; + } + + public override void Flush() + { + } + + 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) + { + // Intentionally do not dispose the inner stream — the caller owns it. + } + + /// + /// Caps the requested read size so a single read never pulls far beyond the + /// remaining allowance. Mirrors the logic in . + /// + private int CapRequestSize(int requestedCount) + { + long remainingAllowance = this._maxBytes - this._totalBytesRead; + return remainingAllowance >= requestedCount + ? requestedCount + : (int)Math.Min(requestedCount, remainingAllowance + 1); + } + + private void EnforceBound(int bytesRead) + { + this._totalBytesRead += bytesRead; + if (this._totalBytesRead > this._maxBytes) + { + throw new InvalidOperationException( + $"The trigger callback body exceeded the maximum allowed size of {this._maxBytes} bytes."); + } + } + } } diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs index 98bcdd1..cfc0f44 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -287,5 +287,74 @@ public async Task ReadBinaryContentAsync_MetadataStream_ReturnsNull() // Assert — an object body is not binary content. Assert.IsNull(content); } + + [TestMethod] + public async Task ReadBinaryContentAsync_MalformedJson_ReturnsNull() + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("this is not json {")); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNull(content); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_NonBase64StringBody_ReturnsNull() + { + // Arrange + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes("""{"body":"not valid base64 !!!"}""")); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNull(content); + } + + [TestMethod] + [DataRow("null")] + [DataRow("[]")] + [DataRow("\"a string root\"")] + [DataRow("42")] + public async Task ReadBinaryContentAsync_NonObjectRoot_ReturnsNull(string payload) + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNull(content); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_ExtraQuotedBase64_DecodesBytes() + { + // Arrange — the Logic Apps expression engine can wrap the base64 string in extra quotes. + byte[] expected = Encoding.UTF8.GetBytes("extra quoted content"); + string base64 = Convert.ToBase64String(expected); + string payload = $$"""{"body":"\"{{base64}}\""}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(content); + CollectionAssert.AreEqual(expected, content); + } } }