diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7119eafcc..1400001af 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -285,6 +285,7 @@ set(COMMON_LINK_LIBRARIES
Shlwapi.lib
synchronization.lib
Bcrypt.lib
+ Crypt32.lib
icu.lib)
set(MSI_LINK_LIBRARIES
diff --git a/packages.config b/packages.config
index 3063f85a1..98e332420 100644
--- a/packages.config
+++ b/packages.config
@@ -21,7 +21,7 @@
-
+
diff --git a/src/windows/WslcSDK/wslcsdk.cpp b/src/windows/WslcSDK/wslcsdk.cpp
index e78f511ea..702d78251 100644
--- a/src/windows/WslcSDK/wslcsdk.cpp
+++ b/src/windows/WslcSDK/wslcsdk.cpp
@@ -685,7 +685,6 @@ try
containerOptions.ContainerNetwork.ContainerNetworkType = internalContainerSettings->networking;
// TODO: No user access
- // containerOptions.Entrypoint;
// containerOptions.Labels;
// containerOptions.LabelsCount;
// containerOptions.StopSignal;
@@ -1186,8 +1185,7 @@ try
auto progressCallback = ProgressCallback::CreateIf(options);
- // TODO: Auth
- return errorInfoWrapper.CaptureResult(internalType->session->PullImage(options->uri, nullptr, progressCallback.get()));
+ return errorInfoWrapper.CaptureResult(internalType->session->PullImage(options->uri, options->registryAuth, progressCallback.get()));
}
CATCH_RETURN();
@@ -1282,6 +1280,72 @@ try
}
CATCH_RETURN();
+STDAPI WslcTagSessionImage(_In_ WslcSession session, _In_ const WslcTagImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage)
+try
+{
+ ErrorInfoWrapper errorInfoWrapper{errorMessage};
+ auto internalType = CheckAndGetInternalType(session);
+ RETURN_HR_IF_NULL(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), internalType->session);
+ RETURN_HR_IF_NULL(E_POINTER, options);
+ RETURN_HR_IF_NULL(E_INVALIDARG, options->image);
+ RETURN_HR_IF_NULL(E_INVALIDARG, options->repo);
+ RETURN_HR_IF_NULL(E_INVALIDARG, options->tag);
+
+ WSLCTagImageOptions runtimeOptions{};
+ runtimeOptions.Image = options->image;
+ runtimeOptions.Repo = options->repo;
+ runtimeOptions.Tag = options->tag;
+
+ return errorInfoWrapper.CaptureResult(internalType->session->TagImage(&runtimeOptions));
+}
+CATCH_RETURN();
+
+STDAPI WslcPushSessionImage(_In_ WslcSession session, _In_ const WslcPushImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage)
+try
+{
+ ErrorInfoWrapper errorInfoWrapper{errorMessage};
+ auto internalType = CheckAndGetInternalType(session);
+ RETURN_HR_IF_NULL(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), internalType->session);
+ RETURN_HR_IF_NULL(E_POINTER, options);
+ RETURN_HR_IF_NULL(E_INVALIDARG, options->image);
+ RETURN_HR_IF_NULL(E_INVALIDARG, options->registryAuth);
+
+ auto progressCallback = ProgressCallback::CreateIf(options);
+
+ return errorInfoWrapper.CaptureResult(internalType->session->PushImage(options->image, options->registryAuth, progressCallback.get()));
+}
+CATCH_RETURN();
+
+STDAPI WslcSessionAuthenticate(
+ _In_ WslcSession session,
+ _In_z_ PCSTR serverAddress,
+ _In_z_ PCSTR username,
+ _In_z_ PCSTR password,
+ _Outptr_result_z_ PSTR* identityToken,
+ _Outptr_opt_result_z_ PWSTR* errorMessage)
+try
+{
+ ErrorInfoWrapper errorInfoWrapper{errorMessage};
+ auto internalType = CheckAndGetInternalType(session);
+ RETURN_HR_IF_NULL(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), internalType->session);
+ RETURN_HR_IF_NULL(E_POINTER, serverAddress);
+ RETURN_HR_IF_NULL(E_POINTER, username);
+ RETURN_HR_IF_NULL(E_POINTER, password);
+ RETURN_HR_IF_NULL(E_POINTER, identityToken);
+
+ *identityToken = nullptr;
+
+ wil::unique_cotaskmem_ansistring token;
+ auto hr = errorInfoWrapper.CaptureResult(internalType->session->Authenticate(serverAddress, username, password, &token));
+ if (SUCCEEDED(hr))
+ {
+ *identityToken = token.release();
+ }
+
+ return errorInfoWrapper;
+}
+CATCH_RETURN();
+
STDAPI WslcListSessionImages(_In_ WslcSession session, _Outptr_result_buffer_(*count) WslcImageInfo** images, _Out_ uint32_t* count)
try
{
diff --git a/src/windows/WslcSDK/wslcsdk.def b/src/windows/WslcSDK/wslcsdk.def
index 63e60a436..800c57d5c 100644
--- a/src/windows/WslcSDK/wslcsdk.def
+++ b/src/windows/WslcSDK/wslcsdk.def
@@ -23,6 +23,7 @@ WslcSetSessionSettingsTimeout
WslcSetSessionSettingsVhd
WslcTerminateSession
+WslcSessionAuthenticate
WslcPullSessionImage
WslcImportSessionImage
WslcImportSessionImageFromFile
@@ -32,6 +33,8 @@ WslcDeleteSessionImage
WslcListSessionImages
WslcCreateSessionVhdVolume
WslcDeleteSessionVhdVolume
+WslcTagSessionImage
+WslcPushSessionImage
WslcSetContainerSettingsDomainName
WslcSetContainerSettingsName
diff --git a/src/windows/WslcSDK/wslcsdk.h b/src/windows/WslcSDK/wslcsdk.h
index 33d45e11e..5aa51dc84 100644
--- a/src/windows/WslcSDK/wslcsdk.h
+++ b/src/windows/WslcSDK/wslcsdk.h
@@ -392,11 +392,6 @@ typedef struct WslcImageProgressMessage
_Out_ WslcImageProgressDetail detail;
} WslcImageProgressMessage;
-typedef struct WslcRegistryAuthenticationInformation
-{
- // TBD
-} WslcRegistryAuthenticationInformation;
-
// pointer-to-function typedef (unambiguous)
typedef HRESULT(CALLBACK* WslcContainerImageProgressCallback)(const WslcImageProgressMessage* progress, PVOID context);
@@ -406,7 +401,7 @@ typedef struct WslcPullImageOptions
_In_z_ PCSTR uri;
WslcContainerImageProgressCallback progressCallback;
PVOID progressCallbackContext;
- _In_opt_ const WslcRegistryAuthenticationInformation* authInfo;
+ _In_opt_z_ PCSTR registryAuth;
} WslcPullImageOptions;
STDAPI WslcPullSessionImage(_In_ WslcSession session, _In_ const WslcPullImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage);
@@ -457,6 +452,58 @@ typedef struct WslcImageInfo
STDAPI WslcDeleteSessionImage(_In_ WslcSession session, _In_z_ PCSTR nameOrId, _Outptr_opt_result_z_ PWSTR* errorMessage);
+typedef struct WslcTagImageOptions
+{
+ _In_z_ PCSTR image; // Source image name or ID.
+ _In_z_ PCSTR repo; // Target repository name.
+ _In_z_ PCSTR tag; // Target tag name.
+} WslcTagImageOptions;
+
+STDAPI WslcTagSessionImage(_In_ WslcSession session, _In_ const WslcTagImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage);
+
+typedef struct WslcPushImageOptions
+{
+ _In_z_ PCSTR image;
+ _In_z_ PCSTR registryAuth; // Base64-encoded X-Registry-Auth header value.
+ _In_opt_ WslcContainerImageProgressCallback progressCallback;
+ _In_opt_ PVOID progressCallbackContext;
+} WslcPushImageOptions;
+
+STDAPI WslcPushSessionImage(_In_ WslcSession session, _In_ const WslcPushImageOptions* options, _Outptr_opt_result_z_ PWSTR* errorMessage);
+
+// Authenticates with a container registry and returns an identity token.
+//
+// Parameters:
+// session
+// A valid WslcSession handle.
+//
+// serverAddress
+// The registry server address (e.g. "127.0.0.1:5000").
+//
+// username
+// The username for authentication.
+//
+// password
+// The password for authentication.
+//
+// identityToken
+// On success, receives a pointer to a null-terminated ANSI string
+// containing the identity token.
+//
+// The string is allocated using CoTaskMemAlloc. The caller takes
+// ownership of the returned memory and must free it by calling
+// CoTaskMemFree when it is no longer needed.
+//
+// Return Value:
+// S_OK on success. Otherwise, an HRESULT error code indicating the failure.
+STDAPI WslcSessionAuthenticate(
+ _In_ WslcSession session,
+ _In_z_ PCSTR serverAddress,
+ _In_z_ PCSTR username,
+ _In_z_ PCSTR password,
+ _Outptr_result_z_ PSTR* identityToken,
+ _Outptr_opt_result_z_ PWSTR* errorMessage);
+
// Retrieves the list of container images
// Parameters:
// session
diff --git a/src/windows/common/WSLCContainerLauncher.cpp b/src/windows/common/WSLCContainerLauncher.cpp
index 4d0744b9c..78eb56403 100644
--- a/src/windows/common/WSLCContainerLauncher.cpp
+++ b/src/windows/common/WSLCContainerLauncher.cpp
@@ -11,6 +11,8 @@ Module Name:
This file contains the implementation for WSLCContainerLauncher.
--*/
+
+#include "precomp.h"
#include "WSLCContainerLauncher.h"
using wsl::windows::common::ClientRunningWSLCProcess;
diff --git a/src/windows/common/WSLCContainerLauncher.h b/src/windows/common/WSLCContainerLauncher.h
index 900a24c06..4643bc875 100644
--- a/src/windows/common/WSLCContainerLauncher.h
+++ b/src/windows/common/WSLCContainerLauncher.h
@@ -79,6 +79,7 @@ class WSLCContainerLauncher : private WSLCProcessLauncher
void SetDnsSearchDomains(std::vector&& DnsSearchDomains);
void SetDnsOptions(std::vector&& DnsOptions);
+ using WSLCProcessLauncher::FormatResult;
using WSLCProcessLauncher::SetUser;
using WSLCProcessLauncher::SetWorkingDirectory;
diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp
index d9a71e77f..3b3783e0e 100644
--- a/src/windows/common/wslutil.cpp
+++ b/src/windows/common/wslutil.cpp
@@ -15,6 +15,7 @@ Module Name:
#include "precomp.h"
#include "wslutil.h"
#include "WslPluginApi.h"
+#include
#include "wslinstallerservice.h"
#include "wslc.h"
@@ -1422,4 +1423,49 @@ catch (...)
{
LOG_CAUGHT_EXCEPTION();
return nullptr;
+}
+
+std::string wsl::windows::common::wslutil::Base64Encode(const std::string& input)
+{
+ DWORD base64Size = 0;
+ THROW_IF_WIN32_BOOL_FALSE(CryptBinaryToStringA(
+ reinterpret_cast(input.c_str()), static_cast(input.size()), CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr, &base64Size));
+
+ auto buffer = std::make_unique(base64Size);
+ THROW_IF_WIN32_BOOL_FALSE(CryptBinaryToStringA(
+ reinterpret_cast(input.c_str()),
+ static_cast(input.size()),
+ CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF,
+ buffer.get(),
+ &base64Size));
+
+ return std::string(buffer.get());
+}
+
+std::string wsl::windows::common::wslutil::Base64Decode(const std::string& encoded)
+{
+ DWORD size = 0;
+ THROW_IF_WIN32_BOOL_FALSE(CryptStringToBinaryA(
+ encoded.c_str(), static_cast(encoded.size()), CRYPT_STRING_BASE64, nullptr, &size, nullptr, nullptr));
+
+ std::string result(size, '\0');
+ THROW_IF_WIN32_BOOL_FALSE(CryptStringToBinaryA(
+ encoded.c_str(), static_cast(encoded.size()), CRYPT_STRING_BASE64, reinterpret_cast(result.data()), &size, nullptr, nullptr));
+
+ result.resize(size);
+ return result;
+}
+
+std::string wsl::windows::common::wslutil::BuildRegistryAuthHeader(const std::string& username, const std::string& password, const std::string& serverAddress)
+{
+ nlohmann::json authJson = {{"username", username}, {"password", password}, {"serveraddress", serverAddress}};
+
+ return Base64Encode(authJson.dump());
+}
+
+std::string wsl::windows::common::wslutil::BuildRegistryAuthHeader(const std::string& identityToken, const std::string& serverAddress)
+{
+ nlohmann::json authJson = {{"identitytoken", identityToken}, {"serveraddress", serverAddress}};
+
+ return Base64Encode(authJson.dump());
}
\ No newline at end of file
diff --git a/src/windows/common/wslutil.h b/src/windows/common/wslutil.h
index defb0d309..5ae95f0c9 100644
--- a/src/windows/common/wslutil.h
+++ b/src/windows/common/wslutil.h
@@ -330,4 +330,15 @@ WSLCHandle ToCOMInputHandle(HANDLE Handle);
winrt::Windows::Management::Deployment::PackageVolume GetSystemVolume();
+std::string Base64Encode(const std::string& input);
+std::string Base64Decode(const std::string& encoded);
+
+// Builds the base64-encoded X-Registry-Auth header value used by Docker APIs
+// (PullImage, PushImage, etc.) from the given credentials.
+std::string BuildRegistryAuthHeader(const std::string& username, const std::string& password, const std::string& serverAddress);
+
+// Builds the base64-encoded X-Registry-Auth header value from an identity token
+// returned by Authenticate().
+std::string BuildRegistryAuthHeader(const std::string& identityToken, const std::string& serverAddress);
+
} // namespace wsl::windows::common::wslutil
diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h
index e56e7ec4a..accb4cfbc 100644
--- a/src/windows/inc/docker_schema.h
+++ b/src/windows/inc/docker_schema.h
@@ -48,6 +48,25 @@ struct EmptyRequest
using TResponse = void;
};
+struct AuthRequest
+{
+ using TResponse = struct AuthResponse;
+
+ std::string username;
+ std::string password;
+ std::string serveraddress;
+
+ NLOHMANN_DEFINE_TYPE_INTRUSIVE(AuthRequest, username, password, serveraddress);
+};
+
+struct AuthResponse
+{
+ std::string Status;
+ std::optional IdentityToken;
+
+ NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(AuthResponse, Status, IdentityToken);
+};
+
struct CreateVolume
{
using TResponse = void;
diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl
index 94ee186c1..f6517fd70 100644
--- a/src/windows/service/inc/wslc.idl
+++ b/src/windows/service/inc/wslc.idl
@@ -677,6 +677,9 @@ interface IWSLCSession : IUnknown
HRESULT DeleteVolume([in] LPCSTR Name);
HRESULT ListVolumes([out, size_is(, *Count)] WSLCVolumeInformation** Volumes, [out] ULONG* Count);
HRESULT InspectVolume([in] LPCSTR Name, [out] LPSTR* Output);
+
+ HRESULT Authenticate([in] LPCSTR ServerAddress, [in] LPCSTR Username, [in] LPCSTR Password, [out] LPSTR* IdentityToken);
+ HRESULT PushImage([in] LPCSTR Image, [in] LPCSTR RegistryAuthenticationInformation, [in, unique] IProgressCallback* ProgressCallback);
}
//
diff --git a/src/windows/wslcsession/DockerHTTPClient.cpp b/src/windows/wslcsession/DockerHTTPClient.cpp
index 38870c82b..793997a44 100644
--- a/src/windows/wslcsession/DockerHTTPClient.cpp
+++ b/src/windows/wslcsession/DockerHTTPClient.cpp
@@ -149,7 +149,8 @@ DockerHTTPClient::DockerHTTPClient(wsl::shared::SocketChannel&& Channel, HANDLE
{
}
-std::unique_ptr DockerHTTPClient::PullImage(const std::string& Repo, const std::optional& tagOrDigest)
+std::unique_ptr DockerHTTPClient::PullImage(
+ const std::string& Repo, const std::optional& tagOrDigest, const std::optional& registryAuth)
{
auto url = URL::Create("/images/create");
@@ -162,16 +163,20 @@ std::unique_ptr DockerHTTPClient::PullImag
url.SetParameter("tag", tagOrDigest.value());
}
- return SendRequestImpl(verb::post, url, {}, {});
+ std::map customHeaders;
+
+ if (registryAuth.has_value())
+ {
+ customHeaders["X-Registry-Auth"] = registryAuth.value();
+ }
+
+ return SendRequestImpl(verb::post, url, {}, customHeaders);
}
std::unique_ptr DockerHTTPClient::LoadImage(uint64_t ContentLength)
{
return SendRequestImpl(
- verb::post,
- URL::Create("/images/load"),
- {},
- {{http::field::content_type, "application/x-tar"}, {http::field::content_length, std::to_string(ContentLength)}});
+ verb::post, URL::Create("/images/load"), {}, {{"Content-Type", "application/x-tar"}, {"Content-Length", std::to_string(ContentLength)}});
}
std::unique_ptr DockerHTTPClient::ImportImage(const std::string& Repo, const std::string& Tag, uint64_t ContentLength)
@@ -181,8 +186,7 @@ std::unique_ptr DockerHTTPClient::ImportIm
url.SetParameter("repo", Repo);
url.SetParameter("fromSrc", "-");
- return SendRequestImpl(
- verb::post, url, {}, {{http::field::content_type, "application/x-tar"}, {http::field::content_length, std::to_string(ContentLength)}});
+ return SendRequestImpl(verb::post, url, {}, {{"Content-Type", "application/x-tar"}, {"Content-Length", std::to_string(ContentLength)}});
}
void DockerHTTPClient::TagImage(const std::string& Id, const std::string& Repo, const std::string& Tag)
@@ -194,6 +198,28 @@ void DockerHTTPClient::TagImage(const std::string& Id, const std::string& Repo,
Transaction(verb::post, url);
}
+std::unique_ptr DockerHTTPClient::PushImage(
+ const std::string& ImageName, const std::optional& tag, const std::string& registryAuth)
+{
+ auto url = URL::Create("/images/{}/push", ImageName);
+
+ if (tag.has_value())
+ {
+ url.SetParameter("tag", tag.value());
+ }
+
+ std::map customHeaders = {{"X-Registry-Auth", registryAuth}};
+ return SendRequestImpl(verb::post, url, {}, customHeaders);
+}
+
+std::string DockerHTTPClient::Authenticate(const std::string& serverAddress, const std::string& username, const std::string& password)
+{
+ auto response = Transaction(
+ verb::post, URL::Create("/auth"), {.username = username, .password = password, .serveraddress = serverAddress});
+
+ return response.IdentityToken.value_or("");
+}
+
std::vector DockerHTTPClient::ListImages(bool all, bool digests, const ListImagesFilters& filters)
{
auto url = URL::Create("/images/json");
@@ -361,8 +387,7 @@ docker_schema::InspectExec DockerHTTPClient::InspectExec(const std::string& Id)
wil::unique_socket DockerHTTPClient::AttachContainer(const std::string& Id, const std::optional& DetachKeys)
{
- std::map headers{
- {boost::beast::http::field::upgrade, "tcp"}, {boost::beast::http::field::connection, "upgrade"}};
+ std::map headers{{"Upgrade", "tcp"}, {"Connection", "upgrade"}};
auto url = URL::Create("/containers/{}/attach", Id);
url.SetParameter("stream", true);
@@ -460,8 +485,7 @@ docker_schema::CreateExecResponse DockerHTTPClient::CreateExec(const std::string
wil::unique_socket DockerHTTPClient::StartExec(const std::string& Id, const common::docker_schema::StartExec& Request)
{
- std::map headers{
- {boost::beast::http::field::upgrade, "tcp"}, {boost::beast::http::field::connection, "upgrade"}};
+ std::map headers{{"Upgrade", "tcp"}, {"Connection", "upgrade"}};
auto url = URL::Create("/exec/{}/start", Id);
@@ -678,7 +702,7 @@ void DockerHTTPClient::DockerHttpResponseHandle::OnResponseBytes(const gsl::span
}
std::unique_ptr DockerHTTPClient::SendRequestImpl(
- verb Method, const URL& Url, const std::string& Body, const std::map& Headers)
+ verb Method, const URL& Url, const std::string& Body, const std::map& Headers)
{
auto context = std::make_unique(ConnectSocket());
@@ -696,9 +720,9 @@ std::unique_ptr DockerHTTPClient::SendRequ
req.set(http::field::connection, "close");
req.set(http::field::accept, "application/json");
- for (const auto [field, value] : Headers)
+ for (const auto& [name, value] : Headers)
{
- req.set(field, value);
+ req.set(name, value);
}
http::write(context->stream, req);
@@ -718,7 +742,7 @@ std::unique_ptr DockerHTTPClient::SendRequ
}
std::pair DockerHTTPClient::SendRequest(
- verb Method, const URL& Url, const std::string& Body, const std::map& Headers)
+ verb Method, const URL& Url, const std::string& Body, const std::map& Headers)
{
// Write the request
auto context = SendRequestImpl(Method, Url, Body, Headers);
diff --git a/src/windows/wslcsession/DockerHTTPClient.h b/src/windows/wslcsession/DockerHTTPClient.h
index fe9d23f66..34628098f 100644
--- a/src/windows/wslcsession/DockerHTTPClient.h
+++ b/src/windows/wslcsession/DockerHTTPClient.h
@@ -166,10 +166,13 @@ class DockerHTTPClient
std::vector absentLabels;
};
- std::unique_ptr PullImage(const std::string& Repo, const std::optional& tagOrDigest);
+ std::unique_ptr PullImage(
+ const std::string& Repo, const std::optional& tagOrDigest, const std::optional& registryAuth = std::nullopt);
std::unique_ptr ImportImage(const std::string& Repo, const std::string& Tag, uint64_t ContentLength);
std::unique_ptr LoadImage(uint64_t ContentLength);
void TagImage(const std::string& Id, const std::string& Repo, const std::string& Tag);
+ std::unique_ptr PushImage(const std::string& ImageName, const std::optional& tag, const std::string& registryAuth);
+ std::string Authenticate(const std::string& serverAddress, const std::string& username, const std::string& password);
std::vector ListImages(bool all = false, bool digests = false, const ListImagesFilters& filters = {});
common::docker_schema::InspectImage InspectImage(const std::string& NameOrId);
std::vector DeleteImage(const char* Image, bool Force, bool NoPrune); // Image can be ID or Repo:Tag.
@@ -240,13 +243,13 @@ class DockerHTTPClient
wil::unique_socket ConnectSocket();
std::unique_ptr SendRequestImpl(
- boost::beast::http::verb Method, const URL& Url, const std::string& Body, const std::map& Headers);
+ boost::beast::http::verb Method, const URL& Url, const std::string& Body, const std::map& Headers = {});
std::pair SendRequestAndReadResponse(
boost::beast::http::verb Method, const URL& Url, const std::string& Body = "");
std::pair SendRequest(
- boost::beast::http::verb Method, const URL& Url, const std::string& Body, const std::map& Headers = {});
+ boost::beast::http::verb Method, const URL& Url, const std::string& Body, const std::map& Headers = {});
template
auto Transaction(boost::beast::http::verb Method, const URL& Url, const TRequest& RequestObject = {})
diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp
index 820ffb4df..f82c4af73 100644
--- a/src/windows/wslcsession/WSLCSession.cpp
+++ b/src/windows/wslcsession/WSLCSession.cpp
@@ -15,7 +15,6 @@ Module Name:
#include "precomp.h"
#include "WSLCSession.h"
#include "WSLCContainer.h"
-#include
#include "ServiceProcessLauncher.h"
#include "WslCoreFilesystem.h"
@@ -30,20 +29,6 @@ constexpr auto c_containerdStorage = "/var/lib/docker";
namespace {
-std::string Base64Decode(const std::string& encoded)
-{
- DWORD size = 0;
- THROW_IF_WIN32_BOOL_FALSE(CryptStringToBinaryA(
- encoded.c_str(), static_cast(encoded.size()), CRYPT_STRING_BASE64, nullptr, &size, nullptr, nullptr));
-
- std::string result(size, '\0');
- THROW_IF_WIN32_BOOL_FALSE(CryptStringToBinaryA(
- encoded.c_str(), static_cast(encoded.size()), CRYPT_STRING_BASE64, reinterpret_cast(result.data()), &size, nullptr, nullptr));
-
- result.resize(size);
- return result;
-}
-
// Resolve \r overwrites: for each \n-delimited line, keep only the content after the last \r.
// This collapses terminal progress updates (e.g. "50%\r75%\r100%") to their final state.
std::string ResolveCarriageReturns(const std::string& input)
@@ -134,19 +119,6 @@ std::string IndentLines(const std::string& input, const std::string& prefix)
return result;
}
-std::pair> ParseImage(const std::string& Input)
-{
- size_t separator = Input.find_last_of(':');
- if (separator == std::string::npos)
- {
- return {Input, {}};
- }
-
- THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessageWslcInvalidImage(Input), separator >= Input.size() - 1 || separator == 0);
-
- return {Input.substr(0, separator), Input.substr(separator + 1)};
-}
-
void ValidateName(LPCSTR Name)
{
const auto& locale = std::locale::classic();
@@ -461,26 +433,8 @@ void WSLCSession::StartDockerd()
m_dockerdProcess->GetExitEvent(), std::bind(&WSLCSession::OnDockerdExited, this)));
}
-HRESULT WSLCSession::PullImage(LPCSTR Image, LPCSTR RegistryAuthenticationInformation, IProgressCallback* ProgressCallback)
-try
+void WSLCSession::StreamImageOperation(DockerHTTPClient::HTTPRequestContext& requestContext, LPCSTR Image, LPCSTR OperationName, IProgressCallback* ProgressCallback)
{
- COMServiceExecutionContext context;
-
- RETURN_HR_IF_NULL(E_POINTER, Image);
- RETURN_HR_IF(E_NOTIMPL, RegistryAuthenticationInformation != nullptr && *RegistryAuthenticationInformation != '\0');
-
- auto lock = m_lock.lock_shared();
- THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value());
-
- auto [repo, tagOrDigest] = wslutil::ParseImage(Image);
-
- if (!tagOrDigest.has_value())
- {
- tagOrDigest = "latest";
- }
-
- auto requestContext = m_dockerClient->PullImage(repo, tagOrDigest);
-
auto io = CreateIOContext();
struct Response
@@ -489,19 +443,22 @@ try
bool isJson = false;
};
- std::optional pullResponse;
+ std::optional httpResponse;
auto onHttpResponse = [&](const boost::beast::http::message& response) {
- WSL_LOG("PullHttpResponse", TraceLoggingValue(static_cast(response.result()), "StatusCode"));
+ WSL_LOG(
+ "ImageOperationHttpResponse",
+ TraceLoggingValue(OperationName, "Operation"),
+ TraceLoggingValue(static_cast(response.result()), "StatusCode"));
auto it = response.find(boost::beast::http::field::content_type);
- pullResponse.emplace(response.result(), it != response.end() && it->value().starts_with("application/json"));
+ httpResponse.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 (httpResponse.has_value() && httpResponse->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());
@@ -509,7 +466,11 @@ try
}
std::string contentString{Content.begin(), Content.end()};
- WSL_LOG("ImagePullProgress", TraceLoggingValue(Image, "Image"), TraceLoggingValue(contentString.c_str(), "Content"));
+ WSL_LOG(
+ "ImageOperationProgress",
+ TraceLoggingValue(OperationName, "Operation"),
+ TraceLoggingValue(Image, "Image"),
+ TraceLoggingValue(contentString.c_str(), "Content"));
auto parsed = wsl::shared::FromJson(contentString.c_str());
@@ -519,7 +480,8 @@ try
{
LOG_HR_MSG(
E_UNEXPECTED,
- "Received multiple error messages during image pull. Previous: %hs, New: %hs",
+ "Received multiple error messages during image %hs. Previous: %hs, New: %hs",
+ OperationName,
reportedError->c_str(),
parsed.errorDetail->message.c_str());
}
@@ -538,18 +500,18 @@ try
auto onCompleted = [&]() { io.Cancel(); };
io.AddHandle(std::make_unique(
- *requestContext, std::move(onHttpResponse), std::move(onChunk), std::move(onCompleted)));
+ requestContext, std::move(onHttpResponse), std::move(onChunk), std::move(onCompleted)));
io.Run({});
- THROW_HR_IF(E_UNEXPECTED, !pullResponse.has_value());
+ THROW_HR_IF(E_UNEXPECTED, !httpResponse.has_value());
- if (pullResponse->result != boost::beast::http::status::ok)
+ if (httpResponse->result != boost::beast::http::status::ok)
{
std::string errorMessage;
- if (pullResponse->isJson)
+ if (httpResponse->isJson)
{
- // pull failed, parse the error message.
+ // operation failed, parse the error message.
errorMessage = wsl::shared::FromJson(errorJson.c_str()).message;
}
else
@@ -558,11 +520,11 @@ try
errorMessage = errorJson;
}
- if (pullResponse->result == boost::beast::http::status::not_found)
+ if (httpResponse->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 (httpResponse->result == boost::beast::http::status::bad_request)
{
THROW_HR_WITH_USER_ERROR(E_INVALIDARG, errorMessage);
}
@@ -576,6 +538,34 @@ try
// 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());
}
+}
+
+HRESULT WSLCSession::PullImage(LPCSTR Image, LPCSTR RegistryAuthenticationInformation, IProgressCallback* ProgressCallback)
+try
+{
+ COMServiceExecutionContext context;
+
+ RETURN_HR_IF_NULL(E_POINTER, Image);
+
+ auto lock = m_lock.lock_shared();
+ THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value());
+
+ auto [repo, tagOrDigest] = wslutil::ParseImage(Image);
+
+ if (!tagOrDigest.has_value())
+ {
+ tagOrDigest = "latest";
+ }
+
+ std::optional registryAuth;
+
+ if (RegistryAuthenticationInformation != nullptr && *RegistryAuthenticationInformation != '\0')
+ {
+ registryAuth = std::string(RegistryAuthenticationInformation);
+ }
+
+ auto requestContext = m_dockerClient->PullImage(repo, tagOrDigest, registryAuth);
+ StreamImageOperation(*requestContext, Image, "Pull", ProgressCallback);
return S_OK;
}
@@ -682,7 +672,7 @@ try
continue;
}
- std::string decoded = Base64Decode(log.data);
+ std::string decoded = wslutil::Base64Decode(log.data);
if (!decoded.empty())
{
auto& logBuffer = vertexLogs[log.vertex];
@@ -1274,6 +1264,25 @@ try
}
CATCH_RETURN();
+HRESULT WSLCSession::PushImage(LPCSTR Image, LPCSTR RegistryAuthenticationInformation, IProgressCallback* ProgressCallback)
+try
+{
+ COMServiceExecutionContext context;
+
+ RETURN_HR_IF_NULL(E_POINTER, Image);
+ RETURN_HR_IF_NULL(E_POINTER, RegistryAuthenticationInformation);
+
+ auto lock = m_lock.lock_shared();
+ THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value());
+
+ auto [repo, tagOrDigest] = wslutil::ParseImage(Image);
+ auto requestContext = m_dockerClient->PushImage(repo, tagOrDigest, RegistryAuthenticationInformation);
+ StreamImageOperation(*requestContext, Image, "Push", ProgressCallback);
+
+ return S_OK;
+}
+CATCH_RETURN();
+
HRESULT WSLCSession::InspectImage(_In_ LPCSTR ImageNameOrId, _Out_ LPSTR* Output)
try
{
@@ -1317,6 +1326,35 @@ try
}
CATCH_RETURN();
+HRESULT WSLCSession::Authenticate(_In_ LPCSTR ServerAddress, _In_ LPCSTR Username, _In_ LPCSTR Password, _Out_ LPSTR* IdentityToken)
+try
+{
+ COMServiceExecutionContext context;
+
+ RETURN_HR_IF_NULL(E_POINTER, ServerAddress);
+ RETURN_HR_IF_NULL(E_POINTER, Username);
+ RETURN_HR_IF_NULL(E_POINTER, Password);
+ RETURN_HR_IF_NULL(E_POINTER, IdentityToken);
+
+ *IdentityToken = nullptr;
+
+ auto lock = m_lock.lock_shared();
+ THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value());
+
+ wil::unique_cotaskmem_ansistring token;
+
+ try
+ {
+ auto response = m_dockerClient->Authenticate(ServerAddress, Username, Password);
+ token = wil::make_unique_ansistring(response.c_str());
+ }
+ CATCH_AND_THROW_DOCKER_USER_ERROR("Failed to authenticate with registry: %hs", ServerAddress);
+
+ *IdentityToken = token.release();
+ return S_OK;
+}
+CATCH_RETURN();
+
HRESULT WSLCSession::PruneImages(const WSLCPruneImagesOptions* Options, WSLCDeletedImageInformation** DeletedImages, ULONG* DeletedImagesCount, ULONGLONG* SpaceReclaimed)
try
{
diff --git a/src/windows/wslcsession/WSLCSession.h b/src/windows/wslcsession/WSLCSession.h
index 43dfb2a00..84f3792f1 100644
--- a/src/windows/wslcsession/WSLCSession.h
+++ b/src/windows/wslcsession/WSLCSession.h
@@ -81,7 +81,9 @@ class DECLSPEC_UUID("4877FEFC-4977-4929-A958-9F36AA1892A4") WSLCSession
IFACEMETHOD(ListImages)(_In_opt_ const WSLCListImageOptions* Options, _Out_ WSLCImageInformation** Images, _Out_ ULONG* Count) override;
IFACEMETHOD(DeleteImage)(_In_ const WSLCDeleteImageOptions* Options, _Out_ WSLCDeletedImageInformation** DeletedImages, _Out_ ULONG* Count) override;
IFACEMETHOD(TagImage)(_In_ const WSLCTagImageOptions* Options) override;
+ IFACEMETHOD(PushImage)(_In_ LPCSTR Image, _In_ LPCSTR RegistryAuthenticationInformation, _In_opt_ IProgressCallback* ProgressCallback) override;
IFACEMETHOD(InspectImage)(_In_ LPCSTR ImageNameOrId, _Out_ LPSTR* Output) override;
+ IFACEMETHOD(Authenticate)(_In_ LPCSTR ServerAddress, _In_ LPCSTR Username, _In_ LPCSTR Password, _Out_ LPSTR* IdentityToken) override;
IFACEMETHOD(PruneImages)(
_In_opt_ const WSLCPruneImagesOptions* Options,
_Out_ WSLCDeletedImageInformation** DeletedImages,
@@ -138,6 +140,7 @@ class DECLSPEC_UUID("4877FEFC-4977-4929-A958-9F36AA1892A4") WSLCSession
void RecoverExistingVolumes();
void SaveImageImpl(std::pair& RequestCodePair, WSLCHandle OutputHandle, HANDLE CancelEvent);
+ void StreamImageOperation(DockerHTTPClient::HTTPRequestContext& requestContext, LPCSTR Image, LPCSTR OperationName, IProgressCallback* ProgressCallback);
std::optional m_dockerClient;
std::optional m_virtualMachine;
diff --git a/test/windows/CMakeLists.txt b/test/windows/CMakeLists.txt
index e2f8b2caf..7af8e465f 100644
--- a/test/windows/CMakeLists.txt
+++ b/test/windows/CMakeLists.txt
@@ -36,7 +36,8 @@ target_link_libraries(wsltests
VirtDisk.lib
Wer.lib
Dbghelp.lib
- sfc.lib)
+ sfc.lib
+ Crypt32.lib)
add_dependencies(wsltests wslserviceidl wslclib wslc wslcsdk)
add_subdirectory(testplugin)
diff --git a/test/windows/Common.cpp b/test/windows/Common.cpp
index f775c2ecf..948a5f826 100644
--- a/test/windows/Common.cpp
+++ b/test/windows/Common.cpp
@@ -2843,6 +2843,10 @@ std::filesystem::path GetTestImagePath(std::string_view imageName)
{
result /= L"HelloWorldSaved.tar";
}
+ else if (imageName == "wslc-registry:latest")
+ {
+ result /= L"wslc-registry.tar";
+ }
else
{
THROW_HR_MSG(E_INVALIDARG, "Unknown test image: %hs", imageName.data());
diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp
index 5f5df349b..826962237 100644
--- a/test/windows/WSLCTests.cpp
+++ b/test/windows/WSLCTests.cpp
@@ -85,6 +85,21 @@ class WSLCTests
LoadTestImage("python:3.12-alpine");
}
+ if (!hasImage("hello-world:latest"))
+ {
+ LoadTestImage("hello-world:latest");
+ }
+
+ if (!hasImage("alpine:latest"))
+ {
+ LoadTestImage("alpine:latest");
+ }
+
+ if (!hasImage("wslc-registry:latest"))
+ {
+ LoadTestImage("wslc-registry:latest");
+ }
+
PruneResult result;
VERIFY_SUCCEEDED(m_defaultSession->PruneContainers(nullptr, 0, 0, &result.result));
if (result.result.ContainersCount > 0)
@@ -167,6 +182,52 @@ class WSLCTests
return RunningWSLCContainer(std::move(rawContainer), {});
}
+ std::pair StartLocalRegistry(const std::string& username = {}, const std::string& password = {}, USHORT port = 5000)
+ {
+ std::vector env = {std::format("REGISTRY_HTTP_ADDR=0.0.0.0:{}", port)};
+ if (!username.empty())
+ {
+ env.push_back(std::format("USERNAME={}", username));
+ env.push_back(std::format("PASSWORD={}", password));
+ }
+
+ WSLCContainerLauncher launcher("wslc-registry:latest", {}, {}, env);
+ launcher.SetEntrypoint({"/entrypoint.sh"});
+ launcher.AddPort(port, port, AF_INET);
+
+ auto container = launcher.Launch(*m_defaultSession, WSLCContainerStartFlagsNone);
+
+ auto registryAddress = std::format("127.0.0.1:{}", port);
+ auto registryUrl = std::format(L"http://{}", registryAddress);
+ ExpectHttpResponse(registryUrl.c_str(), 200, true);
+
+ return {std::move(container), std::move(registryAddress)};
+ }
+
+ std::string PushImageToRegistry(const std::string& imageName, const std::string& registryAddress, const std::string& registryAuth)
+ {
+ auto [repo, tag] = ParseImage(imageName);
+ auto registryImage = std::format("{}/{}:{}", registryAddress, repo, tag.value_or("latest"));
+ auto registryRepo = std::format("{}/{}", registryAddress, repo);
+ auto registryTag = tag.value_or("latest");
+
+ WSLCTagImageOptions tagOptions{};
+ tagOptions.Image = imageName.c_str();
+ tagOptions.Repo = registryRepo.c_str();
+ tagOptions.Tag = registryTag.c_str();
+
+ // Tag the image with the registry address so it can be pushed.
+ VERIFY_SUCCEEDED(m_defaultSession->TagImage(&tagOptions));
+
+ // Ensures the tag is removed to allow tests to try to push or pull the same image again.
+ auto cleanup = wil::scope_exit_log(
+ WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_FAILED(DeleteImageNoThrow(registryImage, WSLCDeleteImageFlagsNone).first); });
+
+ VERIFY_SUCCEEDED(m_defaultSession->PushImage(registryImage.c_str(), registryAuth.c_str(), nullptr));
+
+ return registryImage;
+ }
+
WSLC_TEST_METHOD(GetVersion)
{
wil::com_ptr sessionManager;
@@ -418,27 +479,18 @@ class WSLCTests
WSLC_TEST_METHOD(PullImage)
{
{
- HRESULT pullResult = m_defaultSession->PullImage("hello-world:linux", nullptr, nullptr);
+ // Start a local registry without auth and push hello-world:latest to it.
+ auto [registryContainer, registryAddress] = StartLocalRegistry();
- // Skip test if error is due to rate limit.
- if (pullResult == E_FAIL)
- {
- auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo();
- if (comError.has_value())
- {
- if (wcsstr(comError->Message.get(), L"toomanyrequests") != nullptr)
- {
- LogWarning("Skipping PullImage test due to rate limiting.");
- return;
- }
- }
- }
+ auto image = PushImageToRegistry("hello-world:latest", registryAddress, BuildRegistryAuthHeader("", "", registryAddress));
+ ExpectImagePresent(*m_defaultSession, image.c_str(), false);
- VERIFY_SUCCEEDED(pullResult);
+ VERIFY_SUCCEEDED(m_defaultSession->PullImage(image.c_str(), nullptr, nullptr));
+ auto cleanup = wil::scope_exit([&]() { LOG_IF_FAILED(DeleteImageNoThrow(image, WSLCDeleteImageFlagsForce).first); });
// Verify that the image is in the list of images.
- ExpectImagePresent(*m_defaultSession, "hello-world:linux");
- WSLCContainerLauncher launcher("hello-world:linux", "wslc-pull-image-container");
+ ExpectImagePresent(*m_defaultSession, image.c_str());
+ WSLCContainerLauncher launcher(image, "wslc-pull-image-container");
auto container = launcher.Launch(*m_defaultSession);
auto result = container.GetInitProcess().WaitAndCaptureOutput();
@@ -453,10 +505,7 @@ class WSLCTests
L"access to the resource is denied";
VERIFY_ARE_EQUAL(m_defaultSession->PullImage("does-not:exist", nullptr, nullptr), WSLC_E_IMAGE_NOT_FOUND);
- auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo();
- VERIFY_IS_TRUE(comError.has_value());
-
- VERIFY_ARE_EQUAL(expectedError, comError->Message.get());
+ ValidateCOMErrorMessage(expectedError.c_str());
}
// Validate that PullImage() returns the appropriate error if the session is terminated.
@@ -473,7 +522,30 @@ class WSLCTests
WSLC_TEST_METHOD(PullImageAdvanced)
{
- // TODO: Enable once custom registries are supported, to avoid hitting public registry rate limits.
+ // Start a local registry without auth to avoid Docker Hub rate limits.
+ auto [registryContainer, registryAddress] = StartLocalRegistry();
+ auto auth = BuildRegistryAuthHeader("", "", registryAddress);
+
+ auto validatePull = [&](const std::string& sourceImage) {
+ // Push the source image to the local registry.
+ auto registryImage = PushImageToRegistry(sourceImage, registryAddress, auth);
+ ExpectImagePresent(*m_defaultSession, registryImage.c_str(), false);
+
+ VERIFY_SUCCEEDED(m_defaultSession->PullImage(registryImage.c_str(), nullptr, nullptr));
+
+ auto cleanup =
+ wil::scope_exit([&]() { LOG_IF_FAILED(DeleteImageNoThrow(registryImage, WSLCDeleteImageFlagsForce).first); });
+
+ ExpectImagePresent(*m_defaultSession, registryImage.c_str());
+ };
+
+ validatePull("debian:latest");
+ validatePull("alpine:latest");
+ validatePull("hello-world:latest");
+ }
+
+ WSLC_TEST_METHOD(PullImageFromDockerHub)
+ {
SKIP_TEST_UNSTABLE();
auto validatePull = [&](const std::string& Image, const std::optional& ExpectedTag = {}) {
@@ -528,17 +600,58 @@ class WSLCTests
VERIFY_ARE_EQUAL(session->PullImage("pytorch/pytorch", nullptr, nullptr), E_FAIL);
- auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo();
- VERIFY_IS_TRUE(comError.has_value());
+ ValidateCOMErrorMessageContains(L"no space left on device");
+ }
+ }
- // 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();
- }
+ WSLC_TEST_METHOD(PushImage)
+ {
+ auto emptyAuth = BuildRegistryAuthHeader("", "", "");
+
+ // Validate that pushing a non-existent image fails.
+ {
+ VERIFY_ARE_EQUAL(m_defaultSession->PushImage("does-not-exist:latest", emptyAuth.c_str(), nullptr), E_FAIL);
+ ValidateCOMErrorMessage(L"An image does not exist locally with the tag: does-not-exist");
+ }
+
+ // Validate passing empty auth string returns an appropriate error.
+ {
+ VERIFY_ARE_EQUAL(m_defaultSession->PushImage("does-not-exist:latest", "", nullptr), E_INVALIDARG);
}
+
+ // Validate that PushImage() returns the appropriate error if the session is terminated.
+ {
+ VERIFY_SUCCEEDED(m_defaultSession->Terminate());
+ auto cleanup = wil::scope_exit([&]() { ResetTestSession(); });
+
+ VERIFY_ARE_EQUAL(m_defaultSession->PushImage("hello-world:latest", emptyAuth.c_str(), nullptr), HRESULT_FROM_WIN32(ERROR_INVALID_STATE));
+ }
+ }
+
+ WSLC_TEST_METHOD(Authenticate)
+ {
+ constexpr auto c_username = "wslctest";
+ constexpr auto c_password = "password";
+
+ auto [registryContainer, registryAddress] = StartLocalRegistry(c_username, c_password);
+
+ wil::unique_cotaskmem_ansistring token;
+ VERIFY_ARE_EQUAL(m_defaultSession->Authenticate(registryAddress.c_str(), c_username, "wrong-password", &token), E_FAIL);
+ ValidateCOMErrorMessageContains(L"failed with status: 401 Unauthorized");
+
+ VERIFY_SUCCEEDED(m_defaultSession->Authenticate(registryAddress.c_str(), c_username, c_password, &token));
+ VERIFY_IS_NOT_NULL(token.get());
+
+ auto xRegistryAuth = BuildRegistryAuthHeader(c_username, c_password, registryAddress);
+ auto image = PushImageToRegistry("hello-world:latest", registryAddress, xRegistryAuth);
+
+ // Pulling without credentials should fail.
+ VERIFY_ARE_EQUAL(m_defaultSession->PullImage(image.c_str(), nullptr, nullptr), E_FAIL);
+ ValidateCOMErrorMessageContains(L"no basic auth credentials");
+
+ // Pulling with credentials should succeed.
+ VERIFY_SUCCEEDED(m_defaultSession->PullImage(image.c_str(), xRegistryAuth.c_str(), nullptr));
+ ExpectImagePresent(*m_defaultSession, image.c_str());
}
WSLC_TEST_METHOD(ListImages)
@@ -1002,6 +1115,31 @@ class WSLCTests
}
}
+ void ValidateCOMErrorMessageContains(const std::wstring& ExpectedSubstring)
+ {
+ auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo();
+
+ if (comError.has_value())
+ {
+ if (!comError->Message)
+ {
+ LogError("Expected COM error containing: '%ls', but COM error message was null", ExpectedSubstring.c_str());
+ VERIFY_FAIL();
+ }
+
+ if (wcsstr(comError->Message.get(), ExpectedSubstring.c_str()) == nullptr)
+ {
+ LogError("Expected COM error containing: '%ls', but got: '%ls'", ExpectedSubstring.c_str(), comError->Message.get());
+ VERIFY_FAIL();
+ }
+ }
+ else
+ {
+ LogError("Expected COM error containing: '%ls' but none was set", ExpectedSubstring.c_str());
+ VERIFY_FAIL();
+ }
+ }
+
HRESULT BuildImageFromContext(const std::filesystem::path& contextDir, const WSLCBuildImageOptions* options)
{
auto dockerfileHandle = wil::open_file((contextDir / "Dockerfile").c_str());
@@ -1578,16 +1716,8 @@ class WSLCTests
VERIFY_ARE_EQUAL(E_ABORT, result.get_future().get());
}
- TEST_METHOD(AnonymousVolumes)
+ WSLC_TEST_METHOD(AnonymousVolumes)
{
- // TODO: Add more test coverage once anonymous volumes are fully supported and switch to using -v instead of building an image.
-
- if (!LxsstuVmMode())
- {
- LogSkipped("This test is only applicable to WSL2");
- return;
- }
-
auto contextDir = std::filesystem::current_path() / "build-context";
std::filesystem::create_directories(contextDir);
auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
diff --git a/test/windows/WslcSdkTests.cpp b/test/windows/WslcSdkTests.cpp
index f67e22e7a..55c29ebcc 100644
--- a/test/windows/WslcSdkTests.cpp
+++ b/test/windows/WslcSdkTests.cpp
@@ -15,6 +15,8 @@ Module Name:
#include "precomp.h"
#include "Common.h"
#include "wslcsdk.h"
+#include "WslcsdkPrivate.h"
+#include "WSLCContainerLauncher.h"
#include "wslc_schema.h"
#include
@@ -201,7 +203,7 @@ class WslcSdkTests
VERIFY_SUCCEEDED(WslcCreateSession(&sessionSettings, &m_defaultSession, nullptr));
// Pull images required by the tests (no-op if already present).
- for (const char* image : {"debian:latest", "python:3.12-alpine"})
+ for (const char* image : {"debian:latest", "python:3.12-alpine", "hello-world:latest", "wslc-registry:latest"})
{
LoadTestImage(image);
}
@@ -323,60 +325,6 @@ class WslcSdkTests
// Image tests
// -----------------------------------------------------------------------
- WSLC_TEST_METHOD(PullImage)
- {
- // Positive: pull a well-known image.
- {
- WslcPullImageOptions opts{};
- opts.uri = "hello-world:linux";
- wil::unique_cotaskmem_string errorMsg;
- HRESULT pullResult = WslcPullSessionImage(m_defaultSession, &opts, &errorMsg);
-
- // Skip test if error is due to rate limit.
- if (pullResult == E_FAIL)
- {
- if (errorMsg)
- {
- if (wcsstr(errorMsg.get(), L"toomanyrequests") != nullptr)
- {
- LogWarning("Skipping PullImage test due to rate limiting.");
- return;
- }
- }
- }
-
- VERIFY_SUCCEEDED(pullResult);
-
- // Verify the image is usable by running a container from it.
- auto output = RunContainerAndCapture(m_defaultSession, "hello-world:linux", {});
- VERIFY_IS_TRUE(output.stdoutOutput.find("Hello from Docker!") != std::string::npos);
- }
-
- // Negative: pull an image that does not exist.
- {
- WslcPullImageOptions opts{};
- opts.uri = "does-not:exist";
- wil::unique_cotaskmem_string errorMsg;
- VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), WSLC_E_IMAGE_NOT_FOUND);
-
- // An error message should be present.
- VERIFY_IS_NOT_NULL(errorMsg.get());
- }
-
- // Negative: null options pointer must fail.
- {
- wil::unique_cotaskmem_string errorMsg;
- VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, nullptr, &errorMsg), E_POINTER);
- }
-
- // Negative: null URI inside options must fail.
- {
- WslcPullImageOptions opts{};
- opts.uri = nullptr;
- VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG);
- }
- }
-
WSLC_TEST_METHOD(ImageList)
{
// Positive: session has images pre-loaded — list must return at least one entry.
@@ -565,34 +513,17 @@ class WslcSdkTests
WSLC_TEST_METHOD(ImageDelete)
{
- auto checkForImage = [this](std::string_view image) -> bool {
- WslcImageInfo* images = nullptr;
- uint32_t count = 0;
- VERIFY_SUCCEEDED(WslcListSessionImages(m_defaultSession, &images, &count));
- auto cleanupImages = wil::scope_exit([images]() { CoTaskMemFree(images); });
- bool found = false;
- for (uint32_t i = 0; i < count; ++i)
- {
- if (images[i].name == image)
- {
- found = true;
- break;
- }
- }
- return found;
- };
-
- // Setup: load hello-world:latest so we have something to delete.
- LoadTestImage("hello-world:latest");
-
- VERIFY_IS_TRUE(checkForImage("hello-world:latest"));
+ VERIFY_IS_TRUE(HasImage("hello-world:latest"));
// Positive: delete an existing image.
wil::unique_cotaskmem_string errorMsg;
VERIFY_SUCCEEDED(WslcDeleteSessionImage(m_defaultSession, "hello-world:latest", &errorMsg));
// Verify the image is no longer present in the list.
- VERIFY_IS_FALSE(checkForImage("hello-world:latest"));
+ VERIFY_IS_FALSE(HasImage("hello-world:latest"));
+
+ // Reload the image for subsequent tests.
+ LoadTestImage("hello-world:latest");
// Negative: null name must fail.
VERIFY_ARE_EQUAL(WslcDeleteSessionImage(m_defaultSession, nullptr, nullptr), E_POINTER);
@@ -1976,6 +1907,284 @@ class WslcSdkTests
}
}
+ // -----------------------------------------------------------------------
+ // Authentication helpers
+ // -----------------------------------------------------------------------
+
+ // Starts a local registry container with host-mode networking and returns [container, registryAddress].
+ // Uses the COM API (via GetInternalType) with WSLCContainerLauncher to get host-mode networking,
+ // which the SDK doesn't expose. Host networking shares the VM's network namespace, so the registry
+ // is reachable at 127.0.0.1: from both dockerd (inside the VM) and the host.
+ std::pair StartLocalRegistry(
+ const std::string& username = {}, const std::string& password = {}, uint16_t port = 5000)
+ {
+ VERIFY_IS_TRUE(HasImage("wslc-registry:latest"));
+
+ std::vector env = {std::format("REGISTRY_HTTP_ADDR=0.0.0.0:{}", port)};
+ if (!username.empty())
+ {
+ env.push_back(std::format("USERNAME={}", username));
+ env.push_back(std::format("PASSWORD={}", password));
+ }
+
+ wsl::windows::common::WSLCContainerLauncher launcher("wslc-registry:latest", {}, {}, env);
+ launcher.SetEntrypoint({"/entrypoint.sh"});
+ launcher.AddPort(port, port, AF_INET);
+
+ // Get the IWSLCSession COM object from the SDK session handle.
+ auto& session = *reinterpret_cast(m_defaultSession)->session;
+ auto container = launcher.Launch(session, WSLCContainerStartFlagsNone);
+
+ auto registryAddress = std::format("127.0.0.1:{}", port);
+
+ // Wait for the registry to be ready by probing from the host.
+ auto hostUrl = std::format(L"http://{}", registryAddress);
+ ExpectHttpResponse(hostUrl.c_str(), 200, true);
+
+ return {std::move(container), registryAddress};
+ }
+
+ // Tags and pushes an image to a local registry via the SDK APIs.
+ void PushImageToRegistry(const std::string& repo, const std::string& tag, const std::string& registryAddress, const std::string& registryAuth)
+ {
+ auto imageName = std::format("{}:{}", repo, tag);
+ auto registryImage = std::format("{}/{}:{}", registryAddress, repo, tag);
+ auto registryRepo = std::format("{}/{}", registryAddress, repo);
+
+ VERIFY_IS_TRUE(HasImage(imageName));
+
+ // Tag the image with the registry address so it can be pushed.
+ WslcTagImageOptions tagOptions{};
+ tagOptions.image = imageName.c_str();
+ tagOptions.repo = registryRepo.c_str();
+ tagOptions.tag = tag.c_str();
+ VERIFY_SUCCEEDED(WslcTagSessionImage(m_defaultSession, &tagOptions, nullptr));
+
+ // Ensures the registry-prefixed tag is removed after the push.
+ auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
+ LOG_IF_FAILED(WslcDeleteSessionImage(m_defaultSession, registryImage.c_str(), nullptr));
+ });
+
+ WslcPushImageOptions pushOptions{};
+ pushOptions.image = registryImage.c_str();
+ pushOptions.registryAuth = registryAuth.c_str();
+ VERIFY_SUCCEEDED(WslcPushSessionImage(m_defaultSession, &pushOptions, nullptr));
+ }
+
+ bool HasImage(const std::string& imageName)
+ {
+ wil::unique_cotaskmem_array_ptr images;
+ VERIFY_SUCCEEDED(WslcListSessionImages(m_defaultSession, images.addressof(), images.size_address()));
+
+ for (const auto& image : images)
+ {
+ if (image.name == imageName)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // -----------------------------------------------------------------------
+ // Authentication tests
+ // -----------------------------------------------------------------------
+
+ WSLC_TEST_METHOD(AuthenticateTests)
+ {
+ constexpr auto c_username = "wslctest";
+ constexpr auto c_password = "password";
+
+ auto [registryContainer, registryAddress] = StartLocalRegistry(c_username, c_password);
+
+ // Negative: wrong password must fail.
+ {
+ wil::unique_cotaskmem_ansistring token;
+ wil::unique_cotaskmem_string errorMsg;
+ VERIFY_ARE_EQUAL(
+ WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), c_username, "wrong-password", &token, &errorMsg), E_FAIL);
+ VERIFY_IS_NOT_NULL(errorMsg.get());
+ }
+
+ // Positive: correct credentials must succeed and return a non-null token.
+ {
+ wil::unique_cotaskmem_ansistring token;
+ wil::unique_cotaskmem_string errorMsg;
+ VERIFY_SUCCEEDED(WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), c_username, c_password, &token, &errorMsg));
+ VERIFY_IS_NOT_NULL(token.get());
+ }
+
+ auto xRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader(c_username, c_password, registryAddress);
+ PushImageToRegistry("hello-world", "latest", registryAddress, xRegistryAuth);
+
+ auto image = std::format("{}/hello-world:latest", registryAddress);
+
+ // Pulling with credentials should succeed.
+ {
+ WslcPullImageOptions opts{};
+ opts.uri = image.c_str();
+ opts.registryAuth = xRegistryAuth.c_str();
+ VERIFY_SUCCEEDED(WslcPullSessionImage(m_defaultSession, &opts, nullptr));
+ VERIFY_IS_TRUE(HasImage(image));
+ }
+
+ // Negative: Pulling without credentials should fail.
+ {
+ WslcPullImageOptions opts{};
+ opts.uri = image.c_str();
+
+ wil::unique_cotaskmem_string errorMsg;
+ VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), E_FAIL);
+ VERIFY_IS_NOT_NULL(errorMsg.get());
+ }
+
+ // Negative: Pulling with bad credentials should fail.
+ {
+ auto badAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader(c_username, "wrong", registryAddress);
+
+ WslcPullImageOptions opts{};
+ opts.uri = image.c_str();
+ opts.registryAuth = badAuth.c_str();
+
+ wil::unique_cotaskmem_string errorMsg;
+ VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), E_FAIL);
+ VERIFY_IS_NOT_NULL(errorMsg.get());
+ }
+
+ // Negative: null parameters must fail.
+ {
+ wil::unique_cotaskmem_ansistring token;
+ VERIFY_ARE_EQUAL(WslcSessionAuthenticate(m_defaultSession, nullptr, c_username, c_password, &token, nullptr), E_POINTER);
+ VERIFY_ARE_EQUAL(WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), nullptr, c_password, &token, nullptr), E_POINTER);
+ VERIFY_ARE_EQUAL(WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), c_username, nullptr, &token, nullptr), E_POINTER);
+ VERIFY_ARE_EQUAL(WslcSessionAuthenticate(m_defaultSession, registryAddress.c_str(), c_username, c_password, nullptr, nullptr), E_POINTER);
+ }
+ }
+
+ WSLC_TEST_METHOD(PullImage)
+ {
+ // Start a local registry without auth to avoid Docker Hub rate limits.
+ auto [registryContainer, registryAddress] = StartLocalRegistry();
+ auto xRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader("", "", registryAddress);
+
+ {
+ // Push hello-world:latest to the local registry.
+ PushImageToRegistry("hello-world", "latest", registryAddress, xRegistryAuth);
+
+ auto image = std::format("{}/hello-world:latest", registryAddress);
+
+ // Delete the image locally so the pull is a real pull.
+ WslcDeleteSessionImage(m_defaultSession, image.c_str(), nullptr);
+
+ // Pull from the local registry.
+ {
+ WslcPullImageOptions opts{};
+ opts.uri = image.c_str();
+ VERIFY_SUCCEEDED(WslcPullSessionImage(m_defaultSession, &opts, nullptr));
+ }
+
+ // Verify the pulled image is in the image list.
+ VERIFY_IS_TRUE(HasImage(image));
+
+ // Verify the image is usable by running a container from it.
+ auto output = RunContainerAndCapture(m_defaultSession, image.c_str(), {});
+ VERIFY_IS_TRUE(output.stdoutOutput.find("Hello from Docker!") != std::string::npos);
+ }
+
+ // Negative: pull an image that does not exist.
+ {
+ auto image = std::format("{}/does-not-exist", registryAddress);
+
+ WslcPullImageOptions opts{};
+ opts.uri = image.c_str();
+ opts.registryAuth = xRegistryAuth.c_str();
+
+ wil::unique_cotaskmem_string errorMsg;
+ VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), WSLC_E_IMAGE_NOT_FOUND);
+ }
+
+ // Negative: null URI inside options must fail.
+ {
+ WslcPullImageOptions opts{};
+ opts.uri = nullptr;
+
+ wil::unique_cotaskmem_string errorMsg;
+ VERIFY_ARE_EQUAL(WslcPullSessionImage(m_defaultSession, &opts, &errorMsg), E_INVALIDARG);
+ }
+ }
+
+ WSLC_TEST_METHOD(PushImage)
+ {
+ auto emptyRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader("", "", "");
+
+ // Negative: pushing a non-existent image must fail.
+ {
+ WslcPushImageOptions opts{};
+ opts.image = "does-not-exist";
+ opts.registryAuth = emptyRegistryAuth.c_str();
+
+ wil::unique_cotaskmem_string errorMsg;
+ VERIFY_ARE_EQUAL(WslcPushSessionImage(m_defaultSession, &opts, &errorMsg), E_FAIL);
+ VERIFY_IS_NOT_NULL(errorMsg.get());
+ }
+
+ // Negative: null options must fail.
+ VERIFY_ARE_EQUAL(WslcPushSessionImage(m_defaultSession, nullptr, nullptr), E_POINTER);
+
+ // Negative: null image inside options must fail.
+ {
+ WslcPushImageOptions opts{};
+ opts.image = nullptr;
+ opts.registryAuth = emptyRegistryAuth.c_str();
+
+ VERIFY_ARE_EQUAL(WslcPushSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG);
+ }
+ }
+
+ WSLC_TEST_METHOD(TagImage)
+ {
+ // Positive: tag an existing image.
+ {
+ WslcTagImageOptions opts{};
+ opts.image = "debian:latest";
+ opts.repo = "debian";
+ opts.tag = "sdk-test-tag";
+ VERIFY_SUCCEEDED(WslcTagSessionImage(m_defaultSession, &opts, nullptr));
+
+ // Verify the tag is present.
+ VERIFY_IS_TRUE(HasImage("debian:sdk-test-tag"));
+
+ // Cleanup: delete the tag.
+ WslcDeleteSessionImage(m_defaultSession, "debian:sdk-test-tag", nullptr);
+ }
+
+ // Negative: null options must fail.
+ VERIFY_ARE_EQUAL(WslcTagSessionImage(m_defaultSession, nullptr, nullptr), E_POINTER);
+
+ // Negative: null fields must fail.
+ {
+ WslcTagImageOptions opts{};
+ opts.image = nullptr;
+ opts.repo = "debian";
+ opts.tag = "test";
+ VERIFY_ARE_EQUAL(WslcTagSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG);
+ }
+ {
+ WslcTagImageOptions opts{};
+ opts.image = "debian:latest";
+ opts.repo = nullptr;
+ opts.tag = "test";
+ VERIFY_ARE_EQUAL(WslcTagSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG);
+ }
+ {
+ WslcTagImageOptions opts{};
+ opts.image = "debian:latest";
+ opts.repo = "debian";
+ opts.tag = nullptr;
+ VERIFY_ARE_EQUAL(WslcTagSessionImage(m_defaultSession, &opts, nullptr), E_INVALIDARG);
+ }
+ }
+
// -----------------------------------------------------------------------
// Stub tests for unimplemented (E_NOTIMPL) functions.
// Each of these confirms the current state of the SDK; once the underlying
diff --git a/tools/test/images/build-image.ps1 b/tools/test/images/build-image.ps1
new file mode 100644
index 000000000..b9b11c7e4
--- /dev/null
+++ b/tools/test/images/build-image.ps1
@@ -0,0 +1,48 @@
+<#
+.SYNOPSIS
+ Builds a custom test registry image using wslc and saves it as a .tar file.
+.DESCRIPTION
+ This script builds a custom image using wslc from a specified Dockerfile and saves the resulting image as a .tar file.
+ This is useful for preparing test images for WSL container tests.
+.PARAMETER DockerfileDir
+ Path to the directory containing the Dockerfile to build.
+.PARAMETER ImageTag
+ Tag for the built image.
+.PARAMETER OutputFile
+ Path to save the exported .tar file. Defaults to .tar in the current directory.
+#>
+
+[CmdletBinding(SupportsShouldProcess)]
+param (
+ [string]$DockerfileDir,
+ [string]$ImageTag,
+ [string]$OutputFile = ""
+)
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+if ($OutputFile -eq "") {
+ $OutputFile = Join-Path $PWD "$(Split-Path -Leaf $DockerfileDir).tar"
+}
+
+# Verify $OutputFile is a valid path, we can write to it, and that it has a .tar extension
+if ([System.IO.Path]::GetExtension($OutputFile) -ne ".tar") {
+ if (-not $PSCmdlet.ShouldContinue("Are you sure you want to continue?", "Output file '$OutputFile' is not a .tar file.")) {
+ throw "Aborting due to invalid output file extension."
+ }
+}
+
+
+if ($PSCmdlet.ShouldProcess($ImageTag, "Build image from '$DockerfileDir'")) {
+ & wslc build -t $ImageTag $DockerfileDir
+ if ($LASTEXITCODE -ne 0) { throw "wslc build failed with exit code $LASTEXITCODE" }
+}
+
+if ($PSCmdlet.ShouldProcess($OutputFile, "Save image '$ImageTag'")) {
+ & wslc save --output $OutputFile $ImageTag
+ if ($LASTEXITCODE -ne 0) { throw "wslc save failed with exit code $LASTEXITCODE" }
+
+ Write-Host "Image built and saved to $OutputFile successfully."
+}
+
diff --git a/tools/test/images/wslc-registry/Dockerfile b/tools/test/images/wslc-registry/Dockerfile
new file mode 100644
index 000000000..6d5084810
--- /dev/null
+++ b/tools/test/images/wslc-registry/Dockerfile
@@ -0,0 +1,8 @@
+FROM registry:3
+
+RUN apk add --no-cache apache2-utils
+
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/tools/test/images/wslc-registry/entrypoint.sh b/tools/test/images/wslc-registry/entrypoint.sh
new file mode 100644
index 000000000..08333fab4
--- /dev/null
+++ b/tools/test/images/wslc-registry/entrypoint.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+set -e
+
+if [ -n "$USERNAME" ]; then
+ mkdir -p /auth
+ htpasswd -Bbn "$USERNAME" "$PASSWORD" > /auth/htpasswd
+
+ export REGISTRY_AUTH=htpasswd
+ export REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd
+ export REGISTRY_AUTH_HTPASSWD_REALM="WSLC Registry"
+fi
+
+exec registry serve /etc/distribution/config.yml