Tor control: Add Tokenizer and TorControlReplyReader.
ADD
WalletWasabi.Tests/UnitTests/Tor/Control/Messages/ProtocolInfoReplyTests.cs
+using System.Threading.Tasks;
+using WalletWasabi.Tor.Control.Messages;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.Tor.Control
+{
+ /// <summary>
+ /// Tests for <see cref="ProtocolInfoReply"/> class.
+ /// </summary>
+ public class ProtocolInfoReplyTests
+ {
+ [Fact]
+ public async Task AuthMethodHashedPasswordAsync()
+ {
+ string data = "250-PROTOCOLINFO 1\r\n250-AUTH METHODS=HASHEDPASSWORD\r\n250-VERSION Tor=\"0.4.3.5\"\r\n250 OK\r\n";
+
+ TorControlReply rawReply = await TorControlReplyReaderTest.ParseAsync(data);
+ ProtocolInfoReply reply = ProtocolInfoReply.FromReply(rawReply);
+
+ Assert.Equal(1, reply.ProtocolVersion);
+ Assert.Equal("0.4.3.5", reply.TorVersion);
+ Assert.Single(reply.AuthMethods);
+ Assert.Contains("HASHEDPASSWORD", reply.AuthMethods);
+ }
+
+ [Fact]
+ public async Task AuthMethodCookieAsync()
+ {
+ // Yes, Tor really returns: "C:\\Users\\Wasabi\\AppData\\Roaming\\WalletWasabi\\Client\\control_auth_cookie" path on Windows.
+ string data = "250-PROTOCOLINFO 1\r\n250-AUTH METHODS=COOKIE,SAFECOOKIE COOKIEFILE=\"C:\\\\Users\\\\Wasabi\\\\AppData\\\\Roaming\\\\WalletWasabi\\\\Client\\\\control_auth_cookie\"\r\n250-VERSION Tor=\"0.4.5.7\"\r\n250 OK\r\n";
+
+ TorControlReply rawReply = await TorControlReplyReaderTest.ParseAsync(data);
+ ProtocolInfoReply reply = ProtocolInfoReply.FromReply(rawReply);
+
+ Assert.Equal(1, reply.ProtocolVersion);
+ Assert.Equal("0.4.5.7", reply.TorVersion);
+ Assert.Equal(2, reply.AuthMethods.Length);
+ Assert.Equal("COOKIE", reply.AuthMethods[0]);
+ Assert.Equal("SAFECOOKIE", reply.AuthMethods[1]);
+ Assert.Equal(@"C:\Users\Wasabi\AppData\Roaming\WalletWasabi\Client\control_auth_cookie", reply.CookieFilePath);
+ }
+ }
+}

ADD
WalletWasabi.Tests/UnitTests/Tor/Control/PipeWriterExtensions.cs
+using System;
+using System.IO.Pipelines;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace WalletWasabi.Tests.UnitTests.Tor.Control
+{
+ public static class PipeWriterExtensions
+ {
+ public static ValueTask<FlushResult> WriteAsync(this PipeWriter writer, string data, Encoding encoding, CancellationToken cancellationToken = default)
+ {
+ return writer.WriteAsync(new ReadOnlyMemory<byte>(encoding.GetBytes(data)), cancellationToken);
+ }
+
+ public static ValueTask<FlushResult> WriteAsciiAsync(this PipeWriter writer, string data, CancellationToken cancellationToken = default)
+ {
+ return writer.WriteAsync(new ReadOnlyMemory<byte>(Encoding.ASCII.GetBytes(data)), cancellationToken);
+ }
+ }
+}

