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(); - } - } }