2
\$\begingroup\$

I have these two methods that work, but I also hate because they almost certainly can be improved. I'm hoping to gain some guidance from others who are more knowledgable of .NET's offering for encodings and byte manipulation.

Given a string, any character that is not a-z, A-Z, 0-9 is replaced by hex values enclosed in square brackets.

abc123 => abc123
abc-123 => abc[2D]123
abc[]123 => abc[5B][5D]123
abc©def => abc[C2][A9]def

What magic is out there? I'm on .NET Core 3.1, but everything is appreciated.

public static string Encode(string str) => String.Concat(Encoding.UTF8.GetBytes(str).Select(cc =>
{
    if ('a' <= cc && cc <= 'z' || 'A' <= cc && cc <= 'Z' || '0' <= cc && cc <= '9')
    {
        // convert byte back to a character
        return $"{Convert.ToChar(cc)}";
    }
    else
    {
        // convert byte to hex representation
        return $"[{Convert.ToInt32(cc):X}]";
    }
}));

public static string Decode(string str)
{
    List<byte> bytes = new List<byte>();

    StringBuilder hex = new StringBuilder();
    bool inHex = false;
    foreach (char cc in str.ToCharArray())
    {
        if (cc == '[')
        {
            inHex = true;
        }
        else if (cc == ']')
        {
            inHex = false;
            // convert the hex string to a byte
            bytes.Add(Convert.ToByte(hex.ToString(), fromBase: 16));
            hex.Clear();
        }
        else if (inHex)
        {
            hex.Append(cc);
        }
        else
        {
            bytes.Add((byte)cc);
        }
    }
    return Encoding.UTF8.GetString(bytes.ToArray());
}

\$\endgroup\$

2 Answers 2

