diff --git a/RunCommand.Test/LineOutputHandlerRunTests.cs b/RunCommand.Test/LineOutputHandlerRunTests.cs
new file mode 100644
index 0000000..590f66b
--- /dev/null
+++ b/RunCommand.Test/LineOutputHandlerRunTests.cs
@@ -0,0 +1,206 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.RunCommand.Test;
+
+using System.Runtime.InteropServices;
+
+[TestClass]
+public class LineOutputHandlerRunTests
+{
+ private static readonly string[] ExpectedALast = ["a", "last"];
+ private static readonly string[] ExpectedALastNext = ["a", "last", "next"];
+
+ ///
+ /// Returns a command that prints a file's contents unchanged.
+ ///
+ private static (string FileName, string[] Arguments) GetPrintFileCommand(string path) =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? ("cmd", ["/c", "type", path])
+ : ("cat", [path]);
+
+ [TestMethod]
+ public async Task LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun()
+ {
+ string first = Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)}.{nameof(LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun)}.1.txt");
+ string second = Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)}.{nameof(LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun)}.2.txt");
+ await File.WriteAllTextAsync(first, "a\nlast").ConfigureAwait(false);
+ await File.WriteAllTextAsync(second, "next\n").ConfigureAwait(false);
+
+ try
+ {
+ List lines = [];
+ LineOutputHandler handler = new(onStandardOutput: lines.Add);
+
+ (string fileName, string[] arguments) = GetPrintFileCommand(first);
+ int exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false);
+ Assert.AreEqual(0, exitCode);
+ Assert.AreSequenceEqual(ExpectedALast, lines);
+
+ (fileName, arguments) = GetPrintFileCommand(second);
+ exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false);
+ Assert.AreEqual(0, exitCode);
+ Assert.AreSequenceEqual(ExpectedALastNext, lines);
+ }
+ finally
+ {
+ File.Delete(first);
+ File.Delete(second);
+ }
+ }
+
+ [TestMethod]
+ public async Task ReusedLineOutputHandlerShouldDropTheLineACancelledRunLeftUnfinished()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Inconclusive("Needs sh to print a line without a line break. The reset this covers is in platform independent code, so the other legs cover it.");
+ }
+
+ List lines = [];
+ using SemaphoreSlim partialArrived = new(0);
+ LineOutputHandler handler = new(lines.Add);
+
+ using (CancellationTokenSource cancellationTokenSource = new())
+ {
+ Task cancelled = RunCommand.ExecuteAsync(
+ "sh",
+ ["-c", "printf partial; sleep 30"],
+ new SignallingLineOutputHandler(handler, partialArrived),
+ cancellationTokenSource.Token);
+
+ Assert.IsTrue(await partialArrived.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), "Expected the partial line to arrive.");
+ await cancellationTokenSource.CancelAsync().ConfigureAwait(false);
+ await Assert.ThrowsAsync(() => cancelled).ConfigureAwait(false);
+ }
+
+ Assert.AreEqual("partial", handler.outputBuffer.ToString(), "The cancelled run should have left its partial line buffered.");
+
+ int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "echo hello"], handler).ConfigureAwait(false);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual("hello", string.Join(" | ", lines), "The next run's first line should not carry the cancelled run's leftover text.");
+ }
+
+ [TestMethod]
+ public async Task LineOutputHandlerSharedByOverlappingRunsShouldKeepEachRunsLinesApart()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Inconclusive("Needs sh to print part of a line, pause, then finish it. The per-run splitting this covers is platform independent, so the other legs cover it.");
+ }
+
+ // ktsu-dev/RunCommand#130: the second run's Reset threw away the first run's partial line, and
+ // both runs then appended to the same buffer, so their text came out joined into one line.
+ List lines = [];
+ using SemaphoreSlim firstPartArrived = new(0);
+ LineOutputHandler handler = new(line =>
+ {
+ lock (lines)
+ {
+ lines.Add(line);
+ }
+ });
+
+ Task first = RunCommand.ExecuteAsync(
+ "sh",
+ ["-c", "printf AAAA; sleep 1; printf 'AAAA\\n'"],
+ new SignallingLineOutputHandler(handler, firstPartArrived));
+
+ Assert.IsTrue(await firstPartArrived.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), "Expected the first run's partial line to arrive.");
+
+ Task second = RunCommand.ExecuteAsync("sh", ["-c", "printf BBBB; sleep 0.2; printf 'BBBB\\n'"], handler);
+
+ int[] exitCodes = await Task.WhenAll(first, second).ConfigureAwait(false);
+
+ Assert.AreEqual(0, exitCodes[0], "The first run should succeed.");
+ Assert.AreEqual(0, exitCodes[1], "The second run should succeed.");
+ List sorted;
+ lock (lines)
+ {
+ sorted = [.. lines.Order(StringComparer.Ordinal)];
+ }
+
+ Assert.AreEqual("AAAAAAAA | BBBBBBBB", string.Join(" | ", sorted));
+ }
+
+ [TestMethod]
+ public async Task ReusedLineOutputHandlerShouldDropTheLineARunWhoseCallbackThrewLeftUnfinished()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Inconclusive("Needs sh to print a line without a line break. The reset this covers is in platform independent code, so the other legs cover it.");
+ }
+
+ List lines = [];
+ bool throwOnError = true;
+ LineOutputHandler handler = new(
+ lines.Add,
+ line =>
+ {
+ if (throwOnError)
+ {
+ throw new InvalidOperationException("handler failed");
+ }
+ });
+
+ // Standard output's partial line is buffered before standard error's line makes the callback throw.
+ await Assert.ThrowsAsync(
+ () => RunCommand.ExecuteAsync("sh", ["-c", "printf partial; sleep 0.5; echo boom >&2; sleep 30"], handler)).ConfigureAwait(false);
+
+ // The failed run's partial line stays in that run's own handler (#130), so nothing is left on this one.
+ Assert.AreEqual(string.Empty, handler.outputBuffer.ToString(), "A run should split lines in its own handler, not the shared one.");
+
+ throwOnError = false;
+ int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "echo hello"], handler).ConfigureAwait(false);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual("hello", string.Join(" | ", lines), "The next run's first line should not carry the failed run's leftover text.");
+ }
+
+ [TestMethod]
+ public async Task AbandonedReaderShouldNotDeliverIntoALaterRunOnTheSameHandler()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Inconclusive("Needs a shell that can orphan a child out of its own process tree while that child keeps the pipe it inherited. That a cancelled run's reads stop delivering is platform independent, so the other legs cover it.");
+ }
+
+ List lines = [];
+ using SemaphoreSlim partialArrived = new(0);
+ LineOutputHandler handler = new(lines.Add);
+
+ // The orphaned subshell escapes the kill and keeps the cancelled run's standard output open, then
+ // writes to it a second later, while the next run on the same handler is still going. The
+ // cancelled run gave up on that read, so what arrives there must not reach the handler.
+ using (CancellationTokenSource cancellationTokenSource = new())
+ {
+ Task cancelled = RunCommand.ExecuteAsync(
+ "sh",
+ ["-c", "sh -c '(sleep 1; printf late) &'; printf partial; sleep 30"],
+ new SignallingLineOutputHandler(handler, partialArrived),
+ cancellationTokenSource.Token);
+
+ Assert.IsTrue(await partialArrived.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), "Expected the partial line to arrive.");
+ await cancellationTokenSource.CancelAsync().ConfigureAwait(false);
+ await Assert.ThrowsAsync(() => cancelled).ConfigureAwait(false);
+ }
+
+ int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "sleep 2; echo hello"], handler).ConfigureAwait(false);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual("hello", string.Join(" | ", lines), "Output the cancelled run's abandoned reader received should not reach the later run.");
+ }
+
+ ///
+ /// Forwards standard output to and signals after each chunk,
+ /// so a test can wait for output to arrive before it cancels.
+ ///
+ private sealed class SignallingLineOutputHandler(LineOutputHandler inner, SemaphoreSlim received) : OutputHandler
+ {
+ internal override void HandleStandardOutputData(string data)
+ {
+ inner.HandleStandardOutputData(data);
+ received.Release();
+ }
+ }
+}
diff --git a/RunCommand.Test/OutputDecodingTests.cs b/RunCommand.Test/OutputDecodingTests.cs
new file mode 100644
index 0000000..048d6a8
--- /dev/null
+++ b/RunCommand.Test/OutputDecodingTests.cs
@@ -0,0 +1,228 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.RunCommand.Test;
+
+using System.Diagnostics;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Runtime.InteropServices;
+
+[TestClass]
+public class OutputDecodingTests
+{
+ ///
+ /// Returns a command that writes a file's bytes to standard output unchanged, as an executable
+ /// plus separate arguments.
+ ///
+ ///
+ /// Windows has no reliably byte-faithful built-in for this. cmd /c type looked like one
+ /// but transcodes a file carrying a UTF-16 byte order mark instead of copying it, which is
+ /// exactly the input these tests need, so PowerShell writes the raw bytes to the standard
+ /// output stream instead. checks the result rather than
+ /// trusting it.
+ ///
+ internal static (string FileName, string[] Arguments) GetEmitFileBytesCommand(string path) =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? ("powershell", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ $"$b=[IO.File]::ReadAllBytes('{path}'); $s=[Console]::OpenStandardOutput(); $s.Write($b,0,$b.Length); $s.Flush()"])
+ : ("cat", [path]);
+
+ private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, throwOnInvalidBytes: true);
+
+ // Runs the emitter through the library, so the test controls the exact bytes that reach the
+ // pipe and can assert on what the library makes of them.
+ private static string RunOverPath(string path, Encoding encoding)
+ {
+ StringBuilder output = new();
+ (string fileName, string[] arguments) = GetEmitFileBytesCommand(path);
+ _ = RunCommand.Execute(fileName, arguments, new OutputHandler(o => output.Append(o), null, encoding));
+
+ return output.ToString();
+ }
+
+ ///
+ /// Checks that this platform's emitter really does put on the pipe
+ /// unchanged, so that a mangling emitter reports itself instead of being read as a result about
+ /// the library.
+ ///
+ ///
+ /// This drives the emitter through directly and copies the raw
+ /// , deliberately never touching the code under test. A
+ /// control that went through would measure the very defect these tests
+ /// exist to catch and blame the emitter for it, which would turn a regression into an
+ /// inconclusive result instead of a failure.
+ ///
+ private static bool EmitsBytesFaithfully(string path, byte[] bytes, out string diagnostic)
+ {
+ byte[] actual;
+
+ try
+ {
+ (string fileName, string[] arguments) = GetEmitFileBytesCommand(path);
+ ProcessStartInfo startInfo = new()
+ {
+ FileName = fileName,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ foreach (string argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ using Process process = Process.Start(startInfo)!;
+ using MemoryStream captured = new();
+ process.StandardOutput.BaseStream.CopyTo(captured);
+ process.WaitForExit();
+ actual = captured.ToArray();
+ }
+ catch (System.ComponentModel.Win32Exception ex)
+ {
+ diagnostic = $"This platform's byte emitter could not be started: {ex.Message}";
+ return false;
+ }
+
+ bool faithful = actual.SequenceEqual(bytes);
+ diagnostic = faithful
+ ? string.Empty
+ : "This platform's byte emitter altered the bytes, so the library cannot be judged from them. "
+ + $"Expected [{Convert.ToHexString(bytes)}], the pipe carried [{Convert.ToHexString(actual)}].";
+
+ return faithful;
+ }
+
+ // Writes the bytes to a file of this test's own and confirms the platform can actually put them
+ // on a pipe unchanged, returning the path to feed to the library.
+ private static string WriteBytesForTest(byte[] bytes, string caller)
+ {
+ string path = Path.Join(RunCommandTests.CreateDirectoryForTest(caller), "bytes.bin");
+ File.WriteAllBytes(path, bytes);
+
+ if (!EmitsBytesFaithfully(path, bytes, out string diagnostic))
+ {
+ Assert.Inconclusive(diagnostic);
+ }
+
+ return path;
+ }
+
+ private static void AssertDecodeFailure(byte[] bytes, [CallerMemberName] string caller = "")
+ {
+ // The emitter check has to happen out here: an Assert.Inconclusive raised inside the lambda
+ // below would be caught by Assert.ThrowsExactly and reported as a failing test.
+ string path = WriteBytesForTest(bytes, caller);
+
+ AggregateException thrown = Assert.ThrowsExactly(() => RunOverPath(path, StrictUtf8));
+ Assert.IsTrue(
+ thrown.Flatten().InnerExceptions.Any(e => e is DecoderFallbackException),
+ $"Expected a decode failure, got: {thrown}");
+ }
+
+ [TestMethod]
+ public void OutputEncodingIsNotReplacedByAUtf16ByteOrderMark() =>
+ // "hi" in UTF-16LE behind its byte order mark. Process builds its StandardOutput reader with
+ // byte-order-mark detection on, which used to switch the reader to UTF-16LE and return "hi"
+ // even though the caller asked for strict UTF-8 and these are not valid UTF-8 bytes.
+ AssertDecodeFailure([0xFF, 0xFE, (byte)'h', 0x00, (byte)'i', 0x00]);
+
+ [TestMethod]
+ public void OutputThatIsOnlyAUtf16ByteOrderMarkIsNotReportedAsSuccess() =>
+ // The same detection consumed a lone FF FE as a byte order mark, leaving nothing to decode,
+ // so a strict encoding reported no output and no error for bytes it should have rejected.
+ AssertDecodeFailure([0xFF, 0xFE]);
+
+ [TestMethod]
+ public void AUtf8ByteOrderMarkIsStrippedFromTheStartOfOutput()
+ {
+ // A byte order mark matching the requested encoding is still dropped, so turning the
+ // detection off did not start leaking U+FEFF into captured output.
+ byte[] bytes = [0xEF, 0xBB, 0xBF, (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o'];
+ string path = WriteBytesForTest(bytes, nameof(AUtf8ByteOrderMarkIsStrippedFromTheStartOfOutput));
+
+ Assert.AreEqual("hello", RunOverPath(path, StrictUtf8));
+ }
+
+ ///
+ /// Returns the encoding a byte-order-mark test names, along with that encoding's own BOM.
+ ///
+ ///
+ /// Named rather than passed because only takes constants. The
+ /// cases split on whether the encoding has a preamble: skips a
+ /// matching preamble itself whatever detectEncodingFromByteOrderMarks says, so one with a
+ /// preamble must not be stripped a second time by the library.
+ ///
+ private static (Encoding Encoding, byte[] Bom) GetByteOrderMarkCase(string name) => name switch
+ {
+ "Encoding.UTF8" => (Encoding.UTF8, [0xEF, 0xBB, 0xBF]),
+ "UTF8Encoding(false)" => (new UTF8Encoding(false), [0xEF, 0xBB, 0xBF]),
+ "Encoding.Unicode" => (Encoding.Unicode, [0xFF, 0xFE]),
+ "UnicodeEncoding(false, false)" => (new UnicodeEncoding(bigEndian: false, byteOrderMark: false), [0xFF, 0xFE]),
+ _ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown encoding case."),
+ };
+
+ [TestMethod]
+ [DataRow("Encoding.UTF8")]
+ [DataRow("UTF8Encoding(false)")]
+ [DataRow("Encoding.Unicode")]
+ [DataRow("UnicodeEncoding(false, false)")]
+ public void OnlyTheByteOrderMarkIsStrippedWhenARealU_FEFFFollowsIt(string encodingName)
+ {
+ // A BOM, then U+FEFF that is part of the output, then "A". The BOM goes and the content
+ // stays, whichever of two equivalent encodings the caller passed. With a preamble the reader
+ // dropped the BOM and the library then dropped the real U+FEFF after it.
+ (Encoding encoding, byte[] bom) = GetByteOrderMarkCase(encodingName);
+ byte[] bytes = [.. bom, .. encoding.GetBytes("\uFEFFA")];
+ string path = WriteBytesForTest(bytes, $"{nameof(OnlyTheByteOrderMarkIsStrippedWhenARealU_FEFFFollowsIt)}.{new string([.. encodingName.Where(char.IsLetterOrDigit)])}");
+
+ string output = RunOverPath(path, encoding);
+
+ Assert.AreEqual("FEFF 0041", string.Join(' ', output.Select(c => ((int)c).ToString("X4", CultureInfo.InvariantCulture))));
+ }
+
+ [TestMethod]
+ [DataRow("Encoding.UTF8")]
+ [DataRow("UTF8Encoding(false)")]
+ [DataRow("Encoding.Unicode")]
+ [DataRow("UnicodeEncoding(false, false)")]
+ public void ALoneByteOrderMarkIsStrippedWhicheverEncodingReadsIt(string encodingName)
+ {
+ (Encoding encoding, byte[] bom) = GetByteOrderMarkCase(encodingName);
+ byte[] bytes = [.. bom, .. encoding.GetBytes("hello")];
+ string path = WriteBytesForTest(bytes, $"{nameof(ALoneByteOrderMarkIsStrippedWhicheverEncodingReadsIt)}.{new string([.. encodingName.Where(char.IsLetterOrDigit)])}");
+
+ Assert.AreEqual("hello", RunOverPath(path, encoding));
+ }
+
+ [TestMethod]
+ public void ADecodeFailureIsNotDiscardedWhenTheProcessKeepsRunning()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Inconclusive("Needs a shell that can emit bytes and then stay alive. The race this covers is in platform independent code, so the other legs cover it.");
+ }
+
+ // The read loop stops when the process exits, so a read that faults while the process is
+ // still running used to be replaced by a fresh read on the next pass and its decode failure
+ // thrown away. Sleeping after the bad byte keeps the process alive long enough for that pass
+ // to happen, which makes the race deterministic rather than roughly one run in six.
+ string path = Path.Join(RunCommandTests.CreateDirectoryForTest(), "bytes.bin");
+ File.WriteAllBytes(path, [(byte)'o', (byte)'k', 0xFF]);
+
+ StringBuilder output = new();
+ AggregateException thrown = Assert.ThrowsExactly(
+ () => RunCommand.Execute(
+ "sh",
+ ["-c", $"cat '{path}'; sleep 1"],
+ new OutputHandler(o => output.Append(o), null, StrictUtf8)));
+
+ Assert.IsTrue(
+ thrown.Flatten().InnerExceptions.Any(e => e is DecoderFallbackException),
+ $"Expected a decode failure, got: {thrown}");
+ }
+}
diff --git a/RunCommand.Test/OverlayPathTests.cs b/RunCommand.Test/OverlayPathTests.cs
new file mode 100644
index 0000000..7752d2d
--- /dev/null
+++ b/RunCommand.Test/OverlayPathTests.cs
@@ -0,0 +1,211 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.RunCommand.Test;
+
+using System.Runtime.InteropServices;
+
+[TestClass]
+public class OverlayPathTests
+{
+ private static readonly string[] ExpectedOverlayCopy = ["ran overlay copy"];
+ private static readonly string[] ExpectedStarted = ["started"];
+
+ [TestMethod]
+ public async Task ExecuteAsyncShouldStartAToolThatOnlyTheOverlayPathHolds()
+ {
+ SkipOnWindows();
+ string directory = RunCommandTests.CreateDirectoryForTest();
+ string name = $"runcommand-{nameof(ExecuteAsyncShouldStartAToolThatOnlyTheOverlayPathHolds)}";
+ WriteScript(directory, name, "echo ran overlay copy", executable: true);
+
+ List output = await RunOnOverlayPathAsync(name, directory).ConfigureAwait(false);
+
+ Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the tool on the overlay PATH to start.");
+ }
+
+ [TestMethod]
+ public async Task ExecuteAsyncShouldPreferTheOverlayPathOverTheCallersPath()
+ {
+ SkipOnWindows();
+
+ // echo is on the caller's PATH too, so the old lookup would have started that one and printed the argument.
+ string directory = RunCommandTests.CreateDirectoryForTest();
+ WriteScript(directory, "echo", "printf 'ran overlay copy\\n'", executable: true);
+
+ List output = await RunOnOverlayPathAsync("echo", directory, "ran caller copy").ConfigureAwait(false);
+
+ Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the overlay PATH to choose which echo runs.");
+ }
+
+ [TestMethod]
+ public async Task ExecuteAsyncShouldSkipANonExecutableFileOnTheOverlayPath()
+ {
+ SkipOnWindows();
+ string root = RunCommandTests.CreateDirectoryForTest();
+ string notExecutable = Directory.CreateDirectory(Path.Join(root, "a")).FullName;
+ string executable = Directory.CreateDirectory(Path.Join(root, "b")).FullName;
+ string name = $"runcommand-{nameof(ExecuteAsyncShouldSkipANonExecutableFileOnTheOverlayPath)}";
+ WriteScript(notExecutable, name, "echo ran the file that cannot run", executable: false);
+ WriteScript(executable, name, "echo ran overlay copy", executable: true);
+
+ List output = await RunOnOverlayPathAsync(name, $"{notExecutable}{Path.PathSeparator}{executable}").ConfigureAwait(false);
+
+ Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the search to pass over a file without an execute bit, as a shell does.");
+ }
+
+ [TestMethod]
+ public async Task ExecuteAsyncShouldSkipAFileOnTheOverlayPathThatOnlyOthersMayExecute()
+ {
+ SkipOnWindows();
+ if (Environment.UserName == "root")
+ {
+ Assert.Inconclusive("root may execute any file that has an execute bit, so the owner's missing bit is not visible.");
+ }
+
+ // Group and others may execute this file but its owner, the caller, may not, so a shell passes over it.
+ // Testing for any execute bit accepted it and the start failed with "Permission denied" (#139).
+ string root = RunCommandTests.CreateDirectoryForTest();
+ string othersOnly = Directory.CreateDirectory(Path.Join(root, "a")).FullName;
+ string executable = Directory.CreateDirectory(Path.Join(root, "b")).FullName;
+ string name = $"runcommand-{nameof(ExecuteAsyncShouldSkipAFileOnTheOverlayPathThatOnlyOthersMayExecute)}";
+ WriteScript(othersOnly, name, "echo ran the file the caller cannot run", executable: false);
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(
+ Path.Join(othersOnly, name),
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
+ }
+
+ WriteScript(executable, name, "echo ran overlay copy", executable: true);
+
+ List output = await RunOnOverlayPathAsync(name, $"{othersOnly}{Path.PathSeparator}{executable}").ConfigureAwait(false);
+
+ Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the search to ask whether the caller may execute the file, as a shell does.");
+ }
+
+ [TestMethod]
+ public void CanExecuteShouldReportWhetherTheCallerMayExecuteTheFile()
+ {
+ SkipOnWindows();
+ string directory = RunCommandTests.CreateDirectoryForTest();
+ WriteScript(directory, "runs", "exit 0", executable: true);
+ WriteScript(directory, "does-not-run", "exit 0", executable: false);
+
+ Assert.IsTrue(NativeMethods.CanExecute(Path.Join(directory, "runs")));
+ Assert.IsFalse(NativeMethods.CanExecute(Path.Join(directory, "does-not-run")));
+ }
+
+ [TestMethod]
+ public void CanExecuteShouldKeepTheFileWhenTheCLibraryCannotBeCalled()
+ {
+ // Without access(), the search keeps every existing file, as it did before it checked permissions.
+ Assert.IsTrue(NativeMethods.CanExecute("/any/file", (_, _) => throw new DllNotFoundException("libc")));
+ Assert.IsTrue(NativeMethods.CanExecute("/any/file", (_, _) => throw new EntryPointNotFoundException("access")));
+ }
+
+ [TestMethod]
+ public async Task ExecuteAsyncShouldFindTheToolOnTheCallersPathWhenTheOverlayRemovesPath()
+ {
+ SkipOnWindows();
+ List output = [];
+
+ int exitCode = await RunCommand.ExecuteAsync(
+ "sh",
+ ["-c", "echo started"],
+ new LineOutputHandler(onStandardOutput: output.Add),
+ new CommandOptions { EnvironmentVariables = new Dictionary { ["PATH"] = null } }).ConfigureAwait(false);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreSequenceEqual(ExpectedStarted, output, "Expected a removed PATH to leave the usual lookup in place.");
+ }
+
+ [TestMethod]
+ public void FindOverlayPathShouldMatchTheKeyIgnoringCaseOnlyOnWindows()
+ {
+ Dictionary overlay = new(StringComparer.Ordinal) { ["Path"] = "/pinned" };
+
+ Assert.AreEqual("/pinned", RunCommand.FindOverlayPath(overlay, isWindows: true));
+ Assert.IsNull(RunCommand.FindOverlayPath(overlay, isWindows: false));
+ }
+
+ [TestMethod]
+ public void ResolveOnSearchPathShouldAddExeToANameWithNoExtensionOnWindows()
+ {
+ string directory = RunCommandTests.CreateDirectoryForTest();
+ string name = $"tool-{nameof(ResolveOnSearchPathShouldAddExeToANameWithNoExtensionOnWindows)}";
+ WriteScript(directory, $"{name}.exe", "exit 0", executable: true);
+
+ string? resolved = RunCommand.ResolveOnSearchPath(name, directory, isWindows: true);
+
+ Assert.AreEqual(Path.Join(directory, $"{name}.exe"), resolved);
+ }
+
+ [TestMethod]
+ public void ResolveOnSearchPathShouldNeverResolveABareNameToABatchFileOnWindows()
+ {
+ // cmd.exe re-parses a batch file's command line, so a bare name that reached tool.cmd would let & or | in an
+ // argument run a second command (ktsu-dev/RunCommand#138). CreateProcess only ever adds .exe to such a name.
+ string directory = RunCommandTests.CreateDirectoryForTest();
+ string name = $"tool-{nameof(ResolveOnSearchPathShouldNeverResolveABareNameToABatchFileOnWindows)}";
+ WriteScript(directory, $"{name}.cmd", "exit 0", executable: true);
+ WriteScript(directory, $"{name}.bat", "exit 0", executable: true);
+ WriteScript(directory, $"{name}.com", "exit 0", executable: true);
+
+ Assert.IsNull(RunCommand.ResolveOnSearchPath(name, directory, isWindows: true));
+ Assert.AreEqual(Path.Join(directory, $"{name}.cmd"), RunCommand.ResolveOnSearchPath($"{name}.cmd", directory, isWindows: true), "A batch file named with its extension is the caller's explicit choice.");
+ }
+
+ [TestMethod]
+ public void ResolveOnSearchPathShouldLeaveANameWithADirectoryPartAlone()
+ {
+ string directory = RunCommandTests.CreateDirectoryForTest();
+ WriteScript(directory, "tool", "exit 0", executable: true);
+
+ Assert.IsNull(RunCommand.ResolveOnSearchPath("./tool", directory, isWindows: false));
+ Assert.IsNull(RunCommand.ResolveOnSearchPath(@"bin\tool", directory, isWindows: true));
+ Assert.IsNull(RunCommand.ResolveOnSearchPath("C:tool", directory, isWindows: true));
+ }
+
+ [TestMethod]
+ public void ResolveOnSearchPathShouldSkipRelativeAndMissingEntries()
+ {
+ string directory = RunCommandTests.CreateDirectoryForTest();
+ string name = $"tool-{nameof(ResolveOnSearchPathShouldSkipRelativeAndMissingEntries)}";
+ WriteScript(directory, name, "exit 0", executable: true);
+ string searchPath = string.Join(Path.PathSeparator, "relative", Path.Join(directory, "missing"), "", directory);
+
+ Assert.AreEqual(Path.Join(directory, name), RunCommand.ResolveOnSearchPath(name, searchPath, isWindows: false));
+ Assert.IsNull(RunCommand.ResolveOnSearchPath(name, "relative", isWindows: false));
+ }
+
+ internal static void SkipOnWindows()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Inconclusive("Uses shell scripts and execute permission bits. The lookup this covers is shared, and the Unix legs cover it.");
+ }
+ }
+
+ private static void WriteScript(string directory, string name, string body, bool executable)
+ {
+ string path = Path.Join(directory, name);
+ File.WriteAllText(path, $"#!/bin/sh\n{body}\n");
+ if (!OperatingSystem.IsWindows())
+ {
+ UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+ File.SetUnixFileMode(path, executable ? mode | UnixFileMode.UserExecute : mode);
+ }
+ }
+
+ private static async Task> RunOnOverlayPathAsync(string fileName, string overlayDirectories, params string[] arguments)
+ {
+ List output = [];
+ int exitCode = await RunCommand.ExecuteAsync(
+ fileName,
+ arguments,
+ new LineOutputHandler(onStandardOutput: output.Add),
+ new CommandOptions { EnvironmentVariables = new Dictionary { ["PATH"] = $"{overlayDirectories}{Path.PathSeparator}/usr/bin{Path.PathSeparator}/bin" } }).ConfigureAwait(false);
+ Assert.AreEqual(0, exitCode);
+ return output;
+ }
+}
diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs
index 7477cc3..88b8952 100644
--- a/RunCommand.Test/RunCommandTests.cs
+++ b/RunCommand.Test/RunCommandTests.cs
@@ -3,7 +3,6 @@
namespace ktsu.RunCommand.Test;
using System.Diagnostics;
-using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Runtime.InteropServices;
@@ -12,11 +11,7 @@ namespace ktsu.RunCommand.Test;
[TestClass]
public class RunCommandTests
{
- private static readonly string[] ExpectedALast = ["a", "last"];
- private static readonly string[] ExpectedALastNext = ["a", "last", "next"];
private static readonly string[] ExpectedDone = ["done"];
- private static readonly string[] ExpectedOverlayCopy = ["ran overlay copy"];
- private static readonly string[] ExpectedStarted = ["started"];
private static string GetCopyCommand(string source, string destination) =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
@@ -336,7 +331,7 @@ private static (string FileName, string[] Arguments) GetPrintWorkingDirectoryCom
: ("pwd", []);
// The caller name keeps each test on its own directory, since tests run in parallel.
- private static string CreateDirectoryForTest([CallerMemberName] string caller = "") =>
+ internal static string CreateDirectoryForTest([CallerMemberName] string caller = "") =>
Directory.CreateDirectory(Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)} {caller}")).FullName;
///
@@ -492,7 +487,7 @@ static void PartlyFailingKill(Process _) =>
[TestMethod]
public void KillEntireTreeWhereSupportedShouldEndTheGrandchildrenToo()
{
- SkipOnWindows();
+ OverlayPathTests.SkipOnWindows();
// The netstandard builds, which .NET Core 3.x and .NET 5–7 apps load, kill through this method. Killing only
// the shell would leave its background sleep running (#134).
@@ -1025,205 +1020,6 @@ public async Task ExecuteAsyncShouldInheritTheEnvironmentWhenNoVariablesAreGiven
}
}
- [TestMethod]
- public async Task ExecuteAsyncShouldStartAToolThatOnlyTheOverlayPathHolds()
- {
- SkipOnWindows();
- string directory = CreateDirectoryForTest();
- string name = $"runcommand-{nameof(ExecuteAsyncShouldStartAToolThatOnlyTheOverlayPathHolds)}";
- WriteScript(directory, name, "echo ran overlay copy", executable: true);
-
- List output = await RunOnOverlayPathAsync(name, directory).ConfigureAwait(false);
-
- Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the tool on the overlay PATH to start.");
- }
-
- [TestMethod]
- public async Task ExecuteAsyncShouldPreferTheOverlayPathOverTheCallersPath()
- {
- SkipOnWindows();
-
- // echo is on the caller's PATH too, so the old lookup would have started that one and printed the argument.
- string directory = CreateDirectoryForTest();
- WriteScript(directory, "echo", "printf 'ran overlay copy\\n'", executable: true);
-
- List output = await RunOnOverlayPathAsync("echo", directory, "ran caller copy").ConfigureAwait(false);
-
- Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the overlay PATH to choose which echo runs.");
- }
-
- [TestMethod]
- public async Task ExecuteAsyncShouldSkipANonExecutableFileOnTheOverlayPath()
- {
- SkipOnWindows();
- string root = CreateDirectoryForTest();
- string notExecutable = Directory.CreateDirectory(Path.Join(root, "a")).FullName;
- string executable = Directory.CreateDirectory(Path.Join(root, "b")).FullName;
- string name = $"runcommand-{nameof(ExecuteAsyncShouldSkipANonExecutableFileOnTheOverlayPath)}";
- WriteScript(notExecutable, name, "echo ran the file that cannot run", executable: false);
- WriteScript(executable, name, "echo ran overlay copy", executable: true);
-
- List output = await RunOnOverlayPathAsync(name, $"{notExecutable}{Path.PathSeparator}{executable}").ConfigureAwait(false);
-
- Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the search to pass over a file without an execute bit, as a shell does.");
- }
-
- [TestMethod]
- public async Task ExecuteAsyncShouldSkipAFileOnTheOverlayPathThatOnlyOthersMayExecute()
- {
- SkipOnWindows();
- if (Environment.UserName == "root")
- {
- Assert.Inconclusive("root may execute any file that has an execute bit, so the owner's missing bit is not visible.");
- }
-
- // Group and others may execute this file but its owner, the caller, may not, so a shell passes over it.
- // Testing for any execute bit accepted it and the start failed with "Permission denied" (#139).
- string root = CreateDirectoryForTest();
- string othersOnly = Directory.CreateDirectory(Path.Join(root, "a")).FullName;
- string executable = Directory.CreateDirectory(Path.Join(root, "b")).FullName;
- string name = $"runcommand-{nameof(ExecuteAsyncShouldSkipAFileOnTheOverlayPathThatOnlyOthersMayExecute)}";
- WriteScript(othersOnly, name, "echo ran the file the caller cannot run", executable: false);
- if (!OperatingSystem.IsWindows())
- {
- File.SetUnixFileMode(
- Path.Join(othersOnly, name),
- UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
- }
-
- WriteScript(executable, name, "echo ran overlay copy", executable: true);
-
- List output = await RunOnOverlayPathAsync(name, $"{othersOnly}{Path.PathSeparator}{executable}").ConfigureAwait(false);
-
- Assert.AreSequenceEqual(ExpectedOverlayCopy, output, "Expected the search to ask whether the caller may execute the file, as a shell does.");
- }
-
- [TestMethod]
- public void CanExecuteShouldReportWhetherTheCallerMayExecuteTheFile()
- {
- SkipOnWindows();
- string directory = CreateDirectoryForTest();
- WriteScript(directory, "runs", "exit 0", executable: true);
- WriteScript(directory, "does-not-run", "exit 0", executable: false);
-
- Assert.IsTrue(NativeMethods.CanExecute(Path.Join(directory, "runs")));
- Assert.IsFalse(NativeMethods.CanExecute(Path.Join(directory, "does-not-run")));
- }
-
- [TestMethod]
- public void CanExecuteShouldKeepTheFileWhenTheCLibraryCannotBeCalled()
- {
- // Without access(), the search keeps every existing file, as it did before it checked permissions.
- Assert.IsTrue(NativeMethods.CanExecute("/any/file", (_, _) => throw new DllNotFoundException("libc")));
- Assert.IsTrue(NativeMethods.CanExecute("/any/file", (_, _) => throw new EntryPointNotFoundException("access")));
- }
-
- [TestMethod]
- public async Task ExecuteAsyncShouldFindTheToolOnTheCallersPathWhenTheOverlayRemovesPath()
- {
- SkipOnWindows();
- List output = [];
-
- int exitCode = await RunCommand.ExecuteAsync(
- "sh",
- ["-c", "echo started"],
- new LineOutputHandler(onStandardOutput: output.Add),
- new CommandOptions { EnvironmentVariables = new Dictionary { ["PATH"] = null } }).ConfigureAwait(false);
-
- Assert.AreEqual(0, exitCode);
- Assert.AreSequenceEqual(ExpectedStarted, output, "Expected a removed PATH to leave the usual lookup in place.");
- }
-
- [TestMethod]
- public void FindOverlayPathShouldMatchTheKeyIgnoringCaseOnlyOnWindows()
- {
- Dictionary overlay = new(StringComparer.Ordinal) { ["Path"] = "/pinned" };
-
- Assert.AreEqual("/pinned", RunCommand.FindOverlayPath(overlay, isWindows: true));
- Assert.IsNull(RunCommand.FindOverlayPath(overlay, isWindows: false));
- }
-
- [TestMethod]
- public void ResolveOnSearchPathShouldAddExeToANameWithNoExtensionOnWindows()
- {
- string directory = CreateDirectoryForTest();
- string name = $"tool-{nameof(ResolveOnSearchPathShouldAddExeToANameWithNoExtensionOnWindows)}";
- WriteScript(directory, $"{name}.exe", "exit 0", executable: true);
-
- string? resolved = RunCommand.ResolveOnSearchPath(name, directory, isWindows: true);
-
- Assert.AreEqual(Path.Join(directory, $"{name}.exe"), resolved);
- }
-
- [TestMethod]
- public void ResolveOnSearchPathShouldNeverResolveABareNameToABatchFileOnWindows()
- {
- // cmd.exe re-parses a batch file's command line, so a bare name that reached tool.cmd would let & or | in an
- // argument run a second command (ktsu-dev/RunCommand#138). CreateProcess only ever adds .exe to such a name.
- string directory = CreateDirectoryForTest();
- string name = $"tool-{nameof(ResolveOnSearchPathShouldNeverResolveABareNameToABatchFileOnWindows)}";
- WriteScript(directory, $"{name}.cmd", "exit 0", executable: true);
- WriteScript(directory, $"{name}.bat", "exit 0", executable: true);
- WriteScript(directory, $"{name}.com", "exit 0", executable: true);
-
- Assert.IsNull(RunCommand.ResolveOnSearchPath(name, directory, isWindows: true));
- Assert.AreEqual(Path.Join(directory, $"{name}.cmd"), RunCommand.ResolveOnSearchPath($"{name}.cmd", directory, isWindows: true), "A batch file named with its extension is the caller's explicit choice.");
- }
-
- [TestMethod]
- public void ResolveOnSearchPathShouldLeaveANameWithADirectoryPartAlone()
- {
- string directory = CreateDirectoryForTest();
- WriteScript(directory, "tool", "exit 0", executable: true);
-
- Assert.IsNull(RunCommand.ResolveOnSearchPath("./tool", directory, isWindows: false));
- Assert.IsNull(RunCommand.ResolveOnSearchPath(@"bin\tool", directory, isWindows: true));
- Assert.IsNull(RunCommand.ResolveOnSearchPath("C:tool", directory, isWindows: true));
- }
-
- [TestMethod]
- public void ResolveOnSearchPathShouldSkipRelativeAndMissingEntries()
- {
- string directory = CreateDirectoryForTest();
- string name = $"tool-{nameof(ResolveOnSearchPathShouldSkipRelativeAndMissingEntries)}";
- WriteScript(directory, name, "exit 0", executable: true);
- string searchPath = string.Join(Path.PathSeparator, "relative", Path.Join(directory, "missing"), "", directory);
-
- Assert.AreEqual(Path.Join(directory, name), RunCommand.ResolveOnSearchPath(name, searchPath, isWindows: false));
- Assert.IsNull(RunCommand.ResolveOnSearchPath(name, "relative", isWindows: false));
- }
-
- private static void SkipOnWindows()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Assert.Inconclusive("Uses shell scripts and execute permission bits. The lookup this covers is shared, and the Unix legs cover it.");
- }
- }
-
- private static void WriteScript(string directory, string name, string body, bool executable)
- {
- string path = Path.Join(directory, name);
- File.WriteAllText(path, $"#!/bin/sh\n{body}\n");
- if (!OperatingSystem.IsWindows())
- {
- UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
- File.SetUnixFileMode(path, executable ? mode | UnixFileMode.UserExecute : mode);
- }
- }
-
- private static async Task> RunOnOverlayPathAsync(string fileName, string overlayDirectories, params string[] arguments)
- {
- List output = [];
- int exitCode = await RunCommand.ExecuteAsync(
- fileName,
- arguments,
- new LineOutputHandler(onStandardOutput: output.Add),
- new CommandOptions { EnvironmentVariables = new Dictionary { ["PATH"] = $"{overlayDirectories}{Path.PathSeparator}/usr/bin{Path.PathSeparator}/bin" } }).ConfigureAwait(false);
- Assert.AreEqual(0, exitCode);
- return output;
- }
-
[TestMethod]
public async Task ExecuteAsyncShouldRejectEnvironmentVariablesCombinedWithElevation()
{
@@ -1246,221 +1042,6 @@ await Assert.ThrowsAsync(
})).ConfigureAwait(false);
}
- ///
- /// Returns a command that writes a file's bytes to standard output unchanged, as an executable
- /// plus separate arguments.
- ///
- ///
- /// Windows has no reliably byte-faithful built-in for this. cmd /c type looked like one
- /// but transcodes a file carrying a UTF-16 byte order mark instead of copying it, which is
- /// exactly the input these tests need, so PowerShell writes the raw bytes to the standard
- /// output stream instead. checks the result rather than
- /// trusting it.
- ///
- private static (string FileName, string[] Arguments) GetEmitFileBytesCommand(string path) =>
- RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
- ? ("powershell", [
- "-NoProfile",
- "-NonInteractive",
- "-Command",
- $"$b=[IO.File]::ReadAllBytes('{path}'); $s=[Console]::OpenStandardOutput(); $s.Write($b,0,$b.Length); $s.Flush()"])
- : ("cat", [path]);
-
- private static readonly Encoding StrictUtf8 = new UTF8Encoding(false, throwOnInvalidBytes: true);
-
- // Runs the emitter through the library, so the test controls the exact bytes that reach the
- // pipe and can assert on what the library makes of them.
- private static string RunOverPath(string path, Encoding encoding)
- {
- StringBuilder output = new();
- (string fileName, string[] arguments) = GetEmitFileBytesCommand(path);
- _ = RunCommand.Execute(fileName, arguments, new OutputHandler(o => output.Append(o), null, encoding));
-
- return output.ToString();
- }
-
- ///
- /// Checks that this platform's emitter really does put on the pipe
- /// unchanged, so that a mangling emitter reports itself instead of being read as a result about
- /// the library.
- ///
- ///
- /// This drives the emitter through directly and copies the raw
- /// , deliberately never touching the code under test. A
- /// control that went through would measure the very defect these tests
- /// exist to catch and blame the emitter for it, which would turn a regression into an
- /// inconclusive result instead of a failure.
- ///
- private static bool EmitsBytesFaithfully(string path, byte[] bytes, out string diagnostic)
- {
- byte[] actual;
-
- try
- {
- (string fileName, string[] arguments) = GetEmitFileBytesCommand(path);
- ProcessStartInfo startInfo = new()
- {
- FileName = fileName,
- RedirectStandardOutput = true,
- UseShellExecute = false,
- CreateNoWindow = true,
- };
-
- foreach (string argument in arguments)
- {
- startInfo.ArgumentList.Add(argument);
- }
-
- using Process process = Process.Start(startInfo)!;
- using MemoryStream captured = new();
- process.StandardOutput.BaseStream.CopyTo(captured);
- process.WaitForExit();
- actual = captured.ToArray();
- }
- catch (System.ComponentModel.Win32Exception ex)
- {
- diagnostic = $"This platform's byte emitter could not be started: {ex.Message}";
- return false;
- }
-
- bool faithful = actual.SequenceEqual(bytes);
- diagnostic = faithful
- ? string.Empty
- : "This platform's byte emitter altered the bytes, so the library cannot be judged from them. "
- + $"Expected [{Convert.ToHexString(bytes)}], the pipe carried [{Convert.ToHexString(actual)}].";
-
- return faithful;
- }
-
- // Writes the bytes to a file of this test's own and confirms the platform can actually put them
- // on a pipe unchanged, returning the path to feed to the library.
- private static string WriteBytesForTest(byte[] bytes, string caller)
- {
- string path = Path.Join(CreateDirectoryForTest(caller), "bytes.bin");
- File.WriteAllBytes(path, bytes);
-
- if (!EmitsBytesFaithfully(path, bytes, out string diagnostic))
- {
- Assert.Inconclusive(diagnostic);
- }
-
- return path;
- }
-
- private static void AssertDecodeFailure(byte[] bytes, [CallerMemberName] string caller = "")
- {
- // The emitter check has to happen out here: an Assert.Inconclusive raised inside the lambda
- // below would be caught by Assert.ThrowsExactly and reported as a failing test.
- string path = WriteBytesForTest(bytes, caller);
-
- AggregateException thrown = Assert.ThrowsExactly(() => RunOverPath(path, StrictUtf8));
- Assert.IsTrue(
- thrown.Flatten().InnerExceptions.Any(e => e is DecoderFallbackException),
- $"Expected a decode failure, got: {thrown}");
- }
-
- [TestMethod]
- public void OutputEncodingIsNotReplacedByAUtf16ByteOrderMark() =>
- // "hi" in UTF-16LE behind its byte order mark. Process builds its StandardOutput reader with
- // byte-order-mark detection on, which used to switch the reader to UTF-16LE and return "hi"
- // even though the caller asked for strict UTF-8 and these are not valid UTF-8 bytes.
- AssertDecodeFailure([0xFF, 0xFE, (byte)'h', 0x00, (byte)'i', 0x00]);
-
- [TestMethod]
- public void OutputThatIsOnlyAUtf16ByteOrderMarkIsNotReportedAsSuccess() =>
- // The same detection consumed a lone FF FE as a byte order mark, leaving nothing to decode,
- // so a strict encoding reported no output and no error for bytes it should have rejected.
- AssertDecodeFailure([0xFF, 0xFE]);
-
- [TestMethod]
- public void AUtf8ByteOrderMarkIsStrippedFromTheStartOfOutput()
- {
- // A byte order mark matching the requested encoding is still dropped, so turning the
- // detection off did not start leaking U+FEFF into captured output.
- byte[] bytes = [0xEF, 0xBB, 0xBF, (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o'];
- string path = WriteBytesForTest(bytes, nameof(AUtf8ByteOrderMarkIsStrippedFromTheStartOfOutput));
-
- Assert.AreEqual("hello", RunOverPath(path, StrictUtf8));
- }
-
- ///
- /// Returns the encoding a byte-order-mark test names, along with that encoding's own BOM.
- ///
- ///
- /// Named rather than passed because only takes constants. The
- /// cases split on whether the encoding has a preamble: skips a
- /// matching preamble itself whatever detectEncodingFromByteOrderMarks says, so one with a
- /// preamble must not be stripped a second time by the library.
- ///
- private static (Encoding Encoding, byte[] Bom) GetByteOrderMarkCase(string name) => name switch
- {
- "Encoding.UTF8" => (Encoding.UTF8, [0xEF, 0xBB, 0xBF]),
- "UTF8Encoding(false)" => (new UTF8Encoding(false), [0xEF, 0xBB, 0xBF]),
- "Encoding.Unicode" => (Encoding.Unicode, [0xFF, 0xFE]),
- "UnicodeEncoding(false, false)" => (new UnicodeEncoding(bigEndian: false, byteOrderMark: false), [0xFF, 0xFE]),
- _ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown encoding case."),
- };
-
- [TestMethod]
- [DataRow("Encoding.UTF8")]
- [DataRow("UTF8Encoding(false)")]
- [DataRow("Encoding.Unicode")]
- [DataRow("UnicodeEncoding(false, false)")]
- public void OnlyTheByteOrderMarkIsStrippedWhenARealU_FEFFFollowsIt(string encodingName)
- {
- // A BOM, then U+FEFF that is part of the output, then "A". The BOM goes and the content
- // stays, whichever of two equivalent encodings the caller passed. With a preamble the reader
- // dropped the BOM and the library then dropped the real U+FEFF after it.
- (Encoding encoding, byte[] bom) = GetByteOrderMarkCase(encodingName);
- byte[] bytes = [.. bom, .. encoding.GetBytes("\uFEFFA")];
- string path = WriteBytesForTest(bytes, $"{nameof(OnlyTheByteOrderMarkIsStrippedWhenARealU_FEFFFollowsIt)}.{new string([.. encodingName.Where(char.IsLetterOrDigit)])}");
-
- string output = RunOverPath(path, encoding);
-
- Assert.AreEqual("FEFF 0041", string.Join(' ', output.Select(c => ((int)c).ToString("X4", CultureInfo.InvariantCulture))));
- }
-
- [TestMethod]
- [DataRow("Encoding.UTF8")]
- [DataRow("UTF8Encoding(false)")]
- [DataRow("Encoding.Unicode")]
- [DataRow("UnicodeEncoding(false, false)")]
- public void ALoneByteOrderMarkIsStrippedWhicheverEncodingReadsIt(string encodingName)
- {
- (Encoding encoding, byte[] bom) = GetByteOrderMarkCase(encodingName);
- byte[] bytes = [.. bom, .. encoding.GetBytes("hello")];
- string path = WriteBytesForTest(bytes, $"{nameof(ALoneByteOrderMarkIsStrippedWhicheverEncodingReadsIt)}.{new string([.. encodingName.Where(char.IsLetterOrDigit)])}");
-
- Assert.AreEqual("hello", RunOverPath(path, encoding));
- }
-
- [TestMethod]
- public void ADecodeFailureIsNotDiscardedWhenTheProcessKeepsRunning()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Assert.Inconclusive("Needs a shell that can emit bytes and then stay alive. The race this covers is in platform independent code, so the other legs cover it.");
- }
-
- // The read loop stops when the process exits, so a read that faults while the process is
- // still running used to be replaced by a fresh read on the next pass and its decode failure
- // thrown away. Sleeping after the bad byte keeps the process alive long enough for that pass
- // to happen, which makes the race deterministic rather than roughly one run in six.
- string path = Path.Join(CreateDirectoryForTest(), "bytes.bin");
- File.WriteAllBytes(path, [(byte)'o', (byte)'k', 0xFF]);
-
- StringBuilder output = new();
- AggregateException thrown = Assert.ThrowsExactly(
- () => RunCommand.Execute(
- "sh",
- ["-c", $"cat '{path}'; sleep 1"],
- new OutputHandler(o => output.Append(o), null, StrictUtf8)));
-
- Assert.IsTrue(
- thrown.Flatten().InnerExceptions.Any(e => e is DecoderFallbackException),
- $"Expected a decode failure, got: {thrown}");
- }
-
///
/// Returns a command that reads one line from standard input and then reports what it read.
///
@@ -1580,7 +1161,7 @@ public async Task ExecuteAsyncShouldDeliverOutputStillInThePipeWhenTheProcessExi
try
{
await File.WriteAllBytesAsync(path, [.. Enumerable.Repeat((byte)'a', length)]).ConfigureAwait(false);
- (string fileName, string[] arguments) = GetEmitFileBytesCommand(path);
+ (string fileName, string[] arguments) = OutputDecodingTests.GetEmitFileBytesCommand(path);
// The loss depends on how far the reads have got when the process exits, so one clean
// run proves little. Repeat it.
@@ -1836,198 +1417,4 @@ private static bool IsRunning(string pid)
return false;
}
}
-
- ///
- /// Returns a command that prints a file's contents unchanged.
- ///
- private static (string FileName, string[] Arguments) GetPrintFileCommand(string path) =>
- RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
- ? ("cmd", ["/c", "type", path])
- : ("cat", [path]);
-
- [TestMethod]
- public async Task LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun()
- {
- string first = Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)}.{nameof(LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun)}.1.txt");
- string second = Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)}.{nameof(LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun)}.2.txt");
- await File.WriteAllTextAsync(first, "a\nlast").ConfigureAwait(false);
- await File.WriteAllTextAsync(second, "next\n").ConfigureAwait(false);
-
- try
- {
- List lines = [];
- LineOutputHandler handler = new(onStandardOutput: lines.Add);
-
- (string fileName, string[] arguments) = GetPrintFileCommand(first);
- int exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false);
- Assert.AreEqual(0, exitCode);
- Assert.AreSequenceEqual(ExpectedALast, lines);
-
- (fileName, arguments) = GetPrintFileCommand(second);
- exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false);
- Assert.AreEqual(0, exitCode);
- Assert.AreSequenceEqual(ExpectedALastNext, lines);
- }
- finally
- {
- File.Delete(first);
- File.Delete(second);
- }
- }
-
- [TestMethod]
- public async Task ReusedLineOutputHandlerShouldDropTheLineACancelledRunLeftUnfinished()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Assert.Inconclusive("Needs sh to print a line without a line break. The reset this covers is in platform independent code, so the other legs cover it.");
- }
-
- List lines = [];
- using SemaphoreSlim partialArrived = new(0);
- LineOutputHandler handler = new(lines.Add);
-
- using (CancellationTokenSource cancellationTokenSource = new())
- {
- Task cancelled = RunCommand.ExecuteAsync(
- "sh",
- ["-c", "printf partial; sleep 30"],
- new SignallingLineOutputHandler(handler, partialArrived),
- cancellationTokenSource.Token);
-
- Assert.IsTrue(await partialArrived.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), "Expected the partial line to arrive.");
- await cancellationTokenSource.CancelAsync().ConfigureAwait(false);
- await Assert.ThrowsAsync(() => cancelled).ConfigureAwait(false);
- }
-
- Assert.AreEqual("partial", handler.outputBuffer.ToString(), "The cancelled run should have left its partial line buffered.");
-
- int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "echo hello"], handler).ConfigureAwait(false);
-
- Assert.AreEqual(0, exitCode);
- Assert.AreEqual("hello", string.Join(" | ", lines), "The next run's first line should not carry the cancelled run's leftover text.");
- }
-
- [TestMethod]
- public async Task LineOutputHandlerSharedByOverlappingRunsShouldKeepEachRunsLinesApart()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Assert.Inconclusive("Needs sh to print part of a line, pause, then finish it. The per-run splitting this covers is platform independent, so the other legs cover it.");
- }
-
- // ktsu-dev/RunCommand#130: the second run's Reset threw away the first run's partial line, and
- // both runs then appended to the same buffer, so their text came out joined into one line.
- List lines = [];
- using SemaphoreSlim firstPartArrived = new(0);
- LineOutputHandler handler = new(line =>
- {
- lock (lines)
- {
- lines.Add(line);
- }
- });
-
- Task first = RunCommand.ExecuteAsync(
- "sh",
- ["-c", "printf AAAA; sleep 1; printf 'AAAA\\n'"],
- new SignallingLineOutputHandler(handler, firstPartArrived));
-
- Assert.IsTrue(await firstPartArrived.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), "Expected the first run's partial line to arrive.");
-
- Task second = RunCommand.ExecuteAsync("sh", ["-c", "printf BBBB; sleep 0.2; printf 'BBBB\\n'"], handler);
-
- int[] exitCodes = await Task.WhenAll(first, second).ConfigureAwait(false);
-
- Assert.AreEqual(0, exitCodes[0], "The first run should succeed.");
- Assert.AreEqual(0, exitCodes[1], "The second run should succeed.");
- List sorted;
- lock (lines)
- {
- sorted = [.. lines.Order(StringComparer.Ordinal)];
- }
-
- Assert.AreEqual("AAAAAAAA | BBBBBBBB", string.Join(" | ", sorted));
- }
-
- [TestMethod]
- public async Task ReusedLineOutputHandlerShouldDropTheLineARunWhoseCallbackThrewLeftUnfinished()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Assert.Inconclusive("Needs sh to print a line without a line break. The reset this covers is in platform independent code, so the other legs cover it.");
- }
-
- List lines = [];
- bool throwOnError = true;
- LineOutputHandler handler = new(
- lines.Add,
- line =>
- {
- if (throwOnError)
- {
- throw new InvalidOperationException("handler failed");
- }
- });
-
- // Standard output's partial line is buffered before standard error's line makes the callback throw.
- await Assert.ThrowsAsync(
- () => RunCommand.ExecuteAsync("sh", ["-c", "printf partial; sleep 0.5; echo boom >&2; sleep 30"], handler)).ConfigureAwait(false);
-
- // The failed run's partial line stays in that run's own handler (#130), so nothing is left on this one.
- Assert.AreEqual(string.Empty, handler.outputBuffer.ToString(), "A run should split lines in its own handler, not the shared one.");
-
- throwOnError = false;
- int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "echo hello"], handler).ConfigureAwait(false);
-
- Assert.AreEqual(0, exitCode);
- Assert.AreEqual("hello", string.Join(" | ", lines), "The next run's first line should not carry the failed run's leftover text.");
- }
-
- [TestMethod]
- public async Task AbandonedReaderShouldNotDeliverIntoALaterRunOnTheSameHandler()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Assert.Inconclusive("Needs a shell that can orphan a child out of its own process tree while that child keeps the pipe it inherited. That a cancelled run's reads stop delivering is platform independent, so the other legs cover it.");
- }
-
- List lines = [];
- using SemaphoreSlim partialArrived = new(0);
- LineOutputHandler handler = new(lines.Add);
-
- // The orphaned subshell escapes the kill and keeps the cancelled run's standard output open, then
- // writes to it a second later, while the next run on the same handler is still going. The
- // cancelled run gave up on that read, so what arrives there must not reach the handler.
- using (CancellationTokenSource cancellationTokenSource = new())
- {
- Task cancelled = RunCommand.ExecuteAsync(
- "sh",
- ["-c", "sh -c '(sleep 1; printf late) &'; printf partial; sleep 30"],
- new SignallingLineOutputHandler(handler, partialArrived),
- cancellationTokenSource.Token);
-
- Assert.IsTrue(await partialArrived.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), "Expected the partial line to arrive.");
- await cancellationTokenSource.CancelAsync().ConfigureAwait(false);
- await Assert.ThrowsAsync(() => cancelled).ConfigureAwait(false);
- }
-
- int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "sleep 2; echo hello"], handler).ConfigureAwait(false);
-
- Assert.AreEqual(0, exitCode);
- Assert.AreEqual("hello", string.Join(" | ", lines), "Output the cancelled run's abandoned reader received should not reach the later run.");
- }
-
- ///
- /// Forwards standard output to and signals after each chunk,
- /// so a test can wait for output to arrive before it cancels.
- ///
- private sealed class SignallingLineOutputHandler(LineOutputHandler inner, SemaphoreSlim received) : OutputHandler
- {
- internal override void HandleStandardOutputData(string data)
- {
- inner.HandleStandardOutputData(data);
- received.Release();
- }
- }
}