소스 검색

Add an OrderedDictionary implementation for algorithm priorities (#1611)

* Add an OrderedDictionary implementation for algorithm priorities

During the key exchange, the algorithms to be used are chosen based on the order that
the client sends: first algorithm is most desirable. Currently, the algorithm
collections in ConnectionInfo are defined as IDictionary<,> and backed by
Dictionary<,>, which does not have any guarantees on the order of enumeration
(in practice, when only adding and not removing items it does enumerate in the order
that items were added as an implementation detail, but it's not great to rely on it).

This change adds IOrderedDictionary<,> and uses it in ConnectionInfo. On .NET 9,
this is backed by System.Collections.Generic.OrderedDictionary<,> and on lower
targets, it uses a relatively simple implementation backed by a List and a
Dictionary.

* use ThrowIfNegative
Rob Hague 7 달 전
부모
커밋
153b47d976

+ 25 - 0
src/Renci.SshNet/Common/Extensions.cs

@@ -358,5 +358,30 @@ namespace Renci.SshNet.Common
             // which is not available on all targets.
             return string.Join(separator, values);
         }
+
+#if NETFRAMEWORK || NETSTANDARD2_0
+        internal static bool TryAdd<TKey, TValue>(this Dictionary<TKey, TValue> dictionary, TKey key, TValue value)
+        {
+            if (!dictionary.ContainsKey(key))
+            {
+                dictionary.Add(key, value);
+                return true;
+            }
+
+            return false;
+        }
+
+        internal static bool Remove<TKey, TValue>(this Dictionary<TKey, TValue> dictionary, TKey key, out TValue value)
+        {
+            if (dictionary.TryGetValue(key, out value))
+            {
+                _ = dictionary.Remove(key);
+                return true;
+            }
+
+            value = default;
+            return false;
+        }
+#endif
     }
 }

+ 20 - 22
src/Renci.SshNet/ConnectionInfo.cs

@@ -50,34 +50,32 @@ namespace Renci.SshNet
         /// <summary>
         /// Gets supported key exchange algorithms for this connection.
         /// </summary>
-        public IDictionary<string, Func<IKeyExchange>> KeyExchangeAlgorithms { get; private set; }
+        public IOrderedDictionary<string, Func<IKeyExchange>> KeyExchangeAlgorithms { get; }
 
         /// <summary>
         /// Gets supported encryptions for this connection.
         /// </summary>
-#pragma warning disable CA1859 // Use concrete types when possible for improved performance
-        public IDictionary<string, CipherInfo> Encryptions { get; private set; }
-#pragma warning restore CA1859 // Use concrete types when possible for improved performance
+        public IOrderedDictionary<string, CipherInfo> Encryptions { get; }
 
         /// <summary>
         /// Gets supported hash algorithms for this connection.
         /// </summary>
-        public IDictionary<string, HashInfo> HmacAlgorithms { get; private set; }
+        public IOrderedDictionary<string, HashInfo> HmacAlgorithms { get; }
 
         /// <summary>
         /// Gets supported host key algorithms for this connection.
         /// </summary>
-        public IDictionary<string, Func<byte[], KeyHostAlgorithm>> HostKeyAlgorithms { get; private set; }
+        public IOrderedDictionary<string, Func<byte[], KeyHostAlgorithm>> HostKeyAlgorithms { get; }
 
         /// <summary>
         /// Gets supported authentication methods for this connection.
         /// </summary>
-        public IList<AuthenticationMethod> AuthenticationMethods { get; private set; }
+        public IList<AuthenticationMethod> AuthenticationMethods { get; }
 
         /// <summary>
         /// Gets supported compression algorithms for this connection.
         /// </summary>
-        public IDictionary<string, Func<Compressor>> CompressionAlgorithms { get; private set; }
+        public IOrderedDictionary<string, Func<Compressor>> CompressionAlgorithms { get; }
 
         /// <summary>
         /// Gets the supported channel requests for this connection.
@@ -85,7 +83,7 @@ namespace Renci.SshNet
         /// <value>
         /// The supported channel requests for this connection.
         /// </value>
-        public IDictionary<string, RequestInfo> ChannelRequests { get; private set; }
+        public IDictionary<string, RequestInfo> ChannelRequests { get; }
 
         /// <summary>
         /// Gets a value indicating whether connection is authenticated.
@@ -101,7 +99,7 @@ namespace Renci.SshNet
         /// <value>
         /// The connection host.
         /// </value>
-        public string Host { get; private set; }
+        public string Host { get; }
 
         /// <summary>
         /// Gets connection port.
@@ -109,12 +107,12 @@ namespace Renci.SshNet
         /// <value>
         /// The connection port. The default value is 22.
         /// </value>
-        public int Port { get; private set; }
+        public int Port { get; }
 
         /// <summary>
         /// Gets connection username.
         /// </summary>
-        public string Username { get; private set; }
+        public string Username { get; }
 
         /// <summary>
         /// Gets proxy type.
@@ -122,27 +120,27 @@ namespace Renci.SshNet
         /// <value>
         /// The type of the proxy.
         /// </value>
-        public ProxyTypes ProxyType { get; private set; }
+        public ProxyTypes ProxyType { get; }
 
         /// <summary>
         /// Gets proxy connection host.
         /// </summary>
-        public string ProxyHost { get; private set; }
+        public string ProxyHost { get; }
 
         /// <summary>
         /// Gets proxy connection port.
         /// </summary>
-        public int ProxyPort { get; private set; }
+        public int ProxyPort { get; }
 
         /// <summary>
         /// Gets proxy connection username.
         /// </summary>
-        public string ProxyUsername { get; private set; }
+        public string ProxyUsername { get; }
 
         /// <summary>
         /// Gets proxy connection password.
         /// </summary>
-        public string ProxyPassword { get; private set; }
+        public string ProxyPassword { get; }
 
         /// <summary>
         /// Gets or sets connection timeout.
@@ -347,7 +345,7 @@ namespace Renci.SshNet
             MaxSessions = 10;
             Encoding = Encoding.UTF8;
 
-            KeyExchangeAlgorithms = new Dictionary<string, Func<IKeyExchange>>
+            KeyExchangeAlgorithms = new OrderedDictionary<string, Func<IKeyExchange>>
                 {
                     { "mlkem768x25519-sha256", () => new KeyExchangeMLKem768X25519Sha256() },
                     { "sntrup761x25519-sha512", () => new KeyExchangeSNtruP761X25519Sha512() },
@@ -365,7 +363,7 @@ namespace Renci.SshNet
                     { "diffie-hellman-group1-sha1", () => new KeyExchangeDiffieHellmanGroup1Sha1() },
                 };
 
-            Encryptions = new Dictionary<string, CipherInfo>
+            Encryptions = new OrderedDictionary<string, CipherInfo>
                 {
                     { "aes128-ctr", new CipherInfo(128, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false)) },
                     { "aes192-ctr", new CipherInfo(192, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false)) },
@@ -379,7 +377,7 @@ namespace Renci.SshNet
                     { "3des-cbc", new CipherInfo(192, (key, iv) => new TripleDesCipher(key, iv, CipherMode.CBC, pkcs7Padding: false)) },
                 };
 
