From 8eb10225463607ef4637d683c9855a161fe22b17 Mon Sep 17 00:00:00 2001 From: Blue Date: Wed, 25 Mar 2026 17:02:53 -0700 Subject: [PATCH 1/9] Save state --- localization/strings/en-US/Resources.resw | 4 ++ src/windows/common/wslutil.cpp | 70 +++++++++++++++++++---- src/windows/common/wslutil.h | 1 + test/windows/WSLATests.cpp | 13 +++++ 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index 7a9564335..7ae415608 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -2074,6 +2074,10 @@ For privacy information about this product please visit https://aka.ms/privacy.< Invalid image: '{}' {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + Invalid image repo: '{}' + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + Invalid name: '{}' {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp index a568db57f..702da4fd4 100644 --- a/src/windows/common/wslutil.cpp +++ b/src/windows/common/wslutil.cpp @@ -255,26 +255,47 @@ constexpr GUID EndianSwap(GUID value) return value; } -std::regex BuildImageReferenceRegex() +std::string Capture(const auto& exp) { - // See: https://github.com/containers/image/blob/main/docker/reference/regexp.go + return std::format("({})", exp); +} + +std::string Optional(const auto& exp) +{ + return Group(exp) + "?"; +} + +std::string Repeated(const auto& exp) +{ + return Group(exp) + "+"; +} + +std::string Group(const auto& exp) +{ + return std::format("(?:{})", exp); +} +std::string BuildRepoRegex(bool captureDomain) +{ std::string alphaNum = "[a-z0-9]+"; std::string separator = "(?:[._]|__|[-]*)"; std::string domainComponent = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"; - std::string tag = "[\\w][\\w.-]{0,127}"; - std::string digest = "[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}"; - auto group = [](const auto& exp) { return std::format("(?:{})", exp); }; - auto optional = [&group](const auto& exp) { return group(exp) + "?"; }; - auto repeated = [&group](const auto& exp) { return group(exp) + "+"; }; - auto capture = [](const auto& exp) { return std::format("({})", exp); }; + auto nameComponent = alphaNum + Optional(Repeated(separator + alphaNum)); + auto domain = Optional(domainComponent + Optional(Repeated("\\." + domainComponent)) + Optional(":[0-9]+") + "\\/"); + auto namePat = (captureDomain ? Capture(domain) : domain) + nameComponent + Optional(Repeated("\\/" + nameComponent)); - auto nameComponent = alphaNum + optional(repeated(separator + alphaNum)); - auto domain = domainComponent + optional(repeated("\\." + domainComponent)) + optional(":[0-9]+"); - auto namePat = optional(domain + "\\/") + nameComponent + optional(repeated("\\/" + nameComponent)); + return namePat; +} + +std::regex BuildImageReferenceRegex() +{ + // See: https://github.com/containers/image/blob/main/docker/reference/regexp.go - return std::regex("^" + capture(namePat) + optional(":" + capture(tag)) + optional("@" + capture(digest)) + "$"); + std::string tag = "[\\w][\\w.-]{0,127}"; + std::string digest = "[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}"; + + return std::regex("^" + Capture(BuildRepoRegex(false)) + Optional(":" + Capture(tag)) + Optional("@" + Capture(digest)) + "$"); } } // namespace @@ -1216,6 +1237,31 @@ std::pair> wsl::windows::common::wslutil } } +std::pair, std::string> wsl::windows::common::wslutil::ParseRepo(const std::string& Input) +{ + static const auto regex = std::regex(BuildRepoRegex(true)); + + std::smatch match; + if (!std::regex_match(Input, match, regex)) + { + THROW_HR_WITH_USER_ERROR(E_INVALIDARG, wsl::shared::Localization::MessageWslaInvalidImageRepo(Input.c_str())); + } + + const auto& server = match[1]; + const auto& path = match[2]; + + THROW_HR_IF_MSG(E_UNEXPECTED, !path.matched, "Unexpected regex match. Input: %hs", Input.c_str()); + + if (server.matched) + { + return {server.str(), path.str()}; + } + else + { + return {std::nullopt, path.str()}; + } +} + void wsl::windows::common::wslutil::PrintSystemError(_In_ HRESULT result, _Inout_ FILE* const stream) { fwprintf(stream, L"%ls\n", GetSystemErrorString(result).c_str()); diff --git a/src/windows/common/wslutil.h b/src/windows/common/wslutil.h index 52459b0c1..8e6605a75 100644 --- a/src/windows/common/wslutil.h +++ b/src/windows/common/wslutil.h @@ -225,6 +225,7 @@ void ParseIpv6Address(const char* Address, in_addr6& Result); std::tuple ParseWslPackageVersion(_In_ const std::wstring& Version); std::pair> ParseImage(const std::string& Input); +std::pair, std::string> ParseRepo(const std::string& Input); void PrintSystemError(_In_ HRESULT result, _Inout_ FILE* stream = stdout); diff --git a/test/windows/WSLATests.cpp b/test/windows/WSLATests.cpp index 534b4d663..a72d71530 100644 --- a/test/windows/WSLATests.cpp +++ b/test/windows/WSLATests.cpp @@ -5994,4 +5994,17 @@ class WSLATests VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseImage("a:"); }), E_INVALIDARG); VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseImage(":b"); }), E_INVALIDARG); } + + TEST_METHOD(RepoParsing) + { + using wsl::windows::common::wslutil::ParseRepo; + + auto ValidateRepoParsing = [](const std::string& input, const std::optional& expectedServer, const std::string& expectedPath) { + auto [server, path] = ParseRepo(input); + VERIFY_ARE_EQUAL(server.value_or(""), expectedServer.value_or("")); + VERIFY_ARE_EQUAL(path, expectedPath); + }; + + ValidateRepoParsing("ubuntu", {}, "ubuntu"); + } }; From da348e7e2cb79ef7ad25e0183f150ecdac89d597 Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 26 Mar 2026 11:46:57 -0700 Subject: [PATCH 2/9] Save state --- src/windows/common/wslutil.cpp | 44 +++++++++++++--------------------- test/windows/WSLATests.cpp | 14 +++++++++++ 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp index 702da4fd4..47b984746 100644 --- a/src/windows/common/wslutil.cpp +++ b/src/windows/common/wslutil.cpp @@ -260,6 +260,11 @@ std::string Capture(const auto& exp) return std::format("({})", exp); } +std::string Group(const auto& exp) +{ + return std::format("(?:{})", exp); +} + std::string Optional(const auto& exp) { return Group(exp) + "?"; @@ -270,12 +275,7 @@ std::string Repeated(const auto& exp) return Group(exp) + "+"; } -std::string Group(const auto& exp) -{ - return std::format("(?:{})", exp); -} - -std::string BuildRepoRegex(bool captureDomain) +std::string BuildRepoRegex() { std::string alphaNum = "[a-z0-9]+"; std::string separator = "(?:[._]|__|[-]*)"; @@ -283,7 +283,7 @@ std::string BuildRepoRegex(bool captureDomain) auto nameComponent = alphaNum + Optional(Repeated(separator + alphaNum)); auto domain = Optional(domainComponent + Optional(Repeated("\\." + domainComponent)) + Optional(":[0-9]+") + "\\/"); - auto namePat = (captureDomain ? Capture(domain) : domain) + nameComponent + Optional(Repeated("\\/" + nameComponent)); + auto namePat = Capture(domain) + Capture(nameComponent + Optional(Repeated("\\/" + nameComponent))); return namePat; } @@ -295,7 +295,7 @@ std::regex BuildImageReferenceRegex() std::string tag = "[\\w][\\w.-]{0,127}"; std::string digest = "[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}"; - return std::regex("^" + Capture(BuildRepoRegex(false)) + Optional(":" + Capture(tag)) + Optional("@" + Capture(digest)) + "$"); + return std::regex("^" + Capture(BuildRepoRegex()) + Optional(":" + Capture(tag)) + Optional("@" + Capture(digest)) + "$"); } } // namespace @@ -1218,8 +1218,8 @@ std::pair> wsl::windows::common::wslutil } const auto& repo = match[1]; - const auto& tag = match[2]; - const auto& digest = match[3]; + const auto& tag = match[3]; + const auto& digest = match[5]; THROW_HR_IF_MSG(E_UNEXPECTED, !repo.matched, "Unexpected regex match. Input: %hs", Input.c_str()); @@ -1237,29 +1237,17 @@ std::pair> wsl::windows::common::wslutil } } -std::pair, std::string> wsl::windows::common::wslutil::ParseRepo(const std::string& Input) +std::pair wsl::windows::common::wslutil::CanonicalizeImageReference(const std::string& Input) { - static const auto regex = std::regex(BuildRepoRegex(true)); + constexpr auto defaultRegistry = "docker.io"; - std::smatch match; - if (!std::regex_match(Input, match, regex)) + auto firstSlash = Input.find('/'); + if (firstSlash == std::string::npos) { - THROW_HR_WITH_USER_ERROR(E_INVALIDARG, wsl::shared::Localization::MessageWslaInvalidImageRepo(Input.c_str())); + return {defaultRegistry, Input}; } - const auto& server = match[1]; - const auto& path = match[2]; - - THROW_HR_IF_MSG(E_UNEXPECTED, !path.matched, "Unexpected regex match. Input: %hs", Input.c_str()); - - if (server.matched) - { - return {server.str(), path.str()}; - } - else - { - return {std::nullopt, path.str()}; - } + auto registry = Input.substr(0, firstSlash); } void wsl::windows::common::wslutil::PrintSystemError(_In_ HRESULT result, _Inout_ FILE* const stream) diff --git a/test/windows/WSLATests.cpp b/test/windows/WSLATests.cpp index a72d71530..c26a839ce 100644 --- a/test/windows/WSLATests.cpp +++ b/test/windows/WSLATests.cpp @@ -5987,6 +5987,7 @@ class WSLATests ValidateImageParsing("pytorch/pytorch", "pytorch/pytorch", {}); // Invalid inputs + VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseImage(""); }), E_INVALIDARG); VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseImage(":debian:latest"); }), E_INVALIDARG); VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseImage("debian:latest@"); }), E_INVALIDARG); VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseImage(""); }), E_INVALIDARG); @@ -6006,5 +6007,18 @@ class WSLATests }; ValidateRepoParsing("ubuntu", {}, "ubuntu"); + ValidateRepoParsing("microsoft.com/ubuntu", {"microsoft.com"}, "ubuntu"); + ValidateRepoParsing("microsoft.com:80/ubuntu", {"microsoft.com:80"}, "ubuntu"); + ValidateRepoParsing("microsoft.com:80/ubuntu/foo/bar", {"microsoft.com:80"}, "ubuntu/foo/bar"); + ValidateRepoParsing("127.0.0.1:80/ubuntu/foo/bar", {"127.0.0.1:80"}, "ubuntu/foo/bar"); + ValidateRepoParsing("pytorch/pytorch", {}, "pytorch/pytorch"); + ValidateRepoParsing("2001:0db8:85a3:0000:0000:8a2e:0370:7334/path", {"2001:0db8:85a3:0000:0000:8a2e:0370:7334"}, "pytorch/pytorch"); + ValidateRepoParsing("2001:0db8:85a3:0000:0000:8a2e:0370:7334:80/path", {"2001:0db8:85a3:0000:0000:8a2e:0370:7334:80"}, "pytorch/pytorch"); + + + VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseRepo(""); }), E_INVALIDARG); + VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseRepo(":"); }), E_INVALIDARG); + VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseRepo("foo:bar"); }), E_INVALIDARG); + } }; From ed648984f8d844f70c97494f2963d65fbf284cbc Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 26 Mar 2026 17:00:23 -0700 Subject: [PATCH 3/9] Handle issues during pull --- src/windows/common/wslutil.cpp | 94 ++++++++++---------- src/windows/common/wslutil.h | 3 +- src/windows/inc/docker_schema.h | 3 +- src/windows/wslasession/DockerHTTPClient.cpp | 5 +- src/windows/wslasession/WSLASession.cpp | 46 +++++++--- test/windows/WSLATests.cpp | 41 ++++----- 6 files changed, 103 insertions(+), 89 deletions(-) diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp index 47b984746..7846f24d4 100644 --- a/src/windows/common/wslutil.cpp +++ b/src/windows/common/wslutil.cpp @@ -255,47 +255,26 @@ constexpr GUID EndianSwap(GUID value) return value; } -std::string Capture(const auto& exp) -{ - return std::format("({})", exp); -} - -std::string Group(const auto& exp) -{ - return std::format("(?:{})", exp); -} - -std::string Optional(const auto& exp) -{ - return Group(exp) + "?"; -} - -std::string Repeated(const auto& exp) +std::regex BuildImageReferenceRegex() { - return Group(exp) + "+"; -} + // See: https://github.com/containers/image/blob/main/docker/reference/regexp.go -std::string BuildRepoRegex() -{ std::string alphaNum = "[a-z0-9]+"; std::string separator = "(?:[._]|__|[-]*)"; std::string domainComponent = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"; - - auto nameComponent = alphaNum + Optional(Repeated(separator + alphaNum)); - auto domain = Optional(domainComponent + Optional(Repeated("\\." + domainComponent)) + Optional(":[0-9]+") + "\\/"); - auto namePat = Capture(domain) + Capture(nameComponent + Optional(Repeated("\\/" + nameComponent))); - - return namePat; -} - -std::regex BuildImageReferenceRegex() -{ - // See: https://github.com/containers/image/blob/main/docker/reference/regexp.go - std::string tag = "[\\w][\\w.-]{0,127}"; std::string digest = "[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][[:xdigit:]]{32,}"; - return std::regex("^" + Capture(BuildRepoRegex()) + Optional(":" + Capture(tag)) + Optional("@" + Capture(digest)) + "$"); + auto group = [](const auto& exp) { return std::format("(?:{})", exp); }; + auto optional = [&group](const auto& exp) { return group(exp) + "?"; }; + auto repeated = [&group](const auto& exp) { return group(exp) + "+"; }; + auto capture = [](const auto& exp) { return std::format("({})", exp); }; + + auto nameComponent = alphaNum + optional(repeated(separator + alphaNum)); + auto domain = domainComponent + optional(repeated("\\." + domainComponent)) + optional(":[0-9]+"); + auto namePat = optional(domain + "\\/") + nameComponent + optional(repeated("\\/" + nameComponent)); + + return std::regex("^" + capture(namePat) + optional(":" + capture(tag)) + optional("@" + capture(digest)) + "$"); } } // namespace @@ -1094,6 +1073,38 @@ std::vector wsl::windows::common::wslutil::ListRunningProcesses() return pids; } +std::pair wsl::windows::common::wslutil::NormalizeRepo(const std::string& Input) +{ + // See: https://github.com/distribution/reference/blob/ff14fafe2236e51c2894ac07d4bdfc778e96d682/normalize.go#L126 + + constexpr auto defaultDomain = "docker.io"; + constexpr auto officialPrefix = "library/"; + constexpr auto legacyDomain = "index.docker.io"; + constexpr auto localhost = "localhost"; + + auto slash = Input.find('/'); + if (slash == std::string::npos) + { + return {defaultDomain, officialPrefix + Input}; + } + + auto domain = Input.substr(0, slash); + auto path = Input.substr(slash + 1); + + if (domain != localhost && domain != legacyDomain && domain.find_first_of(".:") == std::string::npos && !std::ranges::any_of(domain, isupper)) + { + domain = defaultDomain; + path = Input; + } + + if (domain == defaultDomain && path.find('/') == std::string::npos) + { + path = "library/" + path; + } + + return {domain, path}; +} + std::pair wsl::windows::common::wslutil::OpenAnonymousPipe(DWORD Size, bool ReadPipeOverlapped, bool WritePipeOverlapped) { // Default to 4096 byte buffer, just like CreatePipe(). @@ -1218,8 +1229,8 @@ std::pair> wsl::windows::common::wslutil } const auto& repo = match[1]; - const auto& tag = match[3]; - const auto& digest = match[5]; + const auto& tag = match[2]; + const auto& digest = match[3]; THROW_HR_IF_MSG(E_UNEXPECTED, !repo.matched, "Unexpected regex match. Input: %hs", Input.c_str()); @@ -1237,19 +1248,6 @@ std::pair> wsl::windows::common::wslutil } } -std::pair wsl::windows::common::wslutil::CanonicalizeImageReference(const std::string& Input) -{ - constexpr auto defaultRegistry = "docker.io"; - - auto firstSlash = Input.find('/'); - if (firstSlash == std::string::npos) - { - return {defaultRegistry, Input}; - } - - auto registry = Input.substr(0, firstSlash); -} - void wsl::windows::common::wslutil::PrintSystemError(_In_ HRESULT result, _Inout_ FILE* const stream) { fwprintf(stream, L"%ls\n", GetSystemErrorString(result).c_str()); diff --git a/src/windows/common/wslutil.h b/src/windows/common/wslutil.h index 8e6605a75..67d3a896b 100644 --- a/src/windows/common/wslutil.h +++ b/src/windows/common/wslutil.h @@ -214,6 +214,8 @@ bool IsVirtualMachinePlatformInstalled(); std::vector ListRunningProcesses(); +std::pair NormalizeRepo(const std::string& Input); + std::pair OpenAnonymousPipe(DWORD Size, bool ReadPipeOverlapped, bool WritePipeOverlapped); wil::unique_handle OpenCallingProcess(_In_ DWORD access); @@ -225,7 +227,6 @@ void ParseIpv6Address(const char* Address, in_addr6& Result); std::tuple ParseWslPackageVersion(_In_ const std::wstring& Version); std::pair> ParseImage(const std::string& Input); -std::pair, std::string> ParseRepo(const std::string& Input); void PrintSystemError(_In_ HRESULT result, _Inout_ FILE* stream = stdout); diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index f59464a6c..e27a5b711 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -444,10 +444,11 @@ struct CreateImageProgress { std::string status; std::string id; + std::optional errorDetail; CreateImageProgressDetails progressDetail; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(CreateImageProgress, status, id, progressDetail); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(CreateImageProgress, status, id, progressDetail, errorDetail); }; } // namespace wsl::windows::common::docker_schema \ No newline at end of file diff --git a/src/windows/wslasession/DockerHTTPClient.cpp b/src/windows/wslasession/DockerHTTPClient.cpp index 54b260b7b..0dcd956c9 100644 --- a/src/windows/wslasession/DockerHTTPClient.cpp +++ b/src/windows/wslasession/DockerHTTPClient.cpp @@ -122,8 +122,9 @@ std::unique_ptr DockerHTTPClient::PullImag { auto url = URL::Create("/images/create"); - // TODO: Support pulling from other registries. - url.SetParameter("fromImage", std::format("library/{}", Repo)); + // Normalize the repo server & path + auto [server, path] = wslutil::NormalizeRepo(Repo); + url.SetParameter("fromImage", std::format("{}/{}", server, path)); if (tagOrDigest.has_value()) { diff --git a/src/windows/wslasession/WSLASession.cpp b/src/windows/wslasession/WSLASession.cpp index 42bbd55a4..ad8fe3434 100644 --- a/src/windows/wslasession/WSLASession.cpp +++ b/src/windows/wslasession/WSLASession.cpp @@ -315,17 +315,18 @@ try auto io = CreateIOContext(); - std::optional pullResult; + std::optional> pullResponse; auto onHttpResponse = [&](const boost::beast::http::message& response) { WSL_LOG("PullHttpResponse", TraceLoggingValue(static_cast(response.result()), "StatusCode")); - pullResult = response.result(); + pullResponse = response; }; std::string errorJson; + std::optional reportedError; auto onChunk = [&](const gsl::span& Content) { - if (pullResult.has_value() && pullResult.value() != boost::beast::http::status::ok) + if (pullResponse.has_value() && pullResponse->result() != boost::beast::http::status::ok) { // If the status code is an error, then this is an error message, not a progress update. errorJson.append(Content.data(), Content.size()); @@ -335,15 +336,28 @@ try std::string contentString{Content.begin(), Content.end()}; WSL_LOG("ImagePullProgress", TraceLoggingValue(Image, "Image"), TraceLoggingValue(contentString.c_str(), "Content")); - if (ProgressCallback == nullptr) + auto parsed = wsl::shared::FromJson(contentString.c_str()); + + if (parsed.errorDetail.has_value()) { + if (reportedError.has_value()) + { + LOG_HR_MSG( + E_UNEXPECTED, + "Received multiple error messages during image pull. Previous: %hs, New: %hs", + reportedError->c_str(), + parsed.errorDetail->message.c_str()); + } + + reportedError = parsed.errorDetail->message; return; } - auto parsed = wsl::shared::FromJson(contentString.c_str()); - - THROW_IF_FAILED(ProgressCallback->OnProgress( - parsed.status.c_str(), parsed.id.c_str(), parsed.progressDetail.current, parsed.progressDetail.total)); + if (ProgressCallback != nullptr) + { + THROW_IF_FAILED(ProgressCallback->OnProgress( + parsed.status.c_str(), parsed.id.c_str(), parsed.progressDetail.current, parsed.progressDetail.total)); + } }; auto onCompleted = [&]() { io.Cancel(); }; @@ -353,22 +367,23 @@ try io.Run({}); - THROW_HR_IF(E_UNEXPECTED, !pullResult.has_value()); + THROW_HR_IF(E_UNEXPECTED, !pullResponse.has_value()); - if (pullResult.value() != boost::beast::http::status::ok) + if (pullResponse->result() != boost::beast::http::status::ok) { std::string errorMessage; - if (static_cast(pullResult.value()) >= 400 && static_cast(pullResult.value()) < 500) + auto it = pullResponse->find(boost::beast::http::field::content_type); + if (it != pullResponse->end() && it->value().starts_with("application/json")) { // pull failed, parse the error message. errorMessage = wsl::shared::FromJson(errorJson.c_str()).message; } - if (pullResult.value() == boost::beast::http::status::not_found) + if (pullResponse->result() == boost::beast::http::status::not_found) { THROW_HR_WITH_USER_ERROR(WSLA_E_IMAGE_NOT_FOUND, errorMessage); } - else if (pullResult.value() == boost::beast::http::status::bad_request) + else if (pullResponse->result() == boost::beast::http::status::bad_request) { THROW_HR_WITH_USER_ERROR(E_INVALIDARG, errorMessage); } @@ -377,6 +392,11 @@ try THROW_HR_WITH_USER_ERROR(E_FAIL, errorMessage); } } + else if (reportedError.has_value()) + { + // Can happen if an error is returned during progress after receiving an OK status. + THROW_HR_WITH_USER_ERROR(E_FAIL, reportedError.value().c_str()); + } return S_OK; } diff --git a/test/windows/WSLATests.cpp b/test/windows/WSLATests.cpp index c26a839ce..d4313853d 100644 --- a/test/windows/WSLATests.cpp +++ b/test/windows/WSLATests.cpp @@ -120,7 +120,7 @@ class WSLATests settings.MemoryMb = 2048; settings.BootTimeoutMs = 30 * 1000; settings.StoragePath = enableStorage ? m_storagePath.c_str() : nullptr; - settings.MaximumStorageSizeMb = 4096; // 4GB. + settings.MaximumStorageSizeMb = 1024 * 20; // 20GB. settings.NetworkingMode = networkingMode; return settings; @@ -438,7 +438,7 @@ class WSLATests WSL2_TEST_ONLY(); // TODO: Enable once custom registries are supported, to avoid hitting public registry rate limits. - SKIP_TEST_UNSTABLE(); + // SKIP_TEST_UNSTABLE(); auto validatePull = [&](const std::string& Image, const std::optional& ExpectedTag = {}) { VERIFY_SUCCEEDED(m_defaultSession->PullImage(Image.c_str(), nullptr, nullptr)); @@ -452,7 +452,6 @@ class WSLATests if (!ExpectedTag.has_value()) { - wil::unique_cotaskmem_array_ptr images; VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, images.addressof(), images.size_address())); @@ -483,11 +482,10 @@ class WSLATests }; validatePull("ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30", {}); - validatePull("ubuntu", "ubuntu:latest"); validatePull("debian:bookworm", "debian:bookworm"); - - // TODO: Add test coverage with custom registries once supported. + validatePull("pytorch/pytorch", "pytorch/pytorch:latest"); + validatePull("registry.k8s.io/pause:3.2", "registry.k8s.io/pause:3.2"); } TEST_METHOD(ListImages) @@ -5998,27 +5996,22 @@ class WSLATests TEST_METHOD(RepoParsing) { - using wsl::windows::common::wslutil::ParseRepo; + using wsl::windows::common::wslutil::NormalizeRepo; - auto ValidateRepoParsing = [](const std::string& input, const std::optional& expectedServer, const std::string& expectedPath) { - auto [server, path] = ParseRepo(input); - VERIFY_ARE_EQUAL(server.value_or(""), expectedServer.value_or("")); + auto ValidateRepoParsing = [](const std::string& input, const std::string& expectedServer, const std::string& expectedPath) { + auto [server, path] = NormalizeRepo(input); + VERIFY_ARE_EQUAL(server, expectedServer); VERIFY_ARE_EQUAL(path, expectedPath); }; - ValidateRepoParsing("ubuntu", {}, "ubuntu"); - ValidateRepoParsing("microsoft.com/ubuntu", {"microsoft.com"}, "ubuntu"); - ValidateRepoParsing("microsoft.com:80/ubuntu", {"microsoft.com:80"}, "ubuntu"); - ValidateRepoParsing("microsoft.com:80/ubuntu/foo/bar", {"microsoft.com:80"}, "ubuntu/foo/bar"); - ValidateRepoParsing("127.0.0.1:80/ubuntu/foo/bar", {"127.0.0.1:80"}, "ubuntu/foo/bar"); - ValidateRepoParsing("pytorch/pytorch", {}, "pytorch/pytorch"); - ValidateRepoParsing("2001:0db8:85a3:0000:0000:8a2e:0370:7334/path", {"2001:0db8:85a3:0000:0000:8a2e:0370:7334"}, "pytorch/pytorch"); - ValidateRepoParsing("2001:0db8:85a3:0000:0000:8a2e:0370:7334:80/path", {"2001:0db8:85a3:0000:0000:8a2e:0370:7334:80"}, "pytorch/pytorch"); - - - VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseRepo(""); }), E_INVALIDARG); - VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseRepo(":"); }), E_INVALIDARG); - VERIFY_ARE_EQUAL(wil::ResultFromException([]() { ParseRepo("foo:bar"); }), E_INVALIDARG); - + ValidateRepoParsing("ubuntu", "docker.io", "library/ubuntu"); + ValidateRepoParsing("microsoft.com/ubuntu", "microsoft.com", "ubuntu"); + ValidateRepoParsing("microsoft.com:80/ubuntu", "microsoft.com:80", "ubuntu"); + ValidateRepoParsing("microsoft.com:80/ubuntu/foo/bar", "microsoft.com:80", "ubuntu/foo/bar"); + ValidateRepoParsing("127.0.0.1:80/ubuntu/foo/bar", "127.0.0.1:80", "ubuntu/foo/bar"); + ValidateRepoParsing("pytorch/pytorch", "docker.io", "pytorch/pytorch"); + ValidateRepoParsing("2001:0db8:85a3:0000:0000:8a2e:0370:7334/path", "2001:0db8:85a3:0000:0000:8a2e:0370:7334", "path"); + ValidateRepoParsing( + "2001:0db8:85a3:0000:0000:8a2e:0370:7334:80/path", "2001:0db8:85a3:0000:0000:8a2e:0370:7334:80", "path"); } }; From 628130771b6e1d72968a44301ce9af2f1ee1b4c1 Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 26 Mar 2026 17:33:15 -0700 Subject: [PATCH 4/9] Disable the pull tests --- test/windows/WSLATests.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/windows/WSLATests.cpp b/test/windows/WSLATests.cpp index d4313853d..070870632 100644 --- a/test/windows/WSLATests.cpp +++ b/test/windows/WSLATests.cpp @@ -438,7 +438,7 @@ class WSLATests WSL2_TEST_ONLY(); // TODO: Enable once custom registries are supported, to avoid hitting public registry rate limits. - // SKIP_TEST_UNSTABLE(); + SKIP_TEST_UNSTABLE(); auto validatePull = [&](const std::string& Image, const std::optional& ExpectedTag = {}) { VERIFY_SUCCEEDED(m_defaultSession->PullImage(Image.c_str(), nullptr, nullptr)); From 4cd28a54eb15c3dbd04cd8a941471857bd1f735c Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 26 Mar 2026 18:25:45 -0700 Subject: [PATCH 5/9] Update the CLI tests --- src/windows/common/wslutil.cpp | 3 ++- src/windows/wslasession/WSLASession.cpp | 5 +++++ test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp | 3 +-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp index 7846f24d4..5b96bc9c1 100644 --- a/src/windows/common/wslutil.cpp +++ b/src/windows/common/wslutil.cpp @@ -1091,7 +1091,8 @@ std::pair wsl::windows::common::wslutil::NormalizeRepo auto domain = Input.substr(0, slash); auto path = Input.substr(slash + 1); - if (domain != localhost && domain != legacyDomain && domain.find_first_of(".:") == std::string::npos && !std::ranges::any_of(domain, isupper)) + if (domain != localhost && domain != legacyDomain && domain.find_first_of(".:") == std::string::npos && + !std::ranges::any_of(domain, [](unsigned char e) { return std::isupper(e); })) { domain = defaultDomain; path = Input; diff --git a/src/windows/wslasession/WSLASession.cpp b/src/windows/wslasession/WSLASession.cpp index ad8fe3434..adfc3cbe7 100644 --- a/src/windows/wslasession/WSLASession.cpp +++ b/src/windows/wslasession/WSLASession.cpp @@ -378,6 +378,11 @@ try // pull failed, parse the error message. errorMessage = wsl::shared::FromJson(errorJson.c_str()).message; } + else + { + // If no error message was explicitely returned, use the response body, if any. + errorMessage = errorJson; + } if (pullResponse->result() == boost::beast::http::status::not_found) { diff --git a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp index 44b63d072..b172681c7 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp @@ -86,8 +86,7 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(L"container create --name " + WslcContainerName + L" " + InvalidImage.NameAndTag()); std::wstringstream expectedError; expectedError << L"Image '" << InvalidImage.NameAndTag() << L"' not found, pulling\r\n" - << L"pull access denied for library/" - << InvalidImage.Name << L", repository does not exist or may require 'docker login': denied: requested access to the resource is denied\r\n" + << L"manifest for " << InvalidImage.NameAndTag() << L" not found: manifest unknown: manifest tagged by \"latest\" is not found\r\n" << L"Error code: WSLA_E_IMAGE_NOT_FOUND\r\n"; result.Verify({.Stderr = expectedError.str(), .ExitCode = 1}); } From 982fd9cb7ce6adcf854b3c11a4a830e486311dfb Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 26 Mar 2026 18:25:57 -0700 Subject: [PATCH 6/9] Format --- test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp index b172681c7..2e7393975 100644 --- a/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp @@ -86,7 +86,8 @@ class WSLCE2EContainerCreateTests auto result = RunWslc(L"container create --name " + WslcContainerName + L" " + InvalidImage.NameAndTag()); std::wstringstream expectedError; expectedError << L"Image '" << InvalidImage.NameAndTag() << L"' not found, pulling\r\n" - << L"manifest for " << InvalidImage.NameAndTag() << L" not found: manifest unknown: manifest tagged by \"latest\" is not found\r\n" + << L"manifest for " << InvalidImage.NameAndTag() + << L" not found: manifest unknown: manifest tagged by \"latest\" is not found\r\n" << L"Error code: WSLA_E_IMAGE_NOT_FOUND\r\n"; result.Verify({.Stderr = expectedError.str(), .ExitCode = 1}); } From f59c8a4fd8a1f47bdcd76f24fa3646a4154870db Mon Sep 17 00:00:00 2001 From: Blue Date: Thu, 26 Mar 2026 18:43:43 -0700 Subject: [PATCH 7/9] Fix legacy index logic --- src/windows/common/wslutil.cpp | 9 +++++++-- test/windows/WSLATests.cpp | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp index 5b96bc9c1..b8678786c 100644 --- a/src/windows/common/wslutil.cpp +++ b/src/windows/common/wslutil.cpp @@ -1091,8 +1091,13 @@ std::pair wsl::windows::common::wslutil::NormalizeRepo auto domain = Input.substr(0, slash); auto path = Input.substr(slash + 1); - if (domain != localhost && domain != legacyDomain && domain.find_first_of(".:") == std::string::npos && - !std::ranges::any_of(domain, [](unsigned char e) { return std::isupper(e); })) + if (domain == legacyDomain) + { + domain = defaultDomain; + } + else if (domain != localhost && domain.find_first_of(".:") == std::string::npos && !std::ranges::any_of(domain, [](unsigned char e) { + return std::isupper(e); + })) { domain = defaultDomain; path = Input; diff --git a/test/windows/WSLATests.cpp b/test/windows/WSLATests.cpp index 070870632..d24230259 100644 --- a/test/windows/WSLATests.cpp +++ b/test/windows/WSLATests.cpp @@ -6005,6 +6005,10 @@ class WSLATests }; ValidateRepoParsing("ubuntu", "docker.io", "library/ubuntu"); + ValidateRepoParsing("docker.io/ubuntu", "docker.io", "library/ubuntu"); + ValidateRepoParsing("index.docker.io/ubuntu", "docker.io", "library/ubuntu"); + ValidateRepoParsing("index.docker.io/library/ubuntu", "docker.io", "library/ubuntu"); + ValidateRepoParsing("docker.io/library/ubuntu", "docker.io", "library/ubuntu"); ValidateRepoParsing("microsoft.com/ubuntu", "microsoft.com", "ubuntu"); ValidateRepoParsing("microsoft.com:80/ubuntu", "microsoft.com:80", "ubuntu"); ValidateRepoParsing("microsoft.com:80/ubuntu/foo/bar", "microsoft.com:80", "ubuntu/foo/bar"); From ddeb16dd0a7e71325edaca5cc8a84adb53604030 Mon Sep 17 00:00:00 2001 From: Blue Date: Fri, 27 Mar 2026 12:59:22 -0700 Subject: [PATCH 8/9] Add test coverage for failed PullImage() --- src/windows/wslcsession/WSLCSession.cpp | 24 +++++++++++++++--------- test/windows/WSLCTests.cpp | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index 9fc2ccdb9..7a9762fef 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -328,18 +328,25 @@ try auto io = CreateIOContext(); - std::optional> pullResponse; + struct Response + { + boost::beast::http::status result; + bool isJson = false; + }; + + std::optional pullResponse; auto onHttpResponse = [&](const boost::beast::http::message& response) { WSL_LOG("PullHttpResponse", TraceLoggingValue(static_cast(response.result()), "StatusCode")); - pullResponse = response; + auto it = response.find(boost::beast::http::field::content_type); + pullResponse.emplace(response.result(), it != response.end() && it->value().starts_with("application/json")); }; std::string errorJson; std::optional reportedError; auto onChunk = [&](const gsl::span& Content) { - if (pullResponse.has_value() && pullResponse->result() != boost::beast::http::status::ok) + if (pullResponse.has_value() && pullResponse->result != boost::beast::http::status::ok) { // If the status code is an error, then this is an error message, not a progress update. errorJson.append(Content.data(), Content.size()); @@ -382,26 +389,25 @@ try THROW_HR_IF(E_UNEXPECTED, !pullResponse.has_value()); - if (pullResponse->result() != boost::beast::http::status::ok) + if (pullResponse->result != boost::beast::http::status::ok) { std::string errorMessage; - auto it = pullResponse->find(boost::beast::http::field::content_type); - if (it != pullResponse->end() && it->value().starts_with("application/json")) + if (pullResponse->isJson) { // pull failed, parse the error message. errorMessage = wsl::shared::FromJson(errorJson.c_str()).message; } else { - // If no error message was explicitely returned, use the response body, if any. + // If no error message was explicitly returned, use the response body, if any. errorMessage = errorJson; } - if (pullResponse->result() == boost::beast::http::status::not_found) + if (pullResponse->result == boost::beast::http::status::not_found) { THROW_HR_WITH_USER_ERROR(WSLC_E_IMAGE_NOT_FOUND, errorMessage); } - else if (pullResponse->result() == boost::beast::http::status::bad_request) + else if (pullResponse->result == boost::beast::http::status::bad_request) { THROW_HR_WITH_USER_ERROR(E_INVALIDARG, errorMessage); } diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index a77f34e8c..5130ebcd4 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -486,6 +486,27 @@ class WSLCTests validatePull("debian:bookworm", "debian:bookworm"); validatePull("pytorch/pytorch", "pytorch/pytorch:latest"); validatePull("registry.k8s.io/pause:3.2", "registry.k8s.io/pause:3.2"); + + // Validate that PullImage() fails appropriately when the sessio runs out of space. + { + auto settings = GetDefaultSessionSettings(L"wslc-pull-image-out-of-space", false); + settings.NetworkingMode = WSLCNetworkingModeVirtioProxy; + settings.MemoryMb = 1024; + auto session = CreateSession(settings); + + VERIFY_ARE_EQUAL(session->PullImage("pytorch/pytorch", nullptr, nullptr), E_FAIL); + + auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo(); + VERIFY_IS_TRUE(comError.has_value()); + + // The error message can't be compared directly because it contains an unpredicable path: + // "write /var/lib/docker/tmp/GetImageBlob1760660623: no space left on device" + if (StrStrW(comError->Message.get(), L"no space left on device") == nullptr) + { + LogError("Unexpected error message: %ls", comError->Message.get()); + VERIFY_FAIL(); + } + } } TEST_METHOD(ListImages) From 2ee97bab0d51b345b1c4aef8cb30894210790a73 Mon Sep 17 00:00:00 2001 From: Blue Date: Fri, 27 Mar 2026 14:37:00 -0700 Subject: [PATCH 9/9] Update test/windows/WSLCTests.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/windows/WSLCTests.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 5130ebcd4..83a3e09a7 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -487,7 +487,7 @@ class WSLCTests validatePull("pytorch/pytorch", "pytorch/pytorch:latest"); validatePull("registry.k8s.io/pause:3.2", "registry.k8s.io/pause:3.2"); - // Validate that PullImage() fails appropriately when the sessio runs out of space. + // Validate that PullImage() fails appropriately when the session runs out of space. { auto settings = GetDefaultSessionSettings(L"wslc-pull-image-out-of-space", false); settings.NetworkingMode = WSLCNetworkingModeVirtioProxy;