Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ OpenTelemetry.OpAmp.Client.Messages.CustomMessageMessage.Capability.get -> strin
OpenTelemetry.OpAmp.Client.Messages.CustomMessageMessage.Data.get -> System.ReadOnlySpan<byte>
OpenTelemetry.OpAmp.Client.Messages.CustomMessageMessage.Type.get -> string!
OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile
OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.Content.get -> System.Memory<byte>
OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.Content.get -> System.ReadOnlyMemory<byte>
OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.ContentType.get -> string!
OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.EffectiveConfigFile(System.Memory<byte> content, string! contentType, string! fileName) -> void
OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.EffectiveConfigFile(System.ReadOnlyMemory<byte> content, string! contentType, string! fileName) -> void
OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.FileName.get -> string!
OpenTelemetry.OpAmp.Client.Messages.OpAmpMessage
OpenTelemetry.OpAmp.Client.Messages.OpAmpMessage.OpAmpMessage() -> void
Expand Down Expand Up @@ -88,6 +88,7 @@ OpenTelemetry.OpAmp.Client.Settings.RemoteConfigSettings.AcceptsRemoteConfig.set
OpenTelemetry.OpAmp.Client.Settings.RemoteConfigSettings.RemoteConfigSettings() -> void
override OpenTelemetry.OpAmp.Client.Settings.AnyValueUnion.Equals(object? obj) -> bool
override OpenTelemetry.OpAmp.Client.Settings.AnyValueUnion.GetHashCode() -> int
static OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.CreateFromFilePath(string! filePath, string! contentType, string? filename = null) -> OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile!
static OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.CreateFromStream(System.IO.Stream! stream, string! contentType, string! fileName, int maxBytes) -> OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile!
static OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile.CreateFromStreamAsync(System.IO.Stream! stream, string! contentType, string! fileName, int maxBytes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<OpenTelemetry.OpAmp.Client.Messages.EffectiveConfigFile!>!
static OpenTelemetry.OpAmp.Client.Settings.AnyValueUnion.operator !=(OpenTelemetry.OpAmp.Client.Settings.AnyValueUnion left, OpenTelemetry.OpAmp.Client.Settings.AnyValueUnion right) -> bool
static OpenTelemetry.OpAmp.Client.Settings.AnyValueUnion.operator ==(OpenTelemetry.OpAmp.Client.Settings.AnyValueUnion left, OpenTelemetry.OpAmp.Client.Settings.AnyValueUnion right) -> bool
7 changes: 7 additions & 0 deletions src/OpenTelemetry.OpAmp.Client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

* Enhance EffectiveConfigFile:
* Remove `CreateFromFilePath` factory method.
* Add `CreateFromSteam` and `CreateFromStreamAsync` methods which enforce max
size limits.
* Content property is now `ReadOnlyMemory`.
([#4285](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4285))

## 0.2.0-alpha.1

Released 2026-Apr-21
Expand Down
5 changes: 5 additions & 0 deletions src/OpenTelemetry.OpAmp.Client/Internal/FrameBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ IFrameBuilder IFrameBuilder.AddEffectiveConfig(IEnumerable<EffectiveConfigFile>
var fileMap = new Dictionary<string, global::OpAmp.Proto.V1.AgentConfigFile>(StringComparer.Ordinal);
foreach (var file in files)
{
if (fileMap.ContainsKey(file.FileName))
{
throw new ArgumentException($"Multiple config files share the same FileName '{file.FileName}'. FileNames must be unique.", nameof(files));
}

fileMap.Add(file.FileName, new global::OpAmp.Proto.V1.AgentConfigFile()
{
Body = ByteString.CopyFrom(file.Content.Span),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal sealed class OpAmpClientEventSource : EventSource
private const int EventIdHttpResponseReceived = 3;
private const int EventIdOversizedWebSocketMessage = 4;
private const int EventIdFrameProcessingFailure = 5;
private const int EventIdEffectiveConfigSizeLimitViolation = 6;

// Service events 500-999
private const int EventIdHeartbeatServiceStart = 500;
Expand Down Expand Up @@ -107,6 +108,21 @@ public void FrameProcessingFailure(string exception)
this.WriteEvent(EventIdFrameProcessingFailure, exception);
}

[NonEvent]
public void EffectiveConfigSizeLimitExceeded(int maxBytes)
{
if (this.IsEnabled(EventLevel.Warning, EventKeywords.All))
{
this.EffectiveConfigSizeLimitViolation(maxBytes);
}
}

[Event(EventIdEffectiveConfigSizeLimitViolation, Message = "Configuration file exceeds maximum allowed size of {0} bytes.", Level = EventLevel.Warning)]
public void EffectiveConfigSizeLimitViolation(int maxBytes)
{
this.WriteEvent(EventIdEffectiveConfigSizeLimitViolation, maxBytes);
}

[Event(EventIdHeartbeatServiceStart, Message = "Heartbeat service started.", Level = EventLevel.Informational)]
public void HeartbeatServiceStart()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

using System.Buffers;
using OpenTelemetry.Internal;
using OpenTelemetry.OpAmp.Client.Internal;

namespace OpenTelemetry.OpAmp.Client.Messages;

/// <summary>
Expand All @@ -12,10 +16,18 @@ public sealed class EffectiveConfigFile
/// Initializes a new instance of the <see cref="EffectiveConfigFile"/> class.
/// </summary>
/// <param name="content">File content.</param>
/// <param name="contentType">File content type.</param>
/// <param name="contentType">File MIME Content-Type.</param>
/// <param name="fileName">File name.</param>
public EffectiveConfigFile(Memory<byte> content, string contentType, string fileName)
/// <remarks>
/// This constructor does not enforce a maximum content size. Callers must bound <paramref name="content"/> themselves.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="content"/>, <paramref name="contentType"/> or <paramref name="fileName"/> are null.</exception>
public EffectiveConfigFile(ReadOnlyMemory<byte> content, string contentType, string fileName)
{
Guard.ThrowIfNull(content);
Guard.ThrowIfNull(contentType);
Guard.ThrowIfNull(fileName);

this.Content = content;
this.ContentType = contentType;
this.FileName = fileName;
Expand All @@ -24,44 +36,228 @@ public EffectiveConfigFile(Memory<byte> content, string contentType, string file
/// <summary>
/// Gets the file content.
/// </summary>
public Memory<byte> Content { get; private set; }
public ReadOnlyMemory<byte> Content { get; }

/// <summary>
/// Gets the file content type.
/// Gets the MIME Content-Type of the file.
/// </summary>
public string ContentType { get; private set; }
public string ContentType { get; }

/// <summary>
/// Gets the file name.
/// </summary>
public string FileName { get; private set; }
public string FileName { get; }

/// <summary>
/// Creates a configuration reference from the file path.
/// Creates an <see cref="EffectiveConfigFile"/> instance from a <see cref="Stream"/>.
/// </summary>
/// <param name="filePath">Path of the configuration file.</param>
/// <param name="contentType">The content type of the configuration file.</param>
/// <param name="filename">The reported filename.</param>
/// <returns>Effective config object.</returns>
/// <param name="stream">A <see cref="Stream"/> containing the effective configuration file content.</param>
/// <param name="contentType">The MIME Content-Type of the configuration file.</param>
/// <param name="fileName">The reported file name.</param>
/// <param name="maxBytes">Maximum allowed file size in bytes. Default is 512 KB.</param>
/// <returns>An instance of <see cref="EffectiveConfigFile"/>.</returns>
/// <remarks>
/// <para>
/// The entire file is read into memory and transmitted as-is to the OpAMP server.
/// Do not include files that contain secrets (passwords, tokens, private keys) unless
/// The entire content is transmitted as-is to the OpAMP server.
/// Do not include file content that contains secrets (passwords, tokens, private keys) unless
/// the transport is secure (e.g. with TLS) and the OpAMP server is fully trusted.
/// </para>
/// <para>
/// When validating stream size for non-seekable streams, this method may consume up to one byte
/// beyond <paramref name="maxBytes"/> before throwing an exception.
/// </para>
/// </remarks>
public static EffectiveConfigFile CreateFromFilePath(string filePath, string contentType, string? filename = null)
/// <exception cref="ArgumentNullException">Thrown when <paramref name="stream"/>, <paramref name="contentType"/>, or <paramref name="fileName"/> are null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="stream"/> does not support reading.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="maxBytes"/> is less than zero.</exception>
/// <exception cref="InvalidDataException">Thrown when the stream length exceeds the specified <paramref name="maxBytes"/>.</exception>
public static EffectiveConfigFile CreateFromStream(Stream stream, string contentType, string fileName, int maxBytes)
{
ValidateArguments(stream, contentType, fileName, maxBytes);
CheckSeekableSize(stream, maxBytes);

if (maxBytes == 0)
{
// For non-seekable streams that CheckSeekableSize skips, we must peek one byte to verify emptiness.
// For seekable streams, CheckSeekableSize already confirmed remaining length is zero.
if (!stream.CanSeek && stream.ReadByte() != -1)
{
ThrowSizeLimitExceeded(maxBytes);
}

return new EffectiveConfigFile(ReadOnlyMemory<byte>.Empty, contentType, fileName);
}

var buffer = ArrayPool<byte>.Shared.Rent(maxBytes);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stuff looks like the code we added recently to encode limits in a shared place - can we add this code there and consume it, rather than make more copies of the pattern we need to verify?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's quite similar to the stuff added to HttpClientHelpers, but that code isn't quite the right fit as-is and would need generalising futher (exception messages etc) for this use case. Could investigate that as a low-priority follow-up?

try
{
var content = File.ReadAllBytes(filePath);
var fileName = filename ?? Path.GetFileName(filePath);
var totalBytesRead = 0;

while (totalBytesRead < maxBytes)
{
var bytesRead = stream.Read(buffer, totalBytesRead, maxBytes - totalBytesRead);
if (bytesRead == 0)
{
break;
}

return new EffectiveConfigFile(content, contentType, fileName);
totalBytesRead += bytesRead;
}

if (totalBytesRead == maxBytes && stream.ReadByte() != -1)
{
ThrowSizeLimitExceeded(maxBytes);
}

return BuildResult(buffer, totalBytesRead, contentType, fileName);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
catch (Exception ex)
}

/// <summary>
/// Creates an <see cref="EffectiveConfigFile"/> instance from a <see cref="Stream"/> asynchronously.
/// </summary>
/// <param name="stream">A <see cref="Stream"/> containing the effective configuration file content.</param>
/// <param name="contentType">The MIME Content-Type of the configuration file.</param>
/// <param name="fileName">The reported file name.</param>
/// <param name="maxBytes">Maximum allowed file size in bytes. Default is 512 KB.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous read, containing the resulting <see cref="EffectiveConfigFile"/>.</returns>
/// <remarks>
/// <para>
/// The entire content is transmitted as-is to the OpAMP server.
/// Do not include file content that contains secrets (passwords, tokens, private keys) unless
/// the transport is secure (e.g. with TLS) and the OpAMP server is fully trusted.
/// </para>
/// <para>
/// When validating stream size for non-seekable streams, this method may consume up to one byte
/// beyond <paramref name="maxBytes"/> before throwing an exception.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="stream"/>, <paramref name="contentType"/>, or <paramref name="fileName"/> are null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="stream"/> does not support reading.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="maxBytes"/> is less than zero.</exception>
/// <exception cref="InvalidDataException">Thrown when the stream length exceeds the specified <paramref name="maxBytes"/>.</exception>
public static async Task<EffectiveConfigFile> CreateFromStreamAsync(Stream stream, string contentType, string fileName, int maxBytes, CancellationToken cancellationToken = default)
{
ValidateArguments(stream, contentType, fileName, maxBytes);
CheckSeekableSize(stream, maxBytes);

if (maxBytes == 0)
{
throw new InvalidOperationException("Could not read configuration file.", ex);
// For non-seekable streams that CheckSeekableSize skips, we must peek one byte to verify emptiness.
// For seekable streams, CheckSeekableSize already confirmed remaining length is zero.
if (!stream.CanSeek)
{
// Zero maxBytes is extremely unlikely to be used, so the cost of this extra allocation is acceptable as an edge case.
var peekBuffer = new byte[1];
#if NET
var peeked = await stream.ReadAsync(peekBuffer.AsMemory(0, 1), cancellationToken).ConfigureAwait(false);
#else
var peeked = await stream.ReadAsync(peekBuffer, 0, 1, cancellationToken).ConfigureAwait(false);
#endif
if (peeked > 0)
{
ThrowSizeLimitExceeded(maxBytes);
}
}

return new EffectiveConfigFile(ReadOnlyMemory<byte>.Empty, contentType, fileName);
}

var buffer = ArrayPool<byte>.Shared.Rent(maxBytes);
try
{
var totalBytesRead = 0;

while (totalBytesRead < maxBytes)
{
#if NET
var bytesRead = await stream.ReadAsync(buffer.AsMemory(totalBytesRead, maxBytes - totalBytesRead), cancellationToken).ConfigureAwait(false);
#else
var bytesRead = await stream.ReadAsync(buffer, totalBytesRead, maxBytes - totalBytesRead, cancellationToken).ConfigureAwait(false);
#endif
if (bytesRead == 0)
{
break;
}

totalBytesRead += bytesRead;
}

if (totalBytesRead == maxBytes)
{
// Peek for overflow by reusing buffer[0] to avoid extra allocation.
// Safety: if 0 bytes are returned (EOF) buffer[0] is not written and BuildResult
// proceeds with intact content. If 1 byte is returned we throw immediately and
// never call BuildResult, so the corrupted slot is irrelevant.
#if NET
var extra = await stream.ReadAsync(buffer.AsMemory(0, 1), cancellationToken).ConfigureAwait(false);
#else
var extra = await stream.ReadAsync(buffer, 0, 1, cancellationToken).ConfigureAwait(false);
#endif
if (extra > 0)
{
ThrowSizeLimitExceeded(maxBytes);
}
}

return BuildResult(buffer, totalBytesRead, contentType, fileName);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
}

private static void ValidateArguments(Stream stream, string contentType, string fileName, int maxBytes)
{
Guard.ThrowIfNull(stream);
Guard.ThrowIfNull(contentType);
Guard.ThrowIfNull(fileName);
Guard.ThrowIfNegative(maxBytes);

if (!stream.CanRead)
{
throw new ArgumentException("Stream must support reading.", nameof(stream));
}
}

private static void CheckSeekableSize(Stream stream, int maxBytes)
{
if (!stream.CanSeek)
{
return;
}

// Clamp to zero: Position > Length is legal for seekable streams (e.g. after an explicit
// Seek past EOF). Without the clamp, remainingBytes would be negative and the comparison
// remainingBytes > maxBytes would silently pass even for custom Stream implementations
// that return data beyond their declared Length.
var remainingBytes = Math.Max(0L, stream.Length - stream.Position);
if (remainingBytes > maxBytes)
{
ThrowSizeLimitExceeded(maxBytes);
}
}

private static EffectiveConfigFile BuildResult(byte[] buffer, int length, string contentType, string fileName)
{
var content = new byte[length];
if (length > 0)
{
Buffer.BlockCopy(buffer, 0, content, 0, length);
}

return new EffectiveConfigFile(content, contentType, fileName);
}

private static void ThrowSizeLimitExceeded(int maxBytes)
{
OpAmpClientEventSource.Log.EffectiveConfigSizeLimitExceeded(maxBytes);
throw new InvalidDataException($"Configuration file exceeds maximum allowed size of {maxBytes} bytes.");
}
}
Loading