diff --git a/src/shared/Core.Tests/StreamExtensionsTests.cs b/src/shared/Core.Tests/StreamExtensionsTests.cs index ee0d6da46..9346d570a 100644 --- a/src/shared/Core.Tests/StreamExtensionsTests.cs +++ b/src/shared/Core.Tests/StreamExtensionsTests.cs @@ -11,6 +11,8 @@ public class StreamExtensionsTests private const string LF = "\n"; private const string CRLF = "\r\n"; + #region Dictionary + [Fact] public void StreamExtensions_ReadDictionary_EmptyString_ReturnsEmptyDictionary() { @@ -183,11 +185,204 @@ public void StreamExtensions_WriteDictionary_TextWriterCRLF_EntriesWithSpaces_Wr Assert.Equal("key a=value 1\r\n key b = value 2 \r\n\tvalue\tc\t=\t3\t\r\n\r\n", output); } + #endregion + + #region MultiDictionary + + [Fact] + public void StreamExtensions_ReadMultiDictionary_EmptyString_ReturnsEmptyDictionary() + { + string input = string.Empty; + + var output = ReadStringStream(input, StreamExtensions.ReadMultiDictionary); + + Assert.NotNull(output); + Assert.Equal(0, output.Count); + } + + [Fact] + public void StreamExtensions_ReadMultiDictionary_TerminatedLF_ReturnsDictionary() + { + string input = "a=1\nb=2\nc=3\n\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadMultiDictionary); + + Assert.NotNull(output); + Assert.Equal(3, output.Count); + + AssertMultiDictionary(new[] { "1" }, "a", output); + AssertMultiDictionary(new[] { "2" }, "b", output); + AssertMultiDictionary(new[] { "3" }, "c", output); + } + + [Fact] + public void StreamExtensions_ReadMultiDictionary_TerminatedCRLF_ReturnsDictionary() + { + string input = "a=1\r\nb=2\r\nc=3\r\n\r\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadMultiDictionary); + + Assert.NotNull(output); + Assert.Equal(3, output.Count); + AssertMultiDictionary(new[] { "1" }, "a", output); + AssertMultiDictionary(new[] { "2" }, "b", output); + AssertMultiDictionary(new[] { "3" }, "c", output); + } + + [Fact] + public void StreamExtensions_ReadMultiDictionary_CaseSensitive_ReturnsDictionaryWithMultipleEntries() + { + string input = "a=1\nA=2\n\n"; + + var output = ReadStringStream(input, x => StreamExtensions.ReadMultiDictionary(x, StringComparer.Ordinal)); + + Assert.NotNull(output); + Assert.Equal(2, output.Count); + AssertMultiDictionary(new[] { "1" }, "a", output); + AssertMultiDictionary(new[] { "2" }, "A", output); + } + + [Fact] + public void StreamExtensions_ReadMultiDictionary_CaseInsensitive_ReturnsDictionaryWithLastValue() + { + string input = "a=1\nA=2\n\n"; + + var output = ReadStringStream(input, x => StreamExtensions.ReadMultiDictionary(x, StringComparer.OrdinalIgnoreCase)); + + Assert.NotNull(output); + Assert.Equal(1, output.Count); + AssertMultiDictionary(new[] { "2" }, "a", output); + } + + [Fact] + public void StreamExtensions_ReadMultiDictionary_Spaces_ReturnsCorrectKeysAndValues() + { + string input = "key a=value 1\n key b = 2 \nkey\tc\t=\t3\t\n\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadMultiDictionary); + + Assert.NotNull(output); + Assert.Equal(3, output.Count); + + AssertMultiDictionary(new[] { "value 1" }, "key a", output); + AssertMultiDictionary(new[] { " 2 " }, " key b ", output); + AssertMultiDictionary(new[] { "\t3\t" }, "key\tc\t", output); + } + + [Fact] + public void StreamExtensions_ReadMultiDictionary_EqualsInValues_ReturnsCorrectKeysAndValues() + { + string input = "a=value=1\nb=value=2\nc=value=3\n\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadMultiDictionary); + + Assert.NotNull(output); + Assert.Equal(3, output.Count); + AssertMultiDictionary(new[] { "value=1" }, "a", output); + AssertMultiDictionary(new[] { "value=2" }, "b", output); + AssertMultiDictionary(new[] { "value=3" }, "c", output); + } + + [Fact] + public void StreamExtensions_ReadMultiDictionary_MultiValue_ReturnsDictionary() + { + string input = "odd[]=1\neven[]=2\neven[]=4\nodd[]=3\n\n"; + + var output = ReadStringStream(input, StreamExtensions.ReadMultiDictionary); + + Assert.NotNull(output); + Assert.Equal(2, output.Count); + AssertMultiDictionary(new[] { "1", "3" }, "odd", output); + AssertMultiDictionary(new[] { "2", "4" }, "even", output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_TextWriterLF_EmptyMultiDictionary_WritesLineLF() + { + var input = new Dictionary>(); + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: LF); + + Assert.Equal(LF, output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_TextWriterCRLF_EmptyMultiDictionary_WritesLineCRLF() + { + var input = new Dictionary>(); + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: CRLF); + + Assert.Equal(CRLF, output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_TextWriterLF_MultiEntries_WritesKVPListsAndLF() + { + var input = new Dictionary> + { + ["a"] = new[] { "1", "2", "3" }, + ["b"] = new[] { "4", "5", }, + ["c"] = new[] { "6" } + }; + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: LF); + + Assert.Equal("a[]=1\na[]=2\na[]=3\nb[]=4\nb[]=5\nc=6\n\n", output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_TextWriterCRLF_MultiEntries_WritesKVPListsAndCRLF() + { + var input = new Dictionary> + { + ["a"] = new[] { "1", "2", "3" }, + ["b"] = new[] { "4", "5", }, + ["c"] = new[] { "6" } + }; + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: CRLF); + + Assert.Equal("a[]=1\r\na[]=2\r\na[]=3\r\nb[]=4\r\nb[]=5\r\nc=6\r\n\r\n", output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_NoMultiEntries_WritesKVPsAndLF() + { + var input = new Dictionary> + { + ["a"] = new[] {"1"}, + ["b"] = new[] {"2"}, + ["c"] = new[] {"3"} + }; + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: LF); + + Assert.Equal("a=1\nb=2\nc=3\n\n", output); + } + + [Fact] + public void StreamExtensions_WriteDictionary_MultiEntriesWithEmpty_WritesKVPListsAndLF() + { + var input = new Dictionary> + { + ["a"] = new[] {"1", "2", "", "3", "4"}, + ["b"] = new[] {"5"}, + ["c"] = new[] {"6", "7", ""} + }; + + string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: LF); + + Assert.Equal("a[]=3\na[]=4\nb=5\n\n", output); + } + + #endregion + #region Helpers - private static IDictionary ReadStringStream(string input, Func> func) + private static T ReadStringStream(string input, Func func) { - IDictionary output; + T output; using (var reader = new StringReader(input)) { output = func(reader); @@ -196,7 +391,7 @@ private static IDictionary ReadStringStream(string input, Func input, Action> action, string newLine) + private static string WriteStringStream(T input, Action action, string newLine) { var output = new StringBuilder(); using (var writer = new StringWriter(output){NewLine = newLine}) @@ -207,6 +402,20 @@ private static string WriteStringStream(IDictionary input, Actio return output.ToString(); } + private static void AssertDictionary(string expectedValue, string key, IDictionary dict) + { + Assert.True(dict.TryGetValue(key, out string actualValue)); + Assert.Equal(expectedValue, actualValue); + } + + private static void AssertMultiDictionary(IList expectedValues, + string key, + IDictionary> dict) + { + Assert.True(dict.TryGetValue(key, out IList actualValues)); + Assert.Equal(expectedValues, actualValues); + } + #endregion } } diff --git a/src/shared/Core/StreamExtensions.cs b/src/shared/Core/StreamExtensions.cs index 7659e8daf..bbf2a359b 100644 --- a/src/shared/Core/StreamExtensions.cs +++ b/src/shared/Core/StreamExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Text; using System.Threading.Tasks; namespace GitCredentialManager @@ -42,6 +41,40 @@ public static IDictionary ReadDictionary(this TextReader reader, return dict; } + /// + /// Read a multi-dictionary in the form `key1=value\nkey2=value\n\n` from the specified . + /// + /// + /// Uses the comparer for dictionary keys. + /// + /// Also accepts dictionary lines terminated using \r\n (CR LF) as well as \n (LF). + /// + /// Text reader to read a dictionary from. + /// Dictionary read from the text reader. + public static IDictionary> ReadMultiDictionary(this TextReader reader) => + ReadMultiDictionary(reader, StringComparer.Ordinal); + + /// + /// Read a multi-dictionary in the form `key1=value\nkey2=value\n\n` from the specified , + /// with the specified used to compare dictionary keys. + /// + /// Also accepts dictionary lines terminated using \r\n (CR LF) as well as \n (LF). + /// Text reader to read a dictionary from. + /// Comparer to use when comparing dictionary keys. + /// Dictionary read from the text reader. + public static IDictionary> ReadMultiDictionary(this TextReader reader, StringComparer comparer) + { + var dict = new Dictionary>(comparer); + + string line; + while ((line = reader.ReadLine()) != null && !string.IsNullOrWhiteSpace(line)) + { + ParseMultiLine(dict, line); + } + + return dict; + } + /// /// Asynchronously read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified . /// @@ -55,6 +88,19 @@ public static IDictionary ReadDictionary(this TextReader reader, public static Task> ReadDictionaryAsync(this TextReader reader) => ReadDictionaryAsync(reader, StringComparer.Ordinal); + /// + /// Asynchronously read a multi-dictionary in the form `key1=value\nkey2=value\n\n` from the specified . + /// + /// + /// Uses the comparer for dictionary keys. + /// + /// Also accepts dictionary lines terminated using \r\n (CR LF) as well as \n (LF). + /// + /// Text reader to read a dictionary from. + /// Dictionary read from the text reader. + public static Task>> ReadMultiDictionaryAsync(this TextReader reader) => + ReadMultiDictionaryAsync(reader, StringComparer.Ordinal); + /// /// Asynchronously read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified , /// with the specified used to compare dictionary keys. @@ -76,18 +122,73 @@ public static async Task> ReadDictionaryAsync(this T return dict; } + /// + /// Asynchronously read a multi-dictionary in the form `key1=value\nkey2=value\n\n` from the specified , + /// with the specified used to compare dictionary keys. + /// + /// Also accepts dictionary lines terminated using \r\n (CR LF) as well as \n (LF). + /// Text reader to read a dictionary from. + /// Comparer to use when comparing dictionary keys. + /// Dictionary read from the text reader. + public static async Task>> ReadMultiDictionaryAsync(this TextReader reader, StringComparer comparer) + { + var dict = new Dictionary>(comparer); + + string line; + while ((line = await reader.ReadLineAsync()) != null && !string.IsNullOrWhiteSpace(line)) + { + ParseMultiLine(dict, line); + } + + return dict; + } + /// /// Write a dictionary in the form `key1=value\nkey2=value\n\n` to the specified , /// where \n is the configured new-line (see ). /// /// The output dictionary new-lines are determined by the property. - /// Text writer to write a dictionary to. + /// Text writer to write a dictionary to. /// Dictionary to write to the text writer. public static void WriteDictionary(this TextWriter writer, IDictionary dict) { foreach (var kvp in dict) { - WriteKeyValuePair(writer, kvp); + writer.WriteLine($"{kvp.Key}={kvp.Value}"); + } + + // Write terminating line + writer.WriteLine(); + } + + /// + /// Write a dictionary in the form `key1=value\nkey2=value\n\n` to the specified , + /// where \n is the configured new-line (see ). + /// + /// The output dictionary new-lines are determined by the property. + /// Text writer to write a dictionary to. + /// Dictionary to write to the text writer. + public static void WriteDictionary(this TextWriter writer, IDictionary> dict) + { + foreach (var kvp in dict) + { + IList values = GetNormalizedValueList(kvp.Value); + switch (values.Count) + { + case 0: + break; + + case 1: + writer.WriteLine($"{kvp.Key}={kvp.Value[0]}"); + break; + + default: + foreach (string value in values) + { + writer.WriteLine($"{kvp.Key}[]={value}"); + } + break; + } } // Write terminating line @@ -99,36 +200,32 @@ public static void WriteDictionary(this TextWriter writer, IDictionary). /// /// The output dictionary new-lines are determined by the property. - /// Text writer to write a dictionary to. + /// Text writer to write a dictionary to. /// Dictionary to write to the text writer. public static async Task WriteDictionaryAsync(this TextWriter writer, IDictionary dict) { foreach (var kvp in dict) { - await WriteKeyValuePairAsync(writer, kvp); + await writer.WriteLineAsync($"{kvp.Key}={kvp.Value}"); } // Write terminating line await writer.WriteLineAsync(); } - private static void WriteKeyValuePair(this TextWriter writer, KeyValuePair kvp) - => WriteKeyValuePair(writer, kvp.Key, kvp.Value); - - private static void WriteKeyValuePair(this TextWriter writer, string key, string value) + private static void ParseLine(IDictionary dict, string line) { - writer.WriteLine($"{key}={value}"); - } - - private static Task WriteKeyValuePairAsync(this TextWriter writer, KeyValuePair kvp) - => WriteKeyValuePairAsync(writer, kvp.Key, kvp.Value); + int splitIndex = line.IndexOf('='); + if (splitIndex > 0) + { + string key = line.Substring(0, splitIndex); + string value = line.Substring(splitIndex + 1); - private static Task WriteKeyValuePairAsync(this TextWriter writer, string key, string value) - { - return writer.WriteLineAsync($"{key}={value}"); + dict[key] = value; + } } - private static void ParseLine(IDictionary dict, string line) + private static void ParseMultiLine(IDictionary> dict, string line) { int splitIndex = line.IndexOf('='); if (splitIndex > 0) @@ -136,8 +233,52 @@ private static void ParseLine(IDictionary dict, string line) string key = line.Substring(0, splitIndex); string value = line.Substring(splitIndex + 1); - dict[key] = value; + bool multi = key.EndsWith("[]"); + if (multi) + { + key = key.Substring(0, key.Length - 2); + } + + if (!dict.TryGetValue(key, out IList list)) + { + list = new List(); + dict[key] = list; + } + + // Only allow one value for non-multi/array entries ("key=value") + // and reset the array of a multi-entry if the value is empty ("key[]=") + bool emptyValue = string.IsNullOrEmpty(value); + if (!multi || emptyValue) + { + list.Clear(); + + if (emptyValue) + { + return; + } + } + + list.Add(value); } } + + private static IList GetNormalizedValueList(IEnumerable values) + { + var result = new List(); + + foreach (string value in values) + { + if (string.IsNullOrWhiteSpace(value)) + { + result.Clear(); + } + else + { + result.Add(value); + } + } + + return result; + } } } diff --git a/src/shared/Core/Trace.cs b/src/shared/Core/Trace.cs index 34055d16f..86a1ec698 100644 --- a/src/shared/Core/Trace.cs +++ b/src/shared/Core/Trace.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -215,13 +216,25 @@ public void WriteDictionarySecrets( { bool isSecretEntry = !(secretKeys is null) && secretKeys.Contains(entry.Key, keyComparer ?? EqualityComparer.Default); - if (isSecretEntry && !this.IsSecretTracingEnabled) + + void WriteSecretLine(object value) + { + var message = isSecretEntry && !IsSecretTracingEnabled + ? $"\t{entry.Key}={SecretMask}" + : $"\t{entry.Key}={value}"; + WriteLine(message, filePath, lineNumber, memberName); + } + + if (entry.Value is IEnumerable values) { - WriteLine($"\t{entry.Key}={SecretMask}", filePath, lineNumber, memberName); + foreach (string value in values) + { + WriteSecretLine(value); + } } else { - WriteLine($"\t{entry.Key}={entry.Value}", filePath, lineNumber, memberName); + WriteSecretLine(entry.Value); } } }