From 1e65e08fc1f2d2051e63918da0b9343476367c9a Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 11 May 2026 22:22:52 +1000 Subject: [PATCH 1/2] Add additional unit tests to improve code coverage. Update licensing version --- .gitattributes | 16 +- ImageSharp.sln | 5 +- shared-infrastructure | 2 +- .../WorkingSpaces/GammaWorkingSpace.cs | 1 + .../WorkingSpaces/RgbWorkingSpace.cs | 6 +- src/ImageSharp/Common/Tuples/Octet{T}.cs | 72 ---- tests/Directory.Build.targets | 5 - .../PixelConversion_Rgba32_To_Bgra32.cs | 11 +- .../Vectorization/WidenBytesToUInt32.cs | 18 +- .../ColorProfiles/RgbWorkingSpaceTests.cs | 122 ++++++ .../Common/SimdUtilsTests.Shuffle.cs | 4 +- .../ImageSharp.Tests/Common/SimdUtilsTests.cs | 4 +- .../Formats/Jpg/Block8x8FTests.cs | 4 +- .../ImageSharp.Tests/Formats/Jpg/DCTTests.cs | 4 +- .../Formats/Jpg/JpegColorConverterTests.cs | 8 +- .../PixelFormats/PixelBlenderTests.cs | 350 ++++++++++++++++++ .../PorterDuffCompositorTests.cs | 2 +- .../FeatureTesting/FeatureTestRunner.cs | 2 +- tests/coverlet.runsettings | 2 +- 19 files changed, 524 insertions(+), 114 deletions(-) delete mode 100644 src/ImageSharp/Common/Tuples/Octet{T}.cs create mode 100644 tests/ImageSharp.Tests/ColorProfiles/RgbWorkingSpaceTests.cs diff --git a/.gitattributes b/.gitattributes index f7bd4d061e..7de2c15977 100644 --- a/.gitattributes +++ b/.gitattributes @@ -85,18 +85,12 @@ ############################################################################### *.basis binary *.dll binary -*.eot binary *.exe binary -*.otf binary *.pdf binary *.ppt binary *.pptx binary *.pvr binary *.snk binary -*.ttc binary -*.ttf binary -*.woff binary -*.woff2 binary *.xls binary *.xlsx binary ############################################################################### @@ -126,6 +120,7 @@ *.dds filter=lfs diff=lfs merge=lfs -text *.ktx filter=lfs diff=lfs merge=lfs -text *.ktx2 filter=lfs diff=lfs merge=lfs -text +*.astc filter=lfs diff=lfs merge=lfs -text *.pam filter=lfs diff=lfs merge=lfs -text *.pbm filter=lfs diff=lfs merge=lfs -text *.pgm filter=lfs diff=lfs merge=lfs -text @@ -143,3 +138,12 @@ # Handle ICC files by git lfs ############################################################################### *.icc filter=lfs diff=lfs merge=lfs -text +############################################################################### +# Handle font files by git lfs +############################################################################### +*.eot filter=lfs diff=lfs merge=lfs -text +*.otf filter=lfs diff=lfs merge=lfs -text +*.ttc filter=lfs diff=lfs merge=lfs -text +*.ttf filter=lfs diff=lfs merge=lfs -text +*.woff filter=lfs diff=lfs merge=lfs -text +*.woff2 filter=lfs diff=lfs merge=lfs -text diff --git a/ImageSharp.sln b/ImageSharp.sln index 13dd2fba7e..7a52040374 100644 --- a/ImageSharp.sln +++ b/ImageSharp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +# Visual Studio Version 18 +VisualStudioVersion = 18.5.11723.231 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_root", "_root", "{C317F1B1-D75E-4C6D-83EB-80367343E0D7}" ProjectSection(SolutionItems) = preProject @@ -45,6 +45,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageSharp", "src\ImageShar EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{56801022-D71A-4FBE-BC5B-CBA08E2284EC}" ProjectSection(SolutionItems) = preProject + tests\coverlet.runsettings = tests\coverlet.runsettings tests\Directory.Build.props = tests\Directory.Build.props tests\Directory.Build.targets = tests\Directory.Build.targets tests\ImageSharp.Tests.ruleset = tests\ImageSharp.Tests.ruleset diff --git a/shared-infrastructure b/shared-infrastructure index a1d3ac2049..7ac5703452 160000 --- a/shared-infrastructure +++ b/shared-infrastructure @@ -1 +1 @@ -Subproject commit a1d3ac20494631e3cc13132897573796b0e4ee6d +Subproject commit 7ac5703452348d9295db31fc0912c2bd9e419dc9 diff --git a/src/ImageSharp/ColorProfiles/WorkingSpaces/GammaWorkingSpace.cs b/src/ImageSharp/ColorProfiles/WorkingSpaces/GammaWorkingSpace.cs index 91fa426241..a3959f12e4 100644 --- a/src/ImageSharp/ColorProfiles/WorkingSpaces/GammaWorkingSpace.cs +++ b/src/ImageSharp/ColorProfiles/WorkingSpaces/GammaWorkingSpace.cs @@ -62,6 +62,7 @@ public sealed class GammaWorkingSpace : RgbWorkingSpace /// public override int GetHashCode() => HashCode.Combine( + typeof(GammaWorkingSpace), this.WhitePoint, this.ChromaticityCoordinates, this.Gamma); diff --git a/src/ImageSharp/ColorProfiles/WorkingSpaces/RgbWorkingSpace.cs b/src/ImageSharp/ColorProfiles/WorkingSpaces/RgbWorkingSpace.cs index 97069b856b..b278dd3177 100644 --- a/src/ImageSharp/ColorProfiles/WorkingSpaces/RgbWorkingSpace.cs +++ b/src/ImageSharp/ColorProfiles/WorkingSpaces/RgbWorkingSpace.cs @@ -70,8 +70,10 @@ public abstract class RgbWorkingSpace return true; } - if (obj is RgbWorkingSpace other) + if (obj.GetType() == this.GetType()) { + RgbWorkingSpace other = (RgbWorkingSpace)obj; + return this.WhitePoint.Equals(other.WhitePoint) && this.ChromaticityCoordinates.Equals(other.ChromaticityCoordinates); } @@ -81,5 +83,5 @@ public abstract class RgbWorkingSpace /// public override int GetHashCode() - => HashCode.Combine(this.WhitePoint, this.ChromaticityCoordinates); + => HashCode.Combine(this.GetType(), this.WhitePoint, this.ChromaticityCoordinates); } diff --git a/src/ImageSharp/Common/Tuples/Octet{T}.cs b/src/ImageSharp/Common/Tuples/Octet{T}.cs deleted file mode 100644 index f73bdd3390..0000000000 --- a/src/ImageSharp/Common/Tuples/Octet{T}.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -namespace SixLabors.ImageSharp.Tuples; - -/// -/// Contains 8 element value tuples of various types. -/// -[StructLayout(LayoutKind.Sequential)] -internal struct Octet - where T : unmanaged -{ - public T V0; - public T V1; - public T V2; - public T V3; - public T V4; - public T V5; - public T V6; - public T V7; - - /// - public override readonly string ToString() - { - return $"Octet<{typeof(T)}>({this.V0},{this.V1},{this.V2},{this.V3},{this.V4},{this.V5},{this.V6},{this.V7})"; - } -} - -/// -/// Extension methods for the type. -/// -internal static class OctetExtensions -{ - /// - /// Loads the fields in a target of from one of type. - /// - /// The target of instance. - /// The source of instance. - [MethodImpl(InliningOptions.ShortMethod)] - public static void LoadFrom(ref this Octet destination, ref Octet source) - { - destination.V0 = source.V0; - destination.V1 = source.V1; - destination.V2 = source.V2; - destination.V3 = source.V3; - destination.V4 = source.V4; - destination.V5 = source.V5; - destination.V6 = source.V6; - destination.V7 = source.V7; - } - - /// - /// Loads the fields in a target of from one of type. - /// - /// The target of instance. - /// The source of instance. - [MethodImpl(InliningOptions.ShortMethod)] - public static void LoadFrom(ref this Octet destination, ref Octet source) - { - destination.V0 = (byte)source.V0; - destination.V1 = (byte)source.V1; - destination.V2 = (byte)source.V2; - destination.V3 = (byte)source.V3; - destination.V4 = (byte)source.V4; - destination.V5 = (byte)source.V5; - destination.V6 = (byte)source.V6; - destination.V7 = (byte)source.V7; - } -} diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets index 6b25509ed8..71f2bc333c 100644 --- a/tests/Directory.Build.targets +++ b/tests/Directory.Build.targets @@ -19,11 +19,6 @@ - - diff --git a/tests/ImageSharp.Benchmarks/General/PixelConversion/PixelConversion_Rgba32_To_Bgra32.cs b/tests/ImageSharp.Benchmarks/General/PixelConversion/PixelConversion_Rgba32_To_Bgra32.cs index 232d8b3e27..c69510bc4d 100644 --- a/tests/ImageSharp.Benchmarks/General/PixelConversion/PixelConversion_Rgba32_To_Bgra32.cs +++ b/tests/ImageSharp.Benchmarks/General/PixelConversion/PixelConversion_Rgba32_To_Bgra32.cs @@ -7,7 +7,6 @@ using System.Runtime.InteropServices; using BenchmarkDotNet.Attributes; using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Tuples; namespace SixLabors.ImageSharp.Benchmarks.General.PixelConversion; @@ -225,8 +224,8 @@ public class PixelConversion_Rgba32_To_Bgra32 // [Benchmark] public void Bitops_Simd() { - ref Octet sBase = ref Unsafe.As>(ref this.source[0]); - ref Octet dBase = ref Unsafe.As>(ref this.dest[0]); + ref InlineArray8 sBase = ref Unsafe.As>(ref this.source[0]); + ref InlineArray8 dBase = ref Unsafe.As>(ref this.dest[0]); for (nuint i = 0; i < (uint)this.Count / 8; i++) { @@ -249,9 +248,9 @@ public class PixelConversion_Rgba32_To_Bgra32 #pragma warning restore SA1132 // Do not combine fields [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void BitopsSimdImpl(ref Octet s, ref Octet d) + private static void BitopsSimdImpl(ref InlineArray8 s, ref InlineArray8 d) { - Vector sVec = Unsafe.As, Vector>(ref s); + Vector sVec = Unsafe.As, Vector>(ref s); Vector aMask = new(0xFF00FF00); Vector bMask = new(0x00FF00FF); @@ -274,7 +273,7 @@ public class PixelConversion_Rgba32_To_Bgra32 Vector cc = Unsafe.As>(ref c); Vector dd = aa + cc; - d = Unsafe.As, Octet>(ref dd); + d = Unsafe.As, InlineArray8>(ref dd); } // [Benchmark] diff --git a/tests/ImageSharp.Benchmarks/General/Vectorization/WidenBytesToUInt32.cs b/tests/ImageSharp.Benchmarks/General/Vectorization/WidenBytesToUInt32.cs index da7ddae41e..fd17262dec 100644 --- a/tests/ImageSharp.Benchmarks/General/Vectorization/WidenBytesToUInt32.cs +++ b/tests/ImageSharp.Benchmarks/General/Vectorization/WidenBytesToUInt32.cs @@ -5,8 +5,6 @@ using System.Numerics; using System.Runtime.CompilerServices; using BenchmarkDotNet.Attributes; -using SixLabors.ImageSharp.Tuples; - namespace SixLabors.ImageSharp.Benchmarks.General.Vectorization; [Config(typeof(Config.Short))] @@ -30,12 +28,22 @@ public class WidenBytesToUInt32 { const int N = Count / 8; - ref Octet sBase = ref Unsafe.As>(ref this.source[0]); - ref Octet dBase = ref Unsafe.As>(ref this.dest[0]); + ref InlineArray8 sBase = ref Unsafe.As>(ref this.source[0]); + ref InlineArray8 dBase = ref Unsafe.As>(ref this.dest[0]); for (nuint i = 0; i < N; i++) { - Unsafe.Add(ref dBase, i).LoadFrom(ref Unsafe.Add(ref sBase, i)); + ref InlineArray8 source = ref Unsafe.Add(ref sBase, i); + ref InlineArray8 destination = ref Unsafe.Add(ref dBase, i); + + destination[0] = source[0]; + destination[1] = source[1]; + destination[2] = source[2]; + destination[3] = source[3]; + destination[4] = source[4]; + destination[5] = source[5]; + destination[6] = source[6]; + destination[7] = source[7]; } } diff --git a/tests/ImageSharp.Tests/ColorProfiles/RgbWorkingSpaceTests.cs b/tests/ImageSharp.Tests/ColorProfiles/RgbWorkingSpaceTests.cs new file mode 100644 index 0000000000..8a38ea11fc --- /dev/null +++ b/tests/ImageSharp.Tests/ColorProfiles/RgbWorkingSpaceTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.ImageSharp.ColorProfiles; +using SixLabors.ImageSharp.ColorProfiles.WorkingSpaces; + +namespace SixLabors.ImageSharp.Tests.ColorProfiles; + +/// +/// Tests the class. +/// +[Trait("Color", "Conversion")] +public class RgbWorkingSpaceTests +{ + private static readonly ApproximateFloatComparer TolerantComparer = new(1e-5F); + + public static readonly TheoryData WorkingSpaces = new() + { + KnownRgbWorkingSpaces.SRgb, + KnownRgbWorkingSpaces.SRgbSimplified, + KnownRgbWorkingSpaces.Rec709, + KnownRgbWorkingSpaces.Rec2020, + KnownRgbWorkingSpaces.ECIRgbv2, + KnownRgbWorkingSpaces.AdobeRgb1998, + KnownRgbWorkingSpaces.ApplesRgb, + KnownRgbWorkingSpaces.BestRgb, + KnownRgbWorkingSpaces.BetaRgb, + KnownRgbWorkingSpaces.BruceRgb, + KnownRgbWorkingSpaces.CIERgb, + KnownRgbWorkingSpaces.ColorMatchRgb, + KnownRgbWorkingSpaces.DonRgb4, + KnownRgbWorkingSpaces.EktaSpacePS5, + KnownRgbWorkingSpaces.NTSCRgb, + KnownRgbWorkingSpaces.PALSECAMRgb, + KnownRgbWorkingSpaces.ProPhotoRgb, + KnownRgbWorkingSpaces.SMPTECRgb, + KnownRgbWorkingSpaces.WideGamutRgb + }; + + [Fact] + public void RgbWorkingSpaceEqualityRequiresSameConcreteType() + { + RgbWorkingSpace sRgb = KnownRgbWorkingSpaces.SRgb; + RgbWorkingSpace sRgbSimplified = KnownRgbWorkingSpaces.SRgbSimplified; + RgbWorkingSpace rec709 = KnownRgbWorkingSpaces.Rec709; + + Assert.False(sRgb.Equals(sRgbSimplified)); + Assert.False(sRgbSimplified.Equals(sRgb)); + Assert.False(sRgb.Equals(rec709)); + Assert.False(rec709.Equals(sRgb)); + + Assert.NotEqual(sRgb.GetHashCode(), sRgbSimplified.GetHashCode()); + Assert.NotEqual(sRgb.GetHashCode(), rec709.GetHashCode()); + } + + [Fact] + public void RgbWorkingSpaceEqualityMatchesSameConcreteTypeValues() + { + RgbWorkingSpace x = new SRgbWorkingSpace( + KnownRgbWorkingSpaces.SRgb.WhitePoint, + KnownRgbWorkingSpaces.SRgb.ChromaticityCoordinates); + + RgbWorkingSpace y = new SRgbWorkingSpace( + KnownRgbWorkingSpaces.SRgb.WhitePoint, + KnownRgbWorkingSpaces.SRgb.ChromaticityCoordinates); + + Assert.Equal(x, y); + Assert.Equal(x.GetHashCode(), y.GetHashCode()); + } + + [Fact] + public void GammaWorkingSpaceEqualityIncludesGamma() + { + GammaWorkingSpace x = new( + 2.2F, + KnownRgbWorkingSpaces.SRgbSimplified.WhitePoint, + KnownRgbWorkingSpaces.SRgbSimplified.ChromaticityCoordinates); + + GammaWorkingSpace y = new( + 1.8F, + KnownRgbWorkingSpaces.SRgbSimplified.WhitePoint, + KnownRgbWorkingSpaces.SRgbSimplified.ChromaticityCoordinates); + + Assert.NotEqual(x, y); + Assert.NotEqual(x.GetHashCode(), y.GetHashCode()); + } + + [Theory] + [MemberData(nameof(WorkingSpaces))] + public void CompressAndExpand_RoundTripsWithTolerance(RgbWorkingSpace workingSpace) + { + Vector4[] linear = + [ + new(0F, .001F, .18F, 1F), // Endpoint, below the sRGB breakpoint, common middle gray, and opaque alpha. + new(.0031308F, .25F, .5F, .75F), // sRGB linear/gamma breakpoint with interior values and partial alpha. + new(.75F, .5F, .25F, .5F), // Reversed interior values ensure channel order does not hide errors. + new(1F, .8F, .2F, .25F) // Upper endpoint, high/low interior values, and low alpha. + ]; + + Vector4[] expectedCompressed = new Vector4[linear.Length]; + + for (int i = 0; i < linear.Length; i++) + { + Vector4 compressed = workingSpace.Compress(linear[i]); + Vector4 expanded = workingSpace.Expand(compressed); + + expectedCompressed[i] = compressed; + + Assert.Equal(linear[i], expanded, TolerantComparer); + } + + Vector4[] actualCompressed = linear.ToArray(); + workingSpace.Compress(actualCompressed); + + Assert.Equal(expectedCompressed, actualCompressed, TolerantComparer); + + workingSpace.Expand(actualCompressed); + + Assert.Equal(linear, actualCompressed, TolerantComparer); + } +} diff --git a/tests/ImageSharp.Tests/Common/SimdUtilsTests.Shuffle.cs b/tests/ImageSharp.Tests/Common/SimdUtilsTests.Shuffle.cs index 81daac3e0b..12b4c34262 100644 --- a/tests/ImageSharp.Tests/Common/SimdUtilsTests.Shuffle.cs +++ b/tests/ImageSharp.Tests/Common/SimdUtilsTests.Shuffle.cs @@ -292,7 +292,7 @@ public partial class SimdUtilsTests FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, count, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); } [Theory] @@ -478,7 +478,7 @@ public partial class SimdUtilsTests FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, count, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); } private static void TestShuffleFloat4Channel( diff --git a/tests/ImageSharp.Tests/Common/SimdUtilsTests.cs b/tests/ImageSharp.Tests/Common/SimdUtilsTests.cs index e39f9456f0..dfd0a6b307 100644 --- a/tests/ImageSharp.Tests/Common/SimdUtilsTests.cs +++ b/tests/ImageSharp.Tests/Common/SimdUtilsTests.cs @@ -133,7 +133,7 @@ public partial class SimdUtilsTests FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, count, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512 | HwIntrinsics.DisableAVX2); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX2); } [Theory] @@ -171,7 +171,7 @@ public partial class SimdUtilsTests FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, count, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512 | HwIntrinsics.DisableAVX2); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX2); } [Theory] diff --git a/tests/ImageSharp.Tests/Formats/Jpg/Block8x8FTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/Block8x8FTests.cs index 544ac85a51..610693c7f4 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/Block8x8FTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/Block8x8FTests.cs @@ -219,7 +219,7 @@ public partial class Block8x8FTests : JpegFixture RunTest, srcSeed, qtSeed, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); } [Fact] @@ -391,7 +391,7 @@ public partial class Block8x8FTests : JpegFixture // 3. DisableAvx2 - call fallback code of float implementation FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); } [Theory] diff --git a/tests/ImageSharp.Tests/Formats/Jpg/DCTTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/DCTTests.cs index 83c3a344da..b17a39f388 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/DCTTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/DCTTests.cs @@ -152,7 +152,7 @@ public static class DCTTests FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, seed, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); } [Theory] @@ -359,7 +359,7 @@ public static class DCTTests FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, seed, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); } } } diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegColorConverterTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegColorConverterTests.cs index 8d94fb5cf4..ba0dce4c54 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegColorConverterTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegColorConverterTests.cs @@ -69,7 +69,7 @@ public class JpegColorConverterTests { FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512 | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); static void RunTest(string arg) { @@ -102,7 +102,7 @@ public class JpegColorConverterTests { FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512 | HwIntrinsics.DisableAVX2 | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX2 | HwIntrinsics.DisableHWIntrinsic); static void RunTest(string arg) { @@ -168,7 +168,7 @@ public class JpegColorConverterTests { FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512 | HwIntrinsics.DisableAVX2 | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX2 | HwIntrinsics.DisableHWIntrinsic); static void RunTest(string arg) { @@ -201,7 +201,7 @@ public class JpegColorConverterTests { FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512 | HwIntrinsics.DisableAVX2 | HwIntrinsics.DisableHWIntrinsic); + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX2 | HwIntrinsics.DisableHWIntrinsic); static void RunTest(string arg) { diff --git a/tests/ImageSharp.Tests/PixelFormats/PixelBlenderTests.cs b/tests/ImageSharp.Tests/PixelFormats/PixelBlenderTests.cs index 924e94d929..ddd79bb756 100644 --- a/tests/ImageSharp.Tests/PixelFormats/PixelBlenderTests.cs +++ b/tests/ImageSharp.Tests/PixelFormats/PixelBlenderTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Numerics; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats.PixelBlenders; using SixLabors.ImageSharp.Tests.TestUtilities; @@ -32,6 +33,172 @@ public class PixelBlenderTests { new TestPixel(), typeof(DefaultPixelBlenders.MultiplySrcOver), PixelColorBlendingMode.Multiply }, }; + public static TheoryData BlenderModeMappings + { + get + { + TheoryData data = new(); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.Clear, + typeof(DefaultPixelBlenders.NormalClear), + typeof(DefaultPixelBlenders.MultiplyClear), + typeof(DefaultPixelBlenders.AddClear), + typeof(DefaultPixelBlenders.SubtractClear), + typeof(DefaultPixelBlenders.ScreenClear), + typeof(DefaultPixelBlenders.DarkenClear), + typeof(DefaultPixelBlenders.LightenClear), + typeof(DefaultPixelBlenders.OverlayClear), + typeof(DefaultPixelBlenders.HardLightClear)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.Xor, + typeof(DefaultPixelBlenders.NormalXor), + typeof(DefaultPixelBlenders.MultiplyXor), + typeof(DefaultPixelBlenders.AddXor), + typeof(DefaultPixelBlenders.SubtractXor), + typeof(DefaultPixelBlenders.ScreenXor), + typeof(DefaultPixelBlenders.DarkenXor), + typeof(DefaultPixelBlenders.LightenXor), + typeof(DefaultPixelBlenders.OverlayXor), + typeof(DefaultPixelBlenders.HardLightXor)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.Src, + typeof(DefaultPixelBlenders.NormalSrc), + typeof(DefaultPixelBlenders.MultiplySrc), + typeof(DefaultPixelBlenders.AddSrc), + typeof(DefaultPixelBlenders.SubtractSrc), + typeof(DefaultPixelBlenders.ScreenSrc), + typeof(DefaultPixelBlenders.DarkenSrc), + typeof(DefaultPixelBlenders.LightenSrc), + typeof(DefaultPixelBlenders.OverlaySrc), + typeof(DefaultPixelBlenders.HardLightSrc)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.SrcAtop, + typeof(DefaultPixelBlenders.NormalSrcAtop), + typeof(DefaultPixelBlenders.MultiplySrcAtop), + typeof(DefaultPixelBlenders.AddSrcAtop), + typeof(DefaultPixelBlenders.SubtractSrcAtop), + typeof(DefaultPixelBlenders.ScreenSrcAtop), + typeof(DefaultPixelBlenders.DarkenSrcAtop), + typeof(DefaultPixelBlenders.LightenSrcAtop), + typeof(DefaultPixelBlenders.OverlaySrcAtop), + typeof(DefaultPixelBlenders.HardLightSrcAtop)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.SrcIn, + typeof(DefaultPixelBlenders.NormalSrcIn), + typeof(DefaultPixelBlenders.MultiplySrcIn), + typeof(DefaultPixelBlenders.AddSrcIn), + typeof(DefaultPixelBlenders.SubtractSrcIn), + typeof(DefaultPixelBlenders.ScreenSrcIn), + typeof(DefaultPixelBlenders.DarkenSrcIn), + typeof(DefaultPixelBlenders.LightenSrcIn), + typeof(DefaultPixelBlenders.OverlaySrcIn), + typeof(DefaultPixelBlenders.HardLightSrcIn)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.SrcOut, + typeof(DefaultPixelBlenders.NormalSrcOut), + typeof(DefaultPixelBlenders.MultiplySrcOut), + typeof(DefaultPixelBlenders.AddSrcOut), + typeof(DefaultPixelBlenders.SubtractSrcOut), + typeof(DefaultPixelBlenders.ScreenSrcOut), + typeof(DefaultPixelBlenders.DarkenSrcOut), + typeof(DefaultPixelBlenders.LightenSrcOut), + typeof(DefaultPixelBlenders.OverlaySrcOut), + typeof(DefaultPixelBlenders.HardLightSrcOut)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.Dest, + typeof(DefaultPixelBlenders.NormalDest), + typeof(DefaultPixelBlenders.MultiplyDest), + typeof(DefaultPixelBlenders.AddDest), + typeof(DefaultPixelBlenders.SubtractDest), + typeof(DefaultPixelBlenders.ScreenDest), + typeof(DefaultPixelBlenders.DarkenDest), + typeof(DefaultPixelBlenders.LightenDest), + typeof(DefaultPixelBlenders.OverlayDest), + typeof(DefaultPixelBlenders.HardLightDest)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.DestAtop, + typeof(DefaultPixelBlenders.NormalDestAtop), + typeof(DefaultPixelBlenders.MultiplyDestAtop), + typeof(DefaultPixelBlenders.AddDestAtop), + typeof(DefaultPixelBlenders.SubtractDestAtop), + typeof(DefaultPixelBlenders.ScreenDestAtop), + typeof(DefaultPixelBlenders.DarkenDestAtop), + typeof(DefaultPixelBlenders.LightenDestAtop), + typeof(DefaultPixelBlenders.OverlayDestAtop), + typeof(DefaultPixelBlenders.HardLightDestAtop)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.DestIn, + typeof(DefaultPixelBlenders.NormalDestIn), + typeof(DefaultPixelBlenders.MultiplyDestIn), + typeof(DefaultPixelBlenders.AddDestIn), + typeof(DefaultPixelBlenders.SubtractDestIn), + typeof(DefaultPixelBlenders.ScreenDestIn), + typeof(DefaultPixelBlenders.DarkenDestIn), + typeof(DefaultPixelBlenders.LightenDestIn), + typeof(DefaultPixelBlenders.OverlayDestIn), + typeof(DefaultPixelBlenders.HardLightDestIn)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.DestOut, + typeof(DefaultPixelBlenders.NormalDestOut), + typeof(DefaultPixelBlenders.MultiplyDestOut), + typeof(DefaultPixelBlenders.AddDestOut), + typeof(DefaultPixelBlenders.SubtractDestOut), + typeof(DefaultPixelBlenders.ScreenDestOut), + typeof(DefaultPixelBlenders.DarkenDestOut), + typeof(DefaultPixelBlenders.LightenDestOut), + typeof(DefaultPixelBlenders.OverlayDestOut), + typeof(DefaultPixelBlenders.HardLightDestOut)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.DestOver, + typeof(DefaultPixelBlenders.NormalDestOver), + typeof(DefaultPixelBlenders.MultiplyDestOver), + typeof(DefaultPixelBlenders.AddDestOver), + typeof(DefaultPixelBlenders.SubtractDestOver), + typeof(DefaultPixelBlenders.ScreenDestOver), + typeof(DefaultPixelBlenders.DarkenDestOver), + typeof(DefaultPixelBlenders.LightenDestOver), + typeof(DefaultPixelBlenders.OverlayDestOver), + typeof(DefaultPixelBlenders.HardLightDestOver)); + + AddBlenderModeMappings( + data, + PixelAlphaCompositionMode.SrcOver, + typeof(DefaultPixelBlenders.NormalSrcOver), + typeof(DefaultPixelBlenders.MultiplySrcOver), + typeof(DefaultPixelBlenders.AddSrcOver), + typeof(DefaultPixelBlenders.SubtractSrcOver), + typeof(DefaultPixelBlenders.ScreenSrcOver), + typeof(DefaultPixelBlenders.DarkenSrcOver), + typeof(DefaultPixelBlenders.LightenSrcOver), + typeof(DefaultPixelBlenders.OverlaySrcOver), + typeof(DefaultPixelBlenders.HardLightSrcOver)); + + return data; + } + } + [Theory] [MemberData(nameof(BlenderMappings))] public void ReturnsCorrectBlender(TestPixel pixel, Type type, PixelColorBlendingMode mode) @@ -41,6 +208,126 @@ public class PixelBlenderTests Assert.IsType(type, blender); } + [Theory] + [MemberData(nameof(BlenderModeMappings))] + public void ReturnsCorrectBlenderForAllModeCombinations(PixelColorBlendingMode colorMode, PixelAlphaCompositionMode alphaMode, Type type) + { + PixelBlender blender = PixelOperations.Instance.GetPixelBlender(colorMode, alphaMode); + Assert.IsType(type, blender); + } + + [Fact] + public void BlendFunctionsAreCalledForAllModeCombinations() => + FeatureTestRunner.RunWithHwIntrinsicsFeature( + ExerciseAllBlenderModeCombinations, + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + + [Fact] + public void Blend_WithConstantSourceAndSingleAmount() + { + PixelBlender blender = new DefaultPixelBlenders.NormalSrcOver(); + Rgba32[] destination = new Rgba32[2]; + Rgba32[] background = + [ + Color.Red.ToPixel(), + Color.Green.ToPixel() + ]; + + Rgba32 source = Color.Blue.ToPixel(); + + blender.Blend(Configuration.Default, destination, background, source, 1F); + + Assert.Equal(source, destination[0]); + Assert.Equal(source, destination[1]); + } + + [Fact] + public void Blend_WithConstantSourceSingleAmountAndWorkingBuffer() + { + PixelBlender blender = new DefaultPixelBlenders.NormalSrcOver(); + Rgba32[] destination = new Rgba32[2]; + Rgba32[] background = + [ + Color.Red.ToPixel(), + Color.Green.ToPixel() + ]; + + Rgba32 source = Color.Blue.ToPixel(); + Vector4[] workingBuffer = new Vector4[destination.Length * 2]; + + blender.Blend(Configuration.Default, destination, background, source, 1F, workingBuffer); + + Assert.Equal(source, destination[0]); + Assert.Equal(source, destination[1]); + } + + [Fact] + public void Blend_WithConstantSourceAndAmountSpan() + { + PixelBlender blender = new DefaultPixelBlenders.NormalSrcOver(); + Rgba32[] destination = new Rgba32[2]; + Rgba32[] background = + [ + Color.Red.ToPixel(), + Color.Green.ToPixel() + ]; + + Rgba32 source = Color.Blue.ToPixel(); + float[] amount = [1F, 1F]; + + blender.Blend(Configuration.Default, destination, background, source, amount); + + Assert.Equal(source, destination[0]); + Assert.Equal(source, destination[1]); + } + + [Fact] + public void Blend_WithConstantSourceAmountSpanAndWorkingBuffer() + { + PixelBlender blender = new DefaultPixelBlenders.NormalSrcOver(); + Rgba32[] destination = new Rgba32[2]; + Rgba32[] background = + [ + Color.Red.ToPixel(), + Color.Green.ToPixel() + ]; + + Rgba32 source = Color.Blue.ToPixel(); + float[] amount = [1F, 1F]; + Vector4[] workingBuffer = new Vector4[destination.Length * 2]; + + blender.Blend(Configuration.Default, destination, background, source, amount, workingBuffer); + + Assert.Equal(source, destination[0]); + Assert.Equal(source, destination[1]); + } + + [Fact] + public void Blend_WithSourceSpanAmountSpanAndWorkingBuffer() + { + PixelBlender blender = new DefaultPixelBlenders.NormalSrcOver(); + Rgba32[] destination = new Rgba32[2]; + Rgba32[] background = + [ + Color.Red.ToPixel(), + Color.Green.ToPixel() + ]; + + Rgba32[] source = + [ + Color.Blue.ToPixel(), + Color.Yellow.ToPixel() + ]; + + float[] amount = [1F, 1F]; + Vector4[] workingBuffer = new Vector4[destination.Length * 3]; + + blender.Blend(Configuration.Default, destination, background, source, amount, workingBuffer); + + Assert.Equal(source[0], destination[0]); + Assert.Equal(source[1], destination[1]); + } + public static TheoryData ColorBlendingExpectedResults = new() { { Color.MistyRose.ToPixel(), Color.MidnightBlue.ToPixel(), 1, PixelColorBlendingMode.Normal, Color.MidnightBlue.ToPixel() }, @@ -92,4 +379,67 @@ public class PixelBlenderTests // var str = actualResult.Rgba.ToString("X8"); // used to extract expectedResults Assert.Equal(actualResult.ToVector4(), expectedResult.ToVector4()); } + + private static void AddBlenderModeMappings( + TheoryData data, + PixelAlphaCompositionMode alphaMode, + Type normal, + Type multiply, + Type add, + Type subtract, + Type screen, + Type darken, + Type lighten, + Type overlay, + Type hardLight) + { + data.Add(PixelColorBlendingMode.Normal, alphaMode, normal); + data.Add(PixelColorBlendingMode.Multiply, alphaMode, multiply); + data.Add(PixelColorBlendingMode.Add, alphaMode, add); + data.Add(PixelColorBlendingMode.Subtract, alphaMode, subtract); + data.Add(PixelColorBlendingMode.Screen, alphaMode, screen); + data.Add(PixelColorBlendingMode.Darken, alphaMode, darken); + data.Add(PixelColorBlendingMode.Lighten, alphaMode, lighten); + data.Add(PixelColorBlendingMode.Overlay, alphaMode, overlay); + data.Add(PixelColorBlendingMode.HardLight, alphaMode, hardLight); + } + + private static void ExerciseAllBlenderModeCombinations() + { + foreach (PixelAlphaCompositionMode alphaMode in Enum.GetValues()) + { + foreach (PixelColorBlendingMode colorMode in Enum.GetValues()) + { + PixelBlender blender = PixelOperations.Instance.GetPixelBlender(colorMode, alphaMode); + ExerciseBlender(blender); + } + } + } + + private static void ExerciseBlender(PixelBlender blender) + { + Rgba32 background = Color.MistyRose.ToPixel(); + Rgba32 source = Color.MidnightBlue.ToPixel(); + float[] amount = [1F, 1F, 1F, 1F]; + + Rgba32 expected = blender.Blend(background, source, 1F); + + Rgba32[] destination = new Rgba32[4]; + Rgba32[] backgroundSpan = [background, background, background, background]; + Rgba32[] sourceSpan = [source, source, source, source]; + Vector4[] sourceSpanBuffer = new Vector4[destination.Length * 3]; + Vector4[] constantSourceBuffer = new Vector4[destination.Length * 2]; + + blender.Blend(Configuration.Default, destination, backgroundSpan, sourceSpan, 1F, sourceSpanBuffer); + Assert.All(destination, x => Assert.Equal(expected, x)); + + blender.Blend(Configuration.Default, destination, backgroundSpan, source, 1F, constantSourceBuffer); + Assert.All(destination, x => Assert.Equal(expected, x)); + + blender.Blend(Configuration.Default, destination, backgroundSpan, sourceSpan, amount, sourceSpanBuffer); + Assert.All(destination, x => Assert.Equal(expected, x)); + + blender.Blend(Configuration.Default, destination, backgroundSpan, source, amount, constantSourceBuffer); + Assert.All(destination, x => Assert.Equal(expected, x)); + } } diff --git a/tests/ImageSharp.Tests/PixelFormats/PixelBlenders/PorterDuffCompositorTests.cs b/tests/ImageSharp.Tests/PixelFormats/PixelBlenders/PorterDuffCompositorTests.cs index 994b7d02ee..f8c296846b 100644 --- a/tests/ImageSharp.Tests/PixelFormats/PixelBlenders/PorterDuffCompositorTests.cs +++ b/tests/ImageSharp.Tests/PixelFormats/PixelBlenders/PorterDuffCompositorTests.cs @@ -59,7 +59,7 @@ public class PorterDuffCompositorTests FeatureTestRunner.RunWithHwIntrinsicsFeature( RunTest, - HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512 | HwIntrinsics.DisableAVX, + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX, provider, mode.ToString()); } diff --git a/tests/ImageSharp.Tests/TestUtilities/FeatureTesting/FeatureTestRunner.cs b/tests/ImageSharp.Tests/TestUtilities/FeatureTesting/FeatureTestRunner.cs index be3e9ccd5d..4cd30fc571 100644 --- a/tests/ImageSharp.Tests/TestUtilities/FeatureTesting/FeatureTestRunner.cs +++ b/tests/ImageSharp.Tests/TestUtilities/FeatureTesting/FeatureTestRunner.cs @@ -439,7 +439,7 @@ public enum HwIntrinsics : long DisableSSE42 = 1L << 1, DisableAVX = 1L << 2, DisableAVX2 = 1L << 3, - DisableAVX512 = 1L << 4, + DisableAVX512F = 1L << 4, DisableAVX512v2 = 1L << 5, DisableAVX512v3 = 1L << 6, DisableAVX10v1 = 1L << 7, diff --git a/tests/coverlet.runsettings b/tests/coverlet.runsettings index cffce3540b..f327b47a2e 100644 --- a/tests/coverlet.runsettings +++ b/tests/coverlet.runsettings @@ -9,7 +9,7 @@ lcov - [SixLabors.*]* + [SixLabors.ImageSharp*]* true From c795d8140444b410659b58eed9f960cf1660a08f Mon Sep 17 00:00:00 2001 From: Sven Claesson Date: Tue, 12 May 2026 09:30:50 +0200 Subject: [PATCH 2/2] Fix integer overflow and bounds-checking vulnerabilities in EXR decoder (#3126) * Fix integer overflow and bounds-checking vulnerabilities in EXR decoder Use ulong arithmetic in CalculateBytesPerRow and block size calculations to prevent integer overflow. Add validation for DataWindow dimensions, block size limits, and row offsets outside stream bounds. * Harden EXR row offset validation * EXR: validate sizes, prevent overflows, dispose image --------- Co-authored-by: James Jackson-South --- src/ImageSharp/Formats/Exr/ExrDecoderCore.cs | 117 ++++++-- src/ImageSharp/Formats/Exr/ExrEncoderCore.cs | 20 +- src/ImageSharp/Formats/Exr/ExrUtils.cs | 8 +- .../Formats/Exr/ExrDecoderSecurityTests.cs | 274 ++++++++++++++++++ 4 files changed, 384 insertions(+), 35 deletions(-) create mode 100644 tests/ImageSharp.Tests/Formats/Exr/ExrDecoderSecurityTests.cs diff --git a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs index 2e60f3a5b9..c24352d576 100644 --- a/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrDecoderCore.cs @@ -91,6 +91,11 @@ internal sealed class ExrDecoderCore : ImageDecoderCore /// private ExrHeaderAttributes HeaderAttributes { get; set; } + /// + /// Gets or sets the earliest valid stream position for a scanline chunk. + /// + private long MinimumChunkOffset { get; set; } + /// protected override Image Decode(BufferedReadStream stream, CancellationToken cancellationToken) { @@ -100,24 +105,33 @@ internal sealed class ExrDecoderCore : ImageDecoderCore ExrThrowHelper.ThrowNotSupported($"Compression {this.Compression} is not yet supported"); } - Image image = new(this.configuration, this.Width, this.Height, this.metadata); - Buffer2D pixels = image.GetRootFramePixelBuffer(); + Image image = null; + try + { + image = new Image(this.configuration, this.Width, this.Height, this.metadata); + Buffer2D pixels = image.GetRootFramePixelBuffer(); + + switch (this.PixelType) + { + case ExrPixelType.Half: + case ExrPixelType.Float: + this.DecodeFloatingPointPixelData(stream, pixels, cancellationToken); + break; + case ExrPixelType.UnsignedInt: + this.DecodeUnsignedIntPixelData(stream, pixels, cancellationToken); + break; + default: + ExrThrowHelper.ThrowNotSupported("Pixel type is not supported"); + break; + } - switch (this.PixelType) + return image; + } + catch { - case ExrPixelType.Half: - case ExrPixelType.Float: - this.DecodeFloatingPointPixelData(stream, pixels, cancellationToken); - break; - case ExrPixelType.UnsignedInt: - this.DecodeUnsignedIntPixelData(stream, pixels, cancellationToken); - break; - default: - ExrThrowHelper.ThrowNotSupported("Pixel type is not supported"); - break; + image?.Dispose(); + throw; } - - return image; } /// @@ -139,9 +153,14 @@ internal sealed class ExrDecoderCore : ImageDecoderCore where TPixel : unmanaged, IPixel { bool hasAlpha = this.HasAlpha(); - uint bytesPerRow = ExrUtils.CalculateBytesPerRow(this.Channels, (uint)this.Width); + ulong bytesPerRow = ExrUtils.CalculateBytesPerRow(this.Channels, (uint)this.Width); uint rowsPerBlock = ExrUtils.RowsPerBlock(this.Compression); - uint bytesPerBlock = bytesPerRow * rowsPerBlock; + ulong bytesPerBlock = bytesPerRow * rowsPerBlock; + if (bytesPerBlock > int.MaxValue) + { + ExrThrowHelper.ThrowInvalidImageContentException("EXR block size exceeds the maximum allowed size."); + } + int width = this.Width; int height = this.Height; int channelCount = this.Channels.Count; @@ -158,8 +177,8 @@ internal sealed class ExrDecoderCore : ImageDecoderCore this.Compression, this.memoryAllocator, width, - bytesPerBlock, - bytesPerRow, + (uint)bytesPerBlock, + (uint)bytesPerRow, rowsPerBlock, channelCount, this.PixelType); @@ -170,6 +189,7 @@ internal sealed class ExrDecoderCore : ImageDecoderCore ulong rowOffset = this.ReadUnsignedLong(stream); long nextRowOffsetPosition = stream.Position; + this.ValidateChunkOffset(rowOffset, stream); stream.Position = (long)rowOffset; uint rowStartIndex = this.ReadUnsignedInteger(stream); @@ -212,9 +232,14 @@ internal sealed class ExrDecoderCore : ImageDecoderCore where TPixel : unmanaged, IPixel { bool hasAlpha = this.HasAlpha(); - uint bytesPerRow = ExrUtils.CalculateBytesPerRow(this.Channels, (uint)this.Width); + ulong bytesPerRow = ExrUtils.CalculateBytesPerRow(this.Channels, (uint)this.Width); uint rowsPerBlock = ExrUtils.RowsPerBlock(this.Compression); - uint bytesPerBlock = bytesPerRow * rowsPerBlock; + ulong bytesPerBlock = bytesPerRow * rowsPerBlock; + if (bytesPerBlock > int.MaxValue) + { + ExrThrowHelper.ThrowInvalidImageContentException("EXR block size exceeds the maximum allowed size."); + } + int width = this.Width; int height = this.Height; int channelCount = this.Channels.Count; @@ -231,8 +256,8 @@ internal sealed class ExrDecoderCore : ImageDecoderCore this.Compression, this.memoryAllocator, width, - bytesPerBlock, - bytesPerRow, + (uint)bytesPerBlock, + (uint)bytesPerRow, rowsPerBlock, channelCount, this.PixelType); @@ -243,6 +268,7 @@ internal sealed class ExrDecoderCore : ImageDecoderCore ulong rowOffset = this.ReadUnsignedLong(stream); long nextRowOffsetPosition = stream.Position; + this.ValidateChunkOffset(rowOffset, stream); stream.Position = (long)rowOffset; uint rowStartIndex = this.ReadUnsignedInteger(stream); @@ -597,10 +623,38 @@ internal sealed class ExrDecoderCore : ImageDecoderCore this.HeaderAttributes = this.ParseHeaderAttributes(stream); - this.Width = this.HeaderAttributes.DataWindow.XMax - this.HeaderAttributes.DataWindow.XMin + 1; - this.Height = this.HeaderAttributes.DataWindow.YMax - this.HeaderAttributes.DataWindow.YMin + 1; + ExrBox2i dataWindow = this.HeaderAttributes.DataWindow; + if (dataWindow.XMax < dataWindow.XMin || dataWindow.YMax < dataWindow.YMin) + { + ExrThrowHelper.ThrowInvalidImageContentException("EXR DataWindow max values must be greater than or equal to min values."); + } + + long width = (long)dataWindow.XMax - dataWindow.XMin + 1; + long height = (long)dataWindow.YMax - dataWindow.YMin + 1; + + // Decoding stages each row as four color planes, so the width must be bounded + // before later width * 4 buffer sizing can overflow. + if (width > int.MaxValue / 4 || height > int.MaxValue) + { + ExrThrowHelper.ThrowInvalidImageContentException("EXR DataWindow dimensions exceed the maximum allowed size."); + } + + this.Width = (int)width; + this.Height = (int)height; this.Channels = this.HeaderAttributes.Channels; this.Compression = this.HeaderAttributes.Compression; + uint rowsPerBlock = ExrUtils.RowsPerBlock(this.Compression); + long chunkCount = (this.Height + (long)rowsPerBlock - 1) / rowsPerBlock; + long offsetTableByteCount = chunkCount * sizeof(ulong); + + // The scanline offset table sits between the header and pixel chunks; proving it + // fits in the stream keeps all later chunk offsets on the pixel-data side. + if (stream.Position > stream.Length || offsetTableByteCount > stream.Length - stream.Position) + { + ExrThrowHelper.ThrowInvalidImageContentException("EXR chunk offset table is outside the bounds of the stream."); + } + + this.MinimumChunkOffset = stream.Position + offsetTableByteCount; this.PixelType = this.ValidateChannels(); this.ImageDataType = this.DetermineImageDataType(); @@ -866,6 +920,19 @@ internal sealed class ExrDecoderCore : ImageDecoderCore _ => false, }; + /// + /// Validates a scanline chunk offset read from the EXR offset table. + /// + /// The chunk offset to validate. + /// The stream containing the image data. + private void ValidateChunkOffset(ulong chunkOffset, BufferedReadStream stream) + { + if (chunkOffset < (ulong)this.MinimumChunkOffset || chunkOffset >= (ulong)stream.Length) + { + ExrThrowHelper.ThrowInvalidImageContentException("EXR chunk offset is outside the bounds of the stream."); + } + } + /// /// Determines whether this image has alpha channel. /// diff --git a/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs b/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs index e2750d8972..81b7c4da11 100644 --- a/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs +++ b/src/ImageSharp/Formats/Exr/ExrEncoderCore.cs @@ -170,9 +170,13 @@ internal sealed class ExrEncoderCore CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - uint bytesPerRow = ExrUtils.CalculateBytesPerRow(channels, (uint)width); + ulong bytesPerRow = ExrUtils.CalculateBytesPerRow(channels, (uint)width); uint rowsPerBlock = ExrUtils.RowsPerBlock(compression); - uint bytesPerBlock = bytesPerRow * rowsPerBlock; + ulong bytesPerBlock = bytesPerRow * rowsPerBlock; + if (bytesPerRow > uint.MaxValue || bytesPerBlock > int.MaxValue) + { + throw new ImageFormatException("Image is too large to encode in EXR format."); + } using IMemoryOwner rgbBuffer = this.memoryAllocator.Allocate(width * 4, AllocationOptions.Clean); using IMemoryOwner rowBlockBuffer = this.memoryAllocator.Allocate((int)bytesPerBlock, AllocationOptions.Clean); @@ -181,7 +185,7 @@ internal sealed class ExrEncoderCore Span blueBuffer = rgbBuffer.GetSpan().Slice(width * 2, width); Span alphaBuffer = rgbBuffer.GetSpan().Slice(width * 3, width); - using ExrBaseCompressor compressor = ExrCompressorFactory.Create(compression, this.memoryAllocator, stream, bytesPerBlock, bytesPerRow, rowsPerBlock, width); + using ExrBaseCompressor compressor = ExrCompressorFactory.Create(compression, this.memoryAllocator, stream, (uint)bytesPerBlock, (uint)bytesPerRow, rowsPerBlock, width); ulong[] rowOffsets = new ulong[height]; for (uint y = 0; y < height; y += rowsPerBlock) @@ -262,9 +266,13 @@ internal sealed class ExrEncoderCore CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - uint bytesPerRow = ExrUtils.CalculateBytesPerRow(channels, (uint)width); + ulong bytesPerRow = ExrUtils.CalculateBytesPerRow(channels, (uint)width); uint rowsPerBlock = ExrUtils.RowsPerBlock(compression); - uint bytesPerBlock = bytesPerRow * rowsPerBlock; + ulong bytesPerBlock = bytesPerRow * rowsPerBlock; + if (bytesPerRow > uint.MaxValue || bytesPerBlock > int.MaxValue) + { + throw new ImageFormatException("Image is too large to encode in EXR format."); + } using IMemoryOwner rgbBuffer = this.memoryAllocator.Allocate(width * 4, AllocationOptions.Clean); using IMemoryOwner rowBlockBuffer = this.memoryAllocator.Allocate((int)bytesPerBlock, AllocationOptions.Clean); @@ -273,7 +281,7 @@ internal sealed class ExrEncoderCore Span blueBuffer = rgbBuffer.GetSpan().Slice(width * 2, width); Span alphaBuffer = rgbBuffer.GetSpan().Slice(width * 3, width); - using ExrBaseCompressor compressor = ExrCompressorFactory.Create(compression, this.memoryAllocator, stream, bytesPerBlock, bytesPerRow, rowsPerBlock, width); + using ExrBaseCompressor compressor = ExrCompressorFactory.Create(compression, this.memoryAllocator, stream, (uint)bytesPerBlock, (uint)bytesPerRow, rowsPerBlock, width); Rgba128 rgb = default; ulong[] rowOffsets = new ulong[height]; diff --git a/src/ImageSharp/Formats/Exr/ExrUtils.cs b/src/ImageSharp/Formats/Exr/ExrUtils.cs index 386210b81d..532950daa9 100644 --- a/src/ImageSharp/Formats/Exr/ExrUtils.cs +++ b/src/ImageSharp/Formats/Exr/ExrUtils.cs @@ -13,9 +13,9 @@ internal static class ExrUtils /// The image channels array. /// The width in pixels of a row. /// The number of bytes per row. - public static uint CalculateBytesPerRow(IList channels, uint width) + public static ulong CalculateBytesPerRow(IList channels, uint width) { - uint bytesPerRow = 0; + ulong bytesPerRow = 0; foreach (ExrChannelInfo channelInfo in channels) { if (channelInfo.ChannelName.Equals("A", StringComparison.Ordinal) @@ -26,11 +26,11 @@ internal static class ExrUtils { if (channelInfo.PixelType == ExrPixelType.Half) { - bytesPerRow += 2 * width; + bytesPerRow += 2UL * width; } else { - bytesPerRow += 4 * width; + bytesPerRow += 4UL * width; } } } diff --git a/tests/ImageSharp.Tests/Formats/Exr/ExrDecoderSecurityTests.cs b/tests/ImageSharp.Tests/Formats/Exr/ExrDecoderSecurityTests.cs new file mode 100644 index 0000000000..2d59c02355 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Exr/ExrDecoderSecurityTests.cs @@ -0,0 +1,274 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Exr; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Tests.Formats.Exr; + +/// +/// Security regression tests for the EXR decoder (Findings EXR-1, EXR-2, EXR-3). +/// The EXR decoder was merged to main but not yet included in a tagged NuGet release. +/// Each test demonstrates a crafted-input crash present in the unfixed code. +/// +[Trait("Format", "Exr")] +[ValidateDisposedMemoryAllocations] +public class ExrDecoderSecurityTests +{ + /// + /// EXR-1 — EXR DataWindow Integer Overflow Produces Negative Image Dimensions (DoS) + /// + /// Width and Height are computed from attacker-controlled DataWindow attributes + /// using unchecked int subtraction: + /// this.Width = XMax - XMin + 1 // overflows to -2147483647 + /// + /// The negative Width is then passed to the Image<TPixel> constructor, which calls + /// Guard.MustBeGreaterThan(width, 0) → ArgumentOutOfRangeException. + /// + /// After a fix this should throw InvalidImageContentException instead. + /// + /// Affected file: + /// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 600–601 + /// + [Fact] + public void Decode_DataWindowOverflow_NegativeWidth_Throws() + { + // XMin = -1073741825, XMax = 1073741823 + // Width = 1073741823 - (-1073741825) + 1 = 2^31 + 1 → wraps to -2147483647 + byte[] data = BuildMinimalExr(xMin: -1073741825, yMin: 0, xMax: 1073741823, yMax: 0); + + using var stream = new MemoryStream(data); + Assert.Throws( + () => ExrDecoder.Instance.Decode(DecoderOptions.Default, stream)); + } + + /// + /// EXR-2 — EXR Row Offset Table Unvalidated Seek (DoS) + /// + /// Row offsets are read from the file and used unconditionally to seek the stream: + /// ulong rowOffset = this.ReadUnsignedLong(stream); + /// stream.Position = (long)rowOffset; // no bounds check + /// + /// A crafted offset of 0xFFFFFFFFFFFFFFFF casts to −1 as long, causing + /// an ArgumentOutOfRangeException when setting stream.Position. + /// + /// After a fix this should throw InvalidImageContentException instead. + /// + /// Affected file: + /// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 170–175 (scanline) + /// lines 243–248 (tile) + /// + [Fact] + public void Decode_CraftedRowOffsets_OutOfBounds_Throws() + { + // Valid 2×2 image (XMin=0,YMin=0,XMax=1,YMax=1 → Width=2,Height=2). + // Row offset table immediately follows the header null byte: + // 2 rows × 8 bytes each, all set to 0xFFFFFFFFFFFFFFFF. + byte[] invalidOffsets = new byte[16]; + BinaryPrimitives.WriteUInt64LittleEndian(invalidOffsets, 0xFFFFFFFFFFFFFFFF); + BinaryPrimitives.WriteUInt64LittleEndian(invalidOffsets.AsSpan(8), 0xFFFFFFFFFFFFFFFF); + + byte[] data = BuildMinimalExr( + xMin: 0, yMin: 0, xMax: 1, yMax: 1, + rowOffsetTableAppend: invalidOffsets); + + using var stream = new MemoryStream(data); + Assert.Throws( + () => ExrDecoder.Instance.Decode(DecoderOptions.Default, stream)); + } + + [Fact] + public void Decode_CraftedRowOffsets_IntoHeader_Throws() + { + // Offset 0 points back into the EXR file header and must be rejected + // before the decoder seeks to attacker-controlled non-pixel data. + byte[] headerOffsets = new byte[16]; + + byte[] data = BuildMinimalExr( + xMin: 0, yMin: 0, xMax: 1, yMax: 1, + rowOffsetTableAppend: headerOffsets); + + using var stream = new MemoryStream(data); + Assert.Throws( + () => ExrDecoder.Instance.Decode(DecoderOptions.Default, stream)); + } + + [Fact] + public void Decode_CraftedRowOffsets_IntoOffsetTable_Throws() + { + byte[] data = BuildMinimalExr( + xMin: 0, yMin: 0, xMax: 1, yMax: 1, + rowOffsetTableAppend: new byte[16]); + + // Point the first row offset at the second row offset entry. + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(data.Length - 16), (ulong)(data.Length - 8)); + + using var stream = new MemoryStream(data); + Assert.Throws( + () => ExrDecoder.Instance.Decode(DecoderOptions.Default, stream)); + } + + /// + /// EXR-3 — Oversized EXR RGBA row sizing is rejected as invalid image content. + /// + /// With 4 RGBA HALF channels and Width = 2^29, the decoded row staging and + /// bytes-per-row arithmetic both exceed the supported buffer sizing limits. + /// The decoder must reject this as InvalidImageContentException before any allocation. + /// + /// Affected file: + /// src/ImageSharp/Formats/Exr/ExrDecoderCore.cs lines 142–150, 215–223 + /// src/ImageSharp/Formats/Exr/ExrUtils.cs CalculateBytesPerRow + /// + [Fact] + public void Decode_RgbaRowSizingExceedsBufferLimits_Throws() + { + // 4 RGBA HALF channels at this width cannot be represented by the decoder's + // int-sized row staging or block buffers. + byte[] data = BuildMinimalRgbaExr(xMin: 0, yMin: 0, xMax: 536870911, yMax: 0); + + using var stream = new MemoryStream(data); + Assert.Throws( + () => ExrDecoder.Instance.Decode(DecoderOptions.Default, stream)); + } + + [Fact] + public void Decode_DataWindowWidthExceedsRowBufferLimit_Throws() + { + // A single HALF channel keeps bytesPerBlock below int.MaxValue, but the decoder + // still stages four color planes and must reject widths that overflow width × 4. + byte[] data = BuildMinimalExr(xMin: 0, yMin: 0, xMax: int.MaxValue / 4, yMax: 0); + + using var stream = new MemoryStream(data); + Assert.Throws( + () => ExrDecoder.Instance.Decode(DecoderOptions.Default, stream)); + } + + [Fact] + public void Identify_RowOffsetTableExceedsStream_Throws() + { + // Identify parses the header only, so this verifies the offset table bound is + // validated before scanline decoding reads from the table. + byte[] data = BuildMinimalExr(xMin: 0, yMin: 0, xMax: 1, yMax: 1); + + using var stream = new MemoryStream(data); + Assert.Throws( + () => ExrDecoder.Instance.Identify(DecoderOptions.Default, stream)); + } + + // ------------------------------------------------------------------------- + // Helpers: construct minimal valid-enough EXR scanline files. + // + // Required attributes per ParseHeaderAttributes validation: + // channels, compression, dataWindow, displayWindow, + // lineOrder, pixelAspectRatio, screenWindowCenter, screenWindowWidth + // ------------------------------------------------------------------------- + + private static byte[] BuildMinimalExr( + int xMin, int yMin, int xMax, int yMax, + byte[] rowOffsetTableAppend = null) + { + // channels: single "R" HALF channel with xSampling=1, ySampling=1 + // Layout per ReadChannelInfo: name\0 (2) + pixelType (4) + pLinear+reserved (4) + // + xSampling (4) + ySampling (4) = 18 bytes/channel + // + list-null (1) = 19 total + byte[] channelData = + [ + 0x52, 0x00, // "R\0" + 0x01, 0x00, 0x00, 0x00, // pixelType = Half (1) + 0x00, 0x00, 0x00, 0x00, // pLinear + 3 reserved bytes + 0x01, 0x00, 0x00, 0x00, // xSampling = 1 + 0x01, 0x00, 0x00, 0x00, // ySampling = 1 + 0x00, // channel-list null terminator + ]; + + return BuildExrWithChannels(xMin, yMin, xMax, yMax, channelData, rowOffsetTableAppend); + } + + private static byte[] BuildMinimalRgbaExr(int xMin, int yMin, int xMax, int yMax) + { + // 4 HALF channels in alphabetical order (A, B, G, R) per EXR spec. + // 18 bytes per channel × 4 channels + 1 list-null = 73 bytes total. + byte[] channelData = + [ + 0x41, 0x00, // "A\0" + 0x01, 0x00, 0x00, 0x00, // pixelType = Half (1) + 0x00, 0x00, 0x00, 0x00, // pLinear + 3 reserved bytes + 0x01, 0x00, 0x00, 0x00, // xSampling = 1 + 0x01, 0x00, 0x00, 0x00, // ySampling = 1 + 0x42, 0x00, // "B\0" + 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0x47, 0x00, // "G\0" + 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0x52, 0x00, // "R\0" + 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, + 0x00, // channel-list null terminator + ]; + + return BuildExrWithChannels(xMin, yMin, xMax, yMax, channelData); + } + + private static byte[] BuildExrWithChannels( + int xMin, int yMin, int xMax, int yMax, + byte[] channelData, + byte[] rowOffsetTableAppend = null) + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms, System.Text.Encoding.ASCII, leaveOpen: true); + + // Magic (0x01312F76 LE) + version 2, scanline (flags = 0x00) + bw.Write(new byte[] { 0x76, 0x2F, 0x31, 0x01, 0x02, 0x00, 0x00, 0x00 }); + + void WriteAttr(string name, string type, byte[] payload) + { + foreach (char c in name) bw.Write((byte)c); + bw.Write((byte)0); + foreach (char c in type) bw.Write((byte)c); + bw.Write((byte)0); + bw.Write(payload.Length); + bw.Write(payload); + } + + WriteAttr("channels", "chlist", channelData); + + WriteAttr("compression", "compression", [0x00]); // None + + byte[] dw = new byte[16]; + BinaryPrimitives.WriteInt32LittleEndian(dw, xMin); + BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(4), yMin); + BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(8), xMax); + BinaryPrimitives.WriteInt32LittleEndian(dw.AsSpan(12), yMax); + WriteAttr("dataWindow", "box2i", dw); + + WriteAttr("displayWindow", "box2i", new byte[16]); // all zeros (0,0,0,0) + + WriteAttr("lineOrder", "lineOrder", [0x00]); // IncreasingY + + byte[] aspect = new byte[4]; + BinaryPrimitives.WriteSingleLittleEndian(aspect, 1.0f); + WriteAttr("pixelAspectRatio", "float", aspect); + + WriteAttr("screenWindowCenter", "v2f", new byte[8]); // (0f, 0f) + + byte[] sww = new byte[4]; + BinaryPrimitives.WriteSingleLittleEndian(sww, 1.0f); + WriteAttr("screenWindowWidth", "float", sww); + + bw.Write((byte)0x00); // end-of-header sentinel + + if (rowOffsetTableAppend is not null) + bw.Write(rowOffsetTableAppend); + + return ms.ToArray(); + } +}