diff --git a/demo/gfoidl.Base64.Demo/Program.cs b/demo/gfoidl.Base64.Demo/Program.cs index 16857db..f424322 100644 --- a/demo/gfoidl.Base64.Demo/Program.cs +++ b/demo/gfoidl.Base64.Demo/Program.cs @@ -8,7 +8,7 @@ class Program { static void Main() { - Action[] demos = { RunGuidEncoding, RunGuidDecoding, RunBufferChainEncode }; + Action[] demos = { RunGuidEncoding, RunGuidDecoding, RunBufferChainEncode, RunDetectEncoding }; foreach (Action demo in demos) { @@ -95,5 +95,32 @@ private static void RunBufferChainEncode() decoded = decoded.Slice(0, written + written1); Debug.Assert(data.SequenceEqual(decoded)); } + //--------------------------------------------------------------------- + private static void RunDetectEncoding() + { + // Let's assume we don't know whether this string is base64 or base64Url + string encodedString = "a-_9"; + + Span data = stackalloc byte[Base64.Default.GetMaxDecodedLength(encodedString.Length)]; + + EncodingType encodingType = Base64.DetectEncoding(encodedString); + + int written = 0; + switch (encodingType) + { + case EncodingType.Base64: + Base64.Default.Decode(encodedString.AsSpan(), data, out int _, out written); + break; + case EncodingType.Base64Url: + Base64.Url.Decode(encodedString.AsSpan(), data, out int _, out written); + break; + case EncodingType.Unknown: + throw new InvalidOperationException("should not be here"); + } + + data = data.Slice(0, written); + + Debug.Assert(data.Length == 3); + } } } diff --git a/source/gfoidl.Base64/Base64.cs b/source/gfoidl.Base64/Base64.cs index 9200ea6..ebbcb9b 100644 --- a/source/gfoidl.Base64/Base64.cs +++ b/source/gfoidl.Base64/Base64.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Runtime.InteropServices; using gfoidl.Base64.Internal; namespace gfoidl.Base64 @@ -226,5 +227,100 @@ public abstract OperationStatus Decode( /// The base64 encoded data in string-form. /// The base64 decoded data. public abstract byte[] Decode(ReadOnlySpan encoded); + //--------------------------------------------------------------------- + /// + /// Detects whether is base64 or base64Url. + /// + /// The base64 encoded data. + /// + /// When false (default) is scanned + /// one time for base64 chars and a second time for base64Url chars. + /// So if there is a mix of them, + /// will be returned. + /// + /// When true is scanned only once + /// and for base64Url chars. So if there is a mix of base64 and base64Url, + /// the result will be , and may + /// throw a on decoding. + /// + /// + /// base64 or base64Url + /// + /// It is an O(n) scan / detection of the encoding type, and input is + /// not validated for conforming the base64 standard. Thus there is no + /// 'Invalid' encoding type. + /// + public static EncodingType DetectEncoding(ReadOnlySpan encoded, bool fast = false) + => DetectEncoding(encoded, fast); + //--------------------------------------------------------------------- + /// + /// Detects whether is base64 or base64Url. + /// + /// The base64 encoded data. + /// + /// When false (default) is scanned + /// one time for base64 chars and a second time for base64Url chars. + /// So if there is a mix of them, + /// will be returned. + /// + /// When true is scanned only once + /// and for base64Url chars. So if there is a mix of base64 and base64Url, + /// the result will be , and may + /// throw a on decoding. + /// + /// + /// base64 or base64Url + /// + /// It is an O(n) fast scan / detection of the encoding type, and input is + /// not validated for conforming the base64 standard. Thus there is no + /// 'Invalid' encoding type. + /// + public static EncodingType DetectEncoding(ReadOnlySpan encoded, bool fast = false) + => DetectEncoding(encoded, fast); + //--------------------------------------------------------------------- + // Also used for tests + internal static EncodingType DetectEncoding(ReadOnlySpan encoded, bool fast = false) + where T : IEquatable + { + if (encoded.Length < 4) return EncodingType.Unknown; + + T plus, slash, minus, underscore; + + if (typeof(T) == typeof(byte)) + { + plus = (T)(object)(byte)'+'; + slash = (T)(object)(byte)'/'; + minus = (T)(object)(byte)'-'; + underscore = (T)(object)(byte)'_'; + } + else if (typeof(T) == typeof(char)) + { + plus = (T)(object)'+'; + slash = (T)(object)'/'; + minus = (T)(object)'-'; + underscore = (T)(object)'_'; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + int indexBase64Url = encoded.LastIndexOfAny(minus, underscore); + + if (fast) + { + return indexBase64Url >= 0 ? EncodingType.Base64Url : EncodingType.Base64; + } + else + { + int indexBase64 = encoded.LastIndexOfAny(plus, slash); + + return indexBase64Url >= 0 + ? indexBase64 >= 0 + ? EncodingType.Unknown + : EncodingType.Base64Url + : EncodingType.Base64; + } + } } } diff --git a/source/gfoidl.Base64/EncodingType.cs b/source/gfoidl.Base64/EncodingType.cs new file mode 100644 index 0000000..33ccf19 --- /dev/null +++ b/source/gfoidl.Base64/EncodingType.cs @@ -0,0 +1,9 @@ +namespace gfoidl.Base64 +{ + public enum EncodingType + { + Base64, + Base64Url, + Unknown + } +} diff --git a/tests/gfoidl.Base64.Tests/Base64Tests/Default.cs b/tests/gfoidl.Base64.Tests/Base64Tests/Default.cs new file mode 100644 index 0000000..42ee514 --- /dev/null +++ b/tests/gfoidl.Base64.Tests/Base64Tests/Default.cs @@ -0,0 +1,75 @@ +using System; +using System.Buffers; +using System.Runtime.InteropServices; +using NUnit.Framework; + +namespace gfoidl.Base64.Tests.Base64Tests +{ + [TestFixture(typeof(byte))] + [TestFixture(typeof(char))] + public class Default where T : unmanaged + { + [Test] + public void Default___base64_is_used() + { + byte[] data = { 0xFF, 0xFE, 0x00 }; + string expected = Convert.ToBase64String(data); + + string actual = Base64.Default.Encode(data); + + Assert.AreEqual(expected, actual); + } + //--------------------------------------------------------------------- + [Test] + public void Default_with_buffers___base64_is_used() + { + byte[] data = { 0x00 }; + const int encodedLength = 4; + Span base64 = stackalloc T[encodedLength]; + OperationStatus status; + int consumed, written; + + if (typeof(T) == typeof(byte)) + { + status = Base64.Default.Encode(data, MemoryMarshal.AsBytes(base64), out consumed, out written); + } + else if (typeof(T) == typeof(char)) + { + status = Base64.Default.Encode(data, MemoryMarshal.Cast(base64), out consumed, out written); + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + Assert.Multiple(() => + { + Assert.AreEqual(OperationStatus.Done, status); + Assert.AreEqual(1, consumed); + Assert.AreEqual(4, written); + }); + + Span decoded = stackalloc byte[10]; + + if (typeof(T) == typeof(byte)) + { + status = Base64.Default.Decode(MemoryMarshal.AsBytes(base64), decoded, out consumed, out written); + } + else if (typeof(T) == typeof(char)) + { + status = Base64.Default.Decode(MemoryMarshal.Cast(base64), decoded, out consumed, out written); + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + Assert.Multiple(() => + { + Assert.AreEqual(OperationStatus.Done, status); + Assert.AreEqual(4, consumed); + Assert.AreEqual(1, written); + }); + } + } +} diff --git a/tests/gfoidl.Base64.Tests/Base64Tests/DetectEncoding.cs b/tests/gfoidl.Base64.Tests/Base64Tests/DetectEncoding.cs new file mode 100644 index 0000000..6633a0b --- /dev/null +++ b/tests/gfoidl.Base64.Tests/Base64Tests/DetectEncoding.cs @@ -0,0 +1,29 @@ +using System; +using NUnit.Framework; + +namespace gfoidl.Base64.Tests.Base64Tests +{ + [TestFixture] + public class DetectEncoding + { + [Test] + public void Base64_given_byte___OK() + { + byte[] base64 = { (byte)'a', (byte)'+', (byte)'b', (byte)'/' }; + + EncodingType actual = Base64.DetectEncoding(base64); + + Assert.AreEqual(EncodingType.Base64, actual); + } + //--------------------------------------------------------------------- + [Test] + public void Base64_given_char___OK() + { + string base64 = "a+b/"; + + EncodingType actual = Base64.DetectEncoding(base64.AsSpan()); + + Assert.AreEqual(EncodingType.Base64, actual); + } + } +} diff --git a/tests/gfoidl.Base64.Tests/Base64Tests/DetectEncoding_T.cs b/tests/gfoidl.Base64.Tests/Base64Tests/DetectEncoding_T.cs new file mode 100644 index 0000000..5a155ab --- /dev/null +++ b/tests/gfoidl.Base64.Tests/Base64Tests/DetectEncoding_T.cs @@ -0,0 +1,252 @@ +using System; +using System.Linq; +using NUnit.Framework; + +namespace gfoidl.Base64.Tests.Base64Tests +{ + [TestFixture(typeof(byte))] + [TestFixture(typeof(char))] + public class DetectEncoding where T : IEquatable + { + [Test] + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void EncodedSpan_length_lt_4___Unknown(int encodedLength) + { + T[] encoded = new T[encodedLength]; + + EncodingType actual = Base64.DetectEncoding(encoded); + + Assert.AreEqual(EncodingType.Unknown, actual); + } + //--------------------------------------------------------------------- + [Test] + public void No_special_characters___Base64() + { + T a; + + if (typeof(T) == typeof(byte)) + { + a = (T)(object)(byte)'a'; + } + else if (typeof(T) == typeof(char)) + { + a = (T)(object)'a'; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + T[] encoded = { a, a, a, a }; + + EncodingType actual = Base64.DetectEncoding(encoded); + + Assert.AreEqual(EncodingType.Base64, actual); + } + //--------------------------------------------------------------------- + [Test] + public void Plus___Base64() + { + T a; + T plus; + + if (typeof(T) == typeof(byte)) + { + a = (T)(object)(byte)'a'; + plus = (T)(object)(byte)'+'; + } + else if (typeof(T) == typeof(char)) + { + a = (T)(object)'a'; + plus = (T)(object)'+'; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + T[] encoded = { a, a, plus, a }; + + EncodingType actual = Base64.DetectEncoding(encoded); + + Assert.AreEqual(EncodingType.Base64, actual); + } + //--------------------------------------------------------------------- + [Test] + public void Slash___Base64() + { + T a; + T slash; + + if (typeof(T) == typeof(byte)) + { + a = (T)(object)(byte)'a'; + slash = (T)(object)(byte)'/'; + } + else if (typeof(T) == typeof(char)) + { + a = (T)(object)'a'; + slash = (T)(object)'/'; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + T[] encoded = { a, a, slash, a }; + + EncodingType actual = Base64.DetectEncoding(encoded); + + Assert.AreEqual(EncodingType.Base64, actual); + } + //--------------------------------------------------------------------- + [Test] + public void No_plus_and_no_slash_but_padding___Base64() + { + T a; + T padding; + + if (typeof(T) == typeof(byte)) + { + a = (T)(object)(byte)'a'; + padding = (T)(object)(byte)'='; + } + else if (typeof(T) == typeof(char)) + { + a = (T)(object)'a'; + padding = (T)(object)'='; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + T[] encoded = { a, a, padding, a }; + + EncodingType actual = Base64.DetectEncoding(encoded); + + Assert.AreEqual(EncodingType.Base64, actual); + } + //--------------------------------------------------------------------- + [Test] + public void Minus___Base64Url() + { + T a; + T minus; + + if (typeof(T) == typeof(byte)) + { + a = (T)(object)(byte)'a'; + minus = (T)(object)(byte)'-'; + } + else if (typeof(T) == typeof(char)) + { + a = (T)(object)'a'; + minus = (T)(object)'-'; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + T[] encoded = { a, a, minus, a }; + + EncodingType actual = Base64.DetectEncoding(encoded); + + Assert.AreEqual(EncodingType.Base64Url, actual); + } + //--------------------------------------------------------------------- + [Test] + public void Underscore___Base64Url() + { + T a; + T underscore; + + if (typeof(T) == typeof(byte)) + { + a = (T)(object)(byte)'a'; + underscore = (T)(object)(byte)'_'; + } + else if (typeof(T) == typeof(char)) + { + a = (T)(object)'a'; + underscore = (T)(object)'_'; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + T[] encoded = { a, a, underscore, a }; + + EncodingType actual = Base64.DetectEncoding(encoded); + + Assert.AreEqual(EncodingType.Base64Url, actual); + } + //--------------------------------------------------------------------- + [Test] + public void Mix___Unknown([Values('+', '/')] char arg1, [Values('-', '_')] char arg2) + { + T a; + T a1; + T a2; + + if (typeof(T) == typeof(byte)) + { + a = (T)(object)(byte)'a'; + a1 = (T)(object)(byte)arg1; + a2 = (T)(object)(byte)arg2; + } + else if (typeof(T) == typeof(char)) + { + a = (T)(object)'a'; + a1 = (T)(object)arg1; + a2 = (T)(object)arg2; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + T[] encoded = { a1, a, a2, a }; + + EncodingType actual = Base64.DetectEncoding(encoded); + + Assert.AreEqual(EncodingType.Unknown, actual); + } + //--------------------------------------------------------------------- + [Test] + public void Mix_and_fast___Base64Url([Values('+', '/')] char arg1, [Values('-', '_')] char arg2) + { + T a; + T a1; + T a2; + + if (typeof(T) == typeof(byte)) + { + a = (T)(object)(byte)'a'; + a1 = (T)(object)(byte)arg1; + a2 = (T)(object)(byte)arg2; + } + else if (typeof(T) == typeof(char)) + { + a = (T)(object)'a'; + a1 = (T)(object)arg1; + a2 = (T)(object)arg2; + } + else + { + throw new NotSupportedException(); // just in case new types are introduced in the future + } + + T[] encoded = { a1, a, a2, a }; + + EncodingType actual = Base64.DetectEncoding(encoded, fast: true); + + Assert.AreEqual(EncodingType.Base64Url, actual); + } + } +} diff --git a/tests/gfoidl.Base64.Tests/Base64.cs b/tests/gfoidl.Base64.Tests/Base64Tests/Url.cs similarity index 50% rename from tests/gfoidl.Base64.Tests/Base64.cs rename to tests/gfoidl.Base64.Tests/Base64Tests/Url.cs index 5e9d614..60740c3 100644 --- a/tests/gfoidl.Base64.Tests/Base64.cs +++ b/tests/gfoidl.Base64.Tests/Base64Tests/Url.cs @@ -3,75 +3,12 @@ using System.Runtime.InteropServices; using NUnit.Framework; -namespace gfoidl.Base64.Tests +namespace gfoidl.Base64.Tests.Base64Tests { [TestFixture(typeof(byte))] [TestFixture(typeof(char))] - public class Base64 where T : unmanaged + public class Url where T : unmanaged { - [Test] - public void Default___base64_is_used() - { - byte[] data = { 0xFF, 0xFE, 0x00 }; - string expected = Convert.ToBase64String(data); - - string actual = Base64.Default.Encode(data); - - Assert.AreEqual(expected, actual); - } - //--------------------------------------------------------------------- - [Test] - public void Default_with_buffers___base64_is_used() - { - byte[] data = { 0x00 }; - const int encodedLength = 4; - Span base64 = stackalloc T[encodedLength]; - OperationStatus status; - int consumed, written; - - if (typeof(T) == typeof(byte)) - { - status = Base64.Default.Encode(data, MemoryMarshal.AsBytes(base64), out consumed, out written); - } - else if (typeof(T) == typeof(char)) - { - status = Base64.Default.Encode(data, MemoryMarshal.Cast(base64), out consumed, out written); - } - else - { - throw new NotSupportedException(); // just in case new types are introduced in the future - } - - Assert.Multiple(() => - { - Assert.AreEqual(OperationStatus.Done, status); - Assert.AreEqual(1, consumed); - Assert.AreEqual(4, written); - }); - - Span decoded = stackalloc byte[10]; - - if (typeof(T) == typeof(byte)) - { - status = Base64.Default.Decode(MemoryMarshal.AsBytes(base64), decoded, out consumed, out written); - } - else if (typeof(T) == typeof(char)) - { - status = Base64.Default.Decode(MemoryMarshal.Cast(base64), decoded, out consumed, out written); - } - else - { - throw new NotSupportedException(); // just in case new types are introduced in the future - } - - Assert.Multiple(() => - { - Assert.AreEqual(OperationStatus.Done, status); - Assert.AreEqual(4, consumed); - Assert.AreEqual(1, written); - }); - } - //--------------------------------------------------------------------- [Test] public void Url___base64Url_is_used() {