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");