diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index cfd811544..491e5802b 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -2281,6 +2281,12 @@ For privacy information about this product please visit https://aka.ms/privacy.< Pulls images. + + Upload an image to a registry. + + + Upload an image to a registry. + Remove images. @@ -2293,6 +2299,71 @@ For privacy information about this product please visit https://aka.ms/privacy.< Saves images. + + Log in to a registry. + + + Log in to a registry. If no server is specified, the default is defined by the session. + + + Log out from a registry. + + + Log out from a registry. If no server is specified, the default is defined by the session. + + + Login Succeeded + + + Removing login credentials for {} + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Not logged in to {} + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Failed to parse credentials file '{}': the file may be corrupted. + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Failed to write '{}': {} + {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + + Server + + + Username + + + Password or Personal Access Token (PAT) + {Locked="PAT"}Acronym should not be translated + + + Take the Password or Personal Access Token (PAT) from stdin + {Locked="PAT"}{Locked="stdin"}Technical terms should not be translated + + + --password and --password-stdin are mutually exclusive + {Locked="--password "}{Locked="--password-stdin "}Command line arguments, file names and string inserts should not be translated + + + Must provide --username with --password-stdin + {Locked="--username "}{Locked="--password-stdin"}Command line arguments, file names and string inserts should not be translated + + + Username: + + + Password: + + + Manage registry credentials. + + + Manage registry credentials, including logging in and out of container registries. + Tag an image. diff --git a/src/windows/common/WSLCContainerLauncher.cpp b/src/windows/common/WSLCContainerLauncher.cpp index 53ce52cf3..78eb56403 100644 --- a/src/windows/common/WSLCContainerLauncher.cpp +++ b/src/windows/common/WSLCContainerLauncher.cpp @@ -11,6 +11,7 @@ Module Name: This file contains the implementation for WSLCContainerLauncher. --*/ + #include "precomp.h" #include "WSLCContainerLauncher.h" diff --git a/src/windows/common/WSLCUserSettings.cpp b/src/windows/common/WSLCUserSettings.cpp index 977f03635..af26c9b3d 100644 --- a/src/windows/common/WSLCUserSettings.cpp +++ b/src/windows/common/WSLCUserSettings.cpp @@ -49,7 +49,10 @@ static constexpr std::string_view s_DefaultSettingsTemplate = " # Default path for session storage. By default, storage is per-session under:\n" " # %LocalAppData%\\wslc\\sessions\\wslc-cli (standard sessions)\n" " # %LocalAppData%\\wslc\\sessions\\wslc-cli-admin (elevated sessions)\n" - " # defaultStoragePath: \"\"\n"; + " # defaultStoragePath: \"\"\n" + "\n" + "# Credential storage backend: \"wincred\" or \"file\" (default: wincred)\n" + "# credentialStore: wincred\n"; // Validate individual setting specializations namespace details { @@ -123,6 +126,20 @@ namespace details { return value; } + WSLC_VALIDATE_SETTING(CredentialStore) + { + if (value == "wincred") + { + return CredentialStoreType::WinCred; + } + if (value == "file") + { + return CredentialStoreType::File; + } + + return std::nullopt; + } + #undef WSLC_VALIDATE_SETTING } // namespace details diff --git a/src/windows/common/WSLCUserSettings.h b/src/windows/common/WSLCUserSettings.h index d47da6b5e..f0201ca22 100644 --- a/src/windows/common/WSLCUserSettings.h +++ b/src/windows/common/WSLCUserSettings.h @@ -42,6 +42,7 @@ enum class Setting : size_t SessionNetworkingMode, SessionHostFileShareMode, SessionDnsTunneling, + CredentialStore, Max }; @@ -52,6 +53,12 @@ enum class HostFileShareMode VirtioFs }; +enum class CredentialStoreType +{ + WinCred, + File +}; + namespace details { template @@ -83,6 +90,7 @@ namespace details { DEFINE_SETTING_MAPPING(SessionNetworkingMode, std::string, WSLCNetworkingMode, WSLCNetworkingModeVirtioProxy, "session.networkingMode") DEFINE_SETTING_MAPPING(SessionHostFileShareMode, std::string, HostFileShareMode, HostFileShareMode::VirtioFs, "session.hostFileShareMode") DEFINE_SETTING_MAPPING(SessionDnsTunneling, bool, bool, true, "session.dnsTunneling") + DEFINE_SETTING_MAPPING(CredentialStore, std::string, CredentialStoreType, CredentialStoreType::WinCred, "credentialStore") #undef DEFINE_SETTING_MAPPING // clang-format on diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp index 318374cca..b1299a24d 100644 --- a/src/windows/common/wslutil.cpp +++ b/src/windows/common/wslutil.cpp @@ -1448,17 +1448,15 @@ std::string wsl::windows::common::wslutil::Base64Decode(const std::string& encod return result; } -std::string wsl::windows::common::wslutil::BuildRegistryAuthHeader(const std::string& username, const std::string& password, const std::string& serverAddress) +std::string wsl::windows::common::wslutil::BuildRegistryAuthHeader(const std::string& username, const std::string& password) { - nlohmann::json authJson = {{"username", username}, {"password", password}, {"serveraddress", serverAddress}}; - + nlohmann::json authJson = {{"username", username}, {"password", password}}; return Base64Encode(authJson.dump()); } -std::string wsl::windows::common::wslutil::BuildRegistryAuthHeader(const std::string& identityToken, const std::string& serverAddress) +std::string wsl::windows::common::wslutil::BuildRegistryAuthHeader(const std::string& identityToken) { - nlohmann::json authJson = {{"identitytoken", identityToken}, {"serveraddress", serverAddress}}; - + nlohmann::json authJson = {{"identitytoken", identityToken}}; return Base64Encode(authJson.dump()); } @@ -1484,4 +1482,4 @@ std::map wsl::windows::common::wslutil::ParseKeyValueP } return result; -} \ No newline at end of file +} diff --git a/src/windows/common/wslutil.h b/src/windows/common/wslutil.h index 63663e573..25d5f5b87 100644 --- a/src/windows/common/wslutil.h +++ b/src/windows/common/wslutil.h @@ -335,11 +335,11 @@ 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); +std::string BuildRegistryAuthHeader(const std::string& username, const std::string& password); // 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); +std::string BuildRegistryAuthHeader(const std::string& identityToken); std::map ParseKeyValuePairs(_In_reads_opt_(count) const KeyValuePair* pairs, ULONG count, _In_opt_ LPCSTR reservedKey = nullptr); diff --git a/src/windows/wslc/CMakeLists.txt b/src/windows/wslc/CMakeLists.txt index b1552aa9a..3988c1da1 100644 --- a/src/windows/wslc/CMakeLists.txt +++ b/src/windows/wslc/CMakeLists.txt @@ -14,7 +14,10 @@ target_include_directories(wslclib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${WSLC_SUB target_link_libraries(wslclib ${COMMON_LINK_LIBRARIES} yaml-cpp - common) + common + advapi32 + crypt32) + target_precompile_headers(wslclib REUSE_FROM common) set_target_properties(wslclib PROPERTIES FOLDER windows) diff --git a/src/windows/wslc/arguments/ArgumentDefinitions.h b/src/windows/wslc/arguments/ArgumentDefinitions.h index 32d74f832..cdde9b05e 100644 --- a/src/windows/wslc/arguments/ArgumentDefinitions.h +++ b/src/windows/wslc/arguments/ArgumentDefinitions.h @@ -64,6 +64,8 @@ _(NoCache, "no-cache", NO_ALIAS, Kind::Flag, L _(NoPrune, "no-prune", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NoPruneArgDescription()) \ _(NoTrunc, "no-trunc", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_NoTruncArgDescription()) \ _(Output, "output", L"o", Kind::Value, Localization::WSLCCLI_OutputArgDescription()) \ +_(Password, "password", L"p", Kind::Value, Localization::WSLCCLI_LoginPasswordArgDescription()) \ +_(PasswordStdin, "password-stdin", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_LoginPasswordStdinArgDescription()) \ _(Path, "path", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_PathArgDescription()) \ /*_(Progress, "progress", NO_ALIAS, Kind::Value, Localization::WSLCCLI_ProgressArgDescription())*/ \ _(Publish, "publish", L"p", Kind::Value, Localization::WSLCCLI_PublishArgDescription()) \ @@ -71,6 +73,7 @@ _(Publish, "publish", L"p", Kind::Value, L _(Quiet, "quiet", L"q", Kind::Flag, Localization::WSLCCLI_QuietArgDescription()) \ _(Remove, "rm", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_RemoveArgDescription()) \ /*_(Scheme, "scheme", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SchemeArgDescription())*/ \ +_(Server, "server", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_LoginServerArgDescription()) \ _(Session, "session", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SessionIdArgDescription()) \ _(SessionId, "session-id", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_SessionIdPositionalArgDescription()) \ _(StoragePath, "storage-path", NO_ALIAS, Kind::Positional, L"Path to the session storage directory") \ @@ -82,6 +85,7 @@ _(Time, "time", L"t", Kind::Value, L _(TMPFS, "tmpfs", NO_ALIAS, Kind::Value, Localization::WSLCCLI_TMPFSArgDescription()) \ _(TTY, "tty", L"t", Kind::Flag, Localization::WSLCCLI_TTYArgDescription()) \ _(User, "user", L"u", Kind::Value, Localization::WSLCCLI_UserArgDescription()) \ +_(Username, "username", L"u", Kind::Value, Localization::WSLCCLI_LoginUsernameArgDescription()) \ _(Verbose, "verbose", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_VerboseArgDescription()) \ _(Version, "version", L"v", Kind::Flag, Localization::WSLCCLI_VersionArgDescription()) \ /*_(Virtual, "virtualization", NO_ALIAS, Kind::Value, Localization::WSLCCLI_VirtualArgDescription())*/ \ diff --git a/src/windows/wslc/commands/ImageCommand.cpp b/src/windows/wslc/commands/ImageCommand.cpp index 1583f704f..c229fd057 100644 --- a/src/windows/wslc/commands/ImageCommand.cpp +++ b/src/windows/wslc/commands/ImageCommand.cpp @@ -28,6 +28,7 @@ std::vector> ImageCommand::GetCommands() const commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); + commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); return commands; diff --git a/src/windows/wslc/commands/ImageCommand.h b/src/windows/wslc/commands/ImageCommand.h index 6b0aef21b..f574a2796 100644 --- a/src/windows/wslc/commands/ImageCommand.h +++ b/src/windows/wslc/commands/ImageCommand.h @@ -146,6 +146,21 @@ struct ImagePullCommand final : public Command void ExecuteInternal(CLIExecutionContext& context) const override; }; +// Push Command +struct ImagePushCommand final : public Command +{ + constexpr static std::wstring_view CommandName = L"push"; + ImagePushCommand(const std::wstring& parent) : Command(CommandName, parent) + { + } + std::vector GetArguments() const override; + std::wstring ShortDescription() const override; + std::wstring LongDescription() const override; + +protected: + void ExecuteInternal(CLIExecutionContext& context) const override; +}; + // Save Command struct ImageSaveCommand final : public Command { diff --git a/src/windows/wslc/commands/ImagePushCommand.cpp b/src/windows/wslc/commands/ImagePushCommand.cpp new file mode 100644 index 000000000..8db9d28cf --- /dev/null +++ b/src/windows/wslc/commands/ImagePushCommand.cpp @@ -0,0 +1,51 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + ImagePushCommand.cpp + +Abstract: + + Implementation of the image push command. + +--*/ + +#include "ImageCommand.h" +#include "CLIExecutionContext.h" +#include "ImageTasks.h" +#include "SessionTasks.h" +#include "Task.h" + +using namespace wsl::windows::wslc::execution; +using namespace wsl::windows::wslc::task; +using namespace wsl::shared; + +namespace wsl::windows::wslc { +// Image Push Command +std::vector ImagePushCommand::GetArguments() const +{ + return { + Argument::Create(ArgType::ImageId, true), + Argument::Create(ArgType::Session), + }; +} + +std::wstring ImagePushCommand::ShortDescription() const +{ + return Localization::WSLCCLI_ImagePushDesc(); +} + +std::wstring ImagePushCommand::LongDescription() const +{ + return Localization::WSLCCLI_ImagePushLongDesc(); +} + +void ImagePushCommand::ExecuteInternal(CLIExecutionContext& context) const +{ + context // + << CreateSession // + << PushImage; +} +} // namespace wsl::windows::wslc diff --git a/src/windows/wslc/commands/RegistryCommand.cpp b/src/windows/wslc/commands/RegistryCommand.cpp new file mode 100644 index 000000000..16a207d7b --- /dev/null +++ b/src/windows/wslc/commands/RegistryCommand.cpp @@ -0,0 +1,182 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + RegistryCommand.cpp + +Abstract: + + Implementation of the registry command tree (login, logout). + +--*/ + +#include "CLIExecutionContext.h" +#include "RegistryCommand.h" +#include "RegistryTasks.h" +#include "SessionTasks.h" +#include "Task.h" +#include + +using namespace wsl::windows::wslc::execution; +using namespace wsl::windows::wslc::task; +using namespace wsl::shared; + +namespace { + +auto MaskInput() +{ + HANDLE input = GetStdHandle(STD_INPUT_HANDLE); + DWORD mode = 0; + + if ((input != INVALID_HANDLE_VALUE) && GetConsoleMode(input, &mode)) + { + THROW_IF_WIN32_BOOL_FALSE(SetConsoleMode(input, mode & ~ENABLE_ECHO_INPUT)); + return wil::scope_exit(std::function([input, mode] { + SetConsoleMode(input, mode); + std::wcerr << L'\n'; + })); + } + + return wil::scope_exit(std::function([] {})); +} + +std::wstring Prompt(const std::wstring& label, bool maskInput) +{ + // Write without a trailing newline so the cursor stays inline (matching Docker's behavior). + std::wcerr << label; + + auto restoreConsole = maskInput ? MaskInput() : wil::scope_exit(std::function([] {})); + + std::wstring value; + std::getline(std::wcin, value); + + return value; +} + +} // namespace + +namespace wsl::windows::wslc { + +// Registry Root Command +std::vector> RegistryCommand::GetCommands() const +{ + std::vector> commands; + commands.push_back(std::make_unique(FullName())); + commands.push_back(std::make_unique(FullName())); + return commands; +} + +std::vector RegistryCommand::GetArguments() const +{ + return {}; +} + +std::wstring RegistryCommand::ShortDescription() const +{ + return Localization::WSLCCLI_RegistryCommandDesc(); +} + +std::wstring RegistryCommand::LongDescription() const +{ + return Localization::WSLCCLI_RegistryCommandLongDesc(); +} + +void RegistryCommand::ExecuteInternal(CLIExecutionContext& context) const +{ + OutputHelp(); +} + +// Registry Login Command +std::vector RegistryLoginCommand::GetArguments() const +{ + return { + Argument::Create(ArgType::Password), + Argument::Create(ArgType::PasswordStdin), + Argument::Create(ArgType::Username), + Argument::Create(ArgType::Server), + Argument::Create(ArgType::Session), + }; +} + +std::wstring RegistryLoginCommand::ShortDescription() const +{ + return Localization::WSLCCLI_LoginDesc(); +} + +std::wstring RegistryLoginCommand::LongDescription() const +{ + return Localization::WSLCCLI_LoginLongDesc(); +} + +void RegistryLoginCommand::ValidateArgumentsInternal(const ArgMap& execArgs) const +{ + if (execArgs.Contains(ArgType::Password) && execArgs.Contains(ArgType::PasswordStdin)) + { + throw CommandException(Localization::WSLCCLI_LoginPasswordAndStdinMutuallyExclusive()); + } + + if (execArgs.Contains(ArgType::PasswordStdin) && !execArgs.Contains(ArgType::Username)) + { + throw CommandException(Localization::WSLCCLI_LoginPasswordStdinRequiresUsername()); + } +} + +void RegistryLoginCommand::ExecuteInternal(CLIExecutionContext& context) const +{ + // Prompt for username if not provided. + if (!context.Args.Contains(ArgType::Username)) + { + context.Args.Add(ArgType::Username, Prompt(Localization::WSLCCLI_LoginUsernamePrompt(), false)); + } + + // Resolve password: --password, --password-stdin, or interactive prompt. + if (!context.Args.Contains(ArgType::Password)) + { + if (context.Args.Contains(ArgType::PasswordStdin)) + { + std::wstring line; + std::getline(std::wcin, line); + if (!line.empty() && line.back() == L'\r') + { + line.pop_back(); + } + + context.Args.Add(ArgType::Password, std::move(line)); + } + else + { + context.Args.Add(ArgType::Password, Prompt(Localization::WSLCCLI_LoginPasswordPrompt(), true)); + } + } + + context // + << CreateSession << Login; +} + +// Registry Logout Command +std::vector RegistryLogoutCommand::GetArguments() const +{ + return { + Argument::Create(ArgType::Server), + }; +} + +std::wstring RegistryLogoutCommand::ShortDescription() const +{ + return Localization::WSLCCLI_LogoutDesc(); +} + +std::wstring RegistryLogoutCommand::LongDescription() const +{ + return Localization::WSLCCLI_LogoutLongDesc(); +} + +void RegistryLogoutCommand::ExecuteInternal(CLIExecutionContext& context) const +{ + context // + << Logout; +} + +} // namespace wsl::windows::wslc diff --git a/src/windows/wslc/commands/RegistryCommand.h b/src/windows/wslc/commands/RegistryCommand.h new file mode 100644 index 000000000..ed8d04e3e --- /dev/null +++ b/src/windows/wslc/commands/RegistryCommand.h @@ -0,0 +1,71 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + RegistryCommand.h + +Abstract: + + Declaration of the registry command tree (login, logout). + +--*/ +#pragma once +#include "Command.h" + +namespace wsl::windows::wslc { + +// Root registry command: wslc registry [login|logout] +struct RegistryCommand final : public Command +{ + constexpr static std::wstring_view CommandName = L"registry"; + RegistryCommand(const std::wstring& parent) : Command(CommandName, parent) + { + } + + std::vector> GetCommands() const override; + std::vector GetArguments() const override; + std::wstring ShortDescription() const override; + std::wstring LongDescription() const override; + +protected: + void ExecuteInternal(CLIExecutionContext& context) const override; +}; + +// Login Command +struct RegistryLoginCommand final : public Command +{ + constexpr static std::wstring_view CommandName = L"login"; + + RegistryLoginCommand(const std::wstring& parent) : Command(CommandName, parent) + { + } + + std::vector GetArguments() const override; + std::wstring ShortDescription() const override; + std::wstring LongDescription() const override; + +protected: + void ValidateArgumentsInternal(const ArgMap& execArgs) const override; + void ExecuteInternal(CLIExecutionContext& context) const override; +}; + +// Logout Command +struct RegistryLogoutCommand final : public Command +{ + constexpr static std::wstring_view CommandName = L"logout"; + + RegistryLogoutCommand(const std::wstring& parent) : Command(CommandName, parent) + { + } + + std::vector GetArguments() const override; + std::wstring ShortDescription() const override; + std::wstring LongDescription() const override; + +protected: + void ExecuteInternal(CLIExecutionContext& context) const override; +}; + +} // namespace wsl::windows::wslc diff --git a/src/windows/wslc/commands/RootCommand.cpp b/src/windows/wslc/commands/RootCommand.cpp index 091bc1cdb..d92c563de 100644 --- a/src/windows/wslc/commands/RootCommand.cpp +++ b/src/windows/wslc/commands/RootCommand.cpp @@ -16,6 +16,7 @@ Module Name: // Include all commands that parent to the root. #include "ContainerCommand.h" #include "ImageCommand.h" +#include "RegistryCommand.h" #include "SessionCommand.h" #include "SettingsCommand.h" #include "VersionCommand.h" @@ -29,6 +30,7 @@ std::vector> RootCommand::GetCommands() const std::vector> commands; commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); + commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); @@ -40,8 +42,11 @@ std::vector> RootCommand::GetCommands() const commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); + commands.push_back(std::make_unique(FullName())); + commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); + commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName())); commands.push_back(std::make_unique(FullName(), true)); commands.push_back(std::make_unique(FullName())); diff --git a/src/windows/wslc/services/ContainerService.cpp b/src/windows/wslc/services/ContainerService.cpp index 217796df0..28164d1c7 100644 --- a/src/windows/wslc/services/ContainerService.cpp +++ b/src/windows/wslc/services/ContainerService.cpp @@ -16,7 +16,7 @@ Module Name: #include "ContainerService.h" #include "ConsoleService.h" #include "ImageService.h" -#include "PullImageCallback.h" +#include "ImageProgressCallback.h" #include #include #include @@ -117,7 +117,7 @@ static wsl::windows::common::RunningWSLCContainer CreateInternal(Session& sessio { { // Attempt to pull the image if not found - PullImageCallback callback; + ImageProgressCallback callback; PrintMessage(Localization::WSLCCLI_ImageNotFoundPulling(wsl::shared::string::MultiByteToWide(image)), stderr); ImageService imageService; imageService.Pull(session, image, &callback); diff --git a/src/windows/wslc/services/FileCredStorage.cpp b/src/windows/wslc/services/FileCredStorage.cpp new file mode 100644 index 000000000..56b67336c --- /dev/null +++ b/src/windows/wslc/services/FileCredStorage.cpp @@ -0,0 +1,254 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + FileCredStorage.cpp + +Abstract: + + DPAPI-encrypted JSON file credential storage implementation. + +--*/ + +#include "precomp.h" +#include "FileCredStorage.h" + +using wsl::shared::Localization; + +using namespace wsl::shared; +using namespace wsl::windows::common::wslutil; +using namespace wsl::windows::wslc::services; + +namespace { + +std::filesystem::path GetFilePath() +{ + return wsl::windows::common::filesystem::GetLocalAppDataPath(nullptr) / L"wslc" / L"registry-credentials.json"; +} + +wil::unique_file RetryOpenFileOnSharingViolation(const std::function& openFunc) +{ + try + { + return wsl::shared::retry::RetryWithTimeout(openFunc, std::chrono::milliseconds(100), std::chrono::seconds(1), []() { + return wil::ResultFromCaughtException() == HRESULT_FROM_WIN32(ERROR_SHARING_VIOLATION); + }); + } + catch (...) + { + auto result = wil::ResultFromCaughtException(); + auto errorString = wsl::windows::common::wslutil::GetSystemErrorString(result); + THROW_HR_WITH_USER_ERROR(result, Localization::MessageWslcFailedToOpenFile(GetFilePath(), errorString)); + } +} + +wil::unique_file OpenFileExclusive() +{ + wil::unique_file f(_wfsopen(GetFilePath().c_str(), L"r+b", _SH_DENYRW)); + if (!f) + { + auto dosError = _doserrno; + if (dosError == ERROR_FILE_NOT_FOUND || dosError == ERROR_PATH_NOT_FOUND) + { + return nullptr; + } + + THROW_WIN32_IF(dosError, dosError != 0); + THROW_HR(E_FAIL); + } + + return f; +} + +wil::unique_file CreateFileExclusive() +{ + auto filePath = GetFilePath(); + std::filesystem::create_directories(filePath.parent_path()); + + using UniqueFd = wil::unique_any; + + UniqueFd fd; + auto err = _wsopen_s(fd.addressof(), filePath.c_str(), _O_RDWR | _O_CREAT | _O_BINARY, _SH_DENYRW, _S_IREAD | _S_IWRITE); + if (err != 0) + { + auto dosError = _doserrno; + THROW_WIN32_IF(dosError, dosError != 0); + THROW_HR(E_FAIL); + } + + wil::unique_file f(_fdopen(fd.get(), "r+b")); + if (!f) + { + auto dosError = _doserrno; + THROW_WIN32_IF(dosError, dosError != 0); + THROW_HR(E_FAIL); + } + + return f; +} + +wil::unique_file OpenFileShared() +{ + wil::unique_file f(_wfsopen(GetFilePath().c_str(), L"rb", _SH_DENYWR)); + if (!f) + { + auto dosError = _doserrno; + if (dosError == ERROR_FILE_NOT_FOUND || dosError == ERROR_PATH_NOT_FOUND) + { + return nullptr; + } + + THROW_WIN32_IF(dosError, dosError != 0); + THROW_HR(E_FAIL); + } + + return f; +} + +CredentialFile ReadCredentialFile(FILE* f) +{ + WI_ASSERT(f != nullptr); + + auto seekResult = fseek(f, 0, SEEK_SET); + THROW_HR_WITH_USER_ERROR_IF(E_FAIL, Localization::MessageWslcFailedToOpenFile(GetFilePath(), _wcserror(errno)), seekResult != 0); + + // Handle newly created empty files (from CreateFileExclusive). + if (_filelengthi64(_fileno(f)) <= 0) + { + return {}; + } + + try + { + return nlohmann::json::parse(f).get(); + } + catch (const nlohmann::json::exception&) + { + THROW_HR_WITH_USER_ERROR(WSL_E_INVALID_JSON, Localization::WSLCCLI_CredentialFileCorrupt(GetFilePath())); + } +} + +void WriteCredentialFile(FILE* f, const CredentialFile& data) +{ + auto error = fseek(f, 0, SEEK_SET); + THROW_HR_WITH_USER_ERROR_IF(E_FAIL, Localization::MessageWslcFailedToWriteFile(GetFilePath(), _wcserror(errno)), error != 0); + + error = _chsize_s(_fileno(f), 0); + THROW_HR_WITH_USER_ERROR_IF(E_FAIL, Localization::MessageWslcFailedToWriteFile(GetFilePath(), _wcserror(error)), error != 0); + + auto content = nlohmann::json(data).dump(2); + auto written = fwrite(content.data(), 1, content.size(), f); + THROW_HR_WITH_USER_ERROR_IF( + E_FAIL, Localization::MessageWslcFailedToWriteFile(GetFilePath(), _wcserror(errno)), written != content.size()); +} + +void ModifyFileStore(FILE* f, const std::function& modifier) +{ + auto data = ReadCredentialFile(f); + + if (modifier(data)) + { + WriteCredentialFile(f, data); + } +} + +std::string Protect(const std::string& plaintext) +{ + DATA_BLOB input{}; + input.cbData = static_cast(plaintext.size()); + input.pbData = reinterpret_cast(const_cast(plaintext.data())); + + DATA_BLOB output{}; + THROW_IF_WIN32_BOOL_FALSE(CryptProtectData(&input, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &output)); + auto cleanup = wil::scope_exit([&]() { LocalFree(output.pbData); }); + + return Base64Encode(std::string(reinterpret_cast(output.pbData), output.cbData)); +} + +std::string Unprotect(const std::string& cipherBase64) +{ + auto decoded = Base64Decode(cipherBase64); + + DATA_BLOB input{}; + input.cbData = static_cast(decoded.size()); + input.pbData = reinterpret_cast(decoded.data()); + + DATA_BLOB output{}; + THROW_IF_WIN32_BOOL_FALSE(CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &output)); + auto cleanup = wil::scope_exit([&]() { LocalFree(output.pbData); }); + + return std::string(reinterpret_cast(output.pbData), output.cbData); +} + +} // namespace + +namespace wsl::windows::wslc::services { + +void FileCredStorage::Store(const std::string& serverAddress, const std::string& username, const std::string& secret) +{ + auto file = RetryOpenFileOnSharingViolation(CreateFileExclusive); + + ModifyFileStore(file.get(), [&](CredentialFile& data) { + data.Credentials[serverAddress] = CredentialEntry{username, Protect(secret)}; + return true; + }); +} + +std::pair FileCredStorage::Get(const std::string& serverAddress) +{ + auto file = RetryOpenFileOnSharingViolation(OpenFileShared); + if (!file) + { + return {}; + } + + auto data = ReadCredentialFile(file.get()); + const auto entry = data.Credentials.find(serverAddress); + + if (entry == data.Credentials.end()) + { + return {}; + } + + return {entry->second.UserName, Unprotect(entry->second.Secret)}; +} + +void FileCredStorage::Erase(const std::string& serverAddress) +{ + auto file = RetryOpenFileOnSharingViolation(OpenFileExclusive); + bool erased = false; + + if (file) + { + ModifyFileStore(file.get(), [&](CredentialFile& data) { + erased = data.Credentials.erase(serverAddress) > 0; + return erased; + }); + } + + THROW_HR_WITH_USER_ERROR_IF(E_NOT_SET, Localization::WSLCCLI_LogoutNotFound(wsl::shared::string::MultiByteToWide(serverAddress)), !erased); +} + +std::vector FileCredStorage::List() +{ + auto file = RetryOpenFileOnSharingViolation(OpenFileShared); + if (!file) + { + return {}; + } + + auto data = ReadCredentialFile(file.get()); + + std::vector result; + + for (const auto& [key, value] : data.Credentials) + { + result.push_back(wsl::shared::string::MultiByteToWide(key)); + } + + return result; +} + +} // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/services/FileCredStorage.h b/src/windows/wslc/services/FileCredStorage.h new file mode 100644 index 000000000..5b03e7c84 --- /dev/null +++ b/src/windows/wslc/services/FileCredStorage.h @@ -0,0 +1,47 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + FileCredStorage.h + +Abstract: + + DPAPI-encrypted JSON file credential storage backend. + +--*/ +#pragma once + +#include "ICredentialStorage.h" +#include "JsonUtils.h" + +namespace wsl::windows::wslc::services { + +inline constexpr int CredentialFileVersion = 1; + +struct CredentialEntry +{ + std::string UserName; + std::string Secret; + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(CredentialEntry, UserName, Secret); +}; + +struct CredentialFile +{ + int Version = CredentialFileVersion; + std::map Credentials; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(CredentialFile, Version, Credentials); +}; + +class FileCredStorage final : public ICredentialStorage +{ +public: + void Store(const std::string& serverAddress, const std::string& username, const std::string& secret) override; + std::pair Get(const std::string& serverAddress) override; + void Erase(const std::string& serverAddress) override; + std::vector List() override; +}; + +} // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/services/ICredentialStorage.cpp b/src/windows/wslc/services/ICredentialStorage.cpp new file mode 100644 index 000000000..6cdd5c2c4 --- /dev/null +++ b/src/windows/wslc/services/ICredentialStorage.cpp @@ -0,0 +1,33 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + ICredentialStorage.cpp + +Abstract: + + Factory for credential storage backends. + +--*/ + +#include "ICredentialStorage.h" +#include "FileCredStorage.h" +#include "WinCredStorage.h" +#include "WSLCUserSettings.h" + +namespace wsl::windows::wslc::services { + +std::unique_ptr OpenCredentialStorage() +{ + auto backend = settings::User().Get(); + if (backend == settings::CredentialStoreType::File) + { + return std::make_unique(); + } + + return std::make_unique(); +} + +} // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/services/ICredentialStorage.h b/src/windows/wslc/services/ICredentialStorage.h new file mode 100644 index 000000000..0ceef96a2 --- /dev/null +++ b/src/windows/wslc/services/ICredentialStorage.h @@ -0,0 +1,36 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + ICredentialStorage.h + +Abstract: + + Interface for credential storage backends. + +--*/ +#pragma once + +#include +#include +#include + +namespace wsl::windows::wslc::services { + +// Abstract interface for credential storage backends (WinCred, file-based, etc.). +struct ICredentialStorage +{ + virtual ~ICredentialStorage() = default; + + virtual void Store(const std::string& serverAddress, const std::string& username, const std::string& secret) = 0; + virtual std::pair Get(const std::string& serverAddress) = 0; + virtual void Erase(const std::string& serverAddress) = 0; + virtual std::vector List() = 0; +}; + +// Returns the credential storage implementation based on user configuration. +std::unique_ptr OpenCredentialStorage(); + +} // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/services/PullImageCallback.cpp b/src/windows/wslc/services/ImageProgressCallback.cpp similarity index 84% rename from src/windows/wslc/services/PullImageCallback.cpp rename to src/windows/wslc/services/ImageProgressCallback.cpp index ebcfd9eb0..412df1096 100644 --- a/src/windows/wslc/services/PullImageCallback.cpp +++ b/src/windows/wslc/services/ImageProgressCallback.cpp @@ -4,16 +4,16 @@ Copyright (c) Microsoft. All rights reserved. Module Name: - PullImageCallback.cpp + ImageProgressCallback.cpp Abstract: - This file contains the PullImageCallback Implementation. + This file contains the ImageProgressCallback Implementation. --*/ #include "precomp.h" -#include "PullImageCallback.h" +#include "ImageProgressCallback.h" #include "ImageService.h" #include @@ -42,7 +42,7 @@ ChangeTerminalMode::~ChangeTerminalMode() } } -auto PullImageCallback::MoveToLine(SHORT line) +auto ImageProgressCallback::MoveToLine(SHORT line) { if (line > 0) { @@ -57,7 +57,7 @@ auto PullImageCallback::MoveToLine(SHORT line) }); } -HRESULT PullImageCallback::OnProgress(LPCSTR status, LPCSTR id, ULONGLONG current, ULONGLONG total) +HRESULT ImageProgressCallback::OnProgress(LPCSTR status, LPCSTR id, ULONGLONG current, ULONGLONG total) { try { @@ -94,14 +94,14 @@ HRESULT PullImageCallback::OnProgress(LPCSTR status, LPCSTR id, ULONGLONG curren CATCH_RETURN(); } -CONSOLE_SCREEN_BUFFER_INFO PullImageCallback::Info() +CONSOLE_SCREEN_BUFFER_INFO ImageProgressCallback::Info() { CONSOLE_SCREEN_BUFFER_INFO info{}; THROW_IF_WIN32_BOOL_FALSE(GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &info)); return info; } -std::wstring PullImageCallback::GenerateStatusLine(LPCSTR status, LPCSTR id, ULONGLONG current, ULONGLONG total, const CONSOLE_SCREEN_BUFFER_INFO& info) +std::wstring ImageProgressCallback::GenerateStatusLine(LPCSTR status, LPCSTR id, ULONGLONG current, ULONGLONG total, const CONSOLE_SCREEN_BUFFER_INFO& info) { std::wstring line; if (total != 0) diff --git a/src/windows/wslc/services/PullImageCallback.h b/src/windows/wslc/services/ImageProgressCallback.h similarity index 87% rename from src/windows/wslc/services/PullImageCallback.h rename to src/windows/wslc/services/ImageProgressCallback.h index b25ce0717..4a9f7ec74 100644 --- a/src/windows/wslc/services/PullImageCallback.h +++ b/src/windows/wslc/services/ImageProgressCallback.h @@ -4,11 +4,11 @@ Copyright (c) Microsoft. All rights reserved. Module Name: - PullImageCallback.h + ImageProgressCallback.h Abstract: - This file contains the PullImageCallback definition + This file contains the ImageProgressCallback definition --*/ #pragma once @@ -35,7 +35,7 @@ class ChangeTerminalMode }; // TODO: Handle terminal resizes. -class DECLSPEC_UUID("7A1D3376-835A-471A-8DC9-23653D9962D0") PullImageCallback +class DECLSPEC_UUID("7A1D3376-835A-471A-8DC9-23653D9962D0") ImageProgressCallback : public Microsoft::WRL::RuntimeClass, IProgressCallback, IFastRundown> { public: diff --git a/src/windows/wslc/services/ImageService.cpp b/src/windows/wslc/services/ImageService.cpp index 34d4aadd6..0910c4f0a 100644 --- a/src/windows/wslc/services/ImageService.cpp +++ b/src/windows/wslc/services/ImageService.cpp @@ -12,6 +12,7 @@ Module Name: --*/ #include "ImageService.h" +#include "RegistryService.h" #include "SessionService.h" #include #include @@ -63,6 +64,13 @@ wil::unique_hfile ResolveBuildFile(const std::filesystem::path& contextPath) THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::MessageWslcBuildFileNotFound(contextPath)); } +std::string GetServerFromImage(const std::string& image) +{ + auto [repo, tag] = wsl::windows::common::wslutil::ParseImage(image); + auto [server, path] = wsl::windows::common::wslutil::NormalizeRepo(repo); + return server; +} + } // namespace namespace wsl::windows::wslc::services { @@ -196,7 +204,9 @@ void ImageService::Delete(wsl::windows::wslc::models::Session& session, const st void ImageService::Pull(wsl::windows::wslc::models::Session& session, const std::string& image, IProgressCallback* callback) { - THROW_IF_FAILED(session.Get()->PullImage(image.c_str(), nullptr, callback)); + auto server = GetServerFromImage(image); + auto auth = RegistryService::Get(server); + THROW_IF_FAILED(session.Get()->PullImage(image.c_str(), auth.c_str(), callback)); } void ImageService::Tag(wsl::windows::wslc::models::Session& session, const std::string& sourceImage, const std::string& targetImage) @@ -223,8 +233,11 @@ InspectImage ImageService::Inspect(wsl::windows::wslc::models::Session& session, return wsl::shared::FromJson(inspectData.get()); } -void ImageService::Push() +void ImageService::Push(wsl::windows::wslc::models::Session& session, const std::string& image, IProgressCallback* callback) { + auto server = GetServerFromImage(image); + auto auth = RegistryService::Get(server); + THROW_IF_FAILED(session.Get()->PushImage(image.c_str(), auth.c_str(), callback)); } void ImageService::Save(wsl::windows::wslc::models::Session& session, const std::string& image, const std::wstring& output, HANDLE cancelEvent) diff --git a/src/windows/wslc/services/ImageService.h b/src/windows/wslc/services/ImageService.h index 4d97b55e2..dfaac435c 100644 --- a/src/windows/wslc/services/ImageService.h +++ b/src/windows/wslc/services/ImageService.h @@ -36,9 +36,9 @@ class ImageService static void Delete(wsl::windows::wslc::models::Session& session, const std::string& image, bool force, bool noPrune); static wsl::windows::common::wslc_schema::InspectImage Inspect(wsl::windows::wslc::models::Session& session, const std::string& image); static void Pull(wsl::windows::wslc::models::Session& session, const std::string& image, IProgressCallback* callback); + static void Push(wsl::windows::wslc::models::Session& session, const std::string& image, IProgressCallback* callback); static void Save(wsl::windows::wslc::models::Session& session, const std::string& image, const std::wstring& output, HANDLE cancelEvent = nullptr); static void Tag(wsl::windows::wslc::models::Session& session, const std::string& sourceImage, const std::string& targetImage); - void Push(); void Prune(); }; } // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/services/RegistryService.cpp b/src/windows/wslc/services/RegistryService.cpp new file mode 100644 index 000000000..5e227d29b --- /dev/null +++ b/src/windows/wslc/services/RegistryService.cpp @@ -0,0 +1,105 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + RegistryService.cpp + +Abstract: + + This file contains the RegistryService implementation + +--*/ + +#include "RegistryService.h" +#include + +using namespace wsl::windows::common::wslutil; + +namespace { + +std::string ResolveCredentialKey(const std::string& serverAddress) +{ + auto input = serverAddress; + + // Strip scheme + if (auto pos = input.find("://"); pos != std::string::npos) + { + input = input.substr(pos + 3); + } + + // Strip path + if (auto pos = input.find('/'); pos != std::string::npos) + { + input = input.substr(0, pos); + } + + // Map Docker Hub aliases to canonical key. + if (input == "docker.io" || input == "index.docker.io") + { + return wsl::windows::wslc::services::RegistryService::DefaultServer; + } + + return input; +} +} // namespace + +namespace wsl::windows::wslc::services { + +// Sentinel username matching Docker's convention for identity-token credentials. +static constexpr auto TokenUsername = ""; + +void RegistryService::Store(const std::string& serverAddress, const std::string& username, const std::string& secret) +{ + THROW_HR_IF(E_INVALIDARG, serverAddress.empty()); + THROW_HR_IF(E_INVALIDARG, secret.empty()); + + auto storage = OpenCredentialStorage(); + storage->Store(ResolveCredentialKey(serverAddress), username, secret); +} + +std::string RegistryService::Get(const std::string& serverAddress) +{ + auto storage = OpenCredentialStorage(); + auto key = ResolveCredentialKey(serverAddress); + auto [username, secret] = storage->Get(key); + + if (username == TokenUsername) + { + return BuildRegistryAuthHeader(secret); + } + + return BuildRegistryAuthHeader(username, secret); +} + +void RegistryService::Erase(const std::string& serverAddress) +{ + THROW_HR_IF(E_INVALIDARG, serverAddress.empty()); + + auto storage = OpenCredentialStorage(); + storage->Erase(ResolveCredentialKey(serverAddress)); +} + +std::vector RegistryService::List() +{ + auto storage = OpenCredentialStorage(); + return storage->List(); +} + +std::pair RegistryService::Authenticate( + wsl::windows::wslc::models::Session& session, const std::string& serverAddress, const std::string& username, const std::string& password) +{ + wil::unique_cotaskmem_ansistring identityToken; + THROW_IF_FAILED(session.Get()->Authenticate(serverAddress.c_str(), username.c_str(), password.c_str(), &identityToken)); + + // If the registry returned an identity token, use it. Otherwise fall back to username/password. + if (identityToken && strlen(identityToken.get()) > 0) + { + return {TokenUsername, identityToken.get()}; + } + + return {username, password}; +} + +} // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/services/RegistryService.h b/src/windows/wslc/services/RegistryService.h new file mode 100644 index 000000000..5494c29ec --- /dev/null +++ b/src/windows/wslc/services/RegistryService.h @@ -0,0 +1,37 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + RegistryService.h + +Abstract: + + This file contains the RegistryService definition + +--*/ +#pragma once + +#include "ICredentialStorage.h" +#include "SessionModel.h" + +namespace wsl::windows::wslc::services { + +// High-level registry authentication service. +// Delegates credential persistence to ICredentialStorage (selected via OpenCredentialStorage). +class RegistryService +{ +public: + static void Store(const std::string& serverAddress, const std::string& username, const std::string& secret); + static std::string Get(const std::string& serverAddress); + static void Erase(const std::string& serverAddress); + static std::vector List(); + static std::pair Authenticate( + wsl::windows::wslc::models::Session& session, const std::string& serverAddress, const std::string& username, const std::string& password); + + // Default registry server address used when no explicit server is provided. + static constexpr auto DefaultServer = "https://index.docker.io/v1/"; +}; + +} // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/services/WinCredStorage.cpp b/src/windows/wslc/services/WinCredStorage.cpp new file mode 100644 index 000000000..2f4bdf616 --- /dev/null +++ b/src/windows/wslc/services/WinCredStorage.cpp @@ -0,0 +1,113 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + WinCredStorage.cpp + +Abstract: + + Windows Credential Manager credential storage implementation. + +--*/ + +#include "precomp.h" +#include "WinCredStorage.h" +#include + +using wsl::shared::Localization; + +using unique_credential = wil::unique_any; +using unique_credential_array = wil::unique_any; + +static constexpr auto WinCredPrefix = L"wslc-credential/"; + +namespace wsl::windows::wslc::services { + +std::wstring WinCredStorage::TargetName(const std::string& serverAddress) +{ + return std::wstring(WinCredPrefix) + wsl::shared::string::MultiByteToWide(serverAddress); +} + +void WinCredStorage::Store(const std::string& serverAddress, const std::string& username, const std::string& secret) +{ + auto targetName = TargetName(serverAddress); + auto wideUsername = wsl::shared::string::MultiByteToWide(username); + + CREDENTIALW cred{}; + cred.Type = CRED_TYPE_GENERIC; + cred.TargetName = const_cast(targetName.c_str()); + cred.UserName = const_cast(wideUsername.c_str()); + cred.CredentialBlobSize = static_cast(secret.size()); + cred.CredentialBlob = reinterpret_cast(const_cast(secret.data())); + cred.Persist = CRED_PERSIST_LOCAL_MACHINE; + + THROW_IF_WIN32_BOOL_FALSE(CredWriteW(&cred, 0)); +} + +std::pair WinCredStorage::Get(const std::string& serverAddress) +{ + auto targetName = TargetName(serverAddress); + + unique_credential cred; + if (!CredReadW(targetName.c_str(), CRED_TYPE_GENERIC, 0, &cred)) + { + THROW_LAST_ERROR_IF(GetLastError() != ERROR_NOT_FOUND); + return {}; + } + + if (cred.get()->CredentialBlobSize == 0 || cred.get()->CredentialBlob == nullptr) + { + return {}; + } + + std::string username; + if (cred.get()->UserName) + { + username = wsl::shared::string::WideToMultiByte(cred.get()->UserName); + } + + return {std::move(username), {reinterpret_cast(cred.get()->CredentialBlob), cred.get()->CredentialBlobSize}}; +} + +void WinCredStorage::Erase(const std::string& serverAddress) +{ + auto targetName = TargetName(serverAddress); + + if (!CredDeleteW(targetName.c_str(), CRED_TYPE_GENERIC, 0)) + { + auto error = GetLastError(); + THROW_HR_WITH_USER_ERROR_IF( + E_NOT_SET, Localization::WSLCCLI_LogoutNotFound(wsl::shared::string::MultiByteToWide(serverAddress)), error == ERROR_NOT_FOUND); + + THROW_WIN32(error); + } +} + +std::vector WinCredStorage::List() +{ + auto prefix = std::wstring(WinCredPrefix); + auto filter = prefix + L"*"; + + DWORD count = 0; + unique_credential_array creds; + if (!CredEnumerateW(filter.c_str(), 0, &count, &creds)) + { + THROW_LAST_ERROR_IF(GetLastError() != ERROR_NOT_FOUND); + return {}; + } + + std::vector result; + result.reserve(count); + + for (DWORD i = 0; i < count; ++i) + { + std::wstring_view name(creds.get()[i]->TargetName); + result.emplace_back(name.substr(prefix.size())); + } + + return result; +} + +} // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/services/WinCredStorage.h b/src/windows/wslc/services/WinCredStorage.h new file mode 100644 index 000000000..35dd2845e --- /dev/null +++ b/src/windows/wslc/services/WinCredStorage.h @@ -0,0 +1,32 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + WinCredStorage.h + +Abstract: + + Windows Credential Manager credential storage backend. + +--*/ +#pragma once + +#include "ICredentialStorage.h" + +namespace wsl::windows::wslc::services { + +class WinCredStorage final : public ICredentialStorage +{ +public: + void Store(const std::string& serverAddress, const std::string& username, const std::string& secret) override; + std::pair Get(const std::string& serverAddress) override; + void Erase(const std::string& serverAddress) override; + std::vector List() override; + +private: + static std::wstring TargetName(const std::string& serverAddress); +}; + +} // namespace wsl::windows::wslc::services diff --git a/src/windows/wslc/tasks/ImageTasks.cpp b/src/windows/wslc/tasks/ImageTasks.cpp index 461e00589..058708af4 100644 --- a/src/windows/wslc/tasks/ImageTasks.cpp +++ b/src/windows/wslc/tasks/ImageTasks.cpp @@ -19,7 +19,7 @@ Module Name: #include "ImageModel.h" #include "ImageService.h" #include "ImageTasks.h" -#include "PullImageCallback.h" +#include "ImageProgressCallback.h" #include "TableOutput.h" #include "Task.h" #include @@ -136,10 +136,21 @@ void PullImage(CLIExecutionContext& context) auto& session = context.Data.Get(); auto& imageId = context.Args.Get(); - PullImageCallback callback; + ImageProgressCallback callback; services::ImageService::Pull(session, WideToMultiByte(imageId), &callback); } +void PushImage(CLIExecutionContext& context) +{ + WI_ASSERT(context.Data.Contains(Data::Session)); + WI_ASSERT(context.Args.Contains(ArgType::ImageId)); + auto& session = context.Data.Get(); + auto& imageId = context.Args.Get(); + + ImageProgressCallback callback; + services::ImageService::Push(session, WideToMultiByte(imageId), &callback); +} + void DeleteImage(CLIExecutionContext& context) { WI_ASSERT(context.Data.Contains(Data::Session)); diff --git a/src/windows/wslc/tasks/ImageTasks.h b/src/windows/wslc/tasks/ImageTasks.h index ae4f61e70..745168421 100644 --- a/src/windows/wslc/tasks/ImageTasks.h +++ b/src/windows/wslc/tasks/ImageTasks.h @@ -22,6 +22,7 @@ void GetImages(CLIExecutionContext& context); void ListImages(CLIExecutionContext& context); void LoadImage(CLIExecutionContext& context); void PullImage(CLIExecutionContext& context); +void PushImage(CLIExecutionContext& context); void DeleteImage(CLIExecutionContext& context); void InspectImages(CLIExecutionContext& context); void TagImage(CLIExecutionContext& context); diff --git a/src/windows/wslc/tasks/RegistryTasks.cpp b/src/windows/wslc/tasks/RegistryTasks.cpp new file mode 100644 index 000000000..279a1ead4 --- /dev/null +++ b/src/windows/wslc/tasks/RegistryTasks.cpp @@ -0,0 +1,66 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + RegistryTasks.cpp + +Abstract: + + Implementation of registry command related execution logic. + +--*/ +#include "Argument.h" +#include "CLIExecutionContext.h" +#include "RegistryService.h" +#include "RegistryTasks.h" +#include "Task.h" + +using namespace wsl::shared; +using namespace wsl::windows::common::string; +using namespace wsl::windows::common::wslutil; +using namespace wsl::windows::wslc::execution; +using namespace wsl::windows::wslc::services; + +namespace wsl::windows::wslc::task { + +void Login(CLIExecutionContext& context) +{ + WI_ASSERT(context.Data.Contains(Data::Session)); + WI_ASSERT(context.Args.Contains(ArgType::Username)); + WI_ASSERT(context.Args.Contains(ArgType::Password)); + + auto& session = context.Data.Get(); + + auto username = WideToMultiByte(context.Args.Get()); + auto password = WideToMultiByte(context.Args.Get()); + + auto serverAddress = std::string(RegistryService::DefaultServer); + + if (context.Args.Contains(ArgType::Server)) + { + serverAddress = WideToMultiByte(context.Args.Get()); + } + + auto [credUsername, credSecret] = RegistryService::Authenticate(session, serverAddress, username, password); + RegistryService::Store(serverAddress, credUsername, credSecret); + + PrintMessage(Localization::WSLCCLI_LoginSucceeded()); +} + +void Logout(CLIExecutionContext& context) +{ + auto serverAddress = std::string(RegistryService::DefaultServer); + + if (context.Args.Contains(ArgType::Server)) + { + serverAddress = WideToMultiByte(context.Args.Get()); + } + + RegistryService::Erase(serverAddress); + + PrintMessage(Localization::WSLCCLI_LogoutSucceeded(MultiByteToWide(serverAddress))); +} + +} // namespace wsl::windows::wslc::task diff --git a/src/windows/wslc/tasks/RegistryTasks.h b/src/windows/wslc/tasks/RegistryTasks.h new file mode 100644 index 000000000..59477ce27 --- /dev/null +++ b/src/windows/wslc/tasks/RegistryTasks.h @@ -0,0 +1,22 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + RegistryTasks.h + +Abstract: + + Declaration of registry command execution tasks. + +--*/ +#pragma once +#include "CLIExecutionContext.h" + +using wsl::windows::wslc::execution::CLIExecutionContext; + +namespace wsl::windows::wslc::task { +void Login(CLIExecutionContext& context); +void Logout(CLIExecutionContext& context); +} // namespace wsl::windows::wslc::task diff --git a/test/windows/Common.cpp b/test/windows/Common.cpp index 948a5f826..00f3467bf 100644 --- a/test/windows/Common.cpp +++ b/test/windows/Common.cpp @@ -68,6 +68,7 @@ static std::wstring g_pipelineBuildId; std::wstring g_testDistroPath; std::wstring g_testDataPath; bool g_fastTestRun = false; // True when test.bat was invoked with -f +static wil::unique_mta_usage_cookie g_mtaCookie; std::pair CreateSubprocessPipe(bool inheritRead, bool inheritWrite, DWORD bufferSize, _In_opt_ SECURITY_ATTRIBUTES* sa) { @@ -1974,6 +1975,8 @@ Return Value: { wsl::windows::common::wslutil::InitializeWil(); + THROW_IF_FAILED(CoIncrementMTAUsage(&g_mtaCookie)); + // Don't crash for unknown exceptions (makes debugging testpasses harder) #ifndef _DEBUG wil::g_fResultFailFastUnknownExceptions = false; @@ -2188,6 +2191,7 @@ Return Value: } WslTraceLoggingUninitialize(); + g_mtaCookie.reset(); return true; } diff --git a/test/windows/WSLCTests.cpp b/test/windows/WSLCTests.cpp index 2f54dc09c..606ead20c 100644 --- a/test/windows/WSLCTests.cpp +++ b/test/windows/WSLCTests.cpp @@ -36,7 +36,6 @@ class WSLCTests { WSLC_TEST_CLASS(WSLCTests) - wil::unique_mta_usage_cookie m_mtaCookie; WSADATA m_wsadata; std::filesystem::path m_storagePath; WSLCSessionSettings m_defaultSessionSettings{}; @@ -59,7 +58,6 @@ class WSLCTests TEST_CLASS_SETUP(TestClassSetup) { - THROW_IF_FAILED(CoIncrementMTAUsage(&m_mtaCookie)); THROW_IF_WIN32_ERROR(WSAStartup(MAKEWORD(2, 2), &m_wsadata)); // The WSLC SDK tests use this same storage to reduce pull overhead. @@ -482,7 +480,7 @@ class WSLCTests // Start a local registry without auth and push hello-world:latest to it. auto [registryContainer, registryAddress] = StartLocalRegistry(); - auto image = PushImageToRegistry("hello-world:latest", registryAddress, BuildRegistryAuthHeader("", "", registryAddress)); + auto image = PushImageToRegistry("hello-world:latest", registryAddress, BuildRegistryAuthHeader("", "")); ExpectImagePresent(*m_defaultSession, image.c_str(), false); VERIFY_SUCCEEDED(m_defaultSession->PullImage(image.c_str(), nullptr, nullptr)); @@ -524,7 +522,7 @@ class WSLCTests { // Start a local registry without auth to avoid Docker Hub rate limits. auto [registryContainer, registryAddress] = StartLocalRegistry(); - auto auth = BuildRegistryAuthHeader("", "", registryAddress); + auto auth = BuildRegistryAuthHeader("", ""); auto validatePull = [&](const std::string& sourceImage) { // Push the source image to the local registry. @@ -606,7 +604,7 @@ class WSLCTests WSLC_TEST_METHOD(PushImage) { - auto emptyAuth = BuildRegistryAuthHeader("", "", ""); + auto emptyAuth = BuildRegistryAuthHeader("", ""); // Validate that pushing a non-existent image fails. { @@ -642,7 +640,7 @@ class WSLCTests 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 xRegistryAuth = BuildRegistryAuthHeader(c_username, c_password); auto image = PushImageToRegistry("hello-world:latest", registryAddress, xRegistryAuth); // Pulling without credentials should fail. diff --git a/test/windows/WslcSdkTests.cpp b/test/windows/WslcSdkTests.cpp index cc23d5c0a..8560d0ac3 100644 --- a/test/windows/WslcSdkTests.cpp +++ b/test/windows/WslcSdkTests.cpp @@ -168,7 +168,6 @@ class WslcSdkTests { WSLC_TEST_CLASS(WslcSdkTests) - wil::unique_mta_usage_cookie m_mtaCookie; WSADATA m_wsadata; std::filesystem::path m_storagePath; WslcSession m_defaultSession = nullptr; @@ -182,7 +181,6 @@ class WslcSdkTests TEST_CLASS_SETUP(TestClassSetup) { - THROW_IF_FAILED(CoIncrementMTAUsage(&m_mtaCookie)); THROW_IF_WIN32_ERROR(WSAStartup(MAKEWORD(2, 2), &m_wsadata)); // Use the same storage path as WSLC runtime tests to reduce pull overhead. @@ -2113,7 +2111,7 @@ class WslcSdkTests VERIFY_IS_NOT_NULL(token.get()); } - auto xRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader(c_username, c_password, registryAddress); + auto xRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader(c_username, c_password); PushImageToRegistry("hello-world", "latest", registryAddress, xRegistryAuth); auto image = std::format("{}/hello-world:latest", registryAddress); @@ -2139,7 +2137,7 @@ class WslcSdkTests // Negative: Pulling with bad credentials should fail. { - auto badAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader(c_username, "wrong", registryAddress); + auto badAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader(c_username, "wrong"); WslcPullImageOptions opts{}; opts.uri = image.c_str(); @@ -2164,7 +2162,7 @@ class WslcSdkTests { // Start a local registry without auth to avoid Docker Hub rate limits. auto [registryContainer, registryAddress] = StartLocalRegistry(); - auto xRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader("", "", registryAddress); + auto xRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader("", ""); { // Push hello-world:latest to the local registry. @@ -2214,7 +2212,7 @@ class WslcSdkTests WSLC_TEST_METHOD(PushImage) { - auto emptyRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader("", "", ""); + auto emptyRegistryAuth = wsl::windows::common::wslutil::BuildRegistryAuthHeader("", ""); // Negative: pushing a non-existent image must fail. { diff --git a/test/windows/wslc/WSLCCLICredStorageUnitTests.cpp b/test/windows/wslc/WSLCCLICredStorageUnitTests.cpp new file mode 100644 index 000000000..1f7a9a1ab --- /dev/null +++ b/test/windows/wslc/WSLCCLICredStorageUnitTests.cpp @@ -0,0 +1,163 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + WSLCCredStorageUnitTests.cpp + +Abstract: + + Unit tests for the FileCredStorage and WinCredStorage credential + storage backends. Tests Store, Get, List, and Erase operations. + +--*/ + +#include "precomp.h" +#include "windows/Common.h" +#include "FileCredStorage.h" +#include "WinCredStorage.h" + +using namespace wsl::windows::wslc::services; +using namespace WEX::Logging; +using namespace WEX::Common; +using namespace WEX::TestExecution; + +namespace WSLCCredStorageUnitTests { + +class WSLCCLICredStorageUnitTests +{ + WSL_TEST_CLASS(WSLCCLICredStorageUnitTests) + + FileCredStorage m_fileStorage; + WinCredStorage m_winCredStorage; + + static void TestStoreAndGetRoundTrips(ICredentialStorage& storage) + { + auto cleanup = wil::scope_exit([&]() { storage.Erase("wslc-test-server1"); }); + storage.Store("wslc-test-server1", "test-user", "credential-data-1"); + + auto [username, secret] = storage.Get("wslc-test-server1"); + VERIFY_ARE_EQUAL(std::string("test-user"), username); + VERIFY_ARE_EQUAL(std::string("credential-data-1"), secret); + } + + TEST_METHOD(FileCred_Store_And_Get_RoundTrips) + { + TestStoreAndGetRoundTrips(m_fileStorage); + } + TEST_METHOD(WinCred_Store_And_Get_RoundTrips) + { + TestStoreAndGetRoundTrips(m_winCredStorage); + } + + static void TestGetNonExistentReturnsEmpty(ICredentialStorage& storage) + { + auto [username, secret] = storage.Get("wslc-test-nonexistent-server"); + VERIFY_IS_TRUE(username.empty()); + VERIFY_IS_TRUE(secret.empty()); + } + + TEST_METHOD(FileCred_Get_NonExistent_ReturnsEmpty) + { + TestGetNonExistentReturnsEmpty(m_fileStorage); + } + TEST_METHOD(WinCred_Get_NonExistent_ReturnsEmpty) + { + TestGetNonExistentReturnsEmpty(m_winCredStorage); + } + + static void TestStoreOverwritesExistingCredential(ICredentialStorage& storage) + { + auto cleanup = wil::scope_exit([&]() { storage.Erase("wslc-test-server2"); }); + storage.Store("wslc-test-server2", "old-user", "old-credential"); + storage.Store("wslc-test-server2", "new-user", "new-credential"); + + auto [username, secret] = storage.Get("wslc-test-server2"); + VERIFY_ARE_EQUAL(std::string("new-user"), username); + VERIFY_ARE_EQUAL(std::string("new-credential"), secret); + } + + TEST_METHOD(FileCred_Store_Overwrites_ExistingCredential) + { + TestStoreOverwritesExistingCredential(m_fileStorage); + } + TEST_METHOD(WinCred_Store_Overwrites_ExistingCredential) + { + TestStoreOverwritesExistingCredential(m_winCredStorage); + } + + static void TestListContainsStoredServers(ICredentialStorage& storage) + { + auto cleanup = wil::scope_exit([&]() { + storage.Erase("wslc-test-list1"); + storage.Erase("wslc-test-list2"); + }); + storage.Store("wslc-test-list1", "user1", "cred1"); + storage.Store("wslc-test-list2", "user2", "cred2"); + + auto servers = storage.List(); + bool found1 = false, found2 = false; + for (const auto& s : servers) + { + if (s == L"wslc-test-list1") + { + found1 = true; + } + if (s == L"wslc-test-list2") + { + found2 = true; + } + } + + VERIFY_IS_TRUE(found1); + VERIFY_IS_TRUE(found2); + } + + TEST_METHOD(FileCred_List_ContainsStoredServers) + { + TestListContainsStoredServers(m_fileStorage); + } + TEST_METHOD(WinCred_List_ContainsStoredServers) + { + TestListContainsStoredServers(m_winCredStorage); + } + + static void TestEraseRemovesCredential(ICredentialStorage& storage) + { + storage.Store("wslc-test-erase", "user", "cred"); + auto [username, secret] = storage.Get("wslc-test-erase"); + VERIFY_IS_FALSE(username.empty()); + + storage.Erase("wslc-test-erase"); + auto [username2, secret2] = storage.Get("wslc-test-erase"); + VERIFY_IS_TRUE(username2.empty()); + } + + TEST_METHOD(FileCred_Erase_RemovesCredential) + { + TestEraseRemovesCredential(m_fileStorage); + } + TEST_METHOD(WinCred_Erase_RemovesCredential) + { + TestEraseRemovesCredential(m_winCredStorage); + } + + static void TestEraseNonExistentThrows(ICredentialStorage& storage) + { + VERIFY_THROWS_SPECIFIC(storage.Erase("wslc-test-nonexistent-erase"), wil::ResultException, [](const wil::ResultException& e) { + return e.GetErrorCode() == E_NOT_SET; + }); + } + + TEST_METHOD(FileCred_Erase_NonExistent_Throws) + { + TestEraseNonExistentThrows(m_fileStorage); + } + TEST_METHOD(WinCred_Erase_NonExistent_Throws) + { + TestEraseNonExistentThrows(m_winCredStorage); + } +}; + +} // namespace WSLCCredStorageUnitTests diff --git a/test/windows/wslc/WSLCCLISettingsUnitTests.cpp b/test/windows/wslc/WSLCCLISettingsUnitTests.cpp index e7152e89a..e2dcc7a23 100644 --- a/test/windows/wslc/WSLCCLISettingsUnitTests.cpp +++ b/test/windows/wslc/WSLCCLISettingsUnitTests.cpp @@ -95,6 +95,7 @@ class WSLCCLISettingsUnitTests VERIFY_ARE_EQUAL(2048u, map.GetOrDefault()); VERIFY_ARE_EQUAL(102400u, map.GetOrDefault()); VERIFY_ARE_EQUAL(std::wstring{}, map.GetOrDefault()); + VERIFY_ARE_EQUAL(static_cast(CredentialStoreType::WinCred), static_cast(map.GetOrDefault())); } // After inserting a value, GetOrDefault must return it rather than the default. @@ -122,6 +123,7 @@ class WSLCCLISettingsUnitTests VERIFY_ARE_EQUAL(2048u, s.Get()); VERIFY_ARE_EQUAL(102400u, s.Get()); VERIFY_ARE_EQUAL(std::wstring{}, s.Get()); + VERIFY_ARE_EQUAL(static_cast(CredentialStoreType::WinCred), static_cast(s.Get())); } // ----------------------------------------------------------------------- @@ -138,7 +140,8 @@ class WSLCCLISettingsUnitTests "session:\n" " cpuCount: 8\n" " memorySize: 4GB\n" - " maxStorageSize: 20000MB\n"); + " maxStorageSize: 20000MB\n" + "credentialStore: file\n"); UserSettingsTest s{dir}; @@ -149,6 +152,7 @@ class WSLCCLISettingsUnitTests VERIFY_ARE_EQUAL(20000u, s.Get()); // Unspecified setting falls back to built-in default. VERIFY_ARE_EQUAL(std::wstring{}, s.Get()); + VERIFY_ARE_EQUAL(static_cast(CredentialStoreType::File), static_cast(s.Get())); } // An empty settings file is valid YAML (null document); all settings use @@ -272,6 +276,18 @@ class WSLCCLISettingsUnitTests VERIFY_ARE_EQUAL(std::wstring{}, s.Get()); } + // credentialStore: invalid value must fall back to default and warn. + TEST_METHOD(Validation_CredentialStore_Invalid_UsesDefaultAndWarns) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "credentialStore: badvalue\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(static_cast(CredentialStoreType::WinCred), static_cast(s.Get())); + VERIFY_IS_TRUE(s.GetWarnings().size() >= 1u); + } + // Extra unknown keys at any level must not cause errors or warnings. TEST_METHOD(Validation_UnknownKeys_NoErrorsOrWarnings) { diff --git a/test/windows/wslc/e2e/WSLCE2EGlobalTests.cpp b/test/windows/wslc/e2e/WSLCE2EGlobalTests.cpp index 81c353471..94fa323f7 100644 --- a/test/windows/wslc/e2e/WSLCE2EGlobalTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EGlobalTests.cpp @@ -525,6 +525,7 @@ class WSLCE2EGlobalTests std::vector> entries = { {L"container", Localization::WSLCCLI_ContainerCommandDesc()}, {L"image", Localization::WSLCCLI_ImageCommandDesc()}, + {L"registry", Localization::WSLCCLI_RegistryCommandDesc()}, {L"session", Localization::WSLCCLI_SessionCommandDesc()}, {L"settings", Localization::WSLCCLI_SettingsCommandDesc()}, {L"attach", Localization::WSLCCLI_ContainerAttachDesc()}, @@ -536,8 +537,11 @@ class WSLCE2EGlobalTests {L"kill", Localization::WSLCCLI_ContainerKillDesc()}, {L"list", Localization::WSLCCLI_ContainerListDesc()}, {L"load", Localization::WSLCCLI_ImageLoadDesc()}, + {L"login", Localization::WSLCCLI_LoginDesc()}, + {L"logout", Localization::WSLCCLI_LogoutDesc()}, {L"logs", Localization::WSLCCLI_ContainerLogsDesc()}, {L"pull", Localization::WSLCCLI_ImagePullDesc()}, + {L"push", Localization::WSLCCLI_ImagePushDesc()}, {L"remove", Localization::WSLCCLI_ContainerRemoveDesc()}, {L"rmi", Localization::WSLCCLI_ImageRemoveDesc()}, {L"run", Localization::WSLCCLI_ContainerRunDesc()}, diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp index 4448ce665..467010a55 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.cpp +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.cpp @@ -18,6 +18,7 @@ Module Name: #include "WSLCExecutor.h" #include "WSLCE2EHelpers.h" #include +#include extern std::wstring g_testDataPath; @@ -367,6 +368,53 @@ void EnsureSessionIsTerminated(const std::wstring& sessionName) } } +wil::com_ptr OpenDefaultElevatedSession() +{ + wil::com_ptr sessionManager; + VERIFY_SUCCEEDED(CoCreateInstance(__uuidof(WSLCSessionManager), nullptr, CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(&sessionManager))); + wsl::windows::common::security::ConfigureForCOMImpersonation(sessionManager.get()); + + wil::com_ptr session; + VERIFY_SUCCEEDED(sessionManager->OpenSessionByName(nullptr, &session)); + wsl::windows::common::security::ConfigureForCOMImpersonation(session.get()); + + return std::move(session); +} + +std::pair StartLocalRegistry(IWSLCSession& session, const std::string& username, const std::string& password, USHORT port) +{ + EnsureImageIsLoaded({L"wslc-registry", L"latest", GetTestImagePath("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)); + } + + WSLCContainerLauncher launcher("wslc-registry:latest", {}, {}, env); + launcher.SetEntrypoint({"/entrypoint.sh"}); + launcher.AddPort(port, port, AF_INET); + + auto container = launcher.Launch(session, WSLCContainerStartFlagsNone); + + auto address = std::format("127.0.0.1:{}", port); + auto url = std::format(L"http://{}/v2/", wsl::shared::string::MultiByteToWide(address)); + + int expectedCode = username.empty() ? 200 : 401; + ExpectHttpResponse(url.c_str(), expectedCode, true); + + return {std::move(container), std::move(address)}; +} + +std::wstring TagImageForRegistry(const std::wstring& imageName, const std::wstring& registryAddress) +{ + auto registryImage = std::format(L"{}/{}", registryAddress, imageName); + RunWslcAndVerify(std::format(L"image tag {} {}", imageName, registryImage), {.ExitCode = 0}); + return registryImage; +} + void WriteTestFile(const std::filesystem::path& filePath, const std::vector& lines) { std::ofstream file(filePath, std::ios::out | std::ios::trunc | std::ios::binary); diff --git a/test/windows/wslc/e2e/WSLCE2EHelpers.h b/test/windows/wslc/e2e/WSLCE2EHelpers.h index a09bdefec..1042d07e3 100644 --- a/test/windows/wslc/e2e/WSLCE2EHelpers.h +++ b/test/windows/wslc/e2e/WSLCE2EHelpers.h @@ -18,6 +18,7 @@ Module Name: #include #include #include +#include namespace WSLCE2ETests { @@ -180,4 +181,15 @@ inline void VerifyContainerIsNotListed(const std::wstring& containerNameOrId) { VerifyContainerIsNotListed(containerNameOrId, std::chrono::milliseconds(0), std::chrono::milliseconds(0)); } -} // namespace WSLCE2ETests \ No newline at end of file + +wil::com_ptr OpenDefaultElevatedSession(); + +// Starts a local registry container with host networking using the COM API. +// Returns the running container (holds it alive) and the registry address (e.g. "127.0.0.1:PORT"). +std::pair StartLocalRegistry( + IWSLCSession& session, const std::string& username = "", const std::string& password = "", USHORT port = 5000); + +// Tags an image for a registry and returns the full registry image reference (e.g. "127.0.0.1:PORT/debian:latest"). +std::wstring TagImageForRegistry(const std::wstring& imageName, const std::wstring& registryAddress); + +} // namespace WSLCE2ETests diff --git a/test/windows/wslc/e2e/WSLCE2EImageTests.cpp b/test/windows/wslc/e2e/WSLCE2EImageTests.cpp index 8a2142804..3b20d656b 100644 --- a/test/windows/wslc/e2e/WSLCE2EImageTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EImageTests.cpp @@ -73,6 +73,7 @@ class WSLCE2EImageTests {L"list", Localization::WSLCCLI_ImageListDesc()}, {L"load", Localization::WSLCCLI_ImageLoadDesc()}, {L"pull", Localization::WSLCCLI_ImagePullDesc()}, + {L"push", Localization::WSLCCLI_ImagePushDesc()}, {L"save", Localization::WSLCCLI_ImageSaveDesc()}, {L"tag", Localization::WSLCCLI_ImageTagDesc()}, }; diff --git a/test/windows/wslc/e2e/WSLCE2EPushPullTests.cpp b/test/windows/wslc/e2e/WSLCE2EPushPullTests.cpp new file mode 100644 index 000000000..aeada2267 --- /dev/null +++ b/test/windows/wslc/e2e/WSLCE2EPushPullTests.cpp @@ -0,0 +1,184 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + WSLCE2EPushPullTests.cpp + +Abstract: + + End-to-end tests for wslc image push and pull against a local registry. + +--*/ + +#include "precomp.h" +#include "windows/Common.h" +#include "WSLCExecutor.h" +#include "WSLCE2EHelpers.h" +#include "Argument.h" + +namespace WSLCE2ETests { +using namespace wsl::shared; + +class WSLCE2EPushPullTests +{ + WSLC_TEST_CLASS(WSLCE2EPushPullTests) + + WSLC_TEST_METHOD(WSLCE2E_Image_Push_HelpCommand) + { + auto result = RunWslc(L"image push --help"); + result.Verify({.Stdout = GetPushHelpMessage(), .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Push_RootAlias) + { + auto result = RunWslc(L"push --help"); + result.Verify({.Stdout = GetPushRootAliasHelpMessage(), .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Pull_HelpCommand) + { + auto result = RunWslc(L"image pull --help"); + result.Verify({.Stdout = GetPullHelpMessage(), .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Pull_RootAlias) + { + auto result = RunWslc(L"pull --help"); + result.Verify({.Stdout = GetPullRootAliasHelpMessage(), .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_PushPull) + { + const auto& debianImage = DebianTestImage(); + EnsureImageIsLoaded(debianImage); + + // Ensure the default elevated session exists. + RunWslcAndVerify(L"container list", {.Stderr = L"", .ExitCode = 0}); + + // Start a local registry without auth. + auto session = OpenDefaultElevatedSession(); + + { + auto [registryContainer, registryAddress] = StartLocalRegistry(*session, "", "", 15003); + auto registryAddressW = string::MultiByteToWide(registryAddress); + + // Tag the image for the local registry. + auto registryImage = TagImageForRegistry(debianImage.NameAndTag(), registryAddressW); + + auto tagCleanup = wil::scope_exit([&]() { RunWslc(std::format(L"image delete --force {}", registryImage)); }); + + // Push should succeed. + auto result = RunWslc(std::format(L"push {}", registryImage)); + result.Verify({.ExitCode = 0}); + + // Delete the local copy and pull it back. + RunWslcAndVerify(std::format(L"image delete --force {}", registryImage), {.ExitCode = 0}); + + result = RunWslc(std::format(L"pull {}", registryImage)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + // Verify the image is now present. + result = RunWslc(L"image list -q"); + result.Verify({.Stderr = L"", .ExitCode = 0}); + VERIFY_IS_TRUE(result.Stdout.has_value()); + VERIFY_IS_TRUE(result.Stdout->find(registryImage) != std::wstring::npos); + } + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Push_NonExistentImage) + { + auto result = RunWslc(L"push does-not-exist:latest"); + auto errorMessage = L"An image does not exist locally with the tag: does-not-exist\r\nError code: E_FAIL\r\n"; + result.Verify({.Stdout = L"", .Stderr = errorMessage, .ExitCode = 1}); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Pull_NonExistentImage) + { + auto result = RunWslc(L"pull does-not-exist:latest"); + auto errorMessage = + L"pull access denied for does-not-exist, repository does not exist or may require 'docker login': denied: requested " + L"access to the resource is denied\r\nError code: WSLC_E_IMAGE_NOT_FOUND\r\n"; + result.Verify({.Stdout = L"", .Stderr = errorMessage, .ExitCode = 1}); + } + +private: + std::wstring GetPushHelpMessage() const + { + std::wstringstream output; + output << GetWslcHeader() << GetPushDescription() << GetPushUsage() << GetAvailableArguments() << GetAvailableOptions(); + return output.str(); + } + + std::wstring GetPushRootAliasHelpMessage() const + { + std::wstringstream output; + output << GetWslcHeader() << GetPushDescription() << GetPushRootUsage() << GetAvailableArguments() << GetAvailableOptions(); + return output.str(); + } + + std::wstring GetPullHelpMessage() const + { + std::wstringstream output; + output << GetWslcHeader() << GetPullDescription() << GetPullUsage() << GetAvailableArguments() << GetAvailableOptions(); + return output.str(); + } + + std::wstring GetPullRootAliasHelpMessage() const + { + std::wstringstream output; + output << GetWslcHeader() << GetPullDescription() << GetPullRootUsage() << GetAvailableArguments() << GetAvailableOptions(); + return output.str(); + } + + std::wstring GetPushDescription() const + { + return Localization::WSLCCLI_ImagePushLongDesc() + L"\r\n\r\n"; + } + + std::wstring GetPullDescription() const + { + return Localization::WSLCCLI_ImagePullLongDesc() + L"\r\n\r\n"; + } + + std::wstring GetPushUsage() const + { + return L"Usage: wslc image push [] \r\n\r\n"; + } + + std::wstring GetPushRootUsage() const + { + return L"Usage: wslc push [] \r\n\r\n"; + } + + std::wstring GetPullUsage() const + { + return L"Usage: wslc image pull [] \r\n\r\n"; + } + + std::wstring GetPullRootUsage() const + { + return L"Usage: wslc pull [] \r\n\r\n"; + } + + std::wstring GetAvailableArguments() const + { + std::wstringstream args; + args << Localization::WSLCCLI_AvailableArguments() << L"\r\n" + << L" image " << Localization::WSLCCLI_ImageIdArgDescription() << L"\r\n" + << L"\r\n"; + return args.str(); + } + + std::wstring GetAvailableOptions() const + { + std::wstringstream options; + options << Localization::WSLCCLI_AvailableOptions() << L"\r\n" + << L" --session " << Localization::WSLCCLI_SessionIdArgDescription() << L"\r\n" + << L" -h,--help " << Localization::WSLCCLI_HelpArgDescription() << L"\r\n" + << L"\r\n"; + return options.str(); + } +}; +} // namespace WSLCE2ETests diff --git a/test/windows/wslc/e2e/WSLCE2ERegistryTests.cpp b/test/windows/wslc/e2e/WSLCE2ERegistryTests.cpp new file mode 100644 index 000000000..d111f8ef3 --- /dev/null +++ b/test/windows/wslc/e2e/WSLCE2ERegistryTests.cpp @@ -0,0 +1,295 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + WSLCE2ERegistryTests.cpp + +Abstract: + + End-to-end tests for wslc registry login/logout auth flows against a local registry. + +--*/ + +#include "precomp.h" +#include "windows/Common.h" +#include "WSLCExecutor.h" +#include "WSLCE2EHelpers.h" +#include "Argument.h" +#include + +namespace WSLCE2ETests { +using namespace wsl::shared; +using namespace WEX::Logging; + +namespace { + + constexpr auto c_username = "wslctest"; + constexpr auto c_password = "password"; + + void VerifyAuthFailure(const WSLCExecutionResult& result) + { + VERIFY_ARE_EQUAL(1u, result.ExitCode.value_or(0)); + VERIFY_IS_TRUE(result.Stderr.has_value()); + VERIFY_IS_TRUE(result.Stderr->find(L"no basic auth credentials") != std::wstring::npos); + } + + void VerifyLogoutSucceeds(const std::wstring& registryAddress) + { + auto result = RunWslc(std::format(L"logout {}", registryAddress)); + result.Verify({.Stdout = Localization::WSLCCLI_LogoutSucceeded(registryAddress) + L"\r\n", .Stderr = L"", .ExitCode = 0}); + } +} // namespace + +class WSLCE2ERegistryTests +{ + WSLC_TEST_CLASS(WSLCE2ERegistryTests) + + WSLC_TEST_METHOD(WSLCE2E_Registry_LoginLogout_PushPull_AuthFlow) + { + const auto& debianImage = DebianTestImage(); + EnsureImageIsLoaded(debianImage); + + // Ensure the default elevated session exists before opening it via COM. + RunWslcAndVerify(L"container list", {.Stderr = L"", .ExitCode = 0}); + + auto session = OpenDefaultElevatedSession(); + + { + auto [registryContainer, registryAddress] = StartLocalRegistry(*session, c_username, c_password, 15001); + auto registryAddressW = string::MultiByteToWide(registryAddress); + + auto registryImageName = TagImageForRegistry(debianImage.NameAndTag(), registryAddressW); + + auto cleanup = wil::scope_exit([&]() { + RunWslc(std::format(L"image delete --force {}", registryImageName)); + RunWslc(std::format(L"logout {}", registryAddressW)); + }); + + // Negative path before login: push and pull should fail. + auto result = RunWslc(std::format(L"push {}", registryImageName)); + VerifyAuthFailure(result); + + RunWslcAndVerify(std::format(L"image delete --force {}", registryImageName), {.ExitCode = 0}); + + result = RunWslc(std::format(L"pull {}", registryImageName)); + VerifyAuthFailure(result); + + // Login and verify that saved credentials are used for push/pull. + result = RunWslc(std::format( + L"login -u {} -p {} {}", string::MultiByteToWide(c_username), string::MultiByteToWide(c_password), registryAddressW)); + result.Verify({.Stdout = Localization::WSLCCLI_LoginSucceeded() + L"\r\n", .Stderr = L"", .ExitCode = 0}); + + registryImageName = TagImageForRegistry(L"debian:latest", registryAddressW); + result = RunWslc(std::format(L"push {}", registryImageName)); + result.Verify({.ExitCode = 0}); + + RunWslcAndVerify(std::format(L"image delete --force {}", registryImageName), {.ExitCode = 0}); + result = RunWslc(std::format(L"pull {}", registryImageName)); + result.Verify({.Stderr = L"", .ExitCode = 0}); + + // Logout and verify both pull and push fail again. + VerifyLogoutSucceeds(registryAddressW); + + RunWslcAndVerify(std::format(L"image delete --force {}", registryImageName), {.ExitCode = 0}); + result = RunWslc(std::format(L"pull {}", registryImageName)); + VerifyAuthFailure(result); + + registryImageName = TagImageForRegistry(L"debian:latest", registryAddressW); + result = RunWslc(std::format(L"push {}", registryImageName)); + VerifyAuthFailure(result); + + // Negative path for logout command: second logout should fail. + result = RunWslc(std::format(L"logout {}", registryAddressW)); + VERIFY_ARE_EQUAL(1u, result.ExitCode.value_or(0)); + VERIFY_IS_TRUE(result.Stderr.has_value()); + VERIFY_IS_TRUE(result.Stderr->find(L"Not logged in to") != std::wstring::npos); + } + } + + WSLC_TEST_METHOD(WSLCE2E_Registry_Login_HelpCommand) + { + auto result = RunWslc(L"registry login --help"); + result.Verify({.Stdout = GetLoginHelpMessage(), .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Registry_Logout_HelpCommand) + { + auto result = RunWslc(L"registry logout --help"); + result.Verify({.Stdout = GetLogoutHelpMessage(), .Stderr = L"", .ExitCode = 0}); + } + + WSLC_TEST_METHOD(WSLCE2E_Registry_Login_PasswordAndStdinMutuallyExclusive) + { + auto result = RunWslc(L"login -u testuser -p testpass --password-stdin localhost:15099"); + VERIFY_ARE_EQUAL(1u, result.ExitCode.value_or(0)); + VERIFY_IS_TRUE(result.Stderr.has_value()); + VERIFY_IS_TRUE(result.Stderr->find(L"--password and --password-stdin are mutually exclusive") != std::wstring::npos); + } + + WSLC_TEST_METHOD(WSLCE2E_Registry_Login_PasswordStdinRequiresUsername) + { + auto result = RunWslc(L"login --password-stdin localhost:15099"); + VERIFY_ARE_EQUAL(1u, result.ExitCode.value_or(0)); + VERIFY_IS_TRUE(result.Stderr.has_value()); + VERIFY_IS_TRUE(result.Stderr->find(L"Must provide --username with --password-stdin") != std::wstring::npos); + } + + WSLC_TEST_METHOD(WSLCE2E_Registry_Login_InvalidCredentials) + { + auto session = OpenDefaultElevatedSession(); + + { + auto [registryContainer, registryAddress] = StartLocalRegistry(*session, c_username, c_password, 15003); + auto registryAddressW = string::MultiByteToWide(registryAddress); + + // Login with wrong password should fail. + { + auto result = + RunWslc(std::format(L"login -u {} -p wrongpassword {}", string::MultiByteToWide(c_username), registryAddressW)); + VERIFY_ARE_EQUAL(1u, result.ExitCode.value_or(0)); + VERIFY_IS_TRUE(result.Stderr.has_value()); + VERIFY_IS_TRUE(result.Stderr->find(L"401 Unauthorized") != std::wstring::npos); + } + + // Login with wrong username should fail. + { + auto result = RunWslc(std::format(L"login -u wronguser -p {} {}", string::MultiByteToWide(c_password), registryAddressW)); + VERIFY_ARE_EQUAL(1u, result.ExitCode.value_or(0)); + VERIFY_IS_TRUE(result.Stderr.has_value()); + VERIFY_IS_TRUE(result.Stderr->find(L"401 Unauthorized") != std::wstring::npos); + } + + // Login with correct credentials should still succeed after failed attempts. + { + auto result = RunWslc(std::format( + L"login -u {} -p {} {}", string::MultiByteToWide(c_username), string::MultiByteToWide(c_password), registryAddressW)); + result.Verify({.Stdout = Localization::WSLCCLI_LoginSucceeded() + L"\r\n", .Stderr = L"", .ExitCode = 0}); + + VerifyLogoutSucceeds(registryAddressW); + } + } + } + + WSLC_TEST_METHOD(WSLCE2E_Registry_Login_CredentialInputMethods) + { + auto session = OpenDefaultElevatedSession(); + + { + auto [registryContainer, registryAddress] = StartLocalRegistry(*session, c_username, c_password, 15002); + auto registryAddressW = string::MultiByteToWide(registryAddress); + auto usernameW = string::MultiByteToWide(c_username); + auto passwordW = string::MultiByteToWide(c_password); + + // Login with -u and -p flags. + { + auto result = RunWslc(std::format(L"login -u {} -p {} {}", usernameW, passwordW, registryAddressW)); + result.Verify({.Stdout = Localization::WSLCCLI_LoginSucceeded() + L"\r\n", .Stderr = L"", .ExitCode = 0}); + + VerifyLogoutSucceeds(registryAddressW); + } + + // Login with -u and --password-stdin. + { + auto interactive = RunWslcInteractive(std::format(L"login -u {} --password-stdin {}", usernameW, registryAddressW)); + interactive.WriteLine(c_password); + interactive.CloseStdin(); + auto exitCode = interactive.Wait(); + VERIFY_ARE_EQUAL(0, exitCode, L"Login with --password-stdin should succeed"); + + VerifyLogoutSucceeds(registryAddressW); + } + + // Login with interactive prompts (no flags). + { + auto interactive = RunWslcInteractive(std::format(L"login {}", registryAddressW)); + interactive.ExpectStderr("Username: "); + interactive.WriteLine(c_username); + interactive.ExpectStderr("Password: "); + interactive.WriteLine(c_password); + auto exitCode = interactive.Wait(); + VERIFY_ARE_EQUAL(0, exitCode, L"Interactive login should succeed"); + + VerifyLogoutSucceeds(registryAddressW); + } + } + } + +private: + std::wstring GetLoginHelpMessage() const + { + std::wstringstream output; + output << GetWslcHeader() << GetLoginDescription() << GetLoginUsage() << GetLoginAvailableArguments() << GetLoginAvailableOptions(); + return output.str(); + } + + std::wstring GetLogoutHelpMessage() const + { + std::wstringstream output; + output << GetWslcHeader() << GetLogoutDescription() << GetLogoutUsage() << GetLogoutAvailableArguments() + << GetLogoutAvailableOptions(); + return output.str(); + } + + std::wstring GetLoginDescription() const + { + return Localization::WSLCCLI_LoginLongDesc() + L"\r\n\r\n"; + } + + std::wstring GetLogoutDescription() const + { + return Localization::WSLCCLI_LogoutLongDesc() + L"\r\n\r\n"; + } + + std::wstring GetLoginUsage() const + { + return L"Usage: wslc registry login [] []\r\n\r\n"; + } + + std::wstring GetLogoutUsage() const + { + return L"Usage: wslc registry logout [] []\r\n\r\n"; + } + + std::wstring GetLoginAvailableArguments() const + { + std::wstringstream args; + args << Localization::WSLCCLI_AvailableArguments() << L"\r\n" + << L" server " << Localization::WSLCCLI_LoginServerArgDescription() << L"\r\n" + << L"\r\n"; + return args.str(); + } + + std::wstring GetLogoutAvailableArguments() const + { + std::wstringstream args; + args << Localization::WSLCCLI_AvailableArguments() << L"\r\n" + << L" server " << Localization::WSLCCLI_LoginServerArgDescription() << L"\r\n" + << L"\r\n"; + return args.str(); + } + + std::wstring GetLoginAvailableOptions() const + { + std::wstringstream options; + options << Localization::WSLCCLI_AvailableOptions() << L"\r\n" + << L" -p,--password " << Localization::WSLCCLI_LoginPasswordArgDescription() << L"\r\n" + << L" --password-stdin " << Localization::WSLCCLI_LoginPasswordStdinArgDescription() << L"\r\n" + << L" -u,--username " << Localization::WSLCCLI_LoginUsernameArgDescription() << L"\r\n" + << L" --session " << Localization::WSLCCLI_SessionIdArgDescription() << L"\r\n" + << L" -h,--help " << Localization::WSLCCLI_HelpArgDescription() << L"\r\n" + << L"\r\n"; + return options.str(); + } + + std::wstring GetLogoutAvailableOptions() const + { + std::wstringstream options; + options << Localization::WSLCCLI_AvailableOptions() << L"\r\n" + << L" -h,--help " << Localization::WSLCCLI_HelpArgDescription() << L"\r\n" + << L"\r\n"; + return options.str(); + } +}; +} // namespace WSLCE2ETests