From a3f1b478d7cc3743998a321e53ae79160703ecae Mon Sep 17 00:00:00 2001 From: Gavin Garzia Date: Mon, 17 Aug 2026 14:11:44 -0700 Subject: [PATCH 1/8] wslc: match docker column order and status text for container list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- localization/strings/en-US/Resources.resw | 6 ++ src/windows/inc/docker_schema.h | 6 +- src/windows/service/inc/wslc.idl | 8 ++ src/windows/wslc/services/ContainerModel.h | 4 + .../wslc/services/ContainerService.cpp | 55 ++++++++++ src/windows/wslc/services/ContainerService.h | 8 ++ src/windows/wslc/tasks/ContainerTasks.cpp | 19 ++-- src/windows/wslcsession/WSLCSession.cpp | 6 ++ .../wslc/WSLCCLIContainerCommandUnitTests.cpp | 100 ++++++++++++++++++ .../wslc/e2e/WSLCE2EContainerListTests.cpp | 31 +++++- test/windows/wslc/e2e/WSLCE2EHelpers.cpp | 20 +++- 11 files changed, 250 insertions(+), 13 deletions(-) create mode 100644 test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index ebf7a6ff72..41186a7588 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -3668,6 +3668,12 @@ On first run, creates the file with all settings commented out at their defaults NAME + + NAMES + + + COMMAND + IMAGE diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index 33379075d6..6ac424f543 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -698,6 +698,9 @@ struct ContainerInfo std::vector Names; std::string Image; std::string ImageID; + std::string Command; + // Human readable state description built by the daemon, e.g. "Up 5 minutes" or "Exited (0) 2 hours ago". + std::string Status; std::map Labels; std::vector Ports; std::vector Mounts; @@ -706,7 +709,8 @@ struct ContainerInfo HostConfig HostConfig; NetworkSettings NetworkSettings; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInfo, Id, Names, Image, ImageID, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT( + ContainerInfo, Id, Names, Image, ImageID, Command, Status, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings); }; struct BuildKitVertex diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 0240724c6c..543206865a 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -31,6 +31,8 @@ cpp_quote("#endif") #define WSLC_MAX_VOLUME_DRIVER_LENGTH 255 #define WSLC_MAX_NETWORK_NAME_LENGTH 255 #define WSLC_MAX_IMAGE_ID_LENGTH 255 +#define WSLC_MAX_CONTAINER_COMMAND_LENGTH 4095 +#define WSLC_MAX_CONTAINER_STATUS_LENGTH 127 #define WSLC_CONTAINER_ID_LENGTH 64 #define WSLC_MAX_BINDING_ADDRESS_LENGTH 45 #define WSLC_EPHEMERAL_PORT 0 @@ -42,6 +44,8 @@ cpp_quote("#define WSLC_MAX_VOLUME_NAME_LENGTH 255") cpp_quote("#define WSLC_MAX_VOLUME_DRIVER_LENGTH 255") cpp_quote("#define WSLC_MAX_NETWORK_NAME_LENGTH 255") cpp_quote("#define WSLC_MAX_IMAGE_ID_LENGTH 255") +cpp_quote("#define WSLC_MAX_CONTAINER_COMMAND_LENGTH 4095") +cpp_quote("#define WSLC_MAX_CONTAINER_STATUS_LENGTH 127") cpp_quote("#define WSLC_CONTAINER_ID_LENGTH 64") cpp_quote("#define WSLC_MAX_BINDING_ADDRESS_LENGTH 45") cpp_quote("#define WSLC_MAX_SAVE_IMAGES_COUNT 256") @@ -373,6 +377,10 @@ typedef struct _WSLCContainerEntry { char Name[WSLC_MAX_CONTAINER_NAME_LENGTH + 1]; char Image[WSLC_MAX_IMAGE_NAME_LENGTH + 1]; + // Command the container runs, as reported by the runtime. Truncated if it exceeds the buffer. + char Command[WSLC_MAX_CONTAINER_COMMAND_LENGTH + 1]; + // Human readable state description built by the runtime, e.g. "Up 5 minutes" or "Exited (0) 2 hours ago". + char Status[WSLC_MAX_CONTAINER_STATUS_LENGTH + 1]; WSLCContainerId Id; ULONGLONG StateChangedAt; ULONGLONG CreatedAt; diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h index 964ea2f192..bd6e6db645 100644 --- a/src/windows/wslc/services/ContainerModel.h +++ b/src/windows/wslc/services/ContainerModel.h @@ -132,6 +132,10 @@ struct ContainerInformation std::string Id; std::string Name; std::string Image; + // Command and runtime supplied status description. Not serialized yet: the json output still + // reports the raw state fields below. + std::string Command; + std::string Status; WSLCContainerState State; ULONGLONG StateChangedAt{}; ULONGLONG CreatedAt{}; diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index 7ae78f338d..931def4be1 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -427,6 +427,59 @@ std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, return std::format(L"{} {}", stateString, FormatRelativeTime(stateChangedAt)); } +std::wstring ContainerService::FormatCommand(const std::string& command, bool truncate) +{ + constexpr size_t c_maxDisplayWidth = 20; + + auto wide = wsl::shared::string::MultiByteToWide(command); + if (truncate) + { + // Count code points rather than code units so a surrogate pair is never split. + size_t codePoints = 0; + size_t index = 0; + size_t cutoff = 0; + for (; index < wide.size(); ++codePoints) + { + if (codePoints == c_maxDisplayWidth - 1) + { + cutoff = index; + } + + index += (IS_HIGH_SURROGATE(wide[index]) && (index + 1 < wide.size()) && IS_LOW_SURROGATE(wide[index + 1])) ? 2 : 1; + } + + if (codePoints > c_maxDisplayWidth) + { + wide.resize(cutoff); + wide += L'\u2026'; + } + } + + std::wstring quoted{L'"'}; + for (const auto character : wide) + { + if (character == L'"' || character == L'\\') + { + quoted += L'\\'; + } + + quoted += character; + } + + quoted += L'"'; + return quoted; +} + +std::wstring ContainerService::FormatStatus(const std::string& status, WSLCContainerState state, ULONGLONG stateChangedAt) +{ + if (!status.empty()) + { + return wsl::shared::string::MultiByteToWide(status); + } + + return ContainerStateToString(state, stateChangedAt); +} + std::wstring ContainerService::FormatPorts(WSLCContainerState state, const std::vector& ports) { if (state != WslcContainerStateRunning || ports.empty()) @@ -603,6 +656,8 @@ std::vector ContainerService::List( ContainerInformation entry; entry.Name = current.Name; entry.Image = current.Image; + entry.Command = current.Command; + entry.Status = current.Status; entry.State = current.State; entry.Id = current.Id; entry.StateChangedAt = current.StateChangedAt; diff --git a/src/windows/wslc/services/ContainerService.h b/src/windows/wslc/services/ContainerService.h index 09203056d9..44829fb7b7 100644 --- a/src/windows/wslc/services/ContainerService.h +++ b/src/windows/wslc/services/ContainerService.h @@ -26,6 +26,14 @@ struct ContainerService static std::wstring FormatRelativeTime(ULONGLONG timestamp); static std::wstring FormatElapsedSeconds(LONGLONG elapsedSeconds); static std::wstring FormatPorts(WSLCContainerState state, const std::vector& ports); + + // Renders a container command the way docker does: optionally shortened to 20 characters with a + // trailing ellipsis, then wrapped in double quotes. + static std::wstring FormatCommand(const std::string& command, bool truncate); + + // Renders a container status, preferring the description supplied by the runtime and falling back + // to a locally built one when it is unavailable. + static std::wstring FormatStatus(const std::string& status, WSLCContainerState state, ULONGLONG stateChangedAt); static int Attach(Terminal& terminal, models::Session& session, const std::string& id); static int Run(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options); static models::CreateContainerResult Create(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options); diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index cb5b1cde58..95d429da12 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -551,34 +551,37 @@ void ListContainers(CLIExecutionContext& context) using enum ColumnOverflow; // Create table with or without column limits based on --no-trunc flag - auto table = trunc ? wsl::windows::wslc::TableOutput<6>( + auto table = trunc ? wsl::windows::wslc::TableOutput<7>( context.Terminal, {{{Localization::WSLCCLI_TableHeaderContainerId(), {.MaxWidth = 12, .Overflow = Shrink}}, - {Localization::WSLCCLI_TableHeaderName(), {.MaxWidth = 20, .Overflow = Shrink}}, {Localization::WSLCCLI_TableHeaderImage(), {.MaxWidth = 20, .Overflow = Shrink}}, + {Localization::WSLCCLI_TableHeaderCommand(), {.Overflow = Shrink}}, {Localization::WSLCCLI_TableHeaderCreated(), {.Overflow = Shrink}}, {Localization::WSLCCLI_TableHeaderStatus(), {.Overflow = Shrink}}, - {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}}}}, + {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}}, + {Localization::WSLCCLI_TableHeaderNames(), {.MaxWidth = 20, .Overflow = Shrink}}}}, containers.size()) - : wsl::windows::wslc::TableOutput<6>( + : wsl::windows::wslc::TableOutput<7>( context.Terminal, {Localization::WSLCCLI_TableHeaderContainerId(), - Localization::WSLCCLI_TableHeaderName(), Localization::WSLCCLI_TableHeaderImage(), + Localization::WSLCCLI_TableHeaderCommand(), Localization::WSLCCLI_TableHeaderCreated(), Localization::WSLCCLI_TableHeaderStatus(), - Localization::WSLCCLI_TableHeaderPorts()}); + Localization::WSLCCLI_TableHeaderPorts(), + Localization::WSLCCLI_TableHeaderNames()}); // Add each container as a row for (const auto& container : containers) { table.WriteRow({ MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id), - MultiByteToWide(container.Name), MultiByteToWide(container.Image), + ContainerService::FormatCommand(container.Command, trunc), ContainerService::FormatRelativeTime(container.CreatedAt), - ContainerService::ContainerStateToString(container.State, container.StateChangedAt), + ContainerService::FormatStatus(container.Status, container.State, container.StateChangedAt), ContainerService::FormatPorts(container.State, container.Ports), + MultiByteToWide(container.Name), }); } diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index a0e4907a73..8f5a380668 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -2562,6 +2562,12 @@ try THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Image, e->Image().c_str()) != 0); THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Name, e->Name().c_str()) != 0); THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Id, e->ID().c_str()) != 0); + + // Commands and status descriptions have no bound imposed by the runtime, so truncate them + // rather than failing the listing. + THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Command, std::size(output[index].Command), dockerContainer.Command.c_str(), _TRUNCATE) == EINVAL); + THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Status, std::size(output[index].Status), dockerContainer.Status.c_str(), _TRUNCATE) == EINVAL); + e->GetState(&output[index].State); e->GetStateChangedAt(&output[index].StateChangedAt); e->GetCreatedAt(&output[index].CreatedAt); diff --git a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp new file mode 100644 index 0000000000..a1b07901a7 --- /dev/null +++ b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp @@ -0,0 +1,100 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +#include "precomp.h" +#include "windows/Common.h" +#include "WSLCCLITestHelpers.h" + +#include "ContainerService.h" + +using namespace wsl::windows::wslc; +using namespace wsl::windows::wslc::services; +using namespace WSLCTestHelpers; +using namespace WEX::Logging; +using namespace WEX::Common; +using namespace WEX::TestExecution; + +namespace WSLCCLIContainerCommandUnitTests { + +class WSLCCLIContainerCommandUnitTests +{ + WSLC_TEST_CLASS(WSLCCLIContainerCommandUnitTests) + + TEST_CLASS_SETUP(TestClassSetup) + { + return true; + } + + TEST_CLASS_CLEANUP(TestClassCleanup) + { + return true; + } + + static std::wstring Truncated(const std::string& command) + { + return ContainerService::FormatCommand(command, true); + } + + TEST_METHOD(FormatCommand_Empty_ReturnsEmptyQuotes) + { + VERIFY_ARE_EQUAL(std::wstring{LR"("")"}, Truncated("")); + } + + TEST_METHOD(FormatCommand_ShortCommand_IsQuotedUnchanged) + { + VERIFY_ARE_EQUAL(std::wstring{LR"("sleep 3600")"}, Truncated("sleep 3600")); + } + + TEST_METHOD(FormatCommand_ExactlyTwentyCharacters_IsNotShortened) + { + VERIFY_ARE_EQUAL(std::wstring{LR"("12345678901234567890")"}, Truncated("12345678901234567890")); + } + + TEST_METHOD(FormatCommand_TwentyOneCharacters_KeepsNineteenAndAppendsEllipsis) + { + VERIFY_ARE_EQUAL(std::wstring{L"\"1234567890123456789\u2026\""}, Truncated("123456789012345678901")); + } + + TEST_METHOD(FormatCommand_LongCommand_MatchesDockerOutput) + { + VERIFY_ARE_EQUAL(std::wstring{L"\"sh -c 'echo this is\u2026\""}, Truncated("sh -c 'echo this is a very long command that should be truncated by docker'")); + } + + TEST_METHOD(FormatCommand_NoTruncate_KeepsFullCommand) + { + const std::string command = "sh -c 'echo this is a very long command that should be truncated by docker'"; + VERIFY_ARE_EQUAL( + std::wstring{L"\"" + wsl::shared::string::MultiByteToWide(command) + L"\""}, ContainerService::FormatCommand(command, false)); + } + + TEST_METHOD(FormatCommand_EmbeddedQuotesAndBackslashes_AreEscaped) + { + VERIFY_ARE_EQUAL(std::wstring{LR"("say \"hi\"")"}, Truncated(R"(say "hi")")); + VERIFY_ARE_EQUAL(std::wstring{LR"("c:\\temp")"}, Truncated(R"(c:\temp)")); + } + + TEST_METHOD(FormatCommand_MultiByteCharacters_CountedAsSingleCodePoints) + { + // U+00E0 encodes as two utf-8 bytes, so a byte based count would shorten these too early. + const std::string accented = "\xC3\xA0"; + std::string twenty; + for (int i = 0; i < 20; ++i) + { + twenty += accented; + } + + VERIFY_ARE_EQUAL(std::wstring(20, L'\u00E0').insert(0, L"\"") + L"\"", Truncated(twenty)); + VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0').insert(0, L"\"") + L"\u2026\"", Truncated(twenty + accented)); + } + + TEST_METHOD(FormatStatus_RuntimeStatus_IsPreferred) + { + VERIFY_ARE_EQUAL(std::wstring{L"Up 5 minutes"}, ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0)); + } + + TEST_METHOD(FormatStatus_EmptyRuntimeStatus_FallsBackToState) + { + VERIFY_ARE_EQUAL(std::wstring{L"created"}, ContainerService::FormatStatus("", WslcContainerStateCreated, 0)); + } +}; + +} // namespace WSLCCLIContainerCommandUnitTests diff --git a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp index d526733f40..7e3fcd4fee 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp @@ -81,7 +81,7 @@ class WSLCE2EContainerListTests // Verify we found the container in the list output VERIFY_IS_TRUE(foundContainerLine.has_value()); - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"created")); + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"Created")); } WSLC_TEST_METHOD(WSLCE2E_Container_List_NoOptions_RunningContainers) @@ -110,7 +110,7 @@ class WSLCE2EContainerListTests // Verify we found the container in the list output VERIFY_IS_TRUE(foundContainerLine.has_value()); - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"running")); + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"Up ")); } WSLC_TEST_METHOD(WSLCE2E_Container_List_NoOptions_ExcludesCreatedContainers) @@ -139,6 +139,33 @@ class WSLCE2EContainerListTests VERIFY_IS_FALSE(isListed); } + // The table layout must match `docker container list` so users can rely on column order. + WSLC_TEST_METHOD(WSLCE2E_Container_List_TableFormat_MatchesDockerColumnOrder) + { + const auto result = RunWslc(L"container list --all"); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto outputLines = result.GetStdoutLines(); + VERIFY_IS_FALSE(outputLines.empty()); + + const auto& header = outputLines.front(); + size_t position = 0; + for (const auto& column : + {Localization::WSLCCLI_TableHeaderContainerId(), + Localization::WSLCCLI_TableHeaderImage(), + Localization::WSLCCLI_TableHeaderCommand(), + Localization::WSLCCLI_TableHeaderCreated(), + Localization::WSLCCLI_TableHeaderStatus(), + Localization::WSLCCLI_TableHeaderPorts(), + Localization::WSLCCLI_TableHeaderNames()}) + { + const auto found = header.find(column, position); + VERIFY_ARE_NOT_EQUAL( + std::wstring::npos, found, std::format(L"Column '{}' missing or out of order in '{}'", column, header).c_str()); + position = found + column.size(); + } + } + WSLC_TEST_METHOD(WSLCE2E_Container_List_QuietOption_OutputsIdsOnly) { VerifyContainerIsNotListed(WslcContainerName); diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp index b762ef21ef..16b650553d 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp @@ -151,6 +151,22 @@ TestSession::~TestSession() void VerifyContainerIsListed(const std::wstring& containerNameOrId, const std::wstring& status, const std::wstring& sessionName) { + // The status column reports the runtime's description, e.g. "Up 5 seconds", so map the logical + // state callers pass in onto the text that description starts with. + std::wstring expectedStatus = status; + if (status == L"created") + { + expectedStatus = L"Created"; + } + else if (status == L"running") + { + expectedStatus = L"Up "; + } + else if (status == L"exited") + { + expectedStatus = L"Exited ("; + } + std::wstring command = L"container list --no-trunc --all"; if (!sessionName.empty()) { @@ -166,8 +182,8 @@ void VerifyContainerIsListed(const std::wstring& containerNameOrId, const std::w if (line.find(containerNameOrId) != std::wstring::npos) { const std::wstring message = L"Container '" + containerNameOrId + L"' found in container list output but status '" + - status + L"' was not found in the same line"; - VERIFY_ARE_NOT_EQUAL(std::wstring::npos, line.find(status), message.c_str()); + expectedStatus + L"' was not found in the same line"; + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, line.find(expectedStatus), message.c_str()); return; } } From 31c043eb7d0bcef3b56c71a944530076464b996a Mon Sep 17 00:00:00 2001 From: Gavin Garzia Date: Tue, 18 Aug 2026 14:22:01 -0700 Subject: [PATCH 2/8] wslc: match docker json shape for container list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- localization/strings/en-US/Resources.resw | 20 ++++ src/windows/service/inc/wslc.idl | 13 ++ src/windows/wslc/services/ContainerModel.h | 44 ++++++- .../wslc/services/ContainerService.cpp | 46 +++++-- src/windows/wslc/services/ContainerService.h | 8 ++ src/windows/wslc/tasks/ContainerTasks.cpp | 51 ++++++-- src/windows/wslcsession/WSLCSession.cpp | 35 ++++++ .../wslc/e2e/WSLCE2EContainerListTests.cpp | 113 ++++++++++++++---- test/windows/wslc/e2e/WSLCE2EHelpers.cpp | 13 +- test/windows/wslc/e2e/WSLCE2EHelpers.h | 2 +- 10 files changed, 292 insertions(+), 53 deletions(-) diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index 41186a7588..009c35d8d0 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -3704,6 +3704,26 @@ On first run, creates the file with all settings commented out at their defaults PIDS + + created + Container state shown in the STATUS column of `wslc container list`. + + + running + Container state shown in the STATUS column of `wslc container list`. + + + stopped + Container state shown in the STATUS column of `wslc container list`. + + + exited + Container state shown in the STATUS column of `wslc container list`. + + + invalid + Container state shown in the STATUS column of `wslc container list`. + Less than a second ago Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'. diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 543206865a..005c652ec7 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -33,6 +33,9 @@ cpp_quote("#endif") #define WSLC_MAX_IMAGE_ID_LENGTH 255 #define WSLC_MAX_CONTAINER_COMMAND_LENGTH 4095 #define WSLC_MAX_CONTAINER_STATUS_LENGTH 127 +#define WSLC_MAX_CONTAINER_LABELS_LENGTH 4095 +#define WSLC_MAX_CONTAINER_NETWORKS_LENGTH 1023 +#define WSLC_MAX_CONTAINER_MOUNTS_LENGTH 4095 #define WSLC_CONTAINER_ID_LENGTH 64 #define WSLC_MAX_BINDING_ADDRESS_LENGTH 45 #define WSLC_EPHEMERAL_PORT 0 @@ -46,6 +49,9 @@ cpp_quote("#define WSLC_MAX_NETWORK_NAME_LENGTH 255") cpp_quote("#define WSLC_MAX_IMAGE_ID_LENGTH 255") cpp_quote("#define WSLC_MAX_CONTAINER_COMMAND_LENGTH 4095") cpp_quote("#define WSLC_MAX_CONTAINER_STATUS_LENGTH 127") +cpp_quote("#define WSLC_MAX_CONTAINER_LABELS_LENGTH 4095") +cpp_quote("#define WSLC_MAX_CONTAINER_NETWORKS_LENGTH 1023") +cpp_quote("#define WSLC_MAX_CONTAINER_MOUNTS_LENGTH 4095") cpp_quote("#define WSLC_CONTAINER_ID_LENGTH 64") cpp_quote("#define WSLC_MAX_BINDING_ADDRESS_LENGTH 45") cpp_quote("#define WSLC_MAX_SAVE_IMAGES_COUNT 256") @@ -381,9 +387,16 @@ typedef struct _WSLCContainerEntry char Command[WSLC_MAX_CONTAINER_COMMAND_LENGTH + 1]; // Human readable state description built by the runtime, e.g. "Up 5 minutes" or "Exited (0) 2 hours ago". char Status[WSLC_MAX_CONTAINER_STATUS_LENGTH + 1]; + // Comma separated "key=value" labels. Truncated if it exceeds the buffer. + char Labels[WSLC_MAX_CONTAINER_LABELS_LENGTH + 1]; + // Comma separated network names the container is attached to. Truncated if it exceeds the buffer. + char Networks[WSLC_MAX_CONTAINER_NETWORKS_LENGTH + 1]; + // Comma separated mount names or sources. Truncated if it exceeds the buffer. + char Mounts[WSLC_MAX_CONTAINER_MOUNTS_LENGTH + 1]; WSLCContainerId Id; ULONGLONG StateChangedAt; ULONGLONG CreatedAt; + ULONG LocalVolumes; WSLCContainerState State; } WSLCContainerEntry; diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h index bd6e6db645..eafd3fee5a 100644 --- a/src/windows/wslc/services/ContainerModel.h +++ b/src/windows/wslc/services/ContainerModel.h @@ -132,10 +132,15 @@ struct ContainerInformation std::string Id; std::string Name; std::string Image; - // Command and runtime supplied status description. Not serialized yet: the json output still - // reports the raw state fields below. + // Command and runtime supplied status description. Not serialized directly: the json output is + // built by ToContainerOutput, which reports the docker shape. std::string Command; std::string Status; + // Comma separated lists as rendered by the docker CLI. + std::string Labels; + std::string Networks; + std::string Mounts; + ULONG LocalVolumes{}; WSLCContainerState State; ULONGLONG StateChangedAt{}; ULONGLONG CreatedAt{}; @@ -144,6 +149,41 @@ struct ContainerInformation NLOHMANN_DEFINE_TYPE_INTRUSIVE(ContainerInformation, Id, Name, Image, State, StateChangedAt, CreatedAt, Ports); }; +// The platform a container runs on. Emitted as a nested object to match docker. +struct ContainerPlatform +{ + std::string architecture; + std::string os; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerPlatform, architecture, os); +}; + +// The shape emitted by "container list --format json". Every value is reported as a string apart +// from Platform, matching the docker CLI, so this is kept separate from ContainerInformation, which +// mirrors the service's native types. +struct ContainerOutputInformation +{ + std::string Command; + std::string CreatedAt; + std::string HealthStatus; + std::string ID; + std::string Image; + std::string Labels; + std::string LocalVolumes; + std::string Mounts; + std::string Names; + std::string Networks; + ContainerPlatform Platform; + std::string Ports; + std::string RunningFor; + std::string Size; + std::string State; + std::string Status; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT( + ContainerOutputInformation, Command, CreatedAt, HealthStatus, ID, Image, Labels, LocalVolumes, Mounts, Names, Networks, Platform, Ports, RunningFor, Size, State, Status); +}; + struct EnvironmentVariable { static std::optional Parse(const std::wstring& entry); diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index 931def4be1..8714936600 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -396,30 +396,50 @@ int ContainerService::Attach(Terminal& terminal, Session& session, const std::st return runningProcess.Wait(); } -std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt) +// The invariant state name. This is what "container list --format json" reports, so it must match +// docker's machine readable output rather than the user's display language. +std::wstring ContainerService::ContainerStateName(WSLCContainerState state) { - std::wstring stateString; switch (state) { case WSLCContainerState::WslcContainerStateCreated: - stateString = L"created"; - break; + return L"created"; case WSLCContainerState::WslcContainerStateRunning: - stateString = L"running"; - break; + return L"running"; case WSLCContainerState::WslcContainerStateDeleted: - stateString = L"stopped"; - break; + return L"stopped"; case WSLCContainerState::WslcContainerStateExited: - stateString = L"exited"; - break; + return L"exited"; case WSLCContainerState::WslcContainerStateInvalid: return L"invalid"; default: THROW_HR(E_UNEXPECTED); } +} - if (stateChangedAt == 0) +std::wstring ContainerService::LocalizedContainerStateName(WSLCContainerState state) +{ + switch (state) + { + case WSLCContainerState::WslcContainerStateCreated: + return Localization::WSLCCLI_ContainerStateCreated(); + case WSLCContainerState::WslcContainerStateRunning: + return Localization::WSLCCLI_ContainerStateRunning(); + case WSLCContainerState::WslcContainerStateDeleted: + return Localization::WSLCCLI_ContainerStateStopped(); + case WSLCContainerState::WslcContainerStateExited: + return Localization::WSLCCLI_ContainerStateExited(); + case WSLCContainerState::WslcContainerStateInvalid: + return Localization::WSLCCLI_ContainerStateInvalid(); + default: + THROW_HR(E_UNEXPECTED); + } +} + +std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt) +{ + auto stateString = LocalizedContainerStateName(state); + if (stateChangedAt == 0 || state == WSLCContainerState::WslcContainerStateInvalid) { return stateString; } @@ -658,6 +678,10 @@ std::vector ContainerService::List( entry.Image = current.Image; entry.Command = current.Command; entry.Status = current.Status; + entry.Labels = current.Labels; + entry.Networks = current.Networks; + entry.Mounts = current.Mounts; + entry.LocalVolumes = current.LocalVolumes; entry.State = current.State; entry.Id = current.Id; entry.StateChangedAt = current.StateChangedAt; diff --git a/src/windows/wslc/services/ContainerService.h b/src/windows/wslc/services/ContainerService.h index 44829fb7b7..b7ac17b57a 100644 --- a/src/windows/wslc/services/ContainerService.h +++ b/src/windows/wslc/services/ContainerService.h @@ -23,6 +23,14 @@ namespace wsl::windows::wslc::services { struct ContainerService { static std::wstring ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt = 0); + + // The bare state name, e.g. "running", without the relative time ContainerStateToString appends. + // Invariant on purpose: this is what "container list --format json" reports, and it has to match + // docker's machine readable output rather than the user's display language. + static std::wstring ContainerStateName(WSLCContainerState state); + + // The display form of ContainerStateName, used for the table output. + static std::wstring LocalizedContainerStateName(WSLCContainerState state); static std::wstring FormatRelativeTime(ULONGLONG timestamp); static std::wstring FormatElapsedSeconds(LONGLONG elapsedSeconds); static std::wstring FormatPorts(WSLCContainerState state, const std::vector& ports); diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index 95d429da12..0d821b49d6 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -114,6 +114,35 @@ nlohmann::json ComputeContainerStatsJson(const wsl::windows::common::docker_sche }; } +// Builds the representation of a container, shared by the table and json output so the two cannot +// drift. Every value is emitted as a string apart from the platform object, and the id is truncated +// unless --no-trunc is passed. +ContainerOutputInformation ToContainerOutput(const ContainerInformation& container, bool truncate) +{ + ContainerOutputInformation entry; + entry.Command = WideToMultiByte(ContainerService::FormatCommand(container.Command, truncate)); + entry.CreatedAt = FormatDockerTimestamp(static_cast(container.CreatedAt)); + // wslc does not surface health checks in the listing, which docker reports as "none". + entry.HealthStatus = "none"; + entry.ID = truncate ? TruncateId(container.Id) : container.Id; + entry.Image = container.Image; + entry.Labels = container.Labels; + entry.LocalVolumes = std::to_string(container.LocalVolumes); + entry.Mounts = container.Mounts; + entry.Names = container.Name; + entry.Networks = container.Networks; + entry.Platform.architecture = wsl::shared::Arm64 ? "arm64" : "amd64"; + entry.Platform.os = "linux"; + entry.Ports = WideToMultiByte(ContainerService::FormatPorts(container.State, container.Ports)); + entry.RunningFor = WideToMultiByte(ContainerService::FormatRelativeTime(container.CreatedAt)); + // Container sizes are only computed when docker is passed --size, which wslc does not support. + entry.Size = WideToMultiByte(FormatDockerSize(0)); + entry.State = WideToMultiByte(ContainerService::ContainerStateName(container.State)); + entry.Status = WideToMultiByte(ContainerService::FormatStatus(container.Status, container.State, container.StateChangedAt)); + + return entry; +} + } // namespace namespace wsl::windows::wslc::task { @@ -524,15 +553,17 @@ void ListContainers(CLIExecutionContext& context) if (context.Args.GetValue()) { // Print only the container ids + bool trunc = !context.Args.GetValue(); for (const auto& container : containers) { - context.Terminal.Output(L"{}\n", MultiByteToWide(container.Id)); + context.Terminal.Output(L"{}\n", MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id)); } return; } const auto format = context.Args.GetValue(FormatType::Table); + bool trunc = !context.Args.GetValue(); switch (format) { @@ -540,14 +571,13 @@ void ListContainers(CLIExecutionContext& context) { for (const auto& container : containers) { - context.Terminal.Output(L"{}\n", ToJsonW(container, c_jsonCompactIndent)); + context.Terminal.Output(L"{}\n", ToJsonW(ToContainerOutput(container, trunc), c_jsonCompactIndent)); } break; } case FormatType::Table: { - bool trunc = !context.Args.GetValue(); using enum ColumnOverflow; // Create table with or without column limits based on --no-trunc flag @@ -574,14 +604,15 @@ void ListContainers(CLIExecutionContext& context) // Add each container as a row for (const auto& container : containers) { + const auto entry = ToContainerOutput(container, trunc); table.WriteRow({ - MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id), - MultiByteToWide(container.Image), - ContainerService::FormatCommand(container.Command, trunc), - ContainerService::FormatRelativeTime(container.CreatedAt), - ContainerService::FormatStatus(container.Status, container.State, container.StateChangedAt), - ContainerService::FormatPorts(container.State, container.Ports), - MultiByteToWide(container.Name), + MultiByteToWide(entry.ID), + MultiByteToWide(entry.Image), + MultiByteToWide(entry.Command), + MultiByteToWide(entry.RunningFor), + MultiByteToWide(entry.Status), + MultiByteToWide(entry.Ports), + MultiByteToWide(entry.Names), }); } diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index 8f5a380668..da0afa13ef 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -2568,6 +2568,41 @@ try THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Command, std::size(output[index].Command), dockerContainer.Command.c_str(), _TRUNCATE) == EINVAL); THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Status, std::size(output[index].Status), dockerContainer.Status.c_str(), _TRUNCATE) == EINVAL); + // Labels, networks and mounts are reported the way the docker CLI renders them: a comma + // separated list. Like the command and status above they are unbounded, so they truncate. + std::vector labels; + for (const auto& [key, value] : dockerContainer.Labels) + { + labels.push_back(std::format("{}={}", key, value)); + } + + std::vector networks; + for (const auto& [name, _] : dockerContainer.NetworkSettings.Networks) + { + networks.push_back(name); + } + + std::vector mounts; + ULONG localVolumes = 0; + for (const auto& mount : dockerContainer.Mounts) + { + // Named volumes report a name, bind mounts only report the host path. + mounts.push_back(mount.Name.empty() ? mount.Source : mount.Name); + if (mount.Type == "volume") + { + localVolumes++; + } + } + + const auto joinedLabels = wsl::shared::string::Join(labels, ','); + const auto joinedNetworks = wsl::shared::string::Join(networks, ','); + const auto joinedMounts = wsl::shared::string::Join(mounts, ','); + + THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Labels, std::size(output[index].Labels), joinedLabels.c_str(), _TRUNCATE) == EINVAL); + THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Networks, std::size(output[index].Networks), joinedNetworks.c_str(), _TRUNCATE) == EINVAL); + THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Mounts, std::size(output[index].Mounts), joinedMounts.c_str(), _TRUNCATE) == EINVAL); + output[index].LocalVolumes = localVolumes; + e->GetState(&output[index].State); e->GetStateChangedAt(&output[index].StateChangedAt); e->GetCreatedAt(&output[index].CreatedAt); diff --git a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp index 7e3fcd4fee..0ecb3e6624 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp @@ -178,7 +178,14 @@ class WSLCE2EContainerListTests result = RunWslc(L"container list --all --quiet"); result.Verify({.Stderr = L"", .ExitCode = 0}); - // Verify the created container ID appears in the quiet output. + // Default --quiet truncates to docker's 12 character short id. + const auto truncatedId = wsl::shared::string::MultiByteToWide(TruncateId(WideToMultiByte(containerId))); + VERIFY_ARE_EQUAL(12u, truncatedId.size()); + VERIFY_IS_TRUE(result.StdoutContainsLine(truncatedId)); + + // --quiet --no-trunc keeps the full id. + result = RunWslc(L"container list --all --quiet --no-trunc"); + result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(containerId)); } @@ -201,15 +208,15 @@ class WSLCE2EContainerListTests VERIFY_IS_FALSE(containerId.empty()); // List containers with json format - result = RunWslc(L"container list --all --format json"); + result = RunWslc(L"container list --all --format json --no-trunc"); result.Verify({.Stderr = L"", .ExitCode = 0}); // Parse json and verify we got the expected container information back - auto containers = ParseNdjsonOutputAs(result); + auto containers = ParseNdjsonOutputAs(result); VERIFY_IS_GREATER_THAN_OR_EQUAL(containers.size(), 1U); VERIFY_ARE_EQUAL(containers.size(), result.GetStdoutLines().size()); - auto findContainer = [](const std::vector& list, const std::wstring& id) { - return std::ranges::any_of(list, [&](const auto& c) { return wsl::shared::string::MultiByteToWide(c.Id) == id; }); + auto findContainer = [](const std::vector& list, const std::wstring& id) { + return std::ranges::any_of(list, [&](const auto& c) { return wsl::shared::string::MultiByteToWide(c.ID) == id; }); }; VERIFY_IS_TRUE(findContainer(containers, containerId)); @@ -221,16 +228,76 @@ class WSLCE2EContainerListTests VERIFY_IS_FALSE(containerId2.empty()); // List containers with json format again - result = RunWslc(L"container list --all --format json"); + result = RunWslc(L"container list --all --format json --no-trunc"); result.Verify({.Stderr = L"", .ExitCode = 0}); // Parse json and verify we got both containers back - containers = ParseNdjsonOutputAs(result); + containers = ParseNdjsonOutputAs(result); VERIFY_IS_GREATER_THAN_OR_EQUAL(containers.size(), 2U); VERIFY_IS_TRUE(findContainer(containers, containerId)); VERIFY_IS_TRUE(findContainer(containers, containerId2)); } + WSLC_TEST_METHOD(WSLCE2E_Container_List_JsonFormat_MatchesDockerShape) + { + const std::set expectedKeys = { + "Command", + "CreatedAt", + "HealthStatus", + "ID", + "Image", + "Labels", + "LocalVolumes", + "Mounts", + "Names", + "Networks", + "Platform", + "Ports", + "RunningFor", + "Size", + "State", + "Status"}; + + VerifyContainerIsNotListed(WslcContainerName); + + auto result = RunWslc(std::format(L"container create --name {} {}", WslcContainerName, DebianImage.NameAndTag())); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + result = RunWslc(L"container list --all --format json"); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + const auto entries = ParseNdjsonOutput(result); + VERIFY_IS_GREATER_THAN_OR_EQUAL(entries.size(), 1u); + + for (const auto& entry : entries) + { + std::set keys; + for (const auto& [key, value] : entry.items()) + { + keys.insert(key); + + // Docker reports the platform as a nested object and every other field as a string. + if (key == "Platform") + { + VERIFY_IS_TRUE(value.is_object()); + } + else + { + VERIFY_IS_TRUE( + value.is_string(), wsl::shared::string::MultiByteToWide(std::format("'{}' must be a string", key)).c_str()); + } + } + + VERIFY_ARE_EQUAL(expectedKeys, keys, L"json output must contain exactly docker's container fields"); + + // Ids are truncated to docker's short form unless --no-trunc is passed. + VERIFY_ARE_EQUAL(12u, entry["ID"].get().size()); + VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Names"].get()); + VERIFY_ARE_NOT_EQUAL(std::string{}, entry["State"].get()); + VERIFY_ARE_EQUAL(std::string{"linux"}, entry["Platform"]["os"].get()); + } + } + WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_InvalidKey) { // Filter keys are validated by the Docker daemon, which rejects unknown keys. @@ -273,9 +340,9 @@ class WSLCE2EContainerListTests result = RunWslc(std::format(L"container list --all --format json --filter name={}", WslcContainerName2)); result.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(result); + const auto containers = ParseNdjsonOutputAs(result); VERIFY_ARE_EQUAL(1U, containers.size()); - VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Name)); + VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Names)); } WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_Status) @@ -294,11 +361,11 @@ class WSLCE2EContainerListTests auto listNames = [&](const std::wstring& filterArgs) { auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs)); r.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(r); + const auto containers = ParseNdjsonOutputAs(r); std::set names; for (const auto& c : containers) { - names.insert(c.Name); + names.insert(c.Names); } return names; }; @@ -341,11 +408,11 @@ class WSLCE2EContainerListTests auto listNames = [&](const std::wstring& filterArgs) { auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs)); r.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(r); + const auto containers = ParseNdjsonOutputAs(r); std::set names; for (const auto& c : containers) { - names.insert(c.Name); + names.insert(c.Names); } return names; }; @@ -385,12 +452,12 @@ class WSLCE2EContainerListTests result.Verify({.Stderr = L"", .ExitCode = 0}); // Filter by id (full id) should return exactly one container. - result = RunWslc(std::format(L"container list --all --format json --filter id={}", containerId)); + result = RunWslc(std::format(L"container list --all --format json --no-trunc --filter id={}", containerId)); result.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(result); + const auto containers = ParseNdjsonOutputAs(result); VERIFY_ARE_EQUAL(1U, containers.size()); - VERIFY_ARE_EQUAL(WideToMultiByte(containerId), std::string(containers[0].Id)); + VERIFY_ARE_EQUAL(WideToMultiByte(containerId), std::string(containers[0].ID)); } WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_Exited) @@ -409,11 +476,11 @@ class WSLCE2EContainerListTests auto listNames = [&](const std::wstring& filterArgs) { auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs)); r.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(r); + const auto containers = ParseNdjsonOutputAs(r); std::set names; for (const auto& c : containers) { - names.insert(c.Name); + names.insert(c.Names); } return names; }; @@ -448,11 +515,11 @@ class WSLCE2EContainerListTests auto listNames = [&](const std::wstring& filterArgs) { auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs)); r.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(r); + const auto containers = ParseNdjsonOutputAs(r); std::set names; for (const auto& c : containers) { - names.insert(c.Name); + names.insert(c.Names); } return names; }; @@ -490,9 +557,9 @@ class WSLCE2EContainerListTests result = RunWslc(L"container list --latest --format json"); result.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(result); + const auto containers = ParseNdjsonOutputAs(result); VERIFY_ARE_EQUAL(1U, containers.size()); - VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Name)); + VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Names)); } // --last 2 should cap output at 2 containers. @@ -500,7 +567,7 @@ class WSLCE2EContainerListTests result = RunWslc(L"container list --last 2 --format json"); result.Verify({.Stderr = L"", .ExitCode = 0}); - const auto containers = ParseNdjsonOutputAs(result); + const auto containers = ParseNdjsonOutputAs(result); VERIFY_IS_TRUE(containers.size() <= 2u); } diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp index 16b650553d..92402a284f 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp @@ -363,7 +363,7 @@ void EnsureContainerDoesNotExist(const std::wstring& containerName) { const auto name = wsl::shared::string::WideToMultiByte(containerName); const auto containers = ListAllContainers(); - auto it = std::ranges::find_if(containers, [&](const auto& c) { return c.Name == name; }); + auto it = std::ranges::find_if(containers, [&](const auto& c) { return c.Names == name; }); if (it == containers.end()) { return; @@ -377,11 +377,12 @@ void EnsureContainerDoesNotExist(const std::wstring& containerName) } } -std::vector ListAllContainers() +std::vector ListAllContainers() { - auto result = RunWslc(L"container list --all --format json"); + // --no-trunc keeps the full ids, which callers use to address containers. + auto result = RunWslc(L"container list --all --format json --no-trunc"); result.Verify({.Stderr = L"", .ExitCode = 0}); - return ParseNdjsonOutputAs(result); + return ParseNdjsonOutputAs(result); } void EnsureImageContainersAreDeleted(const TestImage& image) @@ -392,8 +393,8 @@ void EnsureImageContainersAreDeleted(const TestImage& image) auto nameAndTag = wsl::shared::string::WideToMultiByte(image.NameAndTag()); if (container.Image.find(nameAndTag) != std::string::npos) { - auto result = RunWslc(std::format(L"container remove --force {}", container.Id)); - result.Verify({.Stdout = std::format(L"{}\r\n", container.Id), .Stderr = L"", .ExitCode = 0}); + auto result = RunWslc(std::format(L"container remove --force {}", container.ID)); + result.Verify({.Stdout = std::format(L"{}\r\n", container.ID), .Stderr = L"", .ExitCode = 0}); } } } diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.h b/test/windows/wslc/e2e/WSLCE2EHelpers.h index 3555743020..98935573a8 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.h +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.h @@ -128,7 +128,7 @@ wsl::windows::common::wslc_schema::InspectContainer InspectContainer(const std:: wsl::windows::common::wslc_schema::InspectImage InspectImage(const std::wstring& imageName); wsl::windows::common::wslc_schema::InspectVolume InspectVolume(const std::wstring& volumeName); wsl::windows::common::wslc_schema::Network InspectNetwork(const std::wstring& networkName); -std::vector ListAllContainers(); +std::vector ListAllContainers(); void EnsureContainerDoesNotExist(const std::wstring& containerName); void EnsureImageIsLoaded(const TestImage& image, const std::wstring& sessionName = L""); From 64ab6cf0185a0a32608ca6c4dcf48319d6018866 Mon Sep 17 00:00:00 2001 From: Gavin Garzia Date: Tue, 18 Aug 2026 14:58:38 -0700 Subject: [PATCH 3/8] wslc: trim redundant comments from container list changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/windows/inc/docker_schema.h | 1 - src/windows/service/inc/wslc.idl | 5 ----- src/windows/wslc/services/ContainerModel.h | 8 ++------ src/windows/wslc/services/ContainerService.cpp | 3 +-- src/windows/wslc/services/ContainerService.h | 4 ---- test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp | 1 - test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp | 4 ---- 7 files changed, 3 insertions(+), 23 deletions(-) diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index 6ac424f543..cd6c3127e1 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -699,7 +699,6 @@ struct ContainerInfo std::string Image; std::string ImageID; std::string Command; - // Human readable state description built by the daemon, e.g. "Up 5 minutes" or "Exited (0) 2 hours ago". std::string Status; std::map Labels; std::vector Ports; diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 005c652ec7..29cae2c0c7 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -383,15 +383,10 @@ typedef struct _WSLCContainerEntry { char Name[WSLC_MAX_CONTAINER_NAME_LENGTH + 1]; char Image[WSLC_MAX_IMAGE_NAME_LENGTH + 1]; - // Command the container runs, as reported by the runtime. Truncated if it exceeds the buffer. char Command[WSLC_MAX_CONTAINER_COMMAND_LENGTH + 1]; - // Human readable state description built by the runtime, e.g. "Up 5 minutes" or "Exited (0) 2 hours ago". char Status[WSLC_MAX_CONTAINER_STATUS_LENGTH + 1]; - // Comma separated "key=value" labels. Truncated if it exceeds the buffer. char Labels[WSLC_MAX_CONTAINER_LABELS_LENGTH + 1]; - // Comma separated network names the container is attached to. Truncated if it exceeds the buffer. char Networks[WSLC_MAX_CONTAINER_NETWORKS_LENGTH + 1]; - // Comma separated mount names or sources. Truncated if it exceeds the buffer. char Mounts[WSLC_MAX_CONTAINER_MOUNTS_LENGTH + 1]; WSLCContainerId Id; ULONGLONG StateChangedAt; diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h index eafd3fee5a..6fb4d01243 100644 --- a/src/windows/wslc/services/ContainerModel.h +++ b/src/windows/wslc/services/ContainerModel.h @@ -132,11 +132,9 @@ struct ContainerInformation std::string Id; std::string Name; std::string Image; - // Command and runtime supplied status description. Not serialized directly: the json output is - // built by ToContainerOutput, which reports the docker shape. + // Command and runtime supplied status description. std::string Command; std::string Status; - // Comma separated lists as rendered by the docker CLI. std::string Labels; std::string Networks; std::string Mounts; @@ -158,9 +156,7 @@ struct ContainerPlatform NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerPlatform, architecture, os); }; -// The shape emitted by "container list --format json". Every value is reported as a string apart -// from Platform, matching the docker CLI, so this is kept separate from ContainerInformation, which -// mirrors the service's native types. +// The shape emitted by "container list --format json". struct ContainerOutputInformation { std::string Command; diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index 8714936600..d5725c9314 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -396,8 +396,7 @@ int ContainerService::Attach(Terminal& terminal, Session& session, const std::st return runningProcess.Wait(); } -// The invariant state name. This is what "container list --format json" reports, so it must match -// docker's machine readable output rather than the user's display language. +// The invariant state name. This is what "container list --format json" reports. std::wstring ContainerService::ContainerStateName(WSLCContainerState state) { switch (state) diff --git a/src/windows/wslc/services/ContainerService.h b/src/windows/wslc/services/ContainerService.h index b7ac17b57a..42e5928067 100644 --- a/src/windows/wslc/services/ContainerService.h +++ b/src/windows/wslc/services/ContainerService.h @@ -25,8 +25,6 @@ struct ContainerService static std::wstring ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt = 0); // The bare state name, e.g. "running", without the relative time ContainerStateToString appends. - // Invariant on purpose: this is what "container list --format json" reports, and it has to match - // docker's machine readable output rather than the user's display language. static std::wstring ContainerStateName(WSLCContainerState state); // The display form of ContainerStateName, used for the table output. @@ -35,8 +33,6 @@ struct ContainerService static std::wstring FormatElapsedSeconds(LONGLONG elapsedSeconds); static std::wstring FormatPorts(WSLCContainerState state, const std::vector& ports); - // Renders a container command the way docker does: optionally shortened to 20 characters with a - // trailing ellipsis, then wrapped in double quotes. static std::wstring FormatCommand(const std::string& command, bool truncate); // Renders a container status, preferring the description supplied by the runtime and falling back diff --git a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp index a1b07901a7..a16ddb7ca4 100644 --- a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp +++ b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp @@ -74,7 +74,6 @@ class WSLCCLIContainerCommandUnitTests TEST_METHOD(FormatCommand_MultiByteCharacters_CountedAsSingleCodePoints) { - // U+00E0 encodes as two utf-8 bytes, so a byte based count would shorten these too early. const std::string accented = "\xC3\xA0"; std::string twenty; for (int i = 0; i < 20; ++i) diff --git a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp index 0ecb3e6624..7eefd049d4 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp @@ -178,12 +178,10 @@ class WSLCE2EContainerListTests result = RunWslc(L"container list --all --quiet"); result.Verify({.Stderr = L"", .ExitCode = 0}); - // Default --quiet truncates to docker's 12 character short id. const auto truncatedId = wsl::shared::string::MultiByteToWide(TruncateId(WideToMultiByte(containerId))); VERIFY_ARE_EQUAL(12u, truncatedId.size()); VERIFY_IS_TRUE(result.StdoutContainsLine(truncatedId)); - // --quiet --no-trunc keeps the full id. result = RunWslc(L"container list --all --quiet --no-trunc"); result.Verify({.Stderr = L"", .ExitCode = 0}); VERIFY_IS_TRUE(result.StdoutContainsLine(containerId)); @@ -276,7 +274,6 @@ class WSLCE2EContainerListTests { keys.insert(key); - // Docker reports the platform as a nested object and every other field as a string. if (key == "Platform") { VERIFY_IS_TRUE(value.is_object()); @@ -290,7 +287,6 @@ class WSLCE2EContainerListTests VERIFY_ARE_EQUAL(expectedKeys, keys, L"json output must contain exactly docker's container fields"); - // Ids are truncated to docker's short form unless --no-trunc is passed. VERIFY_ARE_EQUAL(12u, entry["ID"].get().size()); VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Names"].get()); VERIFY_ARE_NOT_EQUAL(std::string{}, entry["State"].get()); From 2f7116729d6a37c887056ef34e977456e5eaddda Mon Sep 17 00:00:00 2001 From: Gavin Garzia Date: Tue, 18 Aug 2026 16:09:28 -0700 Subject: [PATCH 4/8] wslc: address PR feedback - truncating copy helper, drop unused container json macro Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/windows/wslc/services/ContainerModel.h | 2 -- src/windows/wslcsession/WSLCSession.cpp | 19 ++++++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h index 6fb4d01243..8567341bc5 100644 --- a/src/windows/wslc/services/ContainerModel.h +++ b/src/windows/wslc/services/ContainerModel.h @@ -143,8 +143,6 @@ struct ContainerInformation ULONGLONG StateChangedAt{}; ULONGLONG CreatedAt{}; std::vector Ports; - - NLOHMANN_DEFINE_TYPE_INTRUSIVE(ContainerInformation, Id, Name, Image, State, StateChangedAt, CreatedAt, Ports); }; // The platform a container runs on. Emitted as a nested object to match docker. diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index da0afa13ef..3ae82b0907 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -79,6 +79,15 @@ void ValidateNewSessionStorageDirectory(const std::filesystem::path& StoragePath THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcSessionStorageMustBeEmpty(StoragePath.c_str()), !empty); } +// Copies a string into a fixed size buffer, truncating rather than failing when the source doesn't +// fit. Only a genuine copy failure is reported as an error. +template +void CopyTruncated(char (&Destination)[Size], const std::string& Source) +{ + const auto result = strncpy_s(Destination, Size, Source.c_str(), _TRUNCATE); + THROW_HR_IF(E_UNEXPECTED, result != 0 && result != STRUNCATE); +} + // Group policy: WSLContainerRegistryAllowlist restricts which container-image // registries can be pulled from or pushed to. The check is enforced here at the // service boundary so it covers ALL callers (wslc.exe CLI, the WslcSDK C API, and @@ -2565,8 +2574,8 @@ try // Commands and status descriptions have no bound imposed by the runtime, so truncate them // rather than failing the listing. - THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Command, std::size(output[index].Command), dockerContainer.Command.c_str(), _TRUNCATE) == EINVAL); - THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Status, std::size(output[index].Status), dockerContainer.Status.c_str(), _TRUNCATE) == EINVAL); + CopyTruncated(output[index].Command, dockerContainer.Command); + CopyTruncated(output[index].Status, dockerContainer.Status); // Labels, networks and mounts are reported the way the docker CLI renders them: a comma // separated list. Like the command and status above they are unbounded, so they truncate. @@ -2598,9 +2607,9 @@ try const auto joinedNetworks = wsl::shared::string::Join(networks, ','); const auto joinedMounts = wsl::shared::string::Join(mounts, ','); - THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Labels, std::size(output[index].Labels), joinedLabels.c_str(), _TRUNCATE) == EINVAL); - THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Networks, std::size(output[index].Networks), joinedNetworks.c_str(), _TRUNCATE) == EINVAL); - THROW_HR_IF(E_UNEXPECTED, strncpy_s(output[index].Mounts, std::size(output[index].Mounts), joinedMounts.c_str(), _TRUNCATE) == EINVAL); + CopyTruncated(output[index].Labels, joinedLabels); + CopyTruncated(output[index].Networks, joinedNetworks); + CopyTruncated(output[index].Mounts, joinedMounts); output[index].LocalVolumes = localVolumes; e->GetState(&output[index].State); From 3d2064a2ab17c1d5e4086511634446cc23d4c75f Mon Sep 17 00:00:00 2001 From: Gavin Garzia Date: Tue, 18 Aug 2026 17:13:00 -0700 Subject: [PATCH 5/8] wslc: avoid raw string literals in verify macros to fix CI build Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp index a16ddb7ca4..06bb821425 100644 --- a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp +++ b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp @@ -68,8 +68,10 @@ class WSLCCLIContainerCommandUnitTests TEST_METHOD(FormatCommand_EmbeddedQuotesAndBackslashes_AreEscaped) { - VERIFY_ARE_EQUAL(std::wstring{LR"("say \"hi\"")"}, Truncated(R"(say "hi")")); - VERIFY_ARE_EQUAL(std::wstring{LR"("c:\\temp")"}, Truncated(R"(c:\temp)")); + // Raw string literals are avoided here: the compiler mangles them when the verify macro + // stringizes its arguments. + VERIFY_ARE_EQUAL(std::wstring{L"\"say \\\"hi\\\"\""}, Truncated("say \"hi\"")); + VERIFY_ARE_EQUAL(std::wstring{L"\"c:\\\\temp\""}, Truncated("c:\\temp")); } TEST_METHOD(FormatCommand_MultiByteCharacters_CountedAsSingleCodePoints) From 12644673f98e75809af7d89d7805a47e4fbe1a32 Mon Sep 17 00:00:00 2001 From: Gavin Garzia Date: Wed, 19 Aug 2026 13:40:03 -0700 Subject: [PATCH 6/8] wslc: exit command truncation loop early and document quoting order Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/windows/wslc/services/ContainerService.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index d5725c9314..b6896b931e 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -459,6 +459,11 @@ std::wstring ContainerService::FormatCommand(const std::string& command, bool tr size_t cutoff = 0; for (; index < wide.size(); ++codePoints) { + if (codePoints > c_maxDisplayWidth) + { + break; + } + if (codePoints == c_maxDisplayWidth - 1) { cutoff = index; @@ -474,6 +479,8 @@ std::wstring ContainerService::FormatCommand(const std::string& command, bool tr } } + // Quoting happens after truncation, so the result can exceed c_maxDisplayWidth. This matches docker, which truncates + // the command first and quotes the truncated value. std::wstring quoted{L'"'}; for (const auto character : wide) { From 051728643299d90d0612d0f583a2006426309060 Mon Sep 17 00:00:00 2001 From: Gavin Garzia Date: Thu, 20 Aug 2026 14:59:26 -0700 Subject: [PATCH 7/8] wslc: address PR feedback - display-width truncation, unbounded list strings, health status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/windows/common/string.cpp | 51 ++++++ src/windows/common/string.hpp | 6 + src/windows/service/inc/wslc.idl | 22 +-- .../wslc/services/ContainerService.cpp | 166 ++++++++++++++---- src/windows/wslc/services/ContainerService.h | 8 + src/windows/wslc/tasks/ContainerTasks.cpp | 7 +- src/windows/wslcsession/WSLCSession.cpp | 51 ++++-- test/windows/StringUnitTests.cpp | 54 ++++++ .../wslc/WSLCCLIContainerCommandUnitTests.cpp | 148 +++++++++++++++- 9 files changed, 444 insertions(+), 69 deletions(-) diff --git a/src/windows/common/string.cpp b/src/windows/common/string.cpp index a9929339d6..6382d4ab84 100644 --- a/src/windows/common/string.cpp +++ b/src/windows/common/string.cpp @@ -425,6 +425,57 @@ std::string wsl::windows::common::string::TruncateId(_In_ std::string_view id, b return TruncateIdImpl(id, shortenLength); } +// Returns the number of terminal columns a code point occupies. This mirrors docker's charWidth, which treats +// East Asian wide and fullwidth code points as two columns and everything else as one. +static size_t CharacterWidth(UChar32 CodePoint) +{ + const auto width = u_getIntPropertyValue(CodePoint, UCHAR_EAST_ASIAN_WIDTH); + return (width == U_EA_WIDE || width == U_EA_FULLWIDTH) ? 2 : 1; +} + +std::wstring wsl::windows::common::string::Ellipsis(_In_ std::wstring_view Value, _In_ size_t MaxDisplayWidth) +{ + if (MaxDisplayWidth == 0 || Value.empty()) + { + return {}; + } + + const auto length = gsl::narrow_cast(Value.size()); + if (MaxDisplayWidth == 1) + { + // There is no room for both content and an ellipsis, so the leading code point is kept as-is even + // if it is wider than the limit. + int32_t index = 0; + UChar32 codePoint{}; + U16_NEXT(Value.data(), index, length, codePoint); + return std::wstring{Value.substr(0, index)}; + } + + // The ellipsis occupies one column, so the retained content has one column less to work with. + const auto budget = MaxDisplayWidth - 1; + size_t totalWidth = 0; + size_t cutoff = 0; + for (int32_t index = 0; index < length;) + { + UChar32 codePoint{}; + U16_NEXT(Value.data(), index, length, codePoint); + totalWidth += CharacterWidth(codePoint); + if (totalWidth <= budget) + { + cutoff = index; + } + } + + // A cutoff of zero means the first code point alone leaves no room for the ellipsis, in which case docker + // returns the value untouched. + if (totalWidth <= MaxDisplayWidth || cutoff == 0) + { + return std::wstring{Value}; + } + + return std::wstring{Value.substr(0, cutoff)} + L'\u2026'; +} + std::string wsl::windows::common::string::FormatDockerTimestamp(LONGLONG timestamp) { const auto time = diff --git a/src/windows/common/string.hpp b/src/windows/common/string.hpp index 219ed9bb50..afc0abd43e 100644 --- a/src/windows/common/string.hpp +++ b/src/windows/common/string.hpp @@ -67,6 +67,12 @@ std::string WideToMultiByte(_In_ std::wstring_view Source); std::wstring TruncateId(_In_ std::wstring_view id, bool shortenLength = true); std::string TruncateId(_In_ std::string_view id, bool shortenLength = true); +// Shortens a value so it occupies at most MaxDisplayWidth terminal columns, appending an ellipsis when +// characters are dropped. East Asian wide and fullwidth code points occupy two columns, so fewer of them +// fit than narrow ones, and a code point is never split. This matches docker's formatter.Ellipsis +// (cli/command/formatter/displayutils.go), including its handling of widths of one and below. +std::wstring Ellipsis(_In_ std::wstring_view Value, _In_ size_t MaxDisplayWidth); + // Formats a unix timestamp the way docker does, matching Go's time.Time.String() layout. Falls back // to UTC when the time zone database is unavailable. std::string FormatDockerTimestamp(LONGLONG timestamp); diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 29cae2c0c7..ec186ee8b0 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -31,11 +31,6 @@ cpp_quote("#endif") #define WSLC_MAX_VOLUME_DRIVER_LENGTH 255 #define WSLC_MAX_NETWORK_NAME_LENGTH 255 #define WSLC_MAX_IMAGE_ID_LENGTH 255 -#define WSLC_MAX_CONTAINER_COMMAND_LENGTH 4095 -#define WSLC_MAX_CONTAINER_STATUS_LENGTH 127 -#define WSLC_MAX_CONTAINER_LABELS_LENGTH 4095 -#define WSLC_MAX_CONTAINER_NETWORKS_LENGTH 1023 -#define WSLC_MAX_CONTAINER_MOUNTS_LENGTH 4095 #define WSLC_CONTAINER_ID_LENGTH 64 #define WSLC_MAX_BINDING_ADDRESS_LENGTH 45 #define WSLC_EPHEMERAL_PORT 0 @@ -47,11 +42,6 @@ cpp_quote("#define WSLC_MAX_VOLUME_NAME_LENGTH 255") cpp_quote("#define WSLC_MAX_VOLUME_DRIVER_LENGTH 255") cpp_quote("#define WSLC_MAX_NETWORK_NAME_LENGTH 255") cpp_quote("#define WSLC_MAX_IMAGE_ID_LENGTH 255") -cpp_quote("#define WSLC_MAX_CONTAINER_COMMAND_LENGTH 4095") -cpp_quote("#define WSLC_MAX_CONTAINER_STATUS_LENGTH 127") -cpp_quote("#define WSLC_MAX_CONTAINER_LABELS_LENGTH 4095") -cpp_quote("#define WSLC_MAX_CONTAINER_NETWORKS_LENGTH 1023") -cpp_quote("#define WSLC_MAX_CONTAINER_MOUNTS_LENGTH 4095") cpp_quote("#define WSLC_CONTAINER_ID_LENGTH 64") cpp_quote("#define WSLC_MAX_BINDING_ADDRESS_LENGTH 45") cpp_quote("#define WSLC_MAX_SAVE_IMAGES_COUNT 256") @@ -383,11 +373,13 @@ typedef struct _WSLCContainerEntry { char Name[WSLC_MAX_CONTAINER_NAME_LENGTH + 1]; char Image[WSLC_MAX_IMAGE_NAME_LENGTH + 1]; - char Command[WSLC_MAX_CONTAINER_COMMAND_LENGTH + 1]; - char Status[WSLC_MAX_CONTAINER_STATUS_LENGTH + 1]; - char Labels[WSLC_MAX_CONTAINER_LABELS_LENGTH + 1]; - char Networks[WSLC_MAX_CONTAINER_NETWORKS_LENGTH + 1]; - char Mounts[WSLC_MAX_CONTAINER_MOUNTS_LENGTH + 1]; + // The runtime imposes no bound on these values, so they are allocated by the callee and freed + // by the caller. Any of them may be null when the container reports no value. + [string] LPSTR Command; + [string] LPSTR Status; + [string] LPSTR Labels; + [string] LPSTR Networks; + [string] LPSTR Mounts; WSLCContainerId Id; ULONGLONG StateChangedAt; ULONGLONG CreatedAt; diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index b6896b931e..75094ff31b 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -446,6 +446,63 @@ std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, return std::format(L"{} {}", stateString, FormatRelativeTime(stateChangedAt)); } +// Reports whether a code point is printable using the same rule as Go's unicode.IsPrint, which docker relies on when +// quoting: letters, marks, numbers, punctuation, symbols and the ASCII space. +static bool IsPrintable(UChar32 codePoint) +{ + constexpr auto printableMask = U_GC_L_MASK | U_GC_M_MASK | U_GC_N_MASK | U_GC_P_MASK | U_GC_S_MASK; + return codePoint == U' ' || (U_GET_GC_MASK(codePoint) & printableMask) != 0; +} + +// Appends a code point that has no printable representation, mirroring the escapes Go's strconv.Quote emits. +static void AppendEscape(std::wstring& quoted, UChar32 codePoint) +{ + switch (codePoint) + { + case L'\a': + quoted += L"\\a"; + return; + case L'\b': + quoted += L"\\b"; + return; + case L'\f': + quoted += L"\\f"; + return; + case L'\n': + quoted += L"\\n"; + return; + case L'\r': + quoted += L"\\r"; + return; + case L'\t': + quoted += L"\\t"; + return; + case L'\v': + quoted += L"\\v"; + return; + default: + break; + } + + if (codePoint < L' ' || codePoint == 0x7F) + { + quoted += std::format(L"\\x{:02x}", static_cast(codePoint)); + } + else if (U_IS_SURROGATE(codePoint)) + { + // An unpaired surrogate is not a valid code point, and Go substitutes the replacement character. + quoted += L"\\ufffd"; + } + else if (codePoint < 0x10000) + { + quoted += std::format(L"\\u{:04x}", static_cast(codePoint)); + } + else + { + quoted += std::format(L"\\U{:08x}", static_cast(codePoint)); + } +} + std::wstring ContainerService::FormatCommand(const std::string& command, bool truncate) { constexpr size_t c_maxDisplayWidth = 20; @@ -453,49 +510,57 @@ std::wstring ContainerService::FormatCommand(const std::string& command, bool tr auto wide = wsl::shared::string::MultiByteToWide(command); if (truncate) { - // Count code points rather than code units so a surrogate pair is never split. - size_t codePoints = 0; - size_t index = 0; - size_t cutoff = 0; - for (; index < wide.size(); ++codePoints) - { - if (codePoints > c_maxDisplayWidth) - { - break; - } - - if (codePoints == c_maxDisplayWidth - 1) - { - cutoff = index; - } - - index += (IS_HIGH_SURROGATE(wide[index]) && (index + 1 < wide.size()) && IS_LOW_SURROGATE(wide[index + 1])) ? 2 : 1; - } - - if (codePoints > c_maxDisplayWidth) - { - wide.resize(cutoff); - wide += L'\u2026'; - } + wide = wsl::windows::common::string::Ellipsis(wide, c_maxDisplayWidth); } // Quoting happens after truncation, so the result can exceed c_maxDisplayWidth. This matches docker, which truncates // the command first and quotes the truncated value. + const auto length = static_cast(wide.size()); std::wstring quoted{L'"'}; - for (const auto character : wide) + for (int32_t index = 0; index < length;) { - if (character == L'"' || character == L'\\') + const auto start = index; + UChar32 codePoint{}; + U16_NEXT(wide.data(), index, length, codePoint); + + if (codePoint == L'"' || codePoint == L'\\') { quoted += L'\\'; + quoted += static_cast(codePoint); + } + else if (IsPrintable(codePoint)) + { + quoted.append(wide, start, static_cast(index - start)); + } + else + { + AppendEscape(quoted, codePoint); } - - quoted += character; } quoted += L'"'; return quoted; } +std::wstring ContainerService::FormatMounts(const std::string& mounts, bool truncate) +{ + constexpr size_t c_maxDisplayWidth = 15; + + auto wide = wsl::shared::string::MultiByteToWide(mounts); + if (!truncate || wide.empty()) + { + return wide; + } + + std::vector shortened; + for (const auto& mount : wsl::shared::string::SplitPreserveEmpty(std::wstring_view{wide}, L',')) + { + shortened.emplace_back(wsl::windows::common::string::Ellipsis(mount, c_maxDisplayWidth)); + } + + return wsl::shared::string::Join(shortened, L','); +} + std::wstring ContainerService::FormatStatus(const std::string& status, WSLCContainerState state, ULONGLONG stateChangedAt) { if (!status.empty()) @@ -506,6 +571,29 @@ std::wstring ContainerService::FormatStatus(const std::string& status, WSLCConta return ContainerStateToString(state, stateChangedAt); } +std::string ContainerService::FormatHealthStatus(const std::string& status) +{ + const auto open = status.find('('); + if (open == std::string::npos || status.back() != ')') + { + return {}; + } + + constexpr std::string_view c_healthPrefix = "health: "; + auto health = std::string_view{status}.substr(open + 1, status.size() - open - 2); + if (health.starts_with(c_healthPrefix)) + { + health.remove_prefix(c_healthPrefix.size()); + } + + if (health == "healthy" || health == "unhealthy" || health == "starting") + { + return std::string{health}; + } + + return {}; +} + std::wstring ContainerService::FormatPorts(WSLCContainerState state, const std::vector& ports) { if (state != WslcContainerStateRunning || ports.empty()) @@ -677,16 +765,28 @@ std::vector ContainerService::List( std::vector result; + // The listing allocates these fields, so take ownership of each one as it is read. + const auto freeStrings = wil::scope_exit([&] { + for (auto& current : containers) + { + CoTaskMemFree(current.Command); + CoTaskMemFree(current.Status); + CoTaskMemFree(current.Labels); + CoTaskMemFree(current.Networks); + CoTaskMemFree(current.Mounts); + } + }); + for (const auto& current : containers) { ContainerInformation entry; entry.Name = current.Name; entry.Image = current.Image; - entry.Command = current.Command; - entry.Status = current.Status; - entry.Labels = current.Labels; - entry.Networks = current.Networks; - entry.Mounts = current.Mounts; + entry.Command = current.Command == nullptr ? "" : current.Command; + entry.Status = current.Status == nullptr ? "" : current.Status; + entry.Labels = current.Labels == nullptr ? "" : current.Labels; + entry.Networks = current.Networks == nullptr ? "" : current.Networks; + entry.Mounts = current.Mounts == nullptr ? "" : current.Mounts; entry.LocalVolumes = current.LocalVolumes; entry.State = current.State; entry.Id = current.Id; diff --git a/src/windows/wslc/services/ContainerService.h b/src/windows/wslc/services/ContainerService.h index 42e5928067..73bbc48380 100644 --- a/src/windows/wslc/services/ContainerService.h +++ b/src/windows/wslc/services/ContainerService.h @@ -35,9 +35,17 @@ struct ContainerService static std::wstring FormatCommand(const std::string& command, bool truncate); + // Renders the comma separated mount list. Docker shortens each name independently, so a long path + // never crowds out the mounts that follow it. + static std::wstring FormatMounts(const std::string& mounts, bool truncate); + // Renders a container status, preferring the description supplied by the runtime and falling back // to a locally built one when it is unavailable. static std::wstring FormatStatus(const std::string& status, WSLCContainerState state, ULONGLONG stateChangedAt); + + // Extracts the health status from a runtime supplied status description, which carries it as a + // parenthesized suffix. Containers without a health check report an empty string. + static std::string FormatHealthStatus(const std::string& status); static int Attach(Terminal& terminal, models::Session& session, const std::string& id); static int Run(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options); static models::CreateContainerResult Create(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options); diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index 0d821b49d6..3f7cf8fcf9 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -122,13 +122,14 @@ ContainerOutputInformation ToContainerOutput(const ContainerInformation& contain ContainerOutputInformation entry; entry.Command = WideToMultiByte(ContainerService::FormatCommand(container.Command, truncate)); entry.CreatedAt = FormatDockerTimestamp(static_cast(container.CreatedAt)); - // wslc does not surface health checks in the listing, which docker reports as "none". - entry.HealthStatus = "none"; + // The runtime reports health as a suffix on the status description, which is the only place it is + // exposed by the listing API. + entry.HealthStatus = ContainerService::FormatHealthStatus(container.Status); entry.ID = truncate ? TruncateId(container.Id) : container.Id; entry.Image = container.Image; entry.Labels = container.Labels; entry.LocalVolumes = std::to_string(container.LocalVolumes); - entry.Mounts = container.Mounts; + entry.Mounts = WideToMultiByte(ContainerService::FormatMounts(container.Mounts, truncate)); entry.Names = container.Name; entry.Networks = container.Networks; entry.Platform.architecture = wsl::shared::Arm64 ? "arm64" : "amd64"; diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index 3ae82b0907..30cf75e52b 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -79,13 +79,15 @@ void ValidateNewSessionStorageDirectory(const std::filesystem::path& StoragePath THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcSessionStorageMustBeEmpty(StoragePath.c_str()), !empty); } -// Copies a string into a fixed size buffer, truncating rather than failing when the source doesn't -// fit. Only a genuine copy failure is reported as an error. -template -void CopyTruncated(char (&Destination)[Size], const std::string& Source) +// Frees the caller-owned strings on a container entry. Safe to call on a partially populated entry +// because unset fields are null. +void FreeContainerEntryStrings(WSLCContainerEntry& Entry) { - const auto result = strncpy_s(Destination, Size, Source.c_str(), _TRUNCATE); - THROW_HR_IF(E_UNEXPECTED, result != 0 && result != STRUNCATE); + CoTaskMemFree(Entry.Command); + CoTaskMemFree(Entry.Status); + CoTaskMemFree(Entry.Labels); + CoTaskMemFree(Entry.Networks); + CoTaskMemFree(Entry.Mounts); } // Group policy: WSLContainerRegistryAllowlist restricts which container-image @@ -2556,6 +2558,13 @@ try // if some IDs returned by Docker aren't in m_containers (e.g. created externally), but in the // common case the two should match. auto output = wil::make_unique_cotaskmem(dockerContainers.size()); + auto freeStrings = wil::scope_exit([&] { + for (size_t i = 0; i < dockerContainers.size(); ++i) + { + FreeContainerEntryStrings(output[i]); + } + }); + std::vector allPorts; size_t index = 0; @@ -2572,13 +2581,13 @@ try THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Name, e->Name().c_str()) != 0); THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Id, e->ID().c_str()) != 0); - // Commands and status descriptions have no bound imposed by the runtime, so truncate them - // rather than failing the listing. - CopyTruncated(output[index].Command, dockerContainer.Command); - CopyTruncated(output[index].Status, dockerContainer.Status); + // Commands and status descriptions have no bound imposed by the runtime, so they are + // allocated rather than copied into a fixed buffer. + output[index].Command = wil::make_unique_ansistring(dockerContainer.Command.c_str()).release(); + output[index].Status = wil::make_unique_ansistring(dockerContainer.Status.c_str()).release(); // Labels, networks and mounts are reported the way the docker CLI renders them: a comma - // separated list. Like the command and status above they are unbounded, so they truncate. + // separated list. Like the command and status above they are unbounded. std::vector labels; for (const auto& [key, value] : dockerContainer.Labels) { @@ -2607,9 +2616,9 @@ try const auto joinedNetworks = wsl::shared::string::Join(networks, ','); const auto joinedMounts = wsl::shared::string::Join(mounts, ','); - CopyTruncated(output[index].Labels, joinedLabels); - CopyTruncated(output[index].Networks, joinedNetworks); - CopyTruncated(output[index].Mounts, joinedMounts); + output[index].Labels = wil::make_unique_ansistring(joinedLabels.c_str()).release(); + output[index].Networks = wil::make_unique_ansistring(joinedNetworks.c_str()).release(); + output[index].Mounts = wil::make_unique_ansistring(joinedMounts.c_str()).release(); output[index].LocalVolumes = localVolumes; e->GetState(&output[index].State); @@ -2632,13 +2641,21 @@ try index++; } + // Finish every allocation before transferring ownership so nothing can throw once the caller + // owns the results. + wil::unique_cotaskmem_ptr portsOutput; + if (!allPorts.empty()) + { + portsOutput = wil::make_unique_cotaskmem(allPorts.size()); + memcpy(portsOutput.get(), allPorts.data(), allPorts.size() * sizeof(WSLCContainerPortMapping)); + } + + freeStrings.release(); *Count = static_cast(index); *Containers = output.release(); - if (!allPorts.empty()) + if (portsOutput) { - auto portsOutput = wil::make_unique_cotaskmem(allPorts.size()); - memcpy(portsOutput.get(), allPorts.data(), allPorts.size() * sizeof(WSLCContainerPortMapping)); *PortsCount = static_cast(allPorts.size()); *Ports = portsOutput.release(); } diff --git a/test/windows/StringUnitTests.cpp b/test/windows/StringUnitTests.cpp index 6a66cd9036..0e01099805 100644 --- a/test/windows/StringUnitTests.cpp +++ b/test/windows/StringUnitTests.cpp @@ -4,6 +4,7 @@ #include "Common.h" #include "string.hpp" +using wsl::windows::common::string::Ellipsis; using wsl::windows::common::string::FormatBytes; using wsl::windows::common::string::FormatDockerSize; using wsl::windows::common::string::FormatStorageSize; @@ -252,6 +253,59 @@ class StringUnitTests VERIFY_ARE_EQUAL(std::wstring{L"1e+03MB"}, FormatDockerSize(999'900'000)); } + // Docker shortens display values with formatter.Ellipsis, which measures terminal columns rather than + // characters so East Asian wide and fullwidth code points count double. + TEST_METHOD(Ellipsis_NarrowCharacters_AreCountedAsOneColumn) + { + VERIFY_ARE_EQUAL(std::wstring{L""}, Ellipsis(L"", 20)); + VERIFY_ARE_EQUAL(std::wstring{L"sleep 3600"}, Ellipsis(L"sleep 3600", 20)); + VERIFY_ARE_EQUAL(std::wstring{L"12345678901234567890"}, Ellipsis(L"12345678901234567890", 20)); + VERIFY_ARE_EQUAL(std::wstring{L"1234567890123456789\u2026"}, Ellipsis(L"123456789012345678901", 20)); + VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0') + L"\u2026", Ellipsis(std::wstring(21, L'\u00E0'), 20)); + } + + TEST_METHOD(Ellipsis_WideCharacters_AreCountedAsTwoColumns) + { + // Ten wide characters fill the twenty columns exactly, so an eleventh forces the value to be shortened + // to the nine characters that leave room for the ellipsis. + VERIFY_ARE_EQUAL(std::wstring(10, L'\u65E5'), Ellipsis(std::wstring(10, L'\u65E5'), 20)); + VERIFY_ARE_EQUAL(std::wstring(9, L'\u65E5') + L"\u2026", Ellipsis(std::wstring(11, L'\u65E5'), 20)); + VERIFY_ARE_EQUAL(std::wstring(9, L'\uFF21') + L"\u2026", Ellipsis(std::wstring(11, L'\uFF21'), 20)); + VERIFY_ARE_EQUAL(std::wstring{L"ab"} + std::wstring(8, L'\u65E5') + L"\u2026", Ellipsis(L"ab" + std::wstring(10, L'\u65E5'), 20)); + } + + TEST_METHOD(Ellipsis_SurrogatePairs_AreNotSplit) + { + // Emoji are wide and encoded as surrogate pairs, so both the column count and the code unit boundary + // have to be honored. + const std::wstring emoji{L"\U0001F600"}; + std::wstring ten; + for (size_t index = 0; index < 10; ++index) + { + ten += emoji; + } + + VERIFY_ARE_EQUAL(ten, Ellipsis(ten, 20)); + VERIFY_ARE_EQUAL(ten.substr(0, 18) + L"\u2026", Ellipsis(ten + emoji, 20)); + } + + TEST_METHOD(Ellipsis_SmallWidths_MatchDocker) + { + VERIFY_ARE_EQUAL(std::wstring{L""}, Ellipsis(L"abc", 0)); + + // A width of one has no room for both content and an ellipsis, so the leading code point is kept even + // when it is wider than the limit. + VERIFY_ARE_EQUAL(std::wstring{L"a"}, Ellipsis(L"abc", 1)); + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5"}, Ellipsis(L"\u65E5\u65E5", 1)); + VERIFY_ARE_EQUAL(std::wstring{L"\U0001F600"}, Ellipsis(L"\U0001F600\U0001F600", 1)); + + // A leading wide character leaves no room for the ellipsis at a width of two, and docker returns the + // value untouched in that case. + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5\u65E5"}, Ellipsis(L"\u65E5\u65E5", 2)); + VERIFY_ARE_EQUAL(std::wstring{L"a\u2026"}, Ellipsis(L"abc", 2)); + VERIFY_ARE_EQUAL(std::wstring{L"\u65E5\u2026"}, Ellipsis(L"\u65E5\u65E5", 3)); + } + TEST_METHOD(StorageSize_BytesToTextRoundTrips) { const auto VerifyRoundTrip = [](uint64_t Bytes, StorageSizeUnit Unit, uint32_t DecimalPlaces, bool IncludeSpace = false) { diff --git a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp index 06bb821425..eaf6f61776 100644 --- a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp +++ b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp @@ -74,7 +74,7 @@ class WSLCCLIContainerCommandUnitTests VERIFY_ARE_EQUAL(std::wstring{L"\"c:\\\\temp\""}, Truncated("c:\\temp")); } - TEST_METHOD(FormatCommand_MultiByteCharacters_CountedAsSingleCodePoints) + TEST_METHOD(FormatCommand_NarrowMultiByteCharacters_CountedAsOneColumn) { const std::string accented = "\xC3\xA0"; std::string twenty; @@ -87,6 +87,119 @@ class WSLCCLIContainerCommandUnitTests VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0').insert(0, L"\"") + L"\u2026\"", Truncated(twenty + accented)); } + TEST_METHOD(FormatCommand_WideCharacters_CountedAsTwoColumns) + { + // Docker measures display columns, so an East Asian wide character consumes two of the twenty + // available columns and only ten of them fit. + const std::string wide = "\xE6\x97\xA5"; + std::string ten; + for (int i = 0; i < 10; ++i) + { + ten += wide; + } + + VERIFY_ARE_EQUAL(std::wstring(10, L'\u65E5').insert(0, L"\"") + L"\"", Truncated(ten)); + VERIFY_ARE_EQUAL(std::wstring(9, L'\u65E5').insert(0, L"\"") + L"\u2026\"", Truncated(ten + wide)); + } + + TEST_METHOD(FormatCommand_MixedWidthCharacters_AreShortenedByColumn) + { + // Two narrow characters leave eighteen columns, so eight wide characters fit before the ellipsis. + const std::string wide = "\xE6\x97\xA5"; + std::string command = "ab"; + for (int i = 0; i < 10; ++i) + { + command += wide; + } + + VERIFY_ARE_EQUAL(std::wstring{L"\"ab"} + std::wstring(8, L'\u65E5') + L"\u2026\"", Truncated(command)); + } + + TEST_METHOD(FormatCommand_SurrogatePairs_AreNotSplit) + { + // Emoji are wide and encoded as surrogate pairs, so a shortened value has to stop on a code point + // boundary as well as a column boundary. + const std::string emoji = "\xF0\x9F\x98\x80"; + std::string ten; + std::wstring expected; + for (int i = 0; i < 10; ++i) + { + ten += emoji; + expected += L"\U0001F600"; + } + + VERIFY_ARE_EQUAL(L"\"" + expected + L"\"", Truncated(ten)); + VERIFY_ARE_EQUAL(L"\"" + expected.substr(0, 18) + L"\u2026\"", Truncated(ten + emoji)); + } + + TEST_METHOD(FormatCommand_ControlCharacters_AreEscaped) + { + // Docker quotes this field with Go's strconv.Quote, which renders control characters as escape sequences + // rather than emitting them raw and breaking the table row. + VERIFY_ARE_EQUAL(std::wstring{L"\"line1\\nline2\""}, Truncated("line1\nline2")); + VERIFY_ARE_EQUAL(std::wstring{L"\"col1\\tcol2\""}, Truncated("col1\tcol2")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\rb\""}, Truncated("a\rb")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\vb\""}, Truncated("a\vb")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\fb\""}, Truncated("a\fb")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\bb\""}, Truncated("a\bb")); + VERIFY_ARE_EQUAL(std::wstring{L"\"a\\ab\""}, Truncated("a\ab")); + } + + TEST_METHOD(FormatCommand_NonPrintableCharacters_AreEscapedAsHex) + { + // Control characters without a dedicated escape use \x, matching strconv.Quote. + VERIFY_ARE_EQUAL( + std::wstring{L"\"a\\x1bb\""}, + Truncated("a\x1b" + "b")); + VERIFY_ARE_EQUAL( + std::wstring{L"\"a\\x7fb\""}, + Truncated("a\x7f" + "b")); + } + + TEST_METHOD(FormatCommand_PrintableUnicode_IsNotEscaped) + { + // Letters and symbols stay verbatim, so only genuinely unprintable values are expanded. + VERIFY_ARE_EQUAL(std::wstring{L"\"caf\u00E9\""}, Truncated("caf\xC3\xA9")); + } + + TEST_METHOD(FormatCommand_EscapesAreCountedAfterTruncation) + { + // Docker truncates before quoting, so escape expansion does not consume the display budget and the + // quoted result is wider than the limit. + VERIFY_ARE_EQUAL(std::wstring{L"\"\\n\\n\\n\\n\\n\""}, Truncated("\n\n\n\n\n")); + } + + TEST_METHOD(FormatMounts_LongNames_AreShortenedIndependently) + { + // Docker shortens every mount name to fifteen columns rather than the list as a whole + // (ContainerContext.Mounts in cli/command/formatter/container.go). + const auto mounts = "/var/lib/docker/volumes/data,logs,/mnt/c/users/test/source"; + VERIFY_ARE_EQUAL(std::wstring{L"/var/lib/docke\u2026,logs,/mnt/c/users/t\u2026"}, ContainerService::FormatMounts(mounts, true)); + VERIFY_ARE_EQUAL(wsl::shared::string::MultiByteToWide(mounts), ContainerService::FormatMounts(mounts, false)); + } + + TEST_METHOD(FormatMounts_ShortNames_AreUnchanged) + { + VERIFY_ARE_EQUAL(std::wstring{L""}, ContainerService::FormatMounts("", true)); + VERIFY_ARE_EQUAL(std::wstring{L"data-volume"}, ContainerService::FormatMounts("data-volume", true)); + VERIFY_ARE_EQUAL(std::wstring{L"123456789012345"}, ContainerService::FormatMounts("123456789012345", true)); + VERIFY_ARE_EQUAL(std::wstring{L"12345678901234\u2026"}, ContainerService::FormatMounts("1234567890123456", true)); + } + + TEST_METHOD(FormatMounts_WideCharacters_CountedAsTwoColumns) + { + // Seven wide characters occupy fourteen columns, so an eighth exceeds the fifteen column budget. + std::string wide; + for (int i = 0; i < 8; ++i) + { + wide += "\xE6\x97\xA5"; + } + + VERIFY_ARE_EQUAL(std::wstring(7, L'\u65E5') + L"\u2026", ContainerService::FormatMounts(wide, true)); + } + TEST_METHOD(FormatStatus_RuntimeStatus_IsPreferred) { VERIFY_ARE_EQUAL(std::wstring{L"Up 5 minutes"}, ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0)); @@ -96,6 +209,39 @@ class WSLCCLIContainerCommandUnitTests { VERIFY_ARE_EQUAL(std::wstring{L"created"}, ContainerService::FormatStatus("", WslcContainerStateCreated, 0)); } + + TEST_METHOD(FormatHealthStatus_Healthy_IsExtracted) + { + VERIFY_ARE_EQUAL(std::string{"healthy"}, ContainerService::FormatHealthStatus("Up 2 minutes (healthy)")); + } + + TEST_METHOD(FormatHealthStatus_Unhealthy_IsExtracted) + { + VERIFY_ARE_EQUAL(std::string{"unhealthy"}, ContainerService::FormatHealthStatus("Up 2 minutes (unhealthy)")); + } + + TEST_METHOD(FormatHealthStatus_Starting_DropsHealthPrefix) + { + VERIFY_ARE_EQUAL(std::string{"starting"}, ContainerService::FormatHealthStatus("Up 2 seconds (health: starting)")); + } + + TEST_METHOD(FormatHealthStatus_NoHealthCheck_IsEmpty) + { + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes")); + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("")); + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Created")); + } + + TEST_METHOD(FormatHealthStatus_NoneIsNotReported) + { + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes (none)")); + } + + TEST_METHOD(FormatHealthStatus_UnrelatedParentheses_AreIgnored) + { + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Exited (0) 8 days ago")); + VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes (Paused)")); + } }; } // namespace WSLCCLIContainerCommandUnitTests From 1a3a93247e7e544e350b8ddc22864b934429a154 Mon Sep 17 00:00:00 2001 From: Gavin Garzia Date: Fri, 21 Aug 2026 10:43:15 -0700 Subject: [PATCH 8/8] wslc: free container entry strings via RAII array deleter to fix leak Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/windows/common/CMakeLists.txt | 1 + src/windows/common/WSLCContainerEntry.h | 28 +++++++++++++++++++ .../wslc/services/ContainerService.cpp | 15 ++-------- src/windows/wslc/tasks/ContainerTasks.cpp | 2 +- src/windows/wslcsession/WSLCSession.cpp | 7 ++--- test/windows/WSLCTests.cpp | 21 +++++++------- 6 files changed, 45 insertions(+), 29 deletions(-) create mode 100644 src/windows/common/WSLCContainerEntry.h diff --git a/src/windows/common/CMakeLists.txt b/src/windows/common/CMakeLists.txt index 97fb2dfd8e..57d4e7307c 100644 --- a/src/windows/common/CMakeLists.txt +++ b/src/windows/common/CMakeLists.txt @@ -128,6 +128,7 @@ set(HEADERS VTSupport.h WindowsUpdateIntegration.h WSLCContainerLauncher.h + WSLCContainerEntry.h ConsommeNetworking.h WSLCProcessLauncher.h WslClient.h diff --git a/src/windows/common/WSLCContainerEntry.h b/src/windows/common/WSLCContainerEntry.h new file mode 100644 index 0000000000..5bee0fc4bb --- /dev/null +++ b/src/windows/common/WSLCContainerEntry.h @@ -0,0 +1,28 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +#pragma once + +#include +#include "wslc.h" + +namespace wsl::windows::common::wslc { + +// IWSLCSession::ListContainers allocates a string for every unbounded field on an entry, so each +// element owns memory that releasing the array alone would miss. +struct ContainerEntryDeleter +{ + void operator()(WSLCContainerEntry& Entry) const + { + CoTaskMemFree(Entry.Command); + CoTaskMemFree(Entry.Status); + CoTaskMemFree(Entry.Labels); + CoTaskMemFree(Entry.Networks); + CoTaskMemFree(Entry.Mounts); + } +}; + +// Owns both the entry array and the per-entry strings. Callers of ListContainers should use this +// rather than a plain cotaskmem array so the strings cannot be leaked. +using unique_container_entry_array = wil::unique_any_array_ptr; + +} // namespace wsl::windows::common::wslc diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index ccafaac984..2d2dfda9ec 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -21,6 +21,7 @@ Module Name: #include #include #include +#include #include #include #include @@ -770,25 +771,13 @@ std::vector ContainerService::List( options.Filters = filterEntries.data(); options.FiltersCount = static_cast(filterEntries.size()); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; THROW_IF_FAILED( session.Get()->ListContainers(&options, &containers, containers.size_address(), &ports, ports.size_address())); std::vector result; - // The listing allocates these fields, so take ownership of each one as it is read. - const auto freeStrings = wil::scope_exit([&] { - for (auto& current : containers) - { - CoTaskMemFree(current.Command); - CoTaskMemFree(current.Status); - CoTaskMemFree(current.Labels); - CoTaskMemFree(current.Networks); - CoTaskMemFree(current.Mounts); - } - }); - for (const auto& current : containers) { ContainerInformation entry; diff --git a/src/windows/wslc/tasks/ContainerTasks.cpp b/src/windows/wslc/tasks/ContainerTasks.cpp index 6d98d6fb14..d3b1f517b6 100644 --- a/src/windows/wslc/tasks/ContainerTasks.cpp +++ b/src/windows/wslc/tasks/ContainerTasks.cpp @@ -137,7 +137,7 @@ ContainerOutputInformation ToContainerOutput(const ContainerInformation& contain entry.Ports = WideToMultiByte(ContainerService::FormatPorts(container.State, container.Ports)); entry.RunningFor = WideToMultiByte(ContainerService::FormatRelativeTime(container.CreatedAt)); // Container sizes are only computed when docker is passed --size, which wslc does not support. - entry.Size = WideToMultiByte(FormatDockerSize(0)); + entry.Size = WideToMultiByte(FormatHumanReadableSize(0)); entry.State = WideToMultiByte(ContainerService::ContainerStateName(container.State)); entry.Status = WideToMultiByte(ContainerService::FormatStatus(container.Status, container.State, container.StateChangedAt)); diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index de96de45d6..fc0b61aeb1 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -24,6 +24,7 @@ Module Name: #include "WSLCSessionDefaults.h" #include "wslpolicies.h" #include "APICompat.h" +#include "WSLCContainerEntry.h" using namespace wsl::windows::common; using io::MultiHandleWait; @@ -83,11 +84,7 @@ void ValidateNewSessionStorageDirectory(const std::filesystem::path& StoragePath // because unset fields are null. void FreeContainerEntryStrings(WSLCContainerEntry& Entry) { - CoTaskMemFree(Entry.Command); - CoTaskMemFree(Entry.Status); - CoTaskMemFree(Entry.Labels); - CoTaskMemFree(Entry.Networks); - CoTaskMemFree(Entry.Mounts); + wsl::windows::common::wslc::ContainerEntryDeleter{}(Entry); } // Group policy: WSLContainerRegistryAllowlist restricts which container-image diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 4e0ea24d15..e997d7d352 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -18,6 +18,7 @@ Module Name: #include "wslccompat.h" #include "WSLCProcessLauncher.h" #include "WSLCContainerLauncher.h" +#include "WSLCContainerEntry.h" #include "WslCoreFilesystem.h" #include "hcs.hpp" #include "ContainerNameGenerator.h" @@ -208,7 +209,7 @@ class WSLCTests struct ListContainersResult { - wil::unique_cotaskmem_array_ptr Containers; + wsl::windows::common::wslc::unique_container_entry_array Containers; wil::unique_cotaskmem_array_ptr Ports; }; @@ -7161,7 +7162,7 @@ class WSLCTests options.Filters = filters.data(); options.FiltersCount = static_cast(filters.size()); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( &options, &containers, containers.size_address(), &ports, ports.size_address())); @@ -7246,7 +7247,7 @@ class WSLCTests options.Flags = WSLCListContainersFlagsAll; options.Limit = 1; - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( &options, &containers, containers.size_address(), &ports, ports.size_address())); @@ -7317,7 +7318,7 @@ class WSLCTests WSLCListContainersOptions options{}; options.Flags = WSLCListContainersFlagsAll; - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; HRESULT hrList = m_defaultSession->ListContainers( &options, &containers, containers.size_address(), &ports, ports.size_address()); @@ -8988,7 +8989,7 @@ class WSLCTests // Verify that ListContainers returns the port data for a running container. { - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(session.ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -9033,7 +9034,7 @@ class WSLCTests auto createdContainer = createdLauncher.Create(session); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(session.ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -9060,7 +9061,7 @@ class WSLCTests // Verify that a stopped container returns no ports. VERIFY_SUCCEEDED(container.Get().Stop(WSLCSignalSIGKILL, 0)); { - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(session.ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -11740,7 +11741,7 @@ class WSLCTests VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer("test-auto-remove", ¬Found), WSLC_E_CONTAINER_NOT_FOUND); VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer(id.c_str(), ¬Found), WSLC_E_CONTAINER_NOT_FOUND); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -11775,7 +11776,7 @@ class WSLCTests wil::com_ptr notFound; VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer("test-auto-remove-stdout", ¬Found), WSLC_E_CONTAINER_NOT_FOUND); - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address())); @@ -11906,7 +11907,7 @@ class WSLCTests // Validate that various operations can be done while the export is in progress. { - wil::unique_cotaskmem_array_ptr containers; + wsl::windows::common::wslc::unique_container_entry_array containers; wil::unique_cotaskmem_array_ptr ports; VERIFY_SUCCEEDED(m_defaultSession->ListContainers( nullptr, &containers, containers.size_address(), &ports, ports.size_address()));