-            HmacAlgorithms = new Dictionary<string, HashInfo>
+            HmacAlgorithms = new OrderedDictionary<string, HashInfo>
                 {
                     /* Encrypt-and-MAC (encrypt-and-authenticate) variants */
                     { "hmac-sha2-256", new HashInfo(32*8, key => new HMACSHA256(key)) },
@@ -392,7 +390,7 @@ namespace Renci.SshNet
                 };
 
 #pragma warning disable SA1107 // Code should not contain multiple statements on one line
-            var hostAlgs = new Dictionary<string, Func<byte[], KeyHostAlgorithm>>();
+            var hostAlgs = new OrderedDictionary<string, Func<byte[], KeyHostAlgorithm>>();
             hostAlgs.Add("ssh-ed25519-cert-v01@openssh.com", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("ssh-ed25519-cert-v01@openssh.com", cert, hostAlgs); });
             hostAlgs.Add("ecdsa-sha2-nistp256-cert-v01@openssh.com", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("ecdsa-sha2-nistp256-cert-v01@openssh.com", cert, hostAlgs); });
             hostAlgs.Add("ecdsa-sha2-nistp384-cert-v01@openssh.com", data => { var cert = new Certificate(data); return new CertificateHostAlgorithm("ecdsa-sha2-nistp384-cert-v01@openssh.com", cert, hostAlgs); });
@@ -411,7 +409,7 @@ namespace Renci.SshNet
 #pragma warning restore SA1107 // Code should not contain multiple statements on one line
             HostKeyAlgorithms = hostAlgs;
 
