CLI: Unify interactive input and output in Reporter (#41215)

David Bennett committed Aug 3, 2026 at 13:27 UTC 05c2387cb030d06a944733a2bc07126212e82965
10 files changed +563 -67
src/windows/wslc/commands/RegistryCommand.cpp
+5 -56
@@ -17,56 +17,11 @@ Abstract:
17 #include "RegistryTasks.h"
18 #include "SessionTasks.h"
19 #include "Task.h"
20 -#include <iostream>
20
21 using namespace wsl::windows::wslc::execution;
22 using namespace wsl::windows::wslc::task;
23 using namespace wsl::shared;
24
26 -namespace {
27 -
28 -std::wstring Prompt(wsl::windows::wslc::Reporter& reporter, const std::wstring& label, bool maskInput)
29 -{
30 - // Write without a trailing newline so the cursor stays inline (matching Docker's behavior).
31 - reporter.Info(L"{}", label);
32 -
33 - HANDLE input = GetStdHandle(STD_INPUT_HANDLE);
34 - DWORD previousMode = 0;
35 - const bool canMask = maskInput && (input != INVALID_HANDLE_VALUE) && GetConsoleMode(input, &previousMode);
36 -
37 - // Armed before echo is disabled and built from a direct lambda (no allocation, can't throw) so a
38 - // failure while masking still restores the console. Only acts once echo was actually disabled.
39 - bool echoDisabled = false;
40 - auto restoreConsole = wil::scope_exit([input, previousMode, &echoDisabled, &reporter]() {
41 - if (!echoDisabled)
42 - {
43 - return;
44 - }
45 -
46 - SetConsoleMode(input, previousMode);
47 - // Runs from a noexcept scope_exit destructor, possibly during unwinding, so swallow any
48 - // output failure to avoid std::terminate.
49 - try
50 - {
51 - reporter.Info(L"\n");
52 - }
53 - CATCH_LOG()
54 - });
55 -
56 - if (canMask)
57 - {
58 - THROW_IF_WIN32_BOOL_FALSE(SetConsoleMode(input, previousMode & ~ENABLE_ECHO_INPUT));
59 - echoDisabled = true;
60 - }
61 -
62 - std::wstring value;
63 - std::getline(std::wcin, value);
64 -
65 - return value;
66 -}
67 -
68 -} // namespace
69 -
25 namespace wsl::windows::wslc {
26
27 // Registry Root Command
@@ -134,10 +89,10 @@ void RegistryLoginCommand::ValidateArgumentsInternal(const ArgMap& execArgs) con
89
90 void RegistryLoginCommand::ExecuteInternal(CLIExecutionContext& context) const
91 {
137 - // Prompt for username if not provided.
92 if (!context.Args.Contains(ArgType::Username))
93 {
140 - context.Args.Add(ArgType::Username, Prompt(context.Reporter, Localization::WSLCCLI_LoginUsernamePrompt(), false));
94 + auto username = context.Reporter.PromptForLine(Localization::WSLCCLI_LoginUsernamePrompt());
95 + context.Args.Add(ArgType::Username, std::move(username));
96 }
97
98 // Resolve password: --password, --password-stdin, or interactive prompt.
@@ -145,18 +100,12 @@ void RegistryLoginCommand::ExecuteInternal(CLIExecutionContext& context) const
100 {
101 if (context.Args.GetFlag<ArgType::PasswordStdin>())
102 {
148 - std::wstring line;
149 - std::getline(std::wcin, line);
150 - if (!line.empty() && line.back() == L'\r')
151 - {
152 - line.pop_back();
153 - }
154 -
155 - context.Args.Add(ArgType::Password, std::move(line));
103 + context.Args.Add(ArgType::Password, context.Reporter.ReadLine().value_or(std::wstring{}));
104 }
105 else
106 {
159 - context.Args.Add(ArgType::Password, Prompt(context.Reporter, Localization::WSLCCLI_LoginPasswordPrompt(), true));
107 + auto password = context.Reporter.PromptForLine(Localization::WSLCCLI_LoginPasswordPrompt(), true);
108 + context.Args.Add(ArgType::Password, std::move(password));
109 }
110 }
111
src/windows/wslc/core/InputChannel.cpp new
+106
@@ -0,0 +1,106 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + InputChannel.cpp
8 +
9 +Abstract:
10 +
11 + Implementation of InputChannel.
12 +
13 +--*/
14 +#include "precomp.h"
15 +#include "InputChannel.h"
16 +
17 +#include <wil/resource.h>
18 +
19 +namespace wsl::windows::wslc {
20 +
21 +InputChannel::InputChannel(HANDLE consoleHandle, FILE* readFile) : m_file(readFile)
22 +{
23 + DWORD mode = 0;
24 + if (consoleHandle != INVALID_HANDLE_VALUE && consoleHandle != nullptr && GetConsoleMode(consoleHandle, &mode))
25 + {
26 + m_consoleHandle = consoleHandle;
27 + }
28 +}
29 +
30 +InputChannel::InputChannel(FILE* readFile, bool interactiveOverride) :
31 + m_file(readFile), m_interactiveOverride(interactiveOverride)
32 +{
33 +}
34 +
35 +bool InputChannel::IsInteractive() const noexcept
36 +{
37 + if (m_consoleHandle != nullptr)
38 + {
39 + DWORD mode = 0;
40 + return GetConsoleMode(m_consoleHandle, &mode) != FALSE;
41 + }
42 +
43 + return m_interactiveOverride;
44 +}
45 +
46 +std::optional<std::wstring> InputChannel::ReadLine(bool mask) const
47 +{
48 + if (m_file == nullptr)
49 + {
50 + return std::nullopt;
51 + }
52 +
53 + // Disable console echo while reading when masking is requested and input is a
54 + // real console. Armed only after echo is actually disabled so the restore is a
55 + // no-op otherwise; runs on every exit path including exceptions.
56 + DWORD previousMode = 0;
57 + bool echoDisabled = false;
58 + auto restoreEcho = wil::scope_exit([&]() {
59 + if (echoDisabled)
60 + {
61 + LOG_IF_WIN32_BOOL_FALSE(SetConsoleMode(m_consoleHandle, previousMode));
62 + }
63 + });
64 +
65 + if (mask && m_consoleHandle != nullptr && GetConsoleMode(m_consoleHandle, &previousMode))
66 + {
67 + // Fail rather than echo a secret if the mode cannot be changed.
68 + THROW_IF_WIN32_BOOL_FALSE(SetConsoleMode(m_consoleHandle, previousMode & ~ENABLE_ECHO_INPUT));
69 + echoDisabled = true;
70 + }
71 +
72 + std::wstring line;
73 + bool anyRead = false;
74 + for (;;)
75 + {
76 + const wint_t ch = fgetwc(m_file);
77 + if (ch == WEOF)
78 + {
79 + break;
80 + }
81 +
82 + anyRead = true;
83 + if (ch == L'\n')
84 + {
85 + break;
86 + }
87 +
88 + line.push_back(static_cast<wchar_t>(ch));
89 + }
90 +
91 + if (!anyRead)
92 + {
93 + return std::nullopt;
94 + }
95 +
96 + // The read stops at LF; strip a paired CR so callers get a bare line regardless
97 + // of the input's line-ending convention.
98 + if (!line.empty() && line.back() == L'\r')
99 + {
100 + line.pop_back();
101 + }
102 +
103 + return line;
104 +}
105 +
106 +} // namespace wsl::windows::wslc
src/windows/wslc/core/InputChannel.h new
+56
@@ -0,0 +1,56 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + InputChannel.h
8 +
9 +Abstract:
10 +
11 + Byte source used by Reporter for user input. Reads a line at a time from the
12 + console (or a redirected file/pipe). For console sources the channel can mask
13 + echo while reading (password entry) and reports whether input is interactive.
14 +
15 +--*/
16 +#pragma once
17 +
18 +#include "defs.h"
19 +
20 +#include <cstdio>
21 +#include <optional>
22 +#include <string>
23 +#include <Windows.h>
24 +
25 +namespace wsl::windows::wslc {
26 +
27 +class InputChannel
28 +{
29 +public:
30 + NON_COPYABLE(InputChannel);
31 + NON_MOVABLE(InputChannel);
32 +
33 + // Console path: probes the handle for console mode (masking, interactivity);
34 + // reads are always drawn from readFile (the CRT stdin stream).
35 + InputChannel(HANDLE consoleHandle, FILE* readFile);
36 +
37 + // FILE* path with explicit interactivity override (for tests). No console
38 + // handle, so masking is a no-op and interactivity is whatever is passed.
39 + InputChannel(FILE* readFile, bool interactiveOverride);
40 +
41 + // True when input is attached to a console (a prompt can be shown, echo can be masked).
42 + bool IsInteractive() const noexcept;
43 +
44 + // Reads a single line, stripping the trailing CR and/or LF. Returns nullopt at
45 + // end of input with nothing read (so an empty line and EOF are distinguishable).
46 + // When mask is true and input is an interactive console, console echo is disabled
47 + // for the duration of the read (restored on return, including on exception).
48 + std::optional<std::wstring> ReadLine(bool mask) const;
49 +
50 +private:
51 + HANDLE m_consoleHandle = nullptr;
52 + FILE* m_file = nullptr;
53 + bool m_interactiveOverride = false;
54 +};
55 +
56 +} // namespace wsl::windows::wslc
src/windows/wslc/core/OutputChannel.cpp
+9
@@ -60,6 +60,15 @@ void OutputChannel::WriteString(std::wstring_view text) const
60 }
61 }
62
63 +void OutputChannel::Flush() const
64 +{
65 + if (m_file != nullptr && fflush(m_file) != 0)
66 + {
67 + const int err = errno;
68 + LOG_HR_MSG(HRESULT_FROM_WIN32(ERROR_WRITE_FAULT), "fflush of redirected output failed (errno=%d)", err);
69 + }
70 +}
71 +
72 std::optional<int> OutputChannel::GetConsoleWidth() const
73 {
74 if (m_consoleHandle == nullptr)
src/windows/wslc/core/OutputChannel.h
+4
@@ -38,6 +38,10 @@ public:
38
39 void WriteString(std::wstring_view text) const;
40
41 + // Flushes buffered output so a prompt is visible before a blocking read. No-op for
42 + // console destinations (WriteConsoleW is unbuffered); flushes the CRT stream otherwise.
43 + void Flush() const;
44 +
45 // Console write width minus one (autowrap guard), or nullopt when redirected.
46 std::optional<int> GetConsoleWidth() const;
47
src/windows/wslc/core/Reporter.cpp
+23 -3
@@ -18,12 +18,13 @@ namespace wsl::windows::wslc {
18
19 using namespace wsl::windows::common::vt;
20
21 -Reporter::Reporter() : m_out(GetStdHandle(STD_OUTPUT_HANDLE), stdout), m_err(GetStdHandle(STD_ERROR_HANDLE), stderr)
21 +Reporter::Reporter() :
22 + m_out(GetStdHandle(STD_OUTPUT_HANDLE), stdout), m_err(GetStdHandle(STD_ERROR_HANDLE), stderr), m_in(GetStdHandle(STD_INPUT_HANDLE), stdin)
23 {
24 }
25
25 -Reporter::Reporter(FILE* outFile, bool outVtEnabled, FILE* errFile, bool errVtEnabled) :
26 - m_out(outFile, outVtEnabled), m_err(errFile, errVtEnabled)
26 +Reporter::Reporter(FILE* outFile, bool outVtEnabled, FILE* errFile, bool errVtEnabled, FILE* inFile, bool inInteractive) :
27 + m_out(outFile, outVtEnabled), m_err(errFile, errVtEnabled), m_in(inFile, inInteractive)
28 {
29 }
30
@@ -60,4 +61,23 @@ std::optional<int> Reporter::GetConsoleWidth(Level level) const
61 return ChannelFor(level).GetConsoleWidth();
62 }
63
64 +std::wstring Reporter::PromptForLine(Level level, std::wstring_view label, bool mask)
65 +{
66 + // Write the label without a trailing newline so the cursor stays inline (matching
67 + // Docker's prompt behavior), then flush so it reaches the user before the blocking read.
68 + Write(level, L"{}", label);
69 + ChannelFor(level).Flush();
70 +
71 + const bool willMask = mask && m_in.IsInteractive();
72 + auto line = m_in.ReadLine(mask);
73 +
74 + // When echo was masked the user's Enter was not echoed, so advance the line here.
75 + if (willMask)
76 + {
77 + Write(level, L"\n");
78 + }
79 +
80 + return line.value_or(std::wstring{});
81 +}
82 +
83 } // namespace wsl::windows::wslc
src/windows/wslc/core/Reporter.h
+38 -5
@@ -8,14 +8,15 @@ Module Name:
8
9 Abstract:
10
11 - Level-filtered, std::format-style user-facing output for the WSLC CLI.
12 - Sequence arguments are stripped when VT is off; color Sequences are also
13 - stripped when color is disabled, while cursor-move Sequences still pass
14 - through.
11 + Level-filtered, std::format-style user-facing output for the WSLC CLI, plus
12 + line-oriented user input (prompts). Sequence arguments are stripped when VT is
13 + off; color Sequences are also stripped when color is disabled, while cursor-move
14 + Sequences still pass through.
15
16 --*/
17 #pragma once
18
19 +#include "InputChannel.h"
20 #include "OutputChannel.h"
21 #include "VTSupport.h"
22
@@ -67,7 +68,7 @@ struct Reporter
68 };
69
70 Reporter();
70 - Reporter(FILE* outFile, bool outVtEnabled, FILE* errFile, bool errVtEnabled);
71 + Reporter(FILE* outFile, bool outVtEnabled, FILE* errFile, bool errVtEnabled, FILE* inFile = nullptr, bool inInteractive = false);
72
73 NON_COPYABLE(Reporter);
74 NON_MOVABLE(Reporter);
@@ -102,6 +103,37 @@ struct Reporter
103 EmitFormatted(Level::Error, std::move(fmt), std::forward<Args>(args)...);
104 }
105
106 + // True when user input is attached to an interactive console (a prompt can be
107 + // shown and echo can be masked); false when input is redirected from a file or pipe.
108 + bool IsInputInteractive() const noexcept
109 + {
110 + return m_in.IsInteractive();
111 + }
112 +
113 + // Reads a single line of user input, stripping the trailing CR and/or LF. Returns
114 + // nullopt at end of input with nothing read. When mask is true and input is an
115 + // interactive console, echo is disabled for the duration of the read.
116 + std::optional<std::wstring> ReadLine(bool mask = false)
117 + {
118 + return m_in.ReadLine(mask);
119 + }
120 +
121 + // Writes label (no trailing newline) at the given level, then reads a line of input.
122 + // When mask is true and input is interactive, echo is disabled during the read and a
123 + // trailing newline is emitted afterward (the un-echoed Enter). Returns the line, or an
124 + // empty string at end of input.
125 + std::wstring PromptForLine(Level level, std::wstring_view label, bool mask);
126 +
127 + // Convenience overload that defaults prompts to stdout (Level::Output) to align with the
128 + // container CLI ecosystem: Docker (cli.Out()), containerd/nerdctl (cmd.OutOrStdout()), and
129 + // Apple container (Swift print) all prompt on stdout. This diverges from general Unix tools
130 + // (sudo, ssh, git, gh) that prompt on stderr/tty to keep stdout pipeable, but WSLC follows
131 + // Docker's CLI semantics.
132 + std::wstring PromptForLine(std::wstring_view label, bool mask = false)
133 + {
134 + return PromptForLine(Level::Output, label, mask);
135 + }
136 +
137 bool IsVTEnabled(Level level) const noexcept;
138
139 bool IsColorEnabled(Level level) const noexcept;
@@ -159,6 +191,7 @@ private:
191
192 OutputChannel m_out;
193 OutputChannel m_err;
194 + InputChannel m_in;
195 bool m_noColor = false;
196 };
197
test/windows/wslc/WSLCCLIReporterUnitTests.cpp
+254 -1
@@ -8,7 +8,7 @@ Module Name:
8
9 Abstract:
10
11 - Unit tests for OutputChannel and Reporter.
11 + Unit tests for OutputChannel, InputChannel, and Reporter.
12
13 --*/
14
@@ -16,6 +16,7 @@ Abstract:
16 #include "windows/Common.h"
17 #include "WSLCCLITestHelpers.h"
18
19 +#include "InputChannel.h"
20 #include "OutputChannel.h"
21 #include "Reporter.h"
22
@@ -40,6 +41,21 @@ struct SplitCaptureReporter
41 }
42 };
43
44 +// Reporter wired with a preloaded input pipe plus split output capture, so prompt
45 +// input and the label/newline it writes can be asserted together.
46 +struct InputCaptureReporter
47 +{
48 + CapturePipe outPipe;
49 + CapturePipe errPipe;
50 + InputPipe inPipe;
51 + Reporter reporter;
52 +
53 + explicit InputCaptureReporter(const std::wstring& input, bool interactive = false) :
54 + inPipe(input), reporter(outPipe.file(), false, errPipe.file(), false, inPipe.file(), interactive)
55 + {
56 + }
57 +};
58 +
59 class WSLCCLIReporterUnitTests
60 {
61 WSLC_TEST_CLASS(WSLCCLIReporterUnitTests)
@@ -309,6 +325,243 @@ class WSLCCLIReporterUnitTests
325 VERIFY_ARE_EQUAL(expected, cap.captured());
326 }
327 }
328 +
329 + TEST_METHOD(InputChannel_ReadLineReturnsNulloptAtEof)
330 + {
331 + InputPipe pipe{L""};
332 + const InputChannel channel{pipe.file(), false};
333 + VERIFY_IS_FALSE(channel.ReadLine(false).has_value());
334 + }
335 +
336 + TEST_METHOD(InputChannel_ReadLineSplitsOnNewline)
337 + {
338 + InputPipe pipe{L"user\npass\n"};
339 + const InputChannel channel{pipe.file(), false};
340 +
341 + auto first = channel.ReadLine(false);
342 + VERIFY_IS_TRUE(first.has_value());
343 + VERIFY_ARE_EQUAL(std::wstring{L"user"}, first.value());
344 +
345 + auto second = channel.ReadLine(false);
346 + VERIFY_IS_TRUE(second.has_value());
347 + VERIFY_ARE_EQUAL(std::wstring{L"pass"}, second.value());
348 +
349 + VERIFY_IS_FALSE(channel.ReadLine(false).has_value());
350 + }
351 +
352 + TEST_METHOD(InputChannel_ReadLineStripsCarriageReturn)
353 + {
354 + InputPipe pipe{L"user\r\npass\r\n"};
355 + const InputChannel channel{pipe.file(), false};
356 +
357 + VERIFY_ARE_EQUAL(std::wstring{L"user"}, channel.ReadLine(false).value_or(L"<eof>"));
358 + VERIFY_ARE_EQUAL(std::wstring{L"pass"}, channel.ReadLine(false).value_or(L"<eof>"));
359 + }
360 +
361 + TEST_METHOD(InputChannel_ReadLineReturnsEmptyStringForBlankLine)
362 + {
363 + // A bare empty line is distinct from EOF: value present but empty.
364 + InputPipe pipe{L"\nsecond\n"};
365 + const InputChannel channel{pipe.file(), false};
366 +
367 + auto blank = channel.ReadLine(false);
368 + VERIFY_IS_TRUE(blank.has_value());
369 + VERIFY_ARE_EQUAL(std::wstring{L""}, blank.value());
370 +
371 + VERIFY_ARE_EQUAL(std::wstring{L"second"}, channel.ReadLine(false).value_or(L"<eof>"));
372 + }
373 +
374 + TEST_METHOD(InputChannel_ReadLineReturnsFinalLineWithoutTrailingNewline)
375 + {
376 + InputPipe pipe{L"only"};
377 + const InputChannel channel{pipe.file(), false};
378 +
379 + VERIFY_ARE_EQUAL(std::wstring{L"only"}, channel.ReadLine(false).value_or(L"<eof>"));
380 + VERIFY_IS_FALSE(channel.ReadLine(false).has_value());
381 + }
382 +
383 + TEST_METHOD(InputChannel_IsInteractiveReflectsOverrideForNonConsole)
384 + {
385 + InputPipe pipe{L"x\n"};
386 + const InputChannel notInteractive{pipe.file(), false};
387 + VERIFY_IS_FALSE(notInteractive.IsInteractive());
388 +
389 + InputPipe pipe2{L"x\n"};
390 + const InputChannel interactive{pipe2.file(), true};
391 + VERIFY_IS_TRUE(interactive.IsInteractive());
392 + }
393 +
394 + TEST_METHOD(InputChannel_ReadLineWithMaskReadsWhenNoConsole)
395 + {
396 + // Masking is a no-op without a real console; the read still succeeds.
397 + InputPipe pipe{L"secret\n"};
398 + const InputChannel channel{pipe.file(), false};
399 + VERIFY_ARE_EQUAL(std::wstring{L"secret"}, channel.ReadLine(true).value_or(L"<eof>"));
400 + }
401 +
402 + TEST_METHOD(InputChannel_ReadLineOnNullFileReturnsNullopt)
403 + {
404 + const InputChannel channel{static_cast<FILE*>(nullptr), false};
405 + VERIFY_IS_FALSE(channel.ReadLine(false).has_value());
406 + }
407 +
408 + TEST_METHOD(InputChannel_ReadLinePreservesInteriorAndSurroundingWhitespace)
409 + {
410 + // Only the trailing CR/LF is stripped. Leading, interior, and trailing spaces
411 + // and tabs are preserved verbatim (WSLC does not trim, unlike Docker's prompt).
412 + InputPipe pipe{L" spaced \t value \n"};
413 + const InputChannel channel{pipe.file(), false};
414 + VERIFY_ARE_EQUAL(std::wstring{L" spaced \t value "}, channel.ReadLine(false).value_or(L"<eof>"));
415 + }
416 +
417 + TEST_METHOD(InputChannel_ReadLineStripsLoneTrailingCarriageReturnAtEof)
418 + {
419 + // A lone trailing CR (no following LF) is not collapsed by the stream's CRLF
420 + // translation, so it reaches ReadLine and exercises the trailing-CR strip.
421 + InputPipe pipe{L"value\r"};
422 + const InputChannel channel{pipe.file(), false};
423 + VERIFY_ARE_EQUAL(std::wstring{L"value"}, channel.ReadLine(false).value_or(L"<eof>"));
424 + VERIFY_IS_FALSE(channel.ReadLine(false).has_value());
425 + }
426 +
427 + TEST_METHOD(InputChannel_ReadLinePreservesEmbeddedCarriageReturn)
428 + {
429 + // Only a trailing CR is stripped; a CR in the middle of a line is preserved.
430 + InputPipe pipe{L"a\rb\n"};
431 + const InputChannel channel{pipe.file(), false};
432 + VERIFY_ARE_EQUAL(std::wstring{L"a\rb"}, channel.ReadLine(false).value_or(L"<eof>"));
433 + }
434 +
435 + TEST_METHOD(InputChannel_ReadLineDecodesUnicode)
436 + {
437 + // UTF-8 bytes on the wire decode back to the original wide characters.
438 + const std::wstring expected = L"\u00e9\u4e2d\u6587\u2013user";
439 + InputPipe pipe{expected + L"\n"};
440 + const InputChannel channel{pipe.file(), false};
441 + VERIFY_ARE_EQUAL(expected, channel.ReadLine(false).value_or(L"<eof>"));
442 + }
443 +
444 + TEST_METHOD(InputChannel_ReadLineHandlesLongLine)
445 + {
446 + // Lines longer than any internal buffer are read in full (fgetwc loop).
447 + const std::wstring expected(8192, L'z');
448 + InputPipe pipe{expected + L"\n"};
449 + const InputChannel channel{pipe.file(), false};
450 + VERIFY_ARE_EQUAL(expected, channel.ReadLine(false).value_or(L"<eof>"));
451 + }
452 +
453 + TEST_METHOD(InputChannel_ReadLineReturnsNulloptAfterAllLinesConsumed)
454 + {
455 + InputPipe pipe{L"one\ntwo\n"};
456 + const InputChannel channel{pipe.file(), false};
457 + VERIFY_ARE_EQUAL(std::wstring{L"one"}, channel.ReadLine(false).value_or(L"<eof>"));
458 + VERIFY_ARE_EQUAL(std::wstring{L"two"}, channel.ReadLine(false).value_or(L"<eof>"));
459 + VERIFY_IS_FALSE(channel.ReadLine(false).has_value());
460 + // Further reads keep returning nullopt (idempotent at EOF).
461 + VERIFY_IS_FALSE(channel.ReadLine(false).has_value());
462 + }
463 +
464 + TEST_METHOD(Reporter_ReadLineReturnsInput)
465 + {
466 + InputCaptureReporter cap{L"line1\nline2\n"};
467 + VERIFY_ARE_EQUAL(std::wstring{L"line1"}, cap.reporter.ReadLine().value_or(L"<eof>"));
468 + VERIFY_ARE_EQUAL(std::wstring{L"line2"}, cap.reporter.ReadLine().value_or(L"<eof>"));
469 + VERIFY_IS_FALSE(cap.reporter.ReadLine().has_value());
470 + }
471 +
472 + TEST_METHOD(Reporter_IsInputInteractiveReflectsChannel)
473 + {
474 + InputCaptureReporter pipeInput{L"x\n", /*interactive*/ false};
475 + VERIFY_IS_FALSE(pipeInput.reporter.IsInputInteractive());
476 +
477 + InputCaptureReporter consoleInput{L"x\n", /*interactive*/ true};
478 + VERIFY_IS_TRUE(consoleInput.reporter.IsInputInteractive());
479 + }
480 +
481 + TEST_METHOD(Reporter_PromptForLineWritesLabelToStdoutAndReturnsInput)
482 + {
483 + InputCaptureReporter cap{L"myuser\n"};
484 +
485 + const auto result = cap.reporter.PromptForLine(Reporter::Level::Output, L"Username: ", false);
486 + VERIFY_ARE_EQUAL(std::wstring{L"myuser"}, result);
487 +
488 + // Label lands on stdout (Docker convention); nothing on stderr; no trailing
489 + // newline because the input was not masked.
490 + VERIFY_ARE_EQUAL(std::wstring{L"Username: "}, cap.outPipe.captured());
491 + VERIFY_ARE_EQUAL(std::wstring{L""}, cap.errPipe.captured());
492 + }
493 +
494 + TEST_METHOD(Reporter_PromptForLineMaskedInteractiveEmitsTrailingNewline)
495 + {
496 + // Interactive override makes willMask true, so the un-echoed Enter is advanced
497 + // with a trailing newline after the label.
498 + InputCaptureReporter cap{L"secret\n", /*interactive*/ true};
499 +
500 + const auto result = cap.reporter.PromptForLine(Reporter::Level::Output, L"Password: ", true);
501 + VERIFY_ARE_EQUAL(std::wstring{L"secret"}, result);
502 + VERIFY_ARE_EQUAL(std::wstring{L"Password: \n"}, cap.outPipe.captured());
503 + }
504 +
505 + TEST_METHOD(Reporter_PromptForLineMaskedNonInteractiveEmitsNoTrailingNewline)
506 + {
507 + // Redirected input is not interactive, so no masking and no trailing newline.
508 + InputCaptureReporter cap{L"secret\n", /*interactive*/ false};
509 +
510 + const auto result = cap.reporter.PromptForLine(Reporter::Level::Output, L"Password: ", true);
511 + VERIFY_ARE_EQUAL(std::wstring{L"secret"}, result);
512 + VERIFY_ARE_EQUAL(std::wstring{L"Password: "}, cap.outPipe.captured());
513 + }
514 +
515 + TEST_METHOD(Reporter_PromptForLineReturnsEmptyStringAtEof)
516 + {
517 + InputCaptureReporter cap{L""};
518 + const auto result = cap.reporter.PromptForLine(Reporter::Level::Output, L"Username: ", false);
519 + VERIFY_ARE_EQUAL(std::wstring{L""}, result);
520 + VERIFY_ARE_EQUAL(std::wstring{L"Username: "}, cap.outPipe.captured());
521 + }
522 +
523 + TEST_METHOD(Reporter_PromptForLineEmitsLabelVerbatimWithFormatCharacters)
524 + {
525 + // The label is passed as a formatting argument, not a format string, so brace
526 + // and percent characters in it must never be interpreted (no format injection).
527 + InputCaptureReporter cap{L"answer\n"};
528 + const std::wstring label = L"Value {} {0} {name} 100% ${var}: ";
529 +
530 + const auto result = cap.reporter.PromptForLine(Reporter::Level::Output, label, false);
531 + VERIFY_ARE_EQUAL(std::wstring{L"answer"}, result);
532 + VERIFY_ARE_EQUAL(label, cap.outPipe.captured());
533 + }
534 +
535 + TEST_METHOD(Reporter_PromptForLineDoesNotTrimPasswordWhitespace)
536 + {
537 + // Secrets are opaque: interior and surrounding whitespace is preserved so a
538 + // password like " a b " is returned exactly as typed.
539 + InputCaptureReporter cap{L" a b \n", /*interactive*/ true};
540 +
541 + const auto result = cap.reporter.PromptForLine(Reporter::Level::Output, L"Password: ", true);
542 + VERIFY_ARE_EQUAL(std::wstring{L" a b "}, result);
543 + VERIFY_ARE_EQUAL(std::wstring{L"Password: \n"}, cap.outPipe.captured());
544 + }
545 +
546 + TEST_METHOD(Reporter_PromptForLineReturnsUnicodeInput)
547 + {
548 + const std::wstring expected = L"\u00fcser\u00f1ame";
549 + InputCaptureReporter cap{expected + L"\n"};
550 +
551 + const auto result = cap.reporter.PromptForLine(Reporter::Level::Output, L"Username: ", false);
552 + VERIFY_ARE_EQUAL(expected, result);
553 + }
554 +
555 + TEST_METHOD(Reporter_ReadLineMaskDefaultsToUnmasked)
556 + {
557 + // ReadLine(bool mask = false): the default reads without masking and returns
558 + // the line, used by the --password-stdin path.
559 + InputCaptureReporter cap{L"piped-secret\n"};
560 + VERIFY_ARE_EQUAL(std::wstring{L"piped-secret"}, cap.reporter.ReadLine().value_or(L"<eof>"));
561 + // Nothing is written for a bare ReadLine (no prompt label).
562 + VERIFY_ARE_EQUAL(std::wstring{L""}, cap.outPipe.captured());
563 + VERIFY_ARE_EQUAL(std::wstring{L""}, cap.errPipe.captured());
564 + }
565 };
566
567 } // namespace WSLCCLIReporterUnitTests
test/windows/wslc/WSLCCLITestHelpers.h
+66
@@ -19,6 +19,7 @@ Abstract:
19 #include <algorithm>
20 #include <memory>
21 #include <string>
22 +#include <thread>
23 #include <vector>
24 #include <Windows.h>
25 #include <WexTestClass.h>
@@ -130,6 +131,71 @@ private:
131 std::unique_ptr<PartialHandleRead> m_reader;
132 };
133
134 +// RAII pipe preloaded with input for tests. A background thread feeds the content
135 +// (UTF-8) into the pipe while the test drains the read end, mirroring how real stdin
136 +// is filled by a separate producer. A synchronous write of the whole content on the
137 +// reading thread would deadlock once the content exceeds the pipe buffer
138 +// (OpenAnonymousPipe defaults to 4096 bytes), so the feeder runs concurrently and
139 +// closes the write end when done, letting file() read the content then hit EOF. The
140 +// read FILE* is configured like real stdin (_O_U8TEXT) and passed to
141 +// InputChannel/Reporter.
142 +struct InputPipe
143 +{
144 + explicit InputPipe(const std::wstring& content)
145 + {
146 + auto [r, w] = wsl::windows::common::wslutil::OpenAnonymousPipe(0, false, false);
147 +
148 + wil::unique_handle readHandle{r.release()};
149 + m_file = FileFromHandle(readHandle, "r");
150 +
151 + const int fd = _fileno(m_file.get());
152 + WI_VERIFY(_setmode(fd, _O_U8TEXT) != -1);
153 +
154 + // Feed the content from a background thread so the reader can drain while the
155 + // writer fills. Closing the write end signals EOF. No VERIFY/THROW macros run
156 + // here since this executes on a separate thread; a broken pipe (the reader
157 + // closed early) simply stops the feed.
158 + m_writer = std::thread([writeEnd = std::move(w), utf8 = WStringToUTF8(content)]() mutable {
159 + size_t offset = 0;
160 + while (offset < utf8.size())
161 + {
162 + DWORD written = 0;
163 + if (!WriteFile(writeEnd.get(), utf8.data() + offset, static_cast<DWORD>(utf8.size() - offset), &written, nullptr))
164 + {
165 + break;
166 + }
167 +
168 + offset += written;
169 + }
170 +
171 + writeEnd.reset();
172 + });
173 + }
174 +
175 + ~InputPipe()
176 + {
177 + // Close the read end first so a feeder still blocked on a full pipe unblocks
178 + // with a broken pipe, then join it.
179 + m_file.reset();
180 + if (m_writer.joinable())
181 + {
182 + m_writer.join();
183 + }
184 + }
185 +
186 + NON_COPYABLE(InputPipe);
187 + NON_MOVABLE(InputPipe);
188 +
189 + FILE* file() const
190 + {
191 + return m_file.get();
192 + }
193 +
194 +private:
195 + wil::unique_file m_file;
196 + std::thread m_writer;
197 +};
198 +
199 // Reporter wired to a single capture pipe for full output capture.
200 // VT is disabled (not a console handle), so error output stays in the same pipe.
201 struct CaptureReporter
test/windows/wslc/e2e/WSLCE2ERegistryTests.cpp
+2 -2
@@ -203,9 +203,9 @@ class WSLCE2ERegistryTests
203 // Login with interactive prompts (no flags).
204 {
205 auto interactive = RunWslcInteractive(std::format(L"login {}", registryAddressW));
206 - interactive.ExpectStderr("Username: ");
206 + interactive.ExpectStdout("Username: ");
207 interactive.WriteLine(c_username);
208 - interactive.ExpectStderr("Password: ");
208 + interactive.ExpectStdout("Password: ");
209 interactive.WriteLine(c_password);
210 auto exitCode = interactive.Wait();
211 VERIFY_ARE_EQUAL(0, exitCode, L"Interactive login should succeed");