From 106f4ec70ed30e6b15541f04e61fa1b680f46c27 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 14 Jul 2026 22:59:40 +0200 Subject: [PATCH 1/2] docs: add AI plan 156 for stream guards Signed-off-by: Kenny Pflug --- ai-plans/0156-stream-guards.md | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 ai-plans/0156-stream-guards.md diff --git a/ai-plans/0156-stream-guards.md b/ai-plans/0156-stream-guards.md new file mode 100644 index 0000000..4e7e589 --- /dev/null +++ b/ai-plans/0156-stream-guards.md @@ -0,0 +1,45 @@ +# Stream Guards + +## Rationale + +Streams are commonly passed between upload, export, and document-generation layers, but an unsupported read, write, or seek operation may fail only after the stream has traveled far from the call site. Add dedicated `MustBeReadable`, `MustBeWritable`, and `MustBeSeekable` guards so callers can state these capability requirements at API boundaries. + +The guards must be thin, allocation-free checks of the corresponding `Stream` capability properties, preserve the fluent API and concrete stream type, and participate in the customizable single-file source distribution. + +## Acceptance Criteria + +- [ ] `MustBeReadable`, `MustBeWritable`, and `MustBeSeekable` are available for `Stream` instances and subclasses on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 with identical semantics. +- [ ] Each guard accepts a non-null stream exactly when its corresponding `CanRead`, `CanWrite`, or `CanSeek` property returns `true`; it does not inspect other capabilities, probe the stream with an I/O operation, change its position, or otherwise mutate it. +- [ ] A null stream throws `ArgumentNullException` by default, while a stream lacking the required capability throws `ArgumentException` with the captured guarded expression and a capability-specific message; no new public exception type is introduced. +- [ ] Every guard returns the original successfully validated instance in its concrete stream shape, accepts an optional custom message, and provides a custom-exception-factory overload consistent with the existing nullable reference APIs. +- [ ] Successful default and custom-factory guards perform no heap allocation. +- [ ] Automated tests cover all supported and unsupported capability states, null values, default exception contracts, custom messages, custom exception factories, caller argument expressions, preservation of the original instance and concrete type, and confirmation that each guard reads only its matching capability property. +- [ ] The source-export whitelist catalog and committed settings contain all three assertion families, and focused source-export tests cover their guard, throw-helper, and exception-factory reachability. +- [ ] The committed .NET Standard 2.0 single-file distribution is regenerated with the new stream guards, and generated source validates for both .NET Standard 2.0 and .NET 10. +- [ ] XML documentation and the assertion overview describe the three guards, their null and failure behavior, fluent return values, and property-only validation semantics. +- [ ] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. + +## Technical Details + +Implement one `Check..cs` file per guard family. Use a generic stream subtype parameter so fluent chains retain types such as `FileStream` and `MemoryStream`. The following signatures illustrate the shared public shape; the writable and seekable families mirror it: + +```csharp +public static TStream MustBeReadable( + [NotNull, ValidatedNotNull] this TStream? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null +) where TStream : Stream; + +public static TStream MustBeReadable( + [NotNull, ValidatedNotNull] this TStream? parameter, + Func exceptionFactory +) where TStream : Stream; +``` + +Apply the repository's established nullable-flow and JetBrains contract annotations. Default overloads should follow the `MustBeAbsoluteUri` null-check pattern: validate null first, inspect the relevant property once, and return the same reference. Factory overloads invoke the factory for either null or capability failure and pass the original nullable stream value to it. + +The hot success path is only a null check, one virtual property read, one conditional branch, and the return. Keep it aggressively inlineable and route default failure construction through non-returning `Throw` helpers. Those helpers should create `ArgumentException` messages that name the missing capability without formatting the stream or calling its `ToString`; failures must not attempt an operation to discover why a capability is unavailable. Boolean `IsReadable`, `IsWritable`, and `IsSeekable` predicates are out of scope because `CanRead`, `CanWrite`, and `CanSeek` already expose the predicates directly. + +Use small hand-written `Stream` test doubles to represent independent capability combinations and to record property access; do not assume that capabilities imply one another. Include disposed framework streams as representative property-defined failures, but preserve the `Stream` implementation's own behavior if a custom capability getter throws. + +Add `MustBeReadable`, `MustBeWritable`, and `MustBeSeekable` to `AssertionWhitelist` and `settings.json`, cover trimming/reachability of their default and factory paths, regenerate `Light.GuardClauses.SingleFile.cs` as the .NET Standard 2.0 artifact, and add a stream assertion section to `docs/assertion-overview.md`. From 94f6931ccb9813ec0cb5f5dcdfe24108b063f7cb Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 14 Jul 2026 23:15:23 +0200 Subject: [PATCH 2/2] feat: add guards for Stream Signed-off-by: Kenny Pflug --- Light.GuardClauses.SingleFile.cs | 163 +++++++ ai-plans/0156-stream-guards.md | 20 +- docs/assertion-overview.md | 13 + .../Check.MustBeReadable.cs | 63 +++ .../Check.MustBeSeekable.cs | 63 +++ .../Check.MustBeWritable.cs | 63 +++ .../ExceptionFactory/Throw.Stream.cs | 35 ++ .../SourceFileMergerWhitelistTests.cs | 53 +++ .../StreamAssertions/StreamGuardTests.cs | 448 ++++++++++++++++++ .../AssertionWhitelist.cs | 6 + .../SourceFileMerger.cs | 1 + .../settings.json | 3 + 12 files changed, 921 insertions(+), 10 deletions(-) create mode 100644 src/Light.GuardClauses/Check.MustBeReadable.cs create mode 100644 src/Light.GuardClauses/Check.MustBeSeekable.cs create mode 100644 src/Light.GuardClauses/Check.MustBeWritable.cs create mode 100644 src/Light.GuardClauses/ExceptionFactory/Throw.Stream.cs create mode 100644 tests/Light.GuardClauses.Tests/StreamAssertions/StreamGuardTests.cs diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index ec16035..9bded8e 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -31,6 +31,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -4420,6 +4421,53 @@ public static TimeSpan MustBePositive(this TimeSpan parameter, Func + /// Ensures that the specified stream supports reading and returns the original stream, or otherwise throws an + /// . Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when does not support reading. + /// Thrown when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeReadable([NotNull, ValidatedNotNull] this TStream? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + where TStream : Stream + { + if (parameter is null) + { + Throw.ArgumentNull(parameterName, message); + } + + if (parameter.CanRead == false) + { + Throw.MustBeReadable(parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the specified stream supports reading and returns the original stream, or otherwise throws your + /// custom exception. Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The delegate that creates the exception to be thrown. is passed to this delegate. + /// Your custom exception thrown when does not support reading, or when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeReadable([NotNull, ValidatedNotNull] this TStream? parameter, Func exceptionFactory) + where TStream : Stream + { + if (parameter is null || parameter.CanRead == false) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + /// /// Ensures that the specified URI is a relative one, or otherwise throws an . /// @@ -4458,6 +4506,53 @@ public static Uri MustBeRelativeUri([NotNull][ValidatedNotNull] this Uri? parame return parameter; } + /// + /// Ensures that the specified stream supports seeking and returns the original stream, or otherwise throws an + /// . Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when does not support seeking. + /// Thrown when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeSeekable([NotNull, ValidatedNotNull] this TStream? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + where TStream : Stream + { + if (parameter is null) + { + Throw.ArgumentNull(parameterName, message); + } + + if (parameter.CanSeek == false) + { + Throw.MustBeSeekable(parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the specified stream supports seeking and returns the original stream, or otherwise throws your + /// custom exception. Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The delegate that creates the exception to be thrown. is passed to this delegate. + /// Your custom exception thrown when does not support seeking, or when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeSeekable([NotNull, ValidatedNotNull] this TStream? parameter, Func exceptionFactory) + where TStream : Stream + { + if (parameter is null || parameter.CanSeek == false) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + /// /// Ensures that the string is shorter than the specified length, or otherwise throws a . /// @@ -5259,6 +5354,53 @@ public static T MustBeValidEnumValue(this T parameter, Func exc return parameter; } + /// + /// Ensures that the specified stream supports writing and returns the original stream, or otherwise throws an + /// . Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when does not support writing. + /// Thrown when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeWritable([NotNull, ValidatedNotNull] this TStream? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + where TStream : Stream + { + if (parameter is null) + { + Throw.ArgumentNull(parameterName, message); + } + + if (parameter.CanWrite == false) + { + Throw.MustBeWritable(parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the specified stream supports writing and returns the original stream, or otherwise throws your + /// custom exception. Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The delegate that creates the exception to be thrown. is passed to this delegate. + /// Your custom exception thrown when does not support writing, or when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeWritable([NotNull, ValidatedNotNull] this TStream? parameter, Func exceptionFactory) + where TStream : Stream + { + if (parameter is null || parameter.CanWrite == false) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + /// /// Ensures that the collection contains the specified item, or otherwise throws a . /// @@ -11676,6 +11818,27 @@ public static void SameObjectReference(T? parameter, [CallerArgumentExpressio [DoesNotReturn] public static void SpanMustBeShorterThanOrEqualTo(ReadOnlySpan parameter, int length, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new InvalidCollectionCountException(parameterName, message ?? $"{parameterName ?? "The span"} must be shorter than or equal to {length}, but it actually has length {parameter.Length}."); /// + /// Throws the default indicating that a stream does not support reading, using + /// the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeReadable(string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be readable.", parameterName); + /// + /// Throws the default indicating that a stream does not support writing, using + /// the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeWritable(string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be writable.", parameterName); + /// + /// Throws the default indicating that a stream does not support seeking, using + /// the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeSeekable(string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be seekable.", parameterName); + /// /// Throws the default indicating that a string does not contain another string as /// a substring, using the optional parameter name and message. /// diff --git a/ai-plans/0156-stream-guards.md b/ai-plans/0156-stream-guards.md index 4e7e589..092525b 100644 --- a/ai-plans/0156-stream-guards.md +++ b/ai-plans/0156-stream-guards.md @@ -8,16 +8,16 @@ The guards must be thin, allocation-free checks of the corresponding `Stream` ca ## Acceptance Criteria -- [ ] `MustBeReadable`, `MustBeWritable`, and `MustBeSeekable` are available for `Stream` instances and subclasses on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 with identical semantics. -- [ ] Each guard accepts a non-null stream exactly when its corresponding `CanRead`, `CanWrite`, or `CanSeek` property returns `true`; it does not inspect other capabilities, probe the stream with an I/O operation, change its position, or otherwise mutate it. -- [ ] A null stream throws `ArgumentNullException` by default, while a stream lacking the required capability throws `ArgumentException` with the captured guarded expression and a capability-specific message; no new public exception type is introduced. -- [ ] Every guard returns the original successfully validated instance in its concrete stream shape, accepts an optional custom message, and provides a custom-exception-factory overload consistent with the existing nullable reference APIs. -- [ ] Successful default and custom-factory guards perform no heap allocation. -- [ ] Automated tests cover all supported and unsupported capability states, null values, default exception contracts, custom messages, custom exception factories, caller argument expressions, preservation of the original instance and concrete type, and confirmation that each guard reads only its matching capability property. -- [ ] The source-export whitelist catalog and committed settings contain all three assertion families, and focused source-export tests cover their guard, throw-helper, and exception-factory reachability. -- [ ] The committed .NET Standard 2.0 single-file distribution is regenerated with the new stream guards, and generated source validates for both .NET Standard 2.0 and .NET 10. -- [ ] XML documentation and the assertion overview describe the three guards, their null and failure behavior, fluent return values, and property-only validation semantics. -- [ ] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. +- [x] `MustBeReadable`, `MustBeWritable`, and `MustBeSeekable` are available for `Stream` instances and subclasses on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 with identical semantics. +- [x] Each guard accepts a non-null stream exactly when its corresponding `CanRead`, `CanWrite`, or `CanSeek` property returns `true`; it does not inspect other capabilities, probe the stream with an I/O operation, change its position, or otherwise mutate it. +- [x] A null stream throws `ArgumentNullException` by default, while a stream lacking the required capability throws `ArgumentException` with the captured guarded expression and a capability-specific message; no new public exception type is introduced. +- [x] Every guard returns the original successfully validated instance in its concrete stream shape, accepts an optional custom message, and provides a custom-exception-factory overload consistent with the existing nullable reference APIs. +- [x] Successful default and custom-factory guards perform no heap allocation. +- [x] Automated tests cover all supported and unsupported capability states, null values, default exception contracts, custom messages, custom exception factories, caller argument expressions, preservation of the original instance and concrete type, and confirmation that each guard reads only its matching capability property. +- [x] The source-export whitelist catalog and committed settings contain all three assertion families, and focused source-export tests cover their guard, throw-helper, and exception-factory reachability. +- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the new stream guards, and generated source validates for both .NET Standard 2.0 and .NET 10. +- [x] XML documentation and the assertion overview describe the three guards, their null and failure behavior, fluent return values, and property-only validation semantics. +- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. ## Technical Details diff --git a/docs/assertion-overview.md b/docs/assertion-overview.md index 05e0fea..8d18058 100644 --- a/docs/assertion-overview.md +++ b/docs/assertion-overview.md @@ -148,6 +148,19 @@ Base64 inspection validates the standard `A`-`Z`, `a`-`z`, `0`-`9`, `+`, and `/` | `MustBeLocal` | Requires `DateTimeKind.Local` | | `MustBeUnspecified` | Requires `DateTimeKind.Unspecified` | +## Stream assertions + +| Assertion | Behavior | +| --- | --- | +| `MustBeReadable` | Requires a non-null stream whose `CanRead` property is `true` | +| `MustBeWritable` | Requires a non-null stream whose `CanWrite` property is `true` | +| `MustBeSeekable` | Requires a non-null stream whose `CanSeek` property is `true` | + +Each stream guard reads only its matching capability property: it performs no I/O, does not inspect the other +capabilities, and does not change the stream position or any other state. A null stream throws +`ArgumentNullException`; a missing capability throws `ArgumentException`. Successful guards return the same instance +while preserving its concrete stream type, and all three guards support custom messages and exception factories. + ## Type-relation assertions | Assertion | Behavior | diff --git a/src/Light.GuardClauses/Check.MustBeReadable.cs b/src/Light.GuardClauses/Check.MustBeReadable.cs new file mode 100644 index 0000000..49b83e6 --- /dev/null +++ b/src/Light.GuardClauses/Check.MustBeReadable.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; +using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Ensures that the specified stream supports reading and returns the original stream, or otherwise throws an + /// . Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when does not support reading. + /// Thrown when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeReadable( + [NotNull, ValidatedNotNull] this TStream? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) where TStream : Stream + { + if (parameter is null) + { + Throw.ArgumentNull(parameterName, message); + } + + if (!parameter.CanRead) + { + Throw.MustBeReadable(parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the specified stream supports reading and returns the original stream, or otherwise throws your + /// custom exception. Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The delegate that creates the exception to be thrown. is passed to this delegate. + /// Your custom exception thrown when does not support reading, or when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeReadable( + [NotNull, ValidatedNotNull] this TStream? parameter, + Func exceptionFactory + ) where TStream : Stream + { + if (parameter is null || !parameter.CanRead) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } +} diff --git a/src/Light.GuardClauses/Check.MustBeSeekable.cs b/src/Light.GuardClauses/Check.MustBeSeekable.cs new file mode 100644 index 0000000..e35aa9f --- /dev/null +++ b/src/Light.GuardClauses/Check.MustBeSeekable.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; +using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Ensures that the specified stream supports seeking and returns the original stream, or otherwise throws an + /// . Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when does not support seeking. + /// Thrown when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeSeekable( + [NotNull, ValidatedNotNull] this TStream? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) where TStream : Stream + { + if (parameter is null) + { + Throw.ArgumentNull(parameterName, message); + } + + if (!parameter.CanSeek) + { + Throw.MustBeSeekable(parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the specified stream supports seeking and returns the original stream, or otherwise throws your + /// custom exception. Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The delegate that creates the exception to be thrown. is passed to this delegate. + /// Your custom exception thrown when does not support seeking, or when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeSeekable( + [NotNull, ValidatedNotNull] this TStream? parameter, + Func exceptionFactory + ) where TStream : Stream + { + if (parameter is null || !parameter.CanSeek) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } +} diff --git a/src/Light.GuardClauses/Check.MustBeWritable.cs b/src/Light.GuardClauses/Check.MustBeWritable.cs new file mode 100644 index 0000000..d1ce8e3 --- /dev/null +++ b/src/Light.GuardClauses/Check.MustBeWritable.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; +using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Ensures that the specified stream supports writing and returns the original stream, or otherwise throws an + /// . Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// Thrown when does not support writing. + /// Thrown when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeWritable( + [NotNull, ValidatedNotNull] this TStream? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) where TStream : Stream + { + if (parameter is null) + { + Throw.ArgumentNull(parameterName, message); + } + + if (!parameter.CanWrite) + { + Throw.MustBeWritable(parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that the specified stream supports writing and returns the original stream, or otherwise throws your + /// custom exception. Validation reads only and performs no I/O. + /// + /// The stream to be checked. + /// The delegate that creates the exception to be thrown. is passed to this delegate. + /// Your custom exception thrown when does not support writing, or when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static TStream MustBeWritable( + [NotNull, ValidatedNotNull] this TStream? parameter, + Func exceptionFactory + ) where TStream : Stream + { + if (parameter is null || !parameter.CanWrite) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.Stream.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.Stream.cs new file mode 100644 index 0000000..7c07098 --- /dev/null +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.Stream.cs @@ -0,0 +1,35 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; + +namespace Light.GuardClauses.ExceptionFactory; + +public static partial class Throw +{ + /// + /// Throws the default indicating that a stream does not support reading, using + /// the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeReadable(string? parameterName = null, string? message = null) => + throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be readable.", parameterName); + + /// + /// Throws the default indicating that a stream does not support writing, using + /// the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeWritable(string? parameterName = null, string? message = null) => + throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be writable.", parameterName); + + /// + /// Throws the default indicating that a stream does not support seeking, using + /// the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeSeekable(string? parameterName = null, string? message = null) => + throw new ArgumentException(message ?? $"{parameterName ?? "The stream"} must be seekable.", parameterName); +} diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index c59799b..3fcd6ed 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -402,6 +402,59 @@ public static void StringInspectionWhitelistTrimsExceptionFactories() sourceCode.Should().NotContain("public static void CustomSpanException"); } + [Fact] + public static void StreamGuardWhitelistsRetainTheirGuardsThrowHelpersAndExceptionFactories() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "StreamGuards.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist( + includedAssertions: + [ + new ("MustBeReadable", true), + new ("MustBeWritable", true), + new ("MustBeSeekable", true), + ] + ) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("TStream MustBeReadable("); + sourceCode.Should().Contain("TStream MustBeWritable("); + sourceCode.Should().Contain("TStream MustBeSeekable("); + sourceCode.Should().Contain("public static void MustBeReadable("); + sourceCode.Should().Contain("public static void MustBeWritable("); + sourceCode.Should().Contain("public static void MustBeSeekable("); + sourceCode.Should().Contain("Func exceptionFactory"); + sourceCode.Should().Contain("public static void CustomException("); + sourceCode.Should().NotContain("MustBeAbsoluteUri("); + } + + [Fact] + public static void StreamGuardWhitelistTrimsFactoryAndUnrelatedStreamThrowHelpers() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "ReadableStreamGuard.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist(includedAssertions: [new ("MustBeReadable", false)]) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("TStream MustBeReadable("); + sourceCode.Should().Contain("public static void MustBeReadable("); + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("public static void MustBeWritable("); + sourceCode.Should().NotContain("public static void MustBeSeekable("); + } + private static SourceFileMergeOptions CreateOptions( string targetFile, AssertionWhitelist assertionWhitelist = null, diff --git a/tests/Light.GuardClauses.Tests/StreamAssertions/StreamGuardTests.cs b/tests/Light.GuardClauses.Tests/StreamAssertions/StreamGuardTests.cs new file mode 100644 index 0000000..ffb4091 --- /dev/null +++ b/tests/Light.GuardClauses.Tests/StreamAssertions/StreamGuardTests.cs @@ -0,0 +1,448 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using FluentAssertions; +using Xunit; + +namespace Light.GuardClauses.Tests.StreamAssertions; + +public static class StreamGuardTests +{ + private static readonly Func AllocationExceptionFactory = + static _ => new InvalidOperationException(); + + [Theory] + [MemberData(nameof(CapabilityStates))] + public static void GuardsAcceptExactlyTheirMatchingCapabilityAndInspectOnlyThatProperty( + Capability capability, + bool canRead, + bool canWrite, + bool canSeek + ) + { + var stream = new TrackingStream(canRead, canWrite, canSeek) { StoredPosition = 42 }; + + Action act = () => InvokeDefault(stream, capability); + + if (GetExpectedCapability(capability, canRead, canWrite, canSeek)) + { + act.Should().NotThrow(); + } + else + { + act.Should().Throw(); + } + + stream.GetCapabilityReadCount(capability).Should().Be(1); + stream.GetOtherCapabilityReadCount(capability).Should().Be(0); + stream.PositionReadCount.Should().Be(0); + stream.LengthReadCount.Should().Be(0); + stream.IoOperationCount.Should().Be(0); + stream.StoredPosition.Should().Be(42); + } + + public static IEnumerable CapabilityStates() + { + foreach (var capability in Enum.GetValues()) + { + for (var state = 0; state < 8; state++) + { + yield return + [ + capability, + (state & 1) != 0, + (state & 2) != 0, + (state & 4) != 0, + ]; + } + } + } + + [Fact] + public static void GuardsPreserveConcreteTypeAndOriginalInstance() + { + var stream = new TrackingStream(true, true, true); + + var readable = stream.MustBeReadable(); + var writable = stream.MustBeWritable(); + var seekable = stream.MustBeSeekable(); + + readable.Should().BeSameAs(stream); + writable.Should().BeSameAs(stream); + seekable.Should().BeSameAs(stream); + } + + [Theory] + [InlineData(Capability.Readable, "readable")] + [InlineData(Capability.Writable, "writable")] + [InlineData(Capability.Seekable, "seekable")] + public static void DefaultFailuresUseCapturedExpressionAndCapabilitySpecificMessage( + Capability capability, + string capabilityName + ) + { + var stream = CreateUnsupportedStream(capability); + + var act = () => InvokeDefault(stream, capability); + + act.Should().Throw() + .WithParameterName(nameof(stream)) + .WithMessage($"*{nameof(stream)} must be {capabilityName}.*"); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void NullStreamsThrowArgumentNullExceptionWithCapturedExpression(Capability capability) + { + TrackingStream? stream = null; + + var act = () => InvokeDefault(stream, capability); + + act.Should().Throw() + .WithParameterName(nameof(stream)); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void CustomMessagesAreUsedForCapabilityFailures(Capability capability) + { + const string message = "The stream has the wrong capability"; + var stream = CreateUnsupportedStream(capability); + + var act = () => InvokeDefault(stream, capability, message: message); + + act.Should().Throw().WithMessage($"{message}*"); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void CustomMessagesAreUsedForNullStreams(Capability capability) + { + const string message = "The stream must be supplied"; + TrackingStream? stream = null; + + var act = () => InvokeDefault(stream, capability, message: message); + + act.Should().Throw().WithMessage($"{message}*"); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void CustomFactoriesReceiveUnsupportedOriginalStream(Capability capability) + { + var stream = CreateUnsupportedStream(capability); + var expectedException = new InvalidOperationException(); + TrackingStream? capturedStream = null; + + Exception ExceptionFactory(TrackingStream? value) + { + capturedStream = value; + return expectedException; + } + + var act = () => InvokeFactory(stream, capability, ExceptionFactory); + + act.Should().Throw().Which.Should().BeSameAs(expectedException); + capturedStream.Should().BeSameAs(stream); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void CustomFactoriesReceiveNullStream(Capability capability) + { + var expectedException = new InvalidOperationException(); + var factoryWasCalled = false; + + Exception ExceptionFactory(TrackingStream? value) + { + value.Should().BeNull(); + factoryWasCalled = true; + return expectedException; + } + + var act = () => InvokeFactory(null, capability, ExceptionFactory); + + act.Should().Throw().Which.Should().BeSameAs(expectedException); + factoryWasCalled.Should().BeTrue(); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void SuccessfulCustomFactoryGuardsDoNotInvokeFactory(Capability capability) + { + var stream = new TrackingStream(true, true, true); + + var result = InvokeFactory(stream, capability, _ => throw new InvalidOperationException()); + + result.Should().BeSameAs(stream); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void DisposedFrameworkStreamsFailAccordingToTheirCapabilityProperties(Capability capability) + { + var stream = new MemoryStream(); + stream.Dispose(); + + var act = () => InvokeDefault(stream, capability); + + act.Should().Throw(); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void ExceptionsFromMatchingCapabilityGetterArePreserved(Capability capability) + { + var expectedException = new NotSupportedException(); + var stream = new TrackingStream(true, true, true) + { + ThrowingCapability = capability, + CapabilityException = expectedException, + }; + + var act = () => InvokeDefault(stream, capability); + + act.Should().Throw().Which.Should().BeSameAs(expectedException); + } + + [Theory] + [InlineData(Capability.Readable)] + [InlineData(Capability.Writable)] + [InlineData(Capability.Seekable)] + public static void SuccessfulDefaultAndFactoryGuardsAllocateNoMemory(Capability capability) + { + var stream = new TrackingStream(true, true, true); + InvokeDefault(stream, capability); + InvokeFactory(stream, capability, AllocationExceptionFactory); + + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 10_000; i++) + { + InvokeDefault(stream, capability); + InvokeFactory(stream, capability, AllocationExceptionFactory); + } + + var allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + + allocatedBytes.Should().Be(0); + } + + private static TrackingStream InvokeDefault( + TrackingStream? stream, + Capability capability, + string? parameterName = null, + string? message = null + ) => parameterName is null ? + capability switch + { + Capability.Readable => stream.MustBeReadable(message: message), + Capability.Writable => stream.MustBeWritable(message: message), + Capability.Seekable => stream.MustBeSeekable(message: message), + _ => throw new ArgumentOutOfRangeException(nameof(capability)), + } : + capability switch + { + Capability.Readable => stream.MustBeReadable(parameterName, message), + Capability.Writable => stream.MustBeWritable(parameterName, message), + Capability.Seekable => stream.MustBeSeekable(parameterName, message), + _ => throw new ArgumentOutOfRangeException(nameof(capability)), + }; + + private static MemoryStream InvokeDefault(MemoryStream stream, Capability capability) => + capability switch + { + Capability.Readable => stream.MustBeReadable(), + Capability.Writable => stream.MustBeWritable(), + Capability.Seekable => stream.MustBeSeekable(), + _ => throw new ArgumentOutOfRangeException(nameof(capability)), + }; + + private static TrackingStream InvokeFactory( + TrackingStream? stream, + Capability capability, + Func exceptionFactory + ) => + capability switch + { + Capability.Readable => stream.MustBeReadable(exceptionFactory), + Capability.Writable => stream.MustBeWritable(exceptionFactory), + Capability.Seekable => stream.MustBeSeekable(exceptionFactory), + _ => throw new ArgumentOutOfRangeException(nameof(capability)), + }; + + private static TrackingStream CreateUnsupportedStream(Capability capability) => + capability switch + { + Capability.Readable => new (false, true, true), + Capability.Writable => new (true, false, true), + Capability.Seekable => new (true, true, false), + _ => throw new ArgumentOutOfRangeException(nameof(capability)), + }; + + private static bool GetExpectedCapability( + Capability capability, + bool canRead, + bool canWrite, + bool canSeek + ) => + capability switch + { + Capability.Readable => canRead, + Capability.Writable => canWrite, + Capability.Seekable => canSeek, + _ => throw new ArgumentOutOfRangeException(nameof(capability)), + }; + + public enum Capability + { + Readable, + Writable, + Seekable, + } + + private sealed class TrackingStream(bool canRead, bool canWrite, bool canSeek) : Stream + { + public Capability? ThrowingCapability { get; init; } + + public Exception? CapabilityException { get; init; } + + public int CanReadReadCount { get; private set; } + + public int CanWriteReadCount { get; private set; } + + public int CanSeekReadCount { get; private set; } + + public int PositionReadCount { get; private set; } + + public int LengthReadCount { get; private set; } + + public int IoOperationCount { get; private set; } + + public long StoredPosition { get; set; } + + public override bool CanRead + { + get + { + CanReadReadCount++; + ThrowIfConfigured(Capability.Readable); + return canRead; + } + } + + public override bool CanWrite + { + get + { + CanWriteReadCount++; + ThrowIfConfigured(Capability.Writable); + return canWrite; + } + } + + public override bool CanSeek + { + get + { + CanSeekReadCount++; + ThrowIfConfigured(Capability.Seekable); + return canSeek; + } + } + + public override long Length + { + get + { + LengthReadCount++; + return 0; + } + } + + public override long Position + { + get + { + PositionReadCount++; + return StoredPosition; + } + set => StoredPosition = value; + } + + public int GetCapabilityReadCount(Capability capability) => + capability switch + { + Capability.Readable => CanReadReadCount, + Capability.Writable => CanWriteReadCount, + Capability.Seekable => CanSeekReadCount, + _ => throw new ArgumentOutOfRangeException(nameof(capability)), + }; + + public int GetOtherCapabilityReadCount(Capability capability) => + capability switch + { + Capability.Readable => CanWriteReadCount + CanSeekReadCount, + Capability.Writable => CanReadReadCount + CanSeekReadCount, + Capability.Seekable => CanReadReadCount + CanWriteReadCount, + _ => throw new ArgumentOutOfRangeException(nameof(capability)), + }; + + public override void Flush() + { + IoOperationCount++; + throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + IoOperationCount++; + throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + IoOperationCount++; + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + IoOperationCount++; + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + IoOperationCount++; + throw new NotSupportedException(); + } + + public override string ToString() => throw new InvalidOperationException("A guard must not format the stream."); + + private void ThrowIfConfigured(Capability capability) + { + if (ThrowingCapability == capability) + { + throw CapabilityException ?? new InvalidOperationException(); + } + } + } +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs index 91d9fab..2588aac 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -153,8 +153,12 @@ public sealed class AssertionWhitelist public AssertionEntry MustBePositive { get; init; } = new(); + public AssertionEntry MustBeReadable { get; init; } = new(); + public AssertionEntry MustBeRelativeUri { get; init; } = new(); + public AssertionEntry MustBeSeekable { get; init; } = new(); + public AssertionEntry MustBeShorterThan { get; init; } = new(); public AssertionEntry MustBeShorterThanOrEqualTo { get; init; } = new(); @@ -177,6 +181,8 @@ public sealed class AssertionWhitelist public AssertionEntry MustBeValidEnumValue { get; init; } = new(); + public AssertionEntry MustBeWritable { get; init; } = new(); + public AssertionEntry MustContain { get; init; } = new(); public AssertionEntry MustContainOnlyDigits { get; init; } = new(); diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs index cc94afd..124885d 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/SourceFileMerger.cs @@ -64,6 +64,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using System.Linq.Expressions; using System.Reflection; diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json index 72ee7e5..1574048 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -90,7 +90,9 @@ "MustBeOfType": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeOneOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBePositive": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeReadable": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeRelativeUri": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeSeekable": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeShorterThan": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeShorterThanOrEqualTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeSubstringOf": { "Include": true, "IncludeExceptionFactoryOverload": true }, @@ -102,6 +104,7 @@ "MustBeUtc": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeUuidVersion7": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeValidEnumValue": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeWritable": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustContain": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustContainOnlyDigits": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustContainOnlyLettersOrDigits": { "Include": true, "IncludeExceptionFactoryOverload": true },