-            CompressionAlgorithms = new Dictionary<string, Func<Compressor>>
+            CompressionAlgorithms = new OrderedDictionary<string, Func<Compressor>>
                 {
                     { "none", null },
                     { "zlib@openssh.com", () => new ZlibOpenSsh() },

+ 149 - 0
src/Renci.SshNet/IOrderedDictionary`2.cs

@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Renci.SshNet
+{
+    /// <summary>
+    /// Represents a collection of key/value pairs that are accessible by the key or index.
+    /// </summary>
+    /// <typeparam name="TKey">The type of the keys in the dictionary.</typeparam>
+    /// <typeparam name="TValue">The type of the values in the dictionary.</typeparam>
+    public interface IOrderedDictionary<TKey, TValue> :
+        IDictionary<TKey, TValue>, IReadOnlyDictionary<TKey, TValue>
+        where TKey : notnull
+    {
+        // Some members are redefined with 'new' to resolve ambiguities.
+
+        /// <summary>Gets or sets the value associated with the specified key.</summary>
+        /// <param name="key">The key of the value to get or set.</param>
+        /// <returns>The value associated with the specified key. If the specified key is not found, a get operation throws a <see cref="KeyNotFoundException"/>, and a set operation creates a new element with the specified key.</returns>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        /// <exception cref="KeyNotFoundException">The property is retrieved and <paramref name="key"/> does not exist in the collection.</exception>
+        /// <remarks>Setting the value of an existing key does not impact its order in the collection.</remarks>
+        new TValue this[TKey key] { get; set; }
+
+        /// <summary>Gets a collection containing the keys in the <see cref="IOrderedDictionary{TKey, TValue}"/>.</summary>
+        new ICollection<TKey> Keys { get; }
+
+        /// <summary>Gets a collection containing the values in the <see cref="IOrderedDictionary{TKey, TValue}"/>.</summary>
+        new ICollection<TValue> Values { get; }
+
+        /// <summary>Gets the number of key/value pairs contained in the <see cref="IOrderedDictionary{TKey, TValue}"/>.</summary>
+        new int Count { get; }
+
+        /// <summary>Determines whether the <see cref="IOrderedDictionary{TKey, TValue}"/> contains the specified key.</summary>
+        /// <param name="key">The key to locate in the <see cref="IOrderedDictionary{TKey, TValue}"/>.</param>
+        /// <returns><see langword="true"/> if the <see cref="IOrderedDictionary{TKey, TValue}"/> contains an element with the specified key; otherwise, <see langword="false"/>.</returns>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        new bool ContainsKey(TKey key);
+
+        /// <summary>Determines whether the <see cref="IOrderedDictionary{TKey, TValue}"/> contains a specific value.</summary>
+        /// <param name="value">The value to locate in the <see cref="IOrderedDictionary{TKey, TValue}"/>. The value can be null for reference types.</param>
+        /// <returns><see langword="true"/> if the <see cref="IOrderedDictionary{TKey, TValue}"/> contains an element with the specified value; otherwise, <see langword="false"/>.</returns>
+        bool ContainsValue(TValue value);
+
+        /// <summary>Gets the key/value pair at the specified index.</summary>
+        /// <param name="index">The zero-based index of the pair to get.</param>
+        /// <returns>The element at the specified index.</returns>
+        /// <exception cref="ArgumentOutOfRangeException"><paramref name="index"/> is less than 0 or greater than or equal to <see cref="Count"/>.</exception>
+        KeyValuePair<TKey, TValue> GetAt(int index);
+
+        /// <summary>Determines the index of a specific key in the <see cref="IOrderedDictionary{TKey, TValue}"/>.</summary>
+        /// <param name="key">The key to locate.</param>
+        /// <returns>The index of <paramref name="key"/> if found; otherwise, -1.</returns>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        int IndexOf(TKey key);
+
+        /// <summary>Inserts an item into the collection at the specified index.</summary>
+        /// <param name="index">The zero-based index at which item should be inserted.</param>
+        /// <param name="key">The key to insert.</param>
+        /// <param name="value">The value to insert.</param>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        /// <exception cref="ArgumentException">An element with the same key already exists in the <see cref="IOrderedDictionary{TKey, TValue}"/>.</exception>
+        /// <exception cref="ArgumentOutOfRangeException"><paramref name="index"/> is less than 0 or greater than <see cref="Count"/>.</exception>
+        void Insert(int index, TKey key, TValue value);
+
+        /// <summary>Removes the value with the specified key from the <see cref="IOrderedDictionary{TKey, TValue}"/> and copies the element to the value parameter.</summary>
+        /// <param name="key">The key of the element to remove.</param>
+        /// <param name="value">The removed element.</param>
+        /// <returns><see langword="true"/> if the element is successfully found and removed; otherwise, <see langword="false"/>.</returns>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        bool Remove(TKey key, [MaybeNullWhen(false)] out TValue value);
+
+        /// <summary>Removes the key/value pair at the specified index.</summary>
+        /// <param name="index">The zero-based index of the item to remove.</param>
+        /// <exception cref="ArgumentOutOfRangeException"><paramref name="index"/> is less than 0 or greater than or equal to <see cref="Count"/>.</exception>
+        void RemoveAt(int index);
+
+        /// <summary>Sets the key/value pair at the specified index.</summary>
+        /// <param name="index">The zero-based index at which to set the key/value pair.</param>
+        /// <param name="key">The key to store at the specified index.</param>
+        /// <param name="value">The value to store at the specified index.</param>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        /// <exception cref="ArgumentException">An element with the same key already exists at an index different to <paramref name="index"/>.</exception>
+        /// <exception cref="ArgumentOutOfRangeException"><paramref name="index"/> is less than 0 or greater than or equal to <see cref="Count"/>.</exception>
+        void SetAt(int index, TKey key, TValue value);
+
+        /// <summary>Sets the value for the key at the specified index.</summary>
+        /// <param name="index">The zero-based index at which to set the key/value pair.</param>
+        /// <param name="value">The value to store at the specified index.</param>
+        /// <exception cref="ArgumentOutOfRangeException"><paramref name="index"/> is less than 0 or greater than or equal to <see cref="Count"/>.</exception>
+        void SetAt(int index, TValue value);
+
+        /// <summary>
+        /// Moves an existing key/value pair to the specified index in the collection.
+        /// </summary>
+        /// <param name="index">The current zero-based index of the key/value pair to move.</param>
+        /// <param name="newIndex">The zero-based index at which to set the key/value pair.</param>
+        /// <exception cref="ArgumentOutOfRangeException">
+        /// <paramref name="index"/> or <paramref name="newIndex"/> are less than 0 or greater than or equal to <see cref="Count"/>.
+        /// </exception>
+        void SetPosition(int index, int newIndex);
+
+        /// <summary>
+        /// Moves an existing key/value pair to the specified index in the collection.
+        /// </summary>
+        /// <param name="key">The key to move.</param>
+        /// <param name="newIndex">The zero-based index at which to set the key/value pair.</param>
+        /// <exception cref="KeyNotFoundException">The specified key does not exist in the collection.</exception>
+        /// <exception cref="ArgumentOutOfRangeException"><paramref name="newIndex"/> is less than 0 or greater than or equal to <see cref="Count"/>.</exception>
+        void SetPosition(TKey key, int newIndex);
+
+        /// <summary>Adds the specified key and value to the dictionary if the key doesn't already exist.</summary>
+        /// <param name="key">The key of the element to add.</param>
+        /// <param name="value">The value of the element to add. The value can be <see langword="null"/> for reference types.</param>
+        /// <returns><see langword="true"/> if the key didn't exist and the key and value were added to the dictionary; otherwise, <see langword="false"/>.</returns>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        bool TryAdd(TKey key, TValue value);
+
+        /// <summary>Adds the specified key and value to the dictionary if the key doesn't already exist.</summary>
+        /// <param name="key">The key of the element to add.</param>
+        /// <param name="value">The value of the element to add. The value can be <see langword="null"/> for reference types.</param>
+        /// <param name="index">The index of the added or existing <paramref name="key"/>. This is always a valid index into the dictionary.</param>
+        /// <returns><see langword="true"/> if the key didn't exist and the key and value were added to the dictionary; otherwise, <see langword="false"/>.</returns>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        bool TryAdd(TKey key, TValue value, out int index);
+
+        /// <summary>Gets the value associated with the specified key.</summary>
+        /// <param name="key">The key of the value to get.</param>
+        /// <param name="value">
+        /// When this method returns, contains the value associated with the specified key, if the key is found;
+        /// otherwise, the default value for the type of the value parameter.
+        /// </param>
+        /// <returns><see langword="true"/> if the <see cref="IOrderedDictionary{TKey, TValue}"/> contains an element with the specified key; otherwise, <see langword="false"/>.</returns>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        new bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value);
+
+        /// <summary>Gets the value associated with the specified key.</summary>
+        /// <param name="key">The key of the value to get.</param>
+        /// <param name="value">
+        /// When this method returns, contains the value associated with the specified key, if the key is found;
+        /// otherwise, the default value for the type of the value parameter.
+        /// </param>
+        /// <param name="index">The index of <paramref name="key"/> if found; otherwise, -1.</param>
+        /// <returns><see langword="true"/> if the <see cref="IOrderedDictionary{TKey, TValue}"/> contains an element with the specified key; otherwise, <see langword="false"/>.</returns>
+        /// <exception cref="ArgumentNullException"><paramref name="key"/> is <see langword="null"/>.</exception>
+        bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value, out int index);
+    }
+}

+ 245 - 0
src/Renci.SshNet/OrderedDictionary.net9.cs

@@ -0,0 +1,245 @@
+#if NET9_0_OR_GREATER
+#nullable enable
+#pragma warning disable SA1649 // File name should match first type name
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Renci.SshNet
+{
+    internal sealed class OrderedDictionary<TKey, TValue> : IOrderedDictionary<TKey, TValue>
+        where TKey : notnull
+    {
+        private readonly System.Collections.Generic.OrderedDictionary<TKey, TValue> _impl;
+
+        public OrderedDictionary(EqualityComparer<TKey>? comparer = null)
+        {
+            _impl = new System.Collections.Generic.OrderedDictionary<TKey, TValue>(comparer);
+        }
+
+        public TValue this[TKey key]
+        {
+            get
+            {
+                return _impl[key];
+            }
+            set
+            {
+                _impl[key] = value;
+            }
+        }
+
+        public ICollection<TKey> Keys
+        {
+            get
+            {
+                return _impl.Keys;
+            }
+        }
+
+        IEnumerable<TKey> IReadOnlyDictionary<TKey, TValue>.Keys
+        {
+            get
+            {
+                return ((IReadOnlyDictionary<TKey, TValue>)_impl).Keys;
+            }
+        }
+
+        public ICollection<TValue> Values
+        {
+            get
+            {
+                return _impl.Values;
+            }
+        }
+
+        IEnumerable<TValue> IReadOnlyDictionary<TKey, TValue>.Values
+        {
+            get
+            {
+                return ((IReadOnlyDictionary<TKey, TValue>)_impl).Values;
+            }
+        }
+
+        public int Count
+        {
+            get
+            {
+                return _impl.Count;
+            }
+        }
+
+        bool ICollection<KeyValuePair<TKey, TValue>>.IsReadOnly
+        {
+            get
+            {
+                return ((ICollection<KeyValuePair<TKey, TValue>>)_impl).IsReadOnly;
+            }
+        }
+
+        public void Add(TKey key, TValue value)
+        {
+            _impl.Add(key, value);
+        }
+
+        void ICollection<KeyValuePair<TKey, TValue>>.Add(KeyValuePair<TKey, TValue> item)
+        {
+            ((ICollection<KeyValuePair<TKey, TValue>>)_impl).Add(item);
+        }
+
+        public void Clear()
+        {
+            _impl.Clear();
+        }
+
+        bool ICollection<KeyValuePair<TKey, TValue>>.Contains(KeyValuePair<TKey, TValue> item)
+        {
+            return ((ICollection<KeyValuePair<TKey, TValue>>)_impl).Contains(item);
+        }
+
+        public bool ContainsKey(TKey key)
+        {
+            return _impl.ContainsKey(key);
+        }
+
+        public bool ContainsValue(TValue value)
+        {
+            return _impl.ContainsValue(value);
+        }
+
+        void ICollection<KeyValuePair<TKey, TValue>>.CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
+        {
+            ((ICollection<KeyValuePair<TKey, TValue>>)_impl).CopyTo(array, arrayIndex);
+        }
+
+        public KeyValuePair<TKey, TValue> GetAt(int index)
+        {
+            return _impl.GetAt(index);
+        }
+
+        public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
+        {
+            return _impl.GetEnumerator();
+        }
+
+        IEnumerator IEnumerable.GetEnumerator()
+        {
+            return GetEnumerator();
+        }
+
+        public int IndexOf(TKey key)
+        {
+            return _impl.IndexOf(key);
+        }
+
+        public void Insert(int index, TKey key, TValue value)
+        {
+            _impl.Insert(index, key, value);
+        }
+
+        public bool Remove(TKey key, [MaybeNullWhen(false)] out TValue value)
+        {
+            return _impl.Remove(key, out value);
+        }
+
+        public bool Remove(TKey key)
+        {
+            return _impl.Remove(key);
+        }
+
+        bool ICollection<KeyValuePair<TKey, TValue>>.Remove(KeyValuePair<TKey, TValue> item)
+        {
+            return ((ICollection<KeyValuePair<TKey, TValue>>)_impl).Remove(item);
+        }
+
+        public void RemoveAt(int index)
+        {
+            _impl.RemoveAt(index);
+        }
+
+        public void SetAt(int index, TKey key, TValue value)
+        {
+            _impl.SetAt(index, key, value);
+        }
+
+        public void SetAt(int index, TValue value)
+        {
+            _impl.SetAt(index, value);
+        }
+
+        public void SetPosition(int index, int newIndex)
+        {
+            if ((uint)newIndex >= Count)
+            {
+                throw new ArgumentOutOfRangeException(nameof(newIndex));
+            }
+
+            var kvp = _impl.GetAt(index);
+
+            _impl.RemoveAt(index);
+
+            _impl.Insert(newIndex, kvp.Key, kvp.Value);
+        }
+
+        public void SetPosition(TKey key, int newIndex)
+        {
+            if ((uint)newIndex >= Count)
+            {
+                throw new ArgumentOutOfRangeException(nameof(newIndex));
+            }
+
+            if (!_impl.Remove(key, out var value))
+            {
+                // Please throw a nicely formatted, localised exception.
+                _ = _impl[key];
+
+                Debug.Fail("Previous line should throw KeyNotFoundException.");
+            }
+
+            _impl.Insert(newIndex, key, value);
+        }
+
+        public bool TryAdd(TKey key, TValue value)
+        {
+            return _impl.TryAdd(key, value);
+        }
+
+        public bool TryAdd(TKey key, TValue value, out int index)
+        {
+#if NET10_0_OR_GREATER
+            return _impl.TryAdd(key, value, out index);
+#else
+            var success = _impl.TryAdd(key, value);
+
+            index = _impl.IndexOf(key);
+
+            return success;
+#endif
+        }
+
+        public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value)
+        {
+            return _impl.TryGetValue(key, out value);
+        }
+
+        public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value, out int index)
+        {
+#if NET10_0_OR_GREATER
+            return _impl.TryGetValue(key, out value, out index);
+#else
+            if (_impl.TryGetValue(key, out value))
+            {
+                index = _impl.IndexOf(key);
+                return true;
+            }
+
+            index = -1;
+            return false;
+#endif
+        }
+    }
+}
+#endif

+ 514 - 0
src/Renci.SshNet/OrderedDictionary.netstandard.cs

@@ -0,0 +1,514 @@
+#if !NET9_0_OR_GREATER
+#nullable enable
+#pragma warning disable SA1649 // File name should match first type name
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+using Renci.SshNet.Common;
+
+namespace Renci.SshNet
+{
+    internal sealed class OrderedDictionary<TKey, TValue> : IOrderedDictionary<TKey, TValue>
+        where TKey : notnull
+    {
+        private readonly Dictionary<TKey, TValue> _dictionary;
+        private readonly List<KeyValuePair<TKey, TValue>> _list;
+
+        private KeyCollection? _keys;
+        private ValueCollection? _values;
+
+        public OrderedDictionary(EqualityComparer<TKey>? comparer = null)
+        {
+            _dictionary = new Dictionary<TKey, TValue>(comparer);
+            _list = new List<KeyValuePair<TKey, TValue>>();
+        }
+
+        public TValue this[TKey key]
+        {
+            get
+            {
+                return _dictionary[key];
+            }
+            set
+            {
+                if (_dictionary.TryAdd(key, value))
+                {
+                    _list.Add(new KeyValuePair<TKey, TValue>(key, value));
+                }
+                else
+                {
+                    _dictionary[key] = value;
+                    _list[IndexOf(key)] = new KeyValuePair<TKey, TValue>(key, value);
+                }
+
+                AssertConsistency();
+            }
+        }
+
+        [Conditional("DEBUG")]
+        private void AssertConsistency()
+        {
+            Debug.Assert(_list.Count == _dictionary.Count);
+
+            foreach (var kvp in _list)
+            {
+                Debug.Assert(_dictionary.TryGetValue(kvp.Key, out var value));
+                Debug.Assert(EqualityComparer<TValue>.Default.Equals(kvp.Value, value));
+            }
+
+            foreach (var kvp in _dictionary)
+            {
+                var index = EnumeratingIndexOf(kvp.Key);
+                Debug.Assert(index >= 0);
+                Debug.Assert(EqualityComparer<TValue>.Default.Equals(kvp.Value, _list[index].Value));
+            }
+        }
+
+        public ICollection<TKey> Keys
+        {
+            get
+            {
+                return _keys ??= new KeyCollection(this);
+            }
+        }
+
+        IEnumerable<TKey> IReadOnlyDictionary<TKey, TValue>.Keys
+        {
+            get
+            {
+                return Keys;
+            }
+        }
+
+        public ICollection<TValue> Values
+        {
+            get
+            {
+                return _values ??= new ValueCollection(this);
+            }
+        }
+
+        IEnumerable<TValue> IReadOnlyDictionary<TKey, TValue>.Values
+        {
+            get
+            {
+                return Values;
+            }
+        }
+
+        public int Count
+        {
+            get
+            {
+                Debug.Assert(_list.Count == _dictionary.Count);
+                return _list.Count;
+            }
+        }
+
+        bool ICollection<KeyValuePair<TKey, TValue>>.IsReadOnly
+        {
+            get
+            {
+                return false;
+            }
+        }
+
+        public void Add(TKey key, TValue value)
+        {
+            _dictionary.Add(key, value);
+            _list.Add(new KeyValuePair<TKey, TValue>(key, value));
+
+            AssertConsistency();
+        }
+
+        void ICollection<KeyValuePair<TKey, TValue>>.Add(KeyValuePair<TKey, TValue> item)
+        {
+            Add(item.Key, item.Value);
+        }
+
+        public void Clear()
+        {
+            _dictionary.Clear();
+            _list.Clear();
+        }
+
+        bool ICollection<KeyValuePair<TKey, TValue>>.Contains(KeyValuePair<TKey, TValue> item)
+        {
+            return ((ICollection<KeyValuePair<TKey, TValue>>)_dictionary).Contains(item);
+        }
+
+        public bool ContainsKey(TKey key)
+        {
+            return _dictionary.ContainsKey(key);
+        }
+
+        public bool ContainsValue(TValue value)
+        {
+            return _dictionary.ContainsValue(value);
+        }
+
+        void ICollection<KeyValuePair<TKey, TValue>>.CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
+        {
+            _list.CopyTo(array, arrayIndex);
+        }
+
+        public KeyValuePair<TKey, TValue> GetAt(int index)
+        {
+            return _list[index];
+        }
+
+        public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
+        {
+            return _list.GetEnumerator();
+        }
+
+        IEnumerator IEnumerable.GetEnumerator()
+        {
+            return GetEnumerator();
+        }
+
+        public int IndexOf(TKey key)
+        {
+            // Fast lookup.
+            if (!_dictionary.ContainsKey(key))
+            {
+                Debug.Assert(EnumeratingIndexOf(key) == -1);
+                return -1;
+            }
+
+            var index = EnumeratingIndexOf(key);
+
+            Debug.Assert(index >= 0);
+
+            return index;
+        }
+
+        private int EnumeratingIndexOf(TKey key)
+        {
+            Debug.Assert(key is not null);
+
+            var i = -1;
+
+            foreach (var kvp in _list)
+            {
+                i++;
+
+                if (_dictionary.Comparer.Equals(key, kvp.Key))
+                {
+                    return i;
+                }
+            }
+
+            return -1;
+        }
+
+        public void Insert(int index, TKey key, TValue value)
+        {
+            // This validation is also done by _list.Insert but we must
+            // do it before _dictionary.Add to avoid corrupting the state.
+            if ((uint)index > Count)
+            {
+                throw new ArgumentOutOfRangeException(nameof(index));
+            }
+
+            _dictionary.Add(key, value);
+            _list.Insert(index, new KeyValuePair<TKey, TValue>(key, value));
+
+            AssertConsistency();
+        }
+
+        public bool Remove(TKey key, [MaybeNullWhen(false)] out TValue value)
+        {
+            if (_dictionary.Remove(key, out value))
+            {
+                _list.RemoveAt(EnumeratingIndexOf(key));
+                AssertConsistency();
+                return true;
+            }
+
+            AssertConsistency();
+            value = default!;
+            return false;
+        }
+
+        public bool Remove(TKey key)
+        {
+            if (_dictionary.Remove(key))
+            {
+                _list.RemoveAt(EnumeratingIndexOf(key));
+                AssertConsistency();
+                return true;
+            }
+
+            AssertConsistency();
+            return false;
+        }
+
+        bool ICollection<KeyValuePair<TKey, TValue>>.Remove(KeyValuePair<TKey, TValue> item)
+        {
+            if (((ICollection<KeyValuePair<TKey, TValue>>)_dictionary).Remove(item))
+            {
+                _list.RemoveAt(EnumeratingIndexOf(item.Key));
+                AssertConsistency();
+                return true;
+            }
+
+            AssertConsistency();
+            return false;
+        }
+
+        public void RemoveAt(int index)
+        {
+            var key = _list[index].Key;
+
+            _list.RemoveAt(index);
+
+            var success = _dictionary.Remove(key);
+            Debug.Assert(success);
+
+            AssertConsistency();
+        }
+
+        public void SetAt(int index, TKey key, TValue value)
+        {
+            if ((uint)index >= Count)
+            {
+                throw new ArgumentOutOfRangeException(nameof(index));
+            }
+
+            if (TryGetValue(key, out _, out var existingIndex))
+            {
+                if (index != existingIndex)
+                {
+                    throw new ArgumentException("An item with the same key has already been added", nameof(key));
+                }
+            }
+            else
+            {
+                var oldKeyRemoved = _dictionary.Remove(_list[index].Key);
+
+                Debug.Assert(oldKeyRemoved);
+            }
+
+            _dictionary[key] = value;
+            _list[index] = new KeyValuePair<TKey, TValue>(key, value);
+
+            AssertConsistency();
+        }
+
+        public void SetAt(int index, TValue value)
+        {
+            var key = _list[index].Key;
+
+            _list[index] = new KeyValuePair<TKey, TValue>(key, value);
+            _dictionary[key] = value;
+
+            AssertConsistency();
+        }
+
+        public void SetPosition(int index, int newIndex)
+        {
+            if ((uint)newIndex >= Count)
+            {
+                throw new ArgumentOutOfRangeException(nameof(newIndex));
+            }
+
+            var kvp = _list[index];
+
+            _list.RemoveAt(index);
+            _list.Insert(newIndex, kvp);
+
+            AssertConsistency();
+        }
+
+        public void SetPosition(TKey key, int newIndex)
+        {
+            // This performs the same lookup that IndexOf would
+            // but throws a nicely formatted KeyNotFoundException
+            // if the key does not exist in the collection.
+            _ = _dictionary[key];
+
+            Debug.Assert(key is not null);
+
+            var oldIndex = EnumeratingIndexOf(key);
+
+            Debug.Assert(oldIndex >= 0);
+
+            SetPosition(oldIndex, newIndex);
+        }
+
+        public bool TryAdd(TKey key, TValue value)
+        {
+            if (_dictionary.TryAdd(key, value))
+            {
+                _list.Add(new KeyValuePair<TKey, TValue>(key, value));
+                AssertConsistency();
+                return true;
+            }
+
+            AssertConsistency();
+            return false;
+        }
+
+        public bool TryAdd(TKey key, TValue value, out int index)
+        {
+            if (_dictionary.TryAdd(key, value))
+            {
+                _list.Add(new KeyValuePair<TKey, TValue>(key, value));
+                index = _list.Count - 1;
+                AssertConsistency();
+                return true;
+            }
+
+            index = EnumeratingIndexOf(key);
+            AssertConsistency();
+            return false;
+        }
+
+#if NET
+        public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value)
+#else
+        public bool TryGetValue(TKey key, out TValue value)
+#endif
+        {
+            return _dictionary.TryGetValue(key, out value);
+        }
+
+        public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value, out int index)
+        {
+            if (_dictionary.TryGetValue(key, out value))
+            {
+                index = EnumeratingIndexOf(key);
+                return true;
+            }
+
+            index = -1;
+            return false;
+        }
+
+        private sealed class KeyCollection : KeyOrValueCollection<TKey>
+        {
+            public KeyCollection(OrderedDictionary<TKey, TValue> orderedDictionary)
+                : base(orderedDictionary)
+            {
+            }
+
+            public override bool Contains(TKey item)
+            {
+                return OrderedDictionary._dictionary.ContainsKey(item);
+            }
+
+            public override void CopyTo(TKey[] array, int arrayIndex)
+            {
+                base.CopyTo(array, arrayIndex); // Validation
+
+                foreach (var kvp in OrderedDictionary._list)
+                {
+                    array[arrayIndex++] = kvp.Key;
+                }
+            }
+
+            public override IEnumerator<TKey> GetEnumerator()
+            {
+                return OrderedDictionary._list.Select(kvp => kvp.Key).GetEnumerator();
+            }
+        }
+
+        private sealed class ValueCollection : KeyOrValueCollection<TValue>
+        {
+            public ValueCollection(OrderedDictionary<TKey, TValue> orderedDictionary)
+                : base(orderedDictionary)
+            {
+            }
+
+            public override bool Contains(TValue item)
+            {
+                return OrderedDictionary._dictionary.ContainsValue(item);
+            }
+
+            public override void CopyTo(TValue[] array, int arrayIndex)
+            {
+                base.CopyTo(array, arrayIndex); // Validation
+
+                foreach (var kvp in OrderedDictionary._list)
+                {
+                    array[arrayIndex++] = kvp.Value;
+                }
+            }
+
+            public override IEnumerator<TValue> GetEnumerator()
+            {
+                return OrderedDictionary._list.Select(kvp => kvp.Value).GetEnumerator();
+            }
+        }
+
+        private abstract class KeyOrValueCollection<T> : ICollection<T>
+        {
+            protected OrderedDictionary<TKey, TValue> OrderedDictionary { get; }
+
+            protected KeyOrValueCollection(OrderedDictionary<TKey, TValue> orderedDictionary)
+            {
+                OrderedDictionary = orderedDictionary;
+            }
+
+            public int Count
+            {
+                get
+                {
+                    return OrderedDictionary.Count;
+                }
+            }
+
+            public bool IsReadOnly
+            {
+                get
+                {
+                    return true;
+                }
+            }
+
+            public void Add(T item)
+            {
+                throw new NotSupportedException();
+            }
+
+            public void Clear()
+            {
+                throw new NotSupportedException();
+            }
+
+            public abstract bool Contains(T item);
+
+            public virtual void CopyTo(T[] array, int arrayIndex)
+            {
+                ThrowHelper.ThrowIfNull(array);
+                ThrowHelper.ThrowIfNegative(arrayIndex);
+
+                if (array.Length - arrayIndex < Count)
+                {
+                    throw new ArgumentException(
+                        "Destination array was not long enough. Check the destination index, length, and the array's lower bounds.",
+                        nameof(array));
+                }
+            }
+
+            public abstract IEnumerator<T> GetEnumerator();
+
+            public bool Remove(T item)
+            {
+                throw new NotSupportedException();
+            }
+
+            IEnumerator IEnumerable.GetEnumerator()
+            {
+                return GetEnumerator();
+            }
+        }
+    }
+}
+#endif

+ 8 - 0
test/Renci.SshNet.Tests/.editorconfig

@@ -309,6 +309,10 @@ dotnet_diagnostic.MA0026.severity = silent
 # https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0042.md
 dotnet_diagnostic.MA0042.severity = silent
 
+# MA0160: Use ContainsKey instead of TryGetValue
+# https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0160.md
+dotnet_diagnostic.MA0160.severity = silent
+
 #### .NET Compiler Platform analysers rules ####
 
 # CA1031: Do not catch general exception types
@@ -343,6 +347,10 @@ dotnet_diagnostic.CA1822.severity = silent
 # https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1825
 dotnet_diagnostic.CA1825.severity = silent
 
+# CA1841: Prefer Dictionary Contains methods
+# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1841
+dotnet_diagnostic.CA1841.severity = silent
+
 # CA1859: Use concrete types when possible for improved performance
 # https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1859
 dotnet_diagnostic.CA1859.severity = silent

+ 469 - 0
test/Renci.SshNet.Tests/Classes/OrderedDictionaryTest.cs

@@ -0,0 +1,469 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Renci.SshNet.Tests.Classes
+{
+    [TestClass]
+    public class OrderedDictionaryTest
+    {
+        private static void AssertEqual<TKey, TValue>(List<KeyValuePair<TKey, TValue>> expected, OrderedDictionary<TKey, TValue> o)
+        {
+            Assert.AreEqual(expected.Count, o.Count);
+
+            CollectionAssert.AreEqual(expected, ToList(o)); // Test the enumerator
+
+            for (int i = 0; i < expected.Count; i++)
+            {
+                Assert.AreEqual(expected[i], o.GetAt(i));
+
+                Assert.AreEqual(expected[i].Value, o[expected[i].Key]);
+
+                Assert.IsTrue(o.TryGetValue(expected[i].Key, out TValue value));
+                Assert.AreEqual(expected[i].Value, value);
+
+                Assert.IsTrue(o.TryGetValue(expected[i].Key, out value, out int index));
+                Assert.AreEqual(expected[i].Value, value);
+                Assert.AreEqual(i, index);
+
+                Assert.IsTrue(((ICollection<KeyValuePair<TKey, TValue>>)o).Contains(expected[i]));
+                Assert.IsTrue(o.ContainsKey(expected[i].Key));
+                Assert.IsTrue(o.ContainsValue(expected[i].Value));
+                Assert.IsTrue(o.Keys.Contains(expected[i].Key));
+                Assert.IsTrue(o.Values.Contains(expected[i].Value));
+
+                Assert.AreEqual(i, o.IndexOf(expected[i].Key));
+
+                Assert.IsFalse(o.TryAdd(expected[i].Key, default));
+                Assert.IsFalse(o.TryAdd(expected[i].Key, default, out index));
+                Assert.AreEqual(i, index);
+            }
+
+            Assert.AreEqual(expected.Count, o.Keys.Count);
+            CollectionAssert.AreEqual(expected.Select(kvp => kvp.Key).ToList(), ToList(o.Keys));
+            CollectionAssert.AreEqual(ToList(o.Keys), ToList(((IReadOnlyDictionary<TKey, TValue>)o).Keys));
+
+            Assert.AreEqual(expected.Count, o.Values.Count);
+            CollectionAssert.AreEqual(expected.Select(kvp => kvp.Value).ToList(), ToList(o.Values));
+            CollectionAssert.AreEqual(ToList(o.Values), ToList(((IReadOnlyDictionary<TKey, TValue>)o).Values));
+
+            // Test CopyTo
+            var kvpArray = new KeyValuePair<TKey, TValue>[1 + expected.Count + 1];
+            ((ICollection<KeyValuePair<TKey, TValue>>)o).CopyTo(kvpArray, 1);
+            CollectionAssert.AreEqual(
+                (List<KeyValuePair<TKey, TValue>>)[default, .. expected, default],
+                kvpArray);
+
+            var keysArray = new TKey[1 + expected.Count + 1];
+            o.Keys.CopyTo(keysArray, 1);
+            CollectionAssert.AreEqual(
+                (List<TKey>)[default, .. expected.Select(kvp => kvp.Key), default],
+                keysArray);
+
+            var valuesArray = new TValue[1 + expected.Count + 1];
+            o.Values.CopyTo(valuesArray, 1);
+            CollectionAssert.AreEqual(
+                (List<TValue>)[default, .. expected.Select(kvp => kvp.Value), default],
+                valuesArray);
+
+            // Creates a List<T> via enumeration, avoiding the ICollection<T>.CopyTo
+            // optimisation in the List<T> constructor.
+            static List<T> ToList<T>(IEnumerable<T> values)
+            {
+                List<T> list = new();
+                foreach (T t in values)
+                {
+                    list.Add(t);
+                }
+                return list;
+            }
+        }
+
+        [TestMethod]
+        public void NullKey_ThrowsArgumentNull()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 } };
+
+            Assert.ThrowsException<ArgumentNullException>(() => o[null]);
+            Assert.ThrowsException<ArgumentNullException>(() => o.Add(null, 1));
+            Assert.ThrowsException<ArgumentNullException>(() => ((ICollection<KeyValuePair<string, int>>)o).Add(new KeyValuePair<string, int>(null, 1)));
+            Assert.ThrowsException<ArgumentNullException>(() => ((ICollection<KeyValuePair<string, int>>)o).Contains(new KeyValuePair<string, int>(null, 1)));
+            Assert.ThrowsException<ArgumentNullException>(() => o.ContainsKey(null));
+            Assert.ThrowsException<ArgumentNullException>(() => o.IndexOf(null));
+            Assert.ThrowsException<ArgumentNullException>(() => o.Insert(0, null, 1));
+            Assert.ThrowsException<ArgumentNullException>(() => o.Remove(null, out _));
+            Assert.ThrowsException<ArgumentNullException>(() => o.Remove(null));
+            Assert.ThrowsException<ArgumentNullException>(() => ((ICollection<KeyValuePair<string, int>>)o).Remove(new KeyValuePair<string, int>(null, 1)));
+            Assert.ThrowsException<ArgumentNullException>(() => o.SetAt(0, null, 1));
+            Assert.ThrowsException<ArgumentNullException>(() => o.SetPosition(null, 0));
+            Assert.ThrowsException<ArgumentNullException>(() => o.TryAdd(null, 1));
+            Assert.ThrowsException<ArgumentNullException>(() => o.TryAdd(null, 1, out _));
+            Assert.ThrowsException<ArgumentNullException>(() => o.TryGetValue(null, out _));
+            Assert.ThrowsException<ArgumentNullException>(() => o.TryGetValue(null, out _, out _));
+        }
+
+        [TestMethod]
+        public void Indexer_Match_GetterReturnsValue()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 } };
+
+            Assert.AreEqual(8, o["b"]);
+        }
+
+        [TestMethod]
+        public void Indexer_Match_SetterChangesValue()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 } };
+
+            o["a"] = 5;
+
+            AssertEqual([new("a", 5), new("b", 8)], o);
+        }
+
+        [TestMethod]
+        public void Indexer_NoMatch_GetterThrowsKeyNotFound()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 } };
+
+            Assert.ThrowsException<KeyNotFoundException>(() => o["b"]);
+        }
+
+        [TestMethod]
+        public void Indexer_NoMatch_SetterAddsItem()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 } };
+
+            o["b"] = 8;
+
+            AssertEqual([new("a", 4), new("b", 8)], o);
+        }
+
+        [TestMethod]
+        public void Add_Match()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 } };
+
+            Assert.ThrowsException<ArgumentException>(() => o.Add("a", 8));
+            Assert.ThrowsException<ArgumentException>(() => ((ICollection<KeyValuePair<string, int>>)o).Add(new KeyValuePair<string, int>("a", 8)));
+        }
+
+        [TestMethod]
+        public void Clear()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 } };
+
+            AssertEqual([new("a", 4), new("b", 8)], o);
+            o.Clear();
+            AssertEqual([], o);
+        }
+
+        [TestMethod]
+        public void CopyTo()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 } };
+
+            Assert.ThrowsException<ArgumentNullException>(() => ((ICollection<KeyValuePair<string, int>>)o).CopyTo(null, 0));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => ((ICollection<KeyValuePair<string, int>>)o).CopyTo(new KeyValuePair<string, int>[3], -1));
+            Assert.ThrowsException<ArgumentException>(() => ((ICollection<KeyValuePair<string, int>>)o).CopyTo(new KeyValuePair<string, int>[3], 3));
+            Assert.ThrowsException<ArgumentException>(() => ((ICollection<KeyValuePair<string, int>>)o).CopyTo(new KeyValuePair<string, int>[3], 2));
+
+            Assert.ThrowsException<ArgumentNullException>(() => o.Keys.CopyTo(null, 0));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.Keys.CopyTo(new string[3], -1));
+            Assert.ThrowsException<ArgumentException>(() => o.Keys.CopyTo(new string[3], 3));
+            Assert.ThrowsException<ArgumentException>(() => o.Keys.CopyTo(new string[3], 2));
+
+            Assert.ThrowsException<ArgumentNullException>(() => o.Values.CopyTo(null, 0));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.Values.CopyTo(new int[3], -1));
+            Assert.ThrowsException<ArgumentException>(() => o.Values.CopyTo(new int[3], 3));
+            Assert.ThrowsException<ArgumentException>(() => o.Values.CopyTo(new int[3], 2));
+        }
+
+        [TestMethod]
+        public void ContainsKvp_ChecksKeyAndValue()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 } };
+
+            Assert.IsFalse(((ICollection<KeyValuePair<string, int>>)o).Contains(new KeyValuePair<string, int>("a", 8)));
+            Assert.IsTrue(((ICollection<KeyValuePair<string, int>>)o).Contains(new KeyValuePair<string, int>("a", 4)));
+        }
+
+        [TestMethod]
+        public void NullValues_Permitted()
+        {
+            OrderedDictionary<string, string> o = new() { { "a", "1" } };
+
+            Assert.IsFalse(o.ContainsValue(null));
+
+            o.Add("b", null);
+
+            AssertEqual([new("a", "1"), new("b", null)], o);
+        }
+
+        [TestMethod]
+        public void GetAt_OutOfRange()
+        {
+            OrderedDictionary<string, string> o = new() { { "a", "1" } };
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.GetAt(-2));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.GetAt(-1));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.GetAt(1));
+        }
+
+        [TestMethod]
+        public void RemoveKvp_ChecksKeyAndValue()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 } };
+
+            Assert.IsFalse(((ICollection<KeyValuePair<string, int>>)o).Remove(new KeyValuePair<string, int>("a", 8)));
+            AssertEqual([new("a", 4)], o);
+
+            Assert.IsTrue(((ICollection<KeyValuePair<string, int>>)o).Remove(new KeyValuePair<string, int>("a", 4)));
+            AssertEqual([], o);
+        }
+
+        [TestMethod]
+        public void SetAt()
+        {
+            OrderedDictionary<string, double> o = new();
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(-2, 1.1));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(-1, 1.1));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(0, 1.1));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(1, 1.1));
+
+            o.Add("a", 4);
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(-2, 1.1));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(-1, 1.1));
+
+            o.SetAt(0, 1.1);
+
+            AssertEqual([new("a", 1.1)], o);
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(1, 5.5));
+        }
+
+        [TestMethod]
+        public void SetAt3Params_OutOfRange()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 }, { "c", 12 } };
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(-1, "d", 16));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetAt(3, "d", 16));
+        }
+
+        [TestMethod]
+        public void SetAt3Params_ExistingKeyCorrectIndex_PermitsChangingValue()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 }, { "c", 12 } };
+
+            o.SetAt(2, "c", 16);
+
+            AssertEqual([new("a", 4), new("b", 8), new("c", 16)], o);
+        }
+
+        [TestMethod]
+        public void SetAt3Params_ExistingKeyDifferentIndex_Throws()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 }, { "c", 12 } };
+
+            Assert.ThrowsException<ArgumentException>(() => o.SetAt(1, "c", 16));
+        }
+
+        [TestMethod]
+        public void SetAt3Params_PermitsChangingToNewKey()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 }, { "c", 12 } };
+
+            o.SetAt(1, "d", 16);
+
+            AssertEqual([new("a", 4), new("d", 16), new("c", 12)], o);
+        }
+
+        [TestMethod]
+        public void Get_NonExistent()
+        {
+            OrderedDictionary<string, float> o = new() { { "a", 4 } };
+
+            Assert.ThrowsException<KeyNotFoundException>(() => o["doesn't exist"]);
+            Assert.IsFalse(((ICollection<KeyValuePair<string, float>>)o).Contains(new KeyValuePair<string, float>("doesn't exist", 1)));
+            Assert.IsFalse(o.ContainsKey("doesn't exist"));
+            Assert.IsFalse(o.ContainsValue(999));
+            Assert.AreEqual(-1, o.IndexOf("doesn't exist"));
+
+            Assert.IsFalse(o.Remove("doesn't exist", out float value));
+            Assert.AreEqual(default, value);
+
+            Assert.IsFalse(o.Remove("doesn't exist"));
+
+            Assert.IsFalse(((ICollection<KeyValuePair<string, float>>)o).Remove(new KeyValuePair<string, float>("doesn't exist", 1)));
+
+            Assert.IsFalse(o.TryGetValue("doesn't exist", out value));
+            Assert.AreEqual(default, value);
+
+            Assert.IsFalse(o.TryGetValue("doesn't exist", out value, out int index));
+            Assert.AreEqual(default, value);
+            Assert.AreEqual(-1, index);
+
+            AssertEqual([new("a", 4)], o);
+        }
+
+        [TestMethod]
+        public void Insert()
+        {
+            OrderedDictionary<string, float> o = new() { { "a", 4 }, { "b", 8 } };
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.Insert(-1, "c", 12));
+
+            o.Insert(0, "c", 12); // Start
+            AssertEqual([new("c", 12), new("a", 4), new("b", 8)], o);
+
+            o.Insert(2, "d", 12); // Middle
+            AssertEqual([new("c", 12), new("a", 4), new("d", 12), new("b", 8)], o);
+
+            o.Insert(o.Count, "e", 16); // End
+            AssertEqual([new("c", 12), new("a", 4), new("d", 12), new("b", 8), new("e", 16)], o);
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.Insert(o.Count + 1, "f", 16));
+
+            // Existing key
+            Assert.ThrowsException<ArgumentException>(() => o.Insert(0, "a", 12));
+        }
+
+        [TestMethod]
+        public void Remove_Success()
+        {
+            OrderedDictionary<string, float> o = new() { { "a", 4 }, { "b", 8 }, { "c", 12 } };
+
+            Assert.IsTrue(o.Remove("b"));
+            AssertEqual([new("a", 4), new("c", 12)], o);
+
+            Assert.IsTrue(o.Remove("a", out float value));
+            Assert.AreEqual(4, value);
+            AssertEqual([new("c", 12)], o);
+
+            // ICollection.Remove must match Key and Value
+            Assert.IsFalse(((ICollection<KeyValuePair<string, float>>)o).Remove(new KeyValuePair<string, float>("c", -1)));
+            AssertEqual([new("c", 12)], o);
+
+            Assert.IsTrue(((ICollection<KeyValuePair<string, float>>)o).Remove(new KeyValuePair<string, float>("c", 12)));
+            AssertEqual([], o);
+        }
+
+        [TestMethod]
+        public void RemoveAt()
+        {
+            OrderedDictionary<string, float> o = new() { { "a", 4 }, { "b", 8 }, { "c", 12 }, { "d", 16 } };
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.RemoveAt(-2));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.RemoveAt(-1));
+
+            o.RemoveAt(0); // Start
+            AssertEqual([new("b", 8), new("c", 12), new("d", 16)], o);
+
+            o.RemoveAt(1); // Middle
+            AssertEqual([new("b", 8), new("d", 16)], o);
+
+            o.RemoveAt(1); // End
+            AssertEqual([new("b", 8)], o);
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.RemoveAt(1));
+        }
+
+        [TestMethod]
+        public void SetPosition_ByIndex()
+        {
+            OrderedDictionary<string, float> o = new() { { "a", 4 }, { "b", 8 }, { "c", 12 }, { "d", 16 } };
+
+            ArgumentOutOfRangeException ex;
+
+            ex = Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetPosition(-1, 0));
+            Assert.AreEqual("index", ex.ParamName);
+
+            ex = Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetPosition(0, -1));
+            Assert.AreEqual("newIndex", ex.ParamName);
+
+            ex = Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetPosition(0, 4));
+            Assert.AreEqual("newIndex", ex.ParamName);
+
+            ex = Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetPosition(4, 0));
+            Assert.AreEqual("index", ex.ParamName);
+
+            o.SetPosition(1, 0);
+            AssertEqual([new("b", 8), new("a", 4), new("c", 12), new("d", 16)], o);
+
+            o.SetPosition(0, 1);
+            AssertEqual([new("a", 4), new("b", 8), new("c", 12), new("d", 16)], o);
+
+            o.SetPosition(1, 2);
+            AssertEqual([new("a", 4), new("c", 12), new("b", 8), new("d", 16)], o);
+
+            o.SetPosition(2, 1);
+            AssertEqual([new("a", 4), new("b", 8), new("c", 12), new("d", 16)], o);
+
+            o.SetPosition(0, 3);
+            AssertEqual([new("b", 8), new("c", 12), new("d", 16), new("a", 4)], o);
+
+            o.SetPosition(3, 1);
+            AssertEqual([new("b", 8), new("a", 4), new("c", 12), new("d", 16)], o);
+
+            o.SetPosition(1, 1); // No-op
+            AssertEqual([new("b", 8), new("a", 4), new("c", 12), new("d", 16)], o);
+        }
+
+        [TestMethod]
+        public void SetPosition_ByKey()
+        {
+            OrderedDictionary<string, float> o = new() { { "a", 4 }, { "b", 8 }, { "c", 12 }, { "d", 16 } };
+
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetPosition("a", -1));
+            Assert.ThrowsException<ArgumentOutOfRangeException>(() => o.SetPosition("a", 4));
+            Assert.ThrowsException<KeyNotFoundException>(() => o.SetPosition("e", 0));
+
+            o.SetPosition("b", 0);
+            AssertEqual([new("b", 8), new("a", 4), new("c", 12), new("d", 16)], o);
+
+            o.SetPosition("b", 1);
+            AssertEqual([new("a", 4), new("b", 8), new("c", 12), new("d", 16)], o);
+
+            o.SetPosition("a", 3);
+            AssertEqual([new("b", 8), new("c", 12), new("d", 16), new("a", 4)], o);
+
+            o.SetPosition("d", 2); // No-op
+            AssertEqual([new("b", 8), new("c", 12), new("d", 16), new("a", 4)], o);
+        }
+
+        [TestMethod]
+        public void TryAdd_Success()
+        {
+            OrderedDictionary<string, float> o = new() { { "a", 4 }, { "b", 8 } };
+
+            Assert.IsTrue(o.TryAdd("c", 12));
+
+            AssertEqual([new("a", 4), new("b", 8), new("c", 12)], o);
+
+            Assert.IsTrue(o.TryAdd("d", 16, out int index));
+            Assert.AreEqual(3, index);
+
+            AssertEqual([new("a", 4), new("b", 8), new("c", 12), new("d", 16)], o);
+        }
+
+        [TestMethod]
+        public void KeysAndValuesAreReadOnly()
+        {
+            OrderedDictionary<string, int> o = new() { { "a", 4 }, { "b", 8 } };
+
+            Assert.IsTrue(o.Keys.IsReadOnly);
+            Assert.ThrowsException<NotSupportedException>(() => o.Keys.Add("c"));
+            Assert.ThrowsException<NotSupportedException>(o.Keys.Clear);
+            Assert.ThrowsException<NotSupportedException>(() => o.Keys.Remove("a"));
+
+            Assert.IsTrue(o.Values.IsReadOnly);
+            Assert.ThrowsException<NotSupportedException>(() => o.Values.Add(12));
+            Assert.ThrowsException<NotSupportedException>(o.Values.Clear);
+            Assert.ThrowsException<NotSupportedException>(() => o.Values.Remove(4));
+        }
+    }
+}