ADD
WalletWasabi.Tests/UnitTests/Tor/Control/TorControlReplyReaderTests.cs
+using System;
+using System.IO.Pipelines;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.Tor.Control;
+using WalletWasabi.Tor.Control.Exceptions;
+using WalletWasabi.Tor.Control.Messages;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.Tor.Control
+{
+ /// <summary>
+ /// Tests for <see cref="TorControlReplyReader"/> class.
+ /// </summary>
+ public class TorControlReplyReaderTest
+ {
+ [Theory]
+ [InlineData(StatusCode.OK, 1, "250 OK\r\n")]
+ [InlineData(StatusCode.OK, 2, "250-SOCKSPORT=9050\r\n250 ORPORT=0\r\n")]
+ [InlineData(StatusCode.OK, 4, "250-PROTOCOLINFO 1\r\n250-AUTH METHODS=HASHEDPASSWORD\r\n250-VERSION Tor=\"0.4.3.5\"\r\n250 OK\r\n")]
+ [InlineData(StatusCode.AsynchronousEventNotify, 1, "650 CIRC 1000 EXTENDED moria1,moria2\r\n")]
+ public async Task WellformedTestsAsync(StatusCode expectedStatusCode, int expectedReplyLines, string data)
+ {
+ TorControlReply reply = await ParseAsync(data);
+
+ if (expectedStatusCode == StatusCode.OK)
+ {
+ Assert.True(reply.Success);
+ }
+
+ Assert.Equal(expectedStatusCode, reply.StatusCode);
+ Assert.Equal(expectedReplyLines, reply.ResponseLines.Count);
+ }
+
+ /// <summary>Makes sure we don't normalize <c>\n</c>, <c>\t</c>, <c>\r</c> in Tor replies.</summary>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">2.1.1. Notes on an escaping bug</seealso>
+ [Fact]
+ public async Task EscapingQuotesTestAsync()
+ {
+ TorControlReply reply = await ParseAsync("250-PROTOCOLINFO 1\r\n250-VERSION Tor=\\\"0.4.3.5\\\"\r\n250 OK\r\n");
+
+ Assert.True(reply.Success);
+ Assert.Equal(3, reply.ResponseLines.Count);
+
+ // We are expected to read: 'VERSION Tor=\"0.4.3.5\"' (with the backslashes).
+ Assert.Equal(@"VERSION Tor=\""0.4.3.5\""", reply.ResponseLines[1]);
+ }
+
+ [Fact]
+ public async Task InvalidStatusCodeAsync()
+ {
+ var ex = await Assert.ThrowsAsync<TorControlReplyParseException>(async () => await ParseAsync("2 OK\r\n").ConfigureAwait(false));
+ Assert.StartsWith("Unknown status code: '2 O'", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("No reply line was received.", "\r" /* no input line */)]
+ [InlineData("Status code requires at least 3 characters.", "OK\r\n")]
+ [InlineData("Unknown status code: 'xx '.", "xx OK\r\n")]
+ [InlineData("Unknown status code: '2 O'.", "2 OK\r\n")]
+ public async Task InvalidRepliesAsync(string expectedExceptionMsg, string data)
+ {
+ var ex = await Assert.ThrowsAsync<TorControlReplyParseException>(async () => await ParseAsync(data).ConfigureAwait(false));
+ Assert.Equal(expectedExceptionMsg, ex.Message);
+ }
+
+ public static async Task<TorControlReply> ParseAsync(string data)
+ {
+ using CancellationTokenSource timeoutCts = new(TimeSpan.FromMinutes(1));
+
+ Pipe pipe = new();
+ await pipe.Writer.WriteAsciiAsync(data, timeoutCts.Token);
+ await pipe.Writer.FlushAsync(timeoutCts.Token);
+ await pipe.Writer.CompleteAsync();
+ return await TorControlReplyReader.ReadReplyAsync(pipe.Reader, timeoutCts.Token);
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/Exceptions/TorControlException.cs
+using System;
+
+namespace WalletWasabi.Tor.Control.Exceptions
+{
+ public class TorControlException : Exception
+ {
+ public TorControlException(string message) : base(message)
+ {
+ }
+
+ public TorControlException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/Exceptions/TorControlReplyParseException.cs
+using System;
+
+namespace WalletWasabi.Tor.Control.Exceptions
+{
+ public class TorControlReplyParseException : TorControlException
+ {
+ public TorControlReplyParseException(string message) : base(message)
+ {
+ }
+
+ public TorControlReplyParseException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/ProtocolInfoReply.cs
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using WalletWasabi.Logging;
+using WalletWasabi.Tor.Control.Exceptions;
+using WalletWasabi.Tor.Control.Utils;
+
+namespace WalletWasabi.Tor.Control.Messages
+{
+ /// <remarks>
+ /// Note that the protocol_version is the only mandatory data for a valid PROTOCOLINFO response.
+ /// </remarks>
+ public class ProtocolInfoReply
+ {
+ private const string LineTypeProtocolInfo = "PROTOCOLINFO";
+ private const string LineTypeVersion = "VERSION";
+ private const string LineTypeAuth = "AUTH";
+
+ /// <summary>Always <c>1</c>.</summary>
+ public int? ProtocolVersion { get; init; }
+
+ /// <example>0.4.5.7</example>
+ public string? TorVersion { get; init; }
+
+ /// <summary>Full path to cookie file.</summary>
+ public string? CookieFilePath { get; init; }
+
+ public ImmutableArray<string> AuthMethods { get; init; }
+
+ /// <exception cref="TorControlReplyParseException"/>
+ public static ProtocolInfoReply FromReply(TorControlReply reply)
+ {
+ if (!reply.Success || reply.ResponseLines.Last() != "250 OK")
+ {
+ throw new TorControlReplyParseException("PROTOCOLINFO: Expected reply with OK status.");
+ }
+
+ // Mandatory piece of information per spec.
+ int protocolVersion = ParseProtocolVersionLine(reply.ResponseLines[0]);
+
+ string? torVersion = null;
+ string? cookieFilePath = null;
+ List<string> authMethods = new();
+
+ // Optional pieces of information per spec.
+ foreach (string line in reply.ResponseLines.Skip(1).SkipLast(1))
+ {
+ (string token, string remainder) = Tokenizer.ReadUntilSeparator(line);
+
+ if (token == LineTypeVersion)
+ {
+ // VersionLine = "250-VERSION" SP "Tor=" TorVersion OptArguments CRLF
+ // TorVersion = QuotedString
+ (string value, _) = Tokenizer.ReadKeyValueAssignment(key: "Tor=", remainder);
+
+ torVersion = value;
+ }
+ else if (token == LineTypeAuth)
+ {
+ remainder = Tokenizer.ReadExactString("METHODS=", remainder);
+ (string methods, string optCookieFileRemainder) = Tokenizer.ReadUntilSeparator(remainder);
+ authMethods.AddRange(methods.Split(','));
+
+ if (optCookieFileRemainder.StartsWith("COOKIEFILE=", StringComparison.Ordinal))
+ {
+ (cookieFilePath, _) = Tokenizer.ReadKeyValueAssignment(key: "COOKIEFILE=", optCookieFileRemainder);
+ }
+ }
+ }
+
+ return new ProtocolInfoReply()
+ {
+ ProtocolVersion = protocolVersion,
+ TorVersion = torVersion,
+ AuthMethods = authMethods.ToImmutableArray(),
+ CookieFilePath = cookieFilePath,
+ };
+ }
+
+ private static int ParseProtocolVersionLine(string line)
+ {
+ (string token, string remainder) = Tokenizer.ReadUntilSeparator(line);
+
+ if (token == LineTypeProtocolInfo)
+ {
+ if (int.TryParse(remainder, out int parsedVersion))
+ {
+ if (parsedVersion != 1)
+ {
+ Logger.LogWarning($"Version {parsedVersion} may not work expected.");
+ }
+
+ return parsedVersion;
+ }
+ else
+ {
+ Logger.LogError("Failed to parse PROTOCOLINFO version.");
+ }
+ }
+
+ throw new TorControlReplyParseException("PROTOCOLINFO: Version is not specified.");
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/TorControlReply.cs
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace WalletWasabi.Tor.Control.Messages
+{
+ /// <summary>
+ /// A class containing information regarding a response received back from a Tor control connection.
+ /// </summary>
+ /// <remarks>
+ /// Tor replies follow the following grammar:
+ /// <code>
+ /// Reply = SyncReply / AsyncReply
+ /// SyncReply = *(MidReplyLine / DataReplyLine) EndReplyLine
+ /// AsyncReply = *(MidReplyLine / DataReplyLine) EndReplyLine
+ ///
+ /// MidReplyLine = StatusCode "-" ReplyLine
+ /// DataReplyLine = StatusCode "+" ReplyLine CmdData
+ /// EndReplyLine = StatusCode SP ReplyLine
+ /// ReplyLine = [ReplyText] CRLF
+ /// ReplyText = XXXX
+ /// StatusCode = 3DIGIT
+ /// </code>
+ /// </remarks>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">Section 2.3</seealso>
+ public class TorControlReply
+ {
+ public TorControlReply(StatusCode statusCode)
+ {
+ StatusCode = statusCode;
+ ResponseLines = new List<string>().AsReadOnly();
+ }
+
+ public TorControlReply(StatusCode statusCode, IList<string> responseLines)
+ {
+ StatusCode = statusCode;
+ ResponseLines = new ReadOnlyCollection<string>(responseLines);
+ }
+
+ public ReadOnlyCollection<string> ResponseLines { get; }
+
+ public StatusCode StatusCode { get; }
+
+ /// <summary>Gets a value indicating whether the reply indicated success.</summary>
+ public bool Success => StatusCode == StatusCode.OK;
+
+ public static implicit operator bool(TorControlReply r) => r.Success;
+
+ /// <inheritdoc/>
+ public override string ToString()
+ {
+ return string.Format("[{0}: '{1}']", StatusCode, string.Join("<CRLF>", ResponseLines));
+ }
+ }
+}
\ No newline at end of file

ADD
WalletWasabi/Tor/Control/PipeReaderLineReaderExtension.cs
+using System;
+using System.Buffers;
+using System.IO;
+using System.IO.Pipelines;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.Logging;
+
+namespace WalletWasabi.Tor.Control
+{
+ /// <summary>
+ /// This class employs <c>System.IO.Pipelines</c> to correctly read incoming streamed data.
+ /// </summary>
+ /// <remarks>Please read the link below to understand common gotchas of that task.</remarks>
+ /// <seealso href="https://docs.microsoft.com/en-us/dotnet/standard/io/pipelines#what-problem-does-systemiopipelines-solve"/>
+ public static class PipeReaderLineReaderExtension
+ {
+ /// <summary>
+ /// Reads a single line ending with <c>\r\n</c> and returns the line without <c>\r\n</c> suffix.
+ /// </summary>
+ /// <remarks>
+ /// <para>
+ /// Parses a single message and updates the consumed SequencePosition and examined <see cref="SequencePosition"/> to point
+ /// to the start of the trimmed input buffer.
+ /// </para>
+ /// <para>
+ /// The two SequencePosition arguments are updated because <see cref="TryParseLine"/> removes the parsed message
+ /// from the input buffer. Generally, when parsing a single message from the buffer, the examined position should be
+ /// one of the following:
+ /// <list item="bullet">
+ /// <item>The end of the message.</item>
+ /// <item>The end of the received buffer if no message was found.</item>
+ /// </list>
+ /// </para>
+ /// <para>
+ /// The single message case has the most potential for errors. Passing the wrong values to examined can result
+ /// in an out of memory exception or an infinite loop. For more information, see the
+ /// <see href="https://docs.microsoft.com/en-us/dotnet/standard/io/pipelines#gotchas">gotchas article</see>
+ /// </para>
+ /// </remarks>
+ /// <seealso href="https://docs.microsoft.com/en-us/dotnet/standard/io/pipelines#read-a-single-message"/>
+ /// <exception cref="InvalidDataException">The message is incomplete and there's no more data to process.</exception>
+ public static async ValueTask<string> ReadLineAsync(this PipeReader reader, CancellationToken cancellationToken = default)
+ {
+ while (true)
+ {
+ ReadResult result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+ ReadOnlySequence<byte> buffer = result.Buffer;
+
+ // In the event that no message is parsed successfully, mark consumed
+ // as nothing and examined as the entire buffer.
+ SequencePosition consumed = buffer.Start;
+ SequencePosition examined = buffer.End;
+
+ try
+ {
+ if (TryParseLine(ref buffer, out ReadOnlySequence<byte> messageBytes))
+ {
+ string message = messageBytes.GetString(Encoding.ASCII);
+ Logger.LogTrace($"Read message: '{message}'");
+
+ // A single message was successfully parsed so mark the start as the
+ // parsed buffer as consumed. TryParseMessage trims the buffer to
+ // point to the data after the message was parsed.
+ consumed = buffer.Start;
+
+ // Examined is marked the same as consumed here, so the next call
+ // to ReadSingleMessageAsync will process the next message if there's
+ // one.
+ examined = consumed;
+
+ return message;
+ }
+
+ // There's no more data to be processed.
+ if (result.IsCompleted)
+ {
+ if (buffer.Length > 0)
+ {
+ // The message is incomplete and there's no more data to process.
+ throw new InvalidDataException("Incomplete message.");
+ }
+
+ break;
+ }
+ }
+ finally
+ {
+ reader.AdvanceTo(consumed, examined);
+ }
+ }
+
+ throw new InvalidDataException("This should never happen.");
+ }
+
+ /// <summary>Finds the first newline (<c>\r\n</c>) in the buffer.</summary>
+ /// <param name="line">Trims that line, excluding the <c>\r\n</c> from the input buffer.</param>
+ /// <seealso href="https://docs.microsoft.com/en-us/dotnet/standard/io/buffers#process-text-data"/>
+ private static bool TryParseLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
+ {
+ SequencePosition position = buffer.Start;
+ SequencePosition previous = position;
+ var index = -1;
+ line = default;
+
+ while (buffer.TryGet(ref position, out ReadOnlyMemory<byte> segment))
+ {
+ ReadOnlySpan<byte> span = segment.Span;
+
+ // Look for \r in the current segment.
+ index = span.IndexOf((byte)'\r');
+
+ if (index != -1)
+ {
+ // Check next segment for \n.
+ if (index + 1 >= span.Length)
+ {
+ SequencePosition next = position;
+ if (!buffer.TryGet(ref next, out ReadOnlyMemory<byte> nextSegment))
+ {
+ // You're at the end of the sequence.
+ return false;
+ }
+ else if (nextSegment.Span[0] == (byte)'\n')
+ {
+ // A match was found.
+ break;
+ }
+ }
+ // Check the current segment of \n.
+ else if (span[index + 1] == (byte)'\n')
+ {
+ // It was found.
+ break;
+ }
+ }
+
+ previous = position;
+ }
+
+ if (index != -1)
+ {
+ // Get the position just before the \r\n.
+ SequencePosition delimeter = buffer.GetPosition(index, previous);
+
+ // Slice the line (excluding \r\n).
+ line = buffer.Slice(buffer.Start, delimeter);
+
+ // Slice the buffer to get the remaining data after the line.
+ buffer = buffer.Slice(buffer.GetPosition(2, delimeter));
+ return true;
+ }
+
+ return false;
+ }
+
+ private static string GetString(in this ReadOnlySequence<byte> payload, Encoding? encoding = null)
+ {
+ encoding ??= Encoding.UTF8;
+
+ return payload.IsSingleSegment
+ ? encoding.GetString(payload.FirstSpan)
+ : GetStringSlow(payload, encoding);
+
+ static string GetStringSlow(in ReadOnlySequence<byte> payload, Encoding encoding)
+ {
+ // linearize
+ int length = checked((int)payload.Length);
+ var oversized = ArrayPool<byte>.Shared.Rent(length);
+ try
+ {
+ payload.CopyTo(oversized);
+ return encoding.GetString(oversized, 0, length);
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(oversized);
+ }
+ }
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/ReplyCode.cs
+namespace WalletWasabi.Tor.Control
+{
+ /// <summary>
+ /// An enumerator containing the status codes sent in response to commands.
+ /// </summary>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">See section "4. Replies".</seealso>
+ public enum StatusCode : int
+ {
+ /// <summary>This should never occur ideally, unless a response was malformed or incomplete.</summary>
+ Unknown = 0,
+
+ /// <summary>The command was processed.</summary>
+ OK = 250,
+
+ /// <summary>The operation was unnecessary.</summary>
+ OperationUnnecessary = 251,
+
+ /// <summary>The resources were exhausted.</summary>
+ ResourceExhausted = 451,
+
+ /// <summary>There was a syntax error in the protocol.</summary>
+ SyntaxErrorProtocol = 500,
+
+ /// <summary>The command was unrecognized.</summary>
+ UnrecognizedCommand = 510,
+
+ /// <summary>The command is unimplemented.</summary>
+ UnimplementedCommand = 511,
+
+ /// <summary>There was a syntax error in a command argument.</summary>
+ SyntaxErrorArgument = 512,
+
+ /// <summary>The command argument was unrecognized.</summary>
+ UnrecognizedCommandArgument = 513,
+
+ /// <summary>The command could not execute because authentication is required.</summary>
+ AuthenticationRequired = 514,
+
+ /// <summary>The command to authenticate returned an invalid authentication response.</summary>
+ BadAuthentication = 515,
+
+ /// <summary>The command generated a non-specific error response.</summary>
+ Unspecified = 550,
+
+ /// <summary>An error occurred within Tor leading to the command failing to execute.</summary>
+ InternalError = 551,
+
+ /// <summary>The command contained a configuration key, stream ID, circuit ID, or event which did not exist.</summary>
+ UnrecognizedEntity = 552,
+
+ /// <summary>The command sent a configuration value incompatible with the configuration.</summary>
+ InvalidConfigurationValue = 553,
+
+ /// <summary>The command contained an invalid descriptor.</summary>
+ InvalidDescriptor = 554,
+
+ /// <summary>The command contained a reference to an unmanaged entity.</summary>
+ UnmanagedEntity = 555,
+
+ /// <summary>A notification sent following an asynchronous operation.</summary>
+ AsynchronousEventNotify = 650,
+ }
+}
\ No newline at end of file

ADD
WalletWasabi/Tor/Control/TorControlReplyReader.cs
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Pipelines;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.Tor.Control.Exceptions;
+using WalletWasabi.Tor.Control.Messages;
+
+namespace WalletWasabi.Tor.Control
+{
+ public class TorControlReplyReader
+ {
+ /// <remarks>
+ /// <see href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">Notes on an escaping bug</see> mentions that
+ /// it is possible to implement a workaround so that a controller is compatible with buggy Tor implementations. This
+ /// implementation DOES NOT do that.
+ /// <para>However, such implementation is available <see href="https://github.com/zcash/zcash/pull/2251">here</see>.</para>
+ /// </remarks>
+ /// <exception cref="OperationCanceledException">When operation is canceled.</exception>
+ /// <exception cref="TorControlReplyParseException">When received message does not follow Tor Control reply grammar.</exception>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">See section "4. Replies".</seealso>
+ /// <seealso href="https://github.com/lontivero/Torino/blob/d891616777ed596ef54dbf1d86c9b4771e45e8f3/src/Reply.cs#L72"/>
+ /// <seealso href="https://github.com/torproject/stem/blob/master/stem/response/__init__.py"/>
+ public static async Task<TorControlReply> ReadReplyAsync(PipeReader reader, CancellationToken cancellationToken = default)
+ {
+ string line;
+
+ try
+ {
+ line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (InvalidDataException e)
+ {
+ throw new TorControlReplyParseException("No reply line was received.", e);
+ }
+
+ if (line.Length < 3)
+ {
+ throw new TorControlReplyParseException("Status code requires at least 3 characters.");
+ }
+
+ if (!int.TryParse(line.AsSpan(0, 3), out int code))
+ {
+ throw new TorControlReplyParseException($"Unknown status code: '{line.Substring(0, 3)}'.");
+ }
+
+ line = line[3..];
+
+ if (line.Length == 0)
+ {
+ return new TorControlReply((StatusCode)code, responseLines: new List<string>() { string.Empty });
+ }
+
+ char id = line[0];
+
+ if (id != '+' && id != '-')
+ {
+ if (id == ' ')
+ {
+ line = line[1..];
+ }
+
+ return new TorControlReply((StatusCode)code, responseLines: new List<string>() { line });
+ }
+
+ List<string> responses = new();
+ responses.Add(line[1..]);
+
+ while (true)
+ {
+ line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+
+ if (line is null)
+ {
+ break;
+ }
+
+ if (line.Length == 0)
+ {
+ continue;
+ }
+
+ if (id == '-' && line.Length > 3 && line[3] == ' ')
+ {
+ responses.Add(line);
+ break;
+ }
+
+ if (line.Length > 3 && id != '+')
+ {
+ line = line[4..];
+ }
+
+ responses.Add(line);
+
+ if (id == '+' && ".".Equals(line))
+ {
+ break;
+ }
+ }
+
+ return new TorControlReply((StatusCode)code, responseLines: responses);
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/Utils/Tokenizer.cs
+using System;
+using WalletWasabi.Tor.Control.Exceptions;
+
+namespace WalletWasabi.Tor.Control.Utils
+{
+ /// <summary>
+ /// Helper functions for parsing Tor control protocol replies.
+ /// </summary>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">See 2.3. Replies from Tor to the controller</seealso>
+ public static class Tokenizer
+ {
+ /// <summary>
+ /// Splits input string using space separator (" ") into at most two parts - token and remainder.
+ /// </summary>
+ public static (string token, string remainder) ReadUntilSeparator(string input)
+ {
+ if (input == "")
+ {
+ throw new TorControlReplyParseException("Expected a token.");
+ }
+
+ string[] parts = input.Split(separator: ' ', count: 2);
+
+ if (parts.Length == 2)
+ {
+ return (parts[0], parts[1]);
+ }
+ else
+ {
+ return (parts[0], "");
+ }
+ }
+
+ /// <summary>
+ /// Reads <c>&lt;KEY&gt;=QuotedString</c> string from <paramref name="input"/>.
+ /// </summary>
+ /// <returns>Quoted string content.</returns>
+ public static (string value, string remainder) ReadKeyValueAssignment(string key, string input)
+ {
+ input = ReadExactString(key, input);
+
+ int startAt = input.IndexOf('"');
+
+ if (startAt == -1)
+ {
+ throw new TorControlReplyParseException("Missing opening quote character.");
+ }
+
+ startAt++;
+ int endAt = startAt;
+
+ if (endAt >= input.Length)
+ {
+ throw new TorControlReplyParseException("Unexpected end of string.");
+ }
+
+ // Do not stop at escaped quotes (").
+ while ((endAt = input.IndexOf('"', endAt)) != -1)
+ {
+ if (input[endAt - 1] != '\\')
+ {
+ break;
+ }
+ }
+
+ if (endAt == -1)
+ {
+ throw new TorControlReplyParseException($"Missing closing quote character.");
+ }
+
+ string value = input[startAt..endAt];
+ string remainder = (endAt == input.Length) ? "" : input[(endAt + 1)..];
+
+ // Unescape: \\ -> '
+ value = value.Replace(@"\\", @"\");
+
+ // Unescape: \" -> "
+ value = value.Replace("\\\"", "\"");
+
+ return (value, remainder);
+ }
+
+ /// <summary>
+ /// Makes sure that <paramref name="input"/> starts with <paramref name="expectedStart"/>.
+ /// </summary>
+ public static string ReadExactString(string expectedStart, string input)
+ {
+ if (!input.StartsWith(expectedStart, StringComparison.Ordinal))
+ {
+ throw new TorControlReplyParseException($"Expected input to start with '{expectedStart}'.");
+ }
+
+ return input[expectedStart.Length..];
+ }
+ }
+}

Tor control: Add Tokenizer and TorControlReplyReader.