From 8177d08efc77df67dc739a44a18179427691ce70 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 11:26:52 +0000 Subject: [PATCH] Split RunCommandTests by area so the test project builds again [patch] RunCommandTests had grown to 98 coupled types, over CA1506's limit of 96, so RunCommand.Test failed to build on main and CI was red. Move the overlay PATH, output decoding, and LineOutputHandler run tests into their own classes. No test changes behaviour. Fixes #152 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5GEJNk1TZFgDdUPFX --- RunCommand.Test/LineOutputHandlerRunTests.cs | 206 ++++++ RunCommand.Test/OutputDecodingTests.cs | 228 +++++++ RunCommand.Test/OverlayPathTests.cs | 211 +++++++ RunCommand.Test/RunCommandTests.cs | 619 +------------------ 4 files changed, 648 insertions(+), 616 deletions(-) create mode 100644 RunCommand.Test/LineOutputHandlerRunTests.cs create mode 100644 RunCommand.Test/OutputDecodingTests.cs create mode 100644 RunCommand.Test/OverlayPathTests.cs 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(); - } - } }