diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw
index e75120b62..00bc92dee 100644
--- a/localization/strings/en-US/Resources.resw
+++ b/localization/strings/en-US/Resources.resw
@@ -3674,6 +3674,12 @@ On first run, creates the file with all settings commented out at their defaults
NAME
+
+ NAMES
+
+
+ COMMAND
+
IMAGE
@@ -3704,6 +3710,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/common/CMakeLists.txt b/src/windows/common/CMakeLists.txt
index 97fb2dfd8..57d4e7307 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 000000000..5bee0fc4b
--- /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/common/string.cpp b/src/windows/common/string.cpp
index c3b8f954a..ab9881d93 100644
--- a/src/windows/common/string.cpp
+++ b/src/windows/common/string.cpp
@@ -426,6 +426,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::uint64_t wsl::windows::common::string::Rfc3339ToEpoch(const std::string& timestamp)
{
std::chrono::sys_seconds utcSeconds;
diff --git a/src/windows/common/string.hpp b/src/windows/common/string.hpp
index 2a5c7403c..0a665892d 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);
+
// Converts an RFC 3339 timestamp to seconds since the unix epoch. Only the 'Z' zone designator is
// accepted; numeric offsets are not.
std::uint64_t Rfc3339ToEpoch(const std::string& timestamp);
diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h
index 256fe0646..57911166e 100644
--- a/src/windows/inc/docker_schema.h
+++ b/src/windows/inc/docker_schema.h
@@ -771,6 +771,8 @@ struct ContainerInfo
std::vector Names;
std::string Image;
std::string ImageID;
+ std::string Command;
+ std::string Status;
std::map Labels;
std::vector Ports;
std::vector Mounts;
@@ -779,7 +781,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 1bd1fce68..b1b599c37 100644
--- a/src/windows/service/inc/wslc.idl
+++ b/src/windows/service/inc/wslc.idl
@@ -373,9 +373,17 @@ typedef struct _WSLCContainerEntry
{
char Name[WSLC_MAX_CONTAINER_NAME_LENGTH + 1];
char Image[WSLC_MAX_IMAGE_NAME_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;
+ ULONG LocalVolumes;
WSLCContainerState State;
} WSLCContainerEntry;
diff --git a/src/windows/wslc/services/ContainerModel.h b/src/windows/wslc/services/ContainerModel.h
index 86c27e6ce..7defbfbb2 100644
--- a/src/windows/wslc/services/ContainerModel.h
+++ b/src/windows/wslc/services/ContainerModel.h
@@ -134,12 +134,50 @@ struct ContainerInformation
std::string Id;
std::string Name;
std::string Image;
+ // Command and runtime supplied status description.
+ std::string Command;
+ std::string Status;
+ std::string Labels;
+ std::string Networks;
+ std::string Mounts;
+ ULONG LocalVolumes{};
WSLCContainerState State;
ULONGLONG StateChangedAt{};
ULONGLONG CreatedAt{};
std::vector 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(ContainerInformation, Id, Name, Image, State, StateChangedAt, CreatedAt, Ports);
+ NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerPlatform, architecture, os);
+};
+
+// The shape emitted by "container list --format json".
+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
diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp
index 06d30c655..2d2dfda9e 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
@@ -408,30 +409,49 @@ 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.
+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);
}
+}
+
+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);
+ }
+}
- if (stateChangedAt == 0)
+std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, ULONGLONG stateChangedAt)
+{
+ auto stateString = LocalizedContainerStateName(state);
+ if (stateChangedAt == 0 || state == WSLCContainerState::WslcContainerStateInvalid)
{
return stateString;
}
@@ -439,6 +459,154 @@ 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;
+
+ auto wide = wsl::shared::string::MultiByteToWide(command);
+ if (truncate)
+ {
+ 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 (int32_t index = 0; index < length;)
+ {
+ 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 += 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())
+ {
+ return wsl::shared::string::MultiByteToWide(status);
+ }
+
+ 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())
@@ -603,7 +771,7 @@ 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()));
@@ -615,6 +783,12 @@ std::vector ContainerService::List(
ContainerInformation entry;
entry.Name = current.Name;
entry.Image = current.Image;
+ 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;
entry.StateChangedAt = current.StateChangedAt;
diff --git a/src/windows/wslc/services/ContainerService.h b/src/windows/wslc/services/ContainerService.h
index 09203056d..73bbc4838 100644
--- a/src/windows/wslc/services/ContainerService.h
+++ b/src/windows/wslc/services/ContainerService.h
@@ -23,9 +23,29 @@ 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.
+ 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);
+
+ 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 695fb61c5..d3b1f517b 100644
--- a/src/windows/wslc/tasks/ContainerTasks.cpp
+++ b/src/windows/wslc/tasks/ContainerTasks.cpp
@@ -114,6 +114,36 @@ 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 = EpochToLocalDisplayTime(static_cast(container.CreatedAt));
+ // 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 = WideToMultiByte(ContainerService::FormatMounts(container.Mounts, truncate));
+ 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(FormatHumanReadableSize(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 +554,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,45 +572,48 @@ 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
- 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)
{
+ const auto entry = ToContainerOutput(container, trunc);
table.WriteRow({
- MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id),
- MultiByteToWide(container.Name),
- MultiByteToWide(container.Image),
- ContainerService::FormatRelativeTime(container.CreatedAt),
- ContainerService::ContainerStateToString(container.State, container.StateChangedAt),
- ContainerService::FormatPorts(container.State, container.Ports),
+ 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 31fff7997..fc0b61aeb 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;
@@ -79,6 +80,13 @@ void ValidateNewSessionStorageDirectory(const std::filesystem::path& StoragePath
THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcSessionStorageMustBeEmpty(StoragePath.c_str()), !empty);
}
+// 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)
+{
+ wsl::windows::common::wslc::ContainerEntryDeleter{}(Entry);
+}
+
// 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
@@ -2547,6 +2555,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;
@@ -2562,6 +2577,47 @@ 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 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.
+ 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, ',');
+
+ 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);
e->GetStateChangedAt(&output[index].StateChangedAt);
e->GetCreatedAt(&output[index].CreatedAt);
@@ -2582,13 +2638,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 1d97a3bf0..6a7953af7 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::FormatHumanReadableSize;
using wsl::windows::common::string::FormatStorageSize;
@@ -268,6 +269,59 @@ class StringUnitTests
}
}
+ // 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/WSLCTests.cpp b/test/windows/WSLCTests.cpp
index 4e0ea24d1..e997d7d35 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()));
diff --git a/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp
new file mode 100644
index 000000000..eaf6f6177
--- /dev/null
+++ b/test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp
@@ -0,0 +1,247 @@
+// 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)
+ {
+ // 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_NarrowMultiByteCharacters_CountedAsOneColumn)
+ {
+ 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(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));
+ }
+
+ TEST_METHOD(FormatStatus_EmptyRuntimeStatus_FallsBackToState)
+ {
+ 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
diff --git a/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp
index 6e9c7c880..3d521e6ff 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);
@@ -151,7 +178,12 @@ 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.
+ const auto truncatedId = wsl::shared::string::MultiByteToWide(TruncateId(WideToMultiByte(containerId)));
+ VERIFY_ARE_EQUAL(12u, truncatedId.size());
+ VERIFY_IS_TRUE(result.StdoutContainsLine(truncatedId));
+
+ result = RunWslc(L"container list --all --quiet --no-trunc");
+ result.Verify({.Stderr = L"", .ExitCode = 0});
VERIFY_IS_TRUE(result.StdoutContainsLine(containerId));
}
@@ -174,15 +206,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));
@@ -194,16 +226,74 @@ 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);
+
+ 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");
+
+ 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.
@@ -246,9 +336,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)
@@ -267,11 +357,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;
};
@@ -314,11 +404,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;
};
@@ -358,12 +448,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)
@@ -382,11 +472,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;
};
@@ -421,11 +511,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;
};
@@ -463,9 +553,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.
@@ -473,7 +563,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 8cd2d4daf..cc5ff705e 100644
--- a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp
+++ b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp
@@ -152,6 +152,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())
{
@@ -167,8 +183,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;
}
}
@@ -348,7 +364,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;
@@ -362,11 +378,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)
@@ -377,8 +394,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 421cb3491..1dcc09f51 100644
--- a/test/windows/wslc/e2e/WSLCE2EHelpers.h
+++ b/test/windows/wslc/e2e/WSLCE2EHelpers.h
@@ -144,7 +144,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 DeleteImagesWithRepositoryPrefix(const std::wstring& repositoryPrefix);