3
\$\begingroup\$
  • There is no arguments validations. this is important, as you need to validate the arguments before processing them. In your case, ArgumentNullException would be suitable. to check if the passed arguments are null or empty strings (whitespace perhaps).

  • you can use char.IsLetterOrDigit(char c) instead of
    if ('a' <= cc && cc <= 'z' || 'A' <= cc && cc <= 'Z' || '0' <= cc && cc <= '9')

  • using Convert.ToXX extensions is fine in safe-senarios (where the values are always valid), however, in most cases you should consider using TryParse as a safe and less error prone parsing approach. (e.g. char.TryParse, int.TryParse ..etc.).

  • str.ToCharArray() is unnecessary as you can access to the string chars directly using indexing.

  • In Decode your List<byte> is not needed, as you can store the two-characters in a temporary string, and then convert it from hexadecimal to its original form, and append the results to the StringBuilder. (or you can store the start index, and use it for the conversion.

  • Decode method assumes that there is an open and close brackets, how do you think it would behave if there is missing close brackets or duplicated brackets or a bracket that is not intent to be a part of the conversion process ?. For simple processing, you can use IndexOf to check the next bracket index, and get the hexadecimal value between them, hence, Substring would be useful here (also Range would be much modern approach). Although, if the hexdecimal value will always have fixed length (in your code you only use one-hexdecimal that would take two characters, so the length is fixed to 2-chars). You can define that in your class, and decode it based on the fixed length (in this case, you would use Substring or Range directly, and you only check for the current index plus the next index (which is index + fixed length)).

(untested) see the Update Below

Example of applying some of the above notes :

public static string Encode(string str)
{
    // no need to do anything, just return the source.
    if (string.IsNullOrWhiteSpace(str)) return str;

    StringBuilder builder = new StringBuilder();

    for(int x = 0; x < str.Length; x++)
    {
        var character = str[x];

        if(!char.IsLetterOrDigit(character))
        {
            builder.Append($"[{((byte)character):X2}]");
        }
        else
        {
            builder.Append(character);
        }
    }

    return builder.ToString();
}

public static string Decode(string str)
{
    // no need to do anything, just return the source.
    if (string.IsNullOrWhiteSpace(str)) return str;

    StringBuilder builder = new StringBuilder();

    for (int i = 0; i < str.Length; i++)
    {
        char character = str[i];
        
        if (character == '[')
        {
            var nextIndex = str.IndexOf(']', i) - 1;

            if(nextIndex > 0)
            {
                var temp =  str.Substring(i + 1, nextIndex - i);

                builder.Append(Encoding.UTF8.GetString(new[] { Convert.ToByte(temp, 16) }));
                
                i = nextIndex + 1;
            }
        }
        else
        {
            builder.Append(character);
        }
    }

    return builder.ToString();
}

In the Encode method, I didn't use LINQ; because it would be more readable and easy to follow if I just use a simple loop. As I prefer readability over LINQ approach, and also it gives more harmony to the code between both Decode and Encode methods.

If you want another way of Decoding you could use Regex.Replace this would give you the same results, but with shorter code:

public static string Decode(string str)
{
    // no need to do anything, just return the source.
    if (string.IsNullOrWhiteSpace(str)) return str;

    return Regex.Replace(str, "\\[([^]]+)\\]", new MatchEvaluator((match) =>
        {
            return Encoding.UTF8.GetString(new[] { Convert.ToByte(match.Groups[1].Value, 16) });
        }), RegexOptions.IgnoreCase);
}

UPDATE

I've done some testing on the above example (thanks to @pm100 for mention it), I just changed it to a better one. The following would only serves the same logic as the original, however, it can be expanded to adopt any new requirements. You can override GetEncodeCharacterBytes to manipulate how the character would be encoded. (the default would returns a hexadecimal bytes).

public class DefaultNonAlphanumericEncoder : NonAlphanumericEncoder
{
    // default configurations
    public DefaultNonAlphanumericEncoder() : base('[', ']', Encoding.UTF8) { }
}

public abstract class NonAlphanumericEncoder
{
    private readonly byte _openTag;
    private readonly byte _closeTag;
    private Encoding _encoding;
    
    /// <summary>
    /// An open constructor to define the open/close tags along with an encoding.
    /// </summary>
    /// <param name="openTag"></param>
    /// <param name="closeTag"></param>
    /// <param name="encoding"></param>
    /// <exception cref="ArgumentNullException"></exception>
    public NonAlphanumericEncoder(char openTag, char closeTag, Encoding encoding)
    {
        _openTag = (byte)openTag;
        _closeTag = (byte)closeTag;
        _encoding = encoding ?? throw new ArgumentNullException(nameof(encoding));
    }
    protected virtual IEnumerable<byte> GetEncodeCharacterBytes(byte character) => _encoding.GetBytes($"[{character:X2}]");

    private IEnumerable<byte> InternalEncode(byte[] bytes)
    {
        if (bytes?.Length == 0) yield break;

        for (int i = 0; i < bytes.Length; i++)
        {
            byte c = bytes[i];

            if (IsNonAlphanumeric(c))
            {
                var encodedBytes = GetEncodeCharacterBytes(c);

                foreach (var encoded in encodedBytes)
                    yield return encoded;
            }
            else
            {
                yield return c;
            }

        }
    }

    private IEnumerable<byte> InternalDecode(byte[] bytes)
    {
        byte[] hexBytes = new byte[2];

        for (int i = 0; i < bytes.Length; i++)
        {
            byte b = bytes[i];

            // covers case 4
            if ((i + 1) < bytes.Length && bytes[i] == _closeTag && bytes[i + 1] == _openTag) { continue; }

            if (b != _openTag)
            {
                yield return b;
            }
            else
            {
                hexBytes[0] = bytes[++i];

                hexBytes[1] = bytes[++i];

                yield return Convert.ToByte(_encoding.GetString(hexBytes), 16);

                // we assumed that the next index would hold the close tag
                // for this, we increament by 1 to skip it. 
                i++;
            }
        }

    }

    public string Encode(string str) 
    {
        // no need to do anything, just return the source.
        if (string.IsNullOrWhiteSpace(str)) return str;

        var sourceBytes = _encoding.GetBytes(str);
        
        var encodedBytes = InternalEncode(sourceBytes).ToArray();

        return _encoding.GetString(encodedBytes);                
    }

    public string Decode(string str)
    {

        // no need to do anything, just return the source.
        if (string.IsNullOrWhiteSpace(str)) return str;

        var sourceBytes = _encoding.GetBytes(str);

        var decodedBytes = InternalDecode(sourceBytes).ToArray();

        return _encoding.GetString(decodedBytes);
    }


    // helpers 
    private bool IsNumber(byte character) => character >= '0' && character <= '9';
    private bool IsLetter(byte character) => (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z');
    private bool IsNonAlphanumeric(byte character) => !IsLetter(character) && !IsNumber(character);

}

usage :

// Default encoder uses UTF-8 with [] tags
var encoder = new DefaultNonAlphanumericEncoder();
var encoded = encoder.Encode("abc©def");
var decoded = encoder.Decode(encoded);
\$\endgroup\$
3
  • \$\begingroup\$ you code doesnt work - builder.Append($"[{((byte)x):X2}]") should be builder.Append($"[{((byte)character):X2}]"); \$\endgroup\$
    – pm100
    Commented May 31, 2022 at 20:24
  • \$\begingroup\$ also the last case with '@' does not work \$\endgroup\$
    – pm100
    Commented May 31, 2022 at 20:27
  • \$\begingroup\$ @pm100 I've update it, thanks for mention it didn't tested, but I've provided a tested version though. \$\endgroup\$
    – iSR5
    Commented Jun 1, 2022 at 9:05
1
\$\begingroup\$

Was going to comment on various bits of the code but I thought I would use this as a place to talk about TDD. It is interesting to capture your specs as test cases. Note that you do not deal with any invalid inputs to Decode, your code is very open to bad inputs, if you use this schem to pass data from one program to another this could be an attack vector..

I started by defining the behavior using MS VS test framework (MSTest)

public class UnitTest1 {
   // helper function to encode and decode a string
    public static void RoundTrip(string str) {
        var enc =  Encoder.Encode(str);
        var dec = Encoder.Decode(enc);
        Assert.AreEqual(str, dec);
    }

    [TestMethod]
    public void GivenEncodes() {
        Assert.AreEqual(Encoder.Encode("abc123"),"abc123");
        Assert.AreEqual(Encoder.Encode("abc-123"), "abc[2D]123");
        Assert.AreEqual(Encoder.Encode("abc[]123"), "abc[5B][5D]123");
        Assert.AreEqual(Encoder.Encode("abc©def"), "abc[C2][A9]def");

    }
    [TestMethod]

    public void GivenRoundTrips() {
        RoundTrip("abc123");
        RoundTrip("abc-123");
        RoundTrip("abc[]123");
        RoundTrip("abc©def");

    }
    [TestMethod]
    public void BoundaryEncodes() {
        Assert.AreEqual(Encoder.Encode(""), "");
        Assert.AreEqual(Encoder.Encode("\0"), "[0]"); 
        RoundTrip("");
        RoundTrip("\0");
    }

    [TestMethod]
    public void ErrorDecodes() {
        Assert.ThrowsException<InvalidDataException>(()=>Encoder.Decode("["));
        Assert.ThrowsException<InvalidDataException>(()=>Encoder.Decode("]"));
        Assert.ThrowsException<InvalidDataException>(()=>Encoder.Decode("[]"));
        Assert.ThrowsException<InvalidDataException>(()=>Encoder.Decode("[x]"));
        Assert.ThrowsException<InvalidDataException>(() => Encoder.Decode("[999999999999999999]"));
        Assert.ThrowsException<InvalidDataException>(() => Encoder.Decode("[[]"));
        Assert.ThrowsException<InvalidDataException>(() => Encoder.Decode("abc["));
        Assert.ThrowsException<InvalidDataException>(() => Encoder.Decode("----"));
        Assert.ThrowsException<InvalidDataException>(() => Encoder.Decode("-[000]-"));
    }

}

Note that I added a few boundary tests.

Then I setup tests for a whole bunch of invalid string and made sure that a modified decode would reject them

Modified decode: originally I did a rewrite of this but you code is basically fine. So I simply added error checks

    public static string Decode(string str) {
        List<byte> bytes = new List<byte>();

        StringBuilder hex = new StringBuilder();
        bool inHex = false;
        foreach (char cc in str.ToCharArray()) {
            if (cc == '[') {
                inHex = true;
            }
            else if (cc == ']') {
                inHex = false;
                // convert the hex string to a byte
                try {
                    bytes.Add(Convert.ToByte(hex.ToString(), fromBase: 16));
                }
                catch (Exception) {
                    // bad number between [and] or too big for byte
                    throw new InvalidDataException();
                }
                hex.Clear();
            }
            else if (inHex) {
                // non digits in [..]
                if (!Char.IsDigit(cc) && !"abcdefABCDEF".Contains(cc))
                    throw new InvalidDataException();
                hex.Append(cc);
            }
            else {
                // special char not [ or ]
                if(!Char.IsLetterOrDigit(cc))
                    throw new InvalidDataException();

                bytes.Add((byte)cc);
            }
        }
        // unterminated [
        if(inHex)
            throw new InvalidDataException();

        return Encoding.UTF8.GetString(bytes.ToArray());
    }

I really recommend getting into the Unit test habit, especially for well defined things like this, you can effectively write the specs before you even write the code and ensure that you never break anything. I tested @iSR5 answer and it failed several tests - v useful

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.