diff --git a/CMakeLists.txt b/CMakeLists.txt index 80b5bf0a9..f40e9b927 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,18 @@ FetchContent_Declare(nlohmannjson FetchContent_MakeAvailable(nlohmannjson) FetchContent_GetProperties(nlohmannjson SOURCE_DIR NLOHMAN_JSON_SOURCE_DIR) +FetchContent_Declare(yaml-cpp + URL https://github.com/jbeder/yaml-cpp/releases/download/yaml-cpp-0.9.0/yaml-cpp-yaml-cpp-0.9.0.tar.gz + URL_HASH SHA256=298593d9c440fd9034b8b193d96318b76d49bc97c6ceadb7b0836edf0b6d7539) + +set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(YAML_CPP_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(YAML_CPP_BUILD_CONTRIB OFF CACHE BOOL "" FORCE) +set(YAML_MSVC_SHARED_RT OFF CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(yaml-cpp) +FetchContent_GetProperties(yaml-cpp SOURCE_DIR YAML_CPP_SOURCE_DIR) + set(BOOST_VERSION "1.90.0") set(BOOST_TARBALL "boost_${BOOST_VERSION}") string(REPLACE "." "_" BOOST_TARBALL "${BOOST_TARBALL}") diff --git a/cgmanifest.json b/cgmanifest.json index c37443585..87de3a5f1 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -41,6 +41,17 @@ "hash": "sha1:44500f8d6b279ec314a4cdce1290ddc30f530ed7" } } + }, + { + "component": { + "type": "other", + "other": { + "name": "yaml-cpp", + "version": "0.9.0", + "downloadUrl": "https://github.com/jbeder/yaml-cpp/releases/download/yaml-cpp-0.9.0/yaml-cpp-yaml-cpp-0.9.0.tar.gz", + "hash": "sha256:298593d9c440fd9034b8b193d96318b76d49bc97c6ceadb7b0836edf0b6d7539" + } + } } ] } \ No newline at end of file diff --git a/src/windows/wslc/CMakeLists.txt b/src/windows/wslc/CMakeLists.txt index 7d57ee9c8..c160840c5 100644 --- a/src/windows/wslc/CMakeLists.txt +++ b/src/windows/wslc/CMakeLists.txt @@ -1,4 +1,4 @@ -set(WSLC_SUBDIRS arguments commands core services tasks) +set(WSLC_SUBDIRS arguments commands core services settings tasks) list(TRANSFORM WSLC_SUBDIRS PREPEND ${CMAKE_CURRENT_SOURCE_DIR}/ OUTPUT_VARIABLE WSLC_SUBDIR_PATHS) list(TRANSFORM WSLC_SUBDIR_PATHS APPEND /*.h OUTPUT_VARIABLE HEADER_PATTERNS) @@ -13,7 +13,8 @@ target_include_directories(wslclib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${WSLC_SUB target_link_libraries(wslclib ${COMMON_LINK_LIBRARIES} - common) + common + yaml-cpp) target_precompile_headers(wslclib REUSE_FROM common) set_target_properties(wslclib PROPERTIES FOLDER windows) diff --git a/src/windows/wslc/commands/RootCommand.cpp b/src/windows/wslc/commands/RootCommand.cpp index faa6a2317..7c62d699a 100644 --- a/src/windows/wslc/commands/RootCommand.cpp +++ b/src/windows/wslc/commands/RootCommand.cpp @@ -17,6 +17,7 @@ Module Name: #include "ContainerCommand.h" #include "ImageCommand.h" #include "SessionCommand.h" +#include "SettingsCommand.h" using namespace wsl::windows::wslc::execution; @@ -27,6 +28,7 @@ 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())); diff --git a/src/windows/wslc/commands/SettingsCommand.cpp b/src/windows/wslc/commands/SettingsCommand.cpp new file mode 100644 index 000000000..886b40092 --- /dev/null +++ b/src/windows/wslc/commands/SettingsCommand.cpp @@ -0,0 +1,92 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + SettingsCommand.cpp + +Abstract: + + Implementation of SettingsCommand command tree. + +--*/ +#include "Argument.h" +#include "SettingsCommand.h" +#include "UserSettings.h" +#include "wslutil.h" + +using namespace wsl::windows::common::wslutil; +using namespace wsl::windows::wslc::execution; +using namespace wsl::windows::wslc::settings; + +namespace wsl::windows::wslc { + +// SettingsCommand +std::vector> SettingsCommand::GetCommands() const +{ + std::vector> commands; + commands.push_back(std::make_unique(FullName())); + return commands; +} + +std::vector SettingsCommand::GetArguments() const +{ + return {}; +} + +std::wstring SettingsCommand::ShortDescription() const +{ + return {L"Open the settings file in the default editor."}; +} + +std::wstring SettingsCommand::LongDescription() const +{ + return { + L"Opens the wslc user settings file in the system default editor for .yaml files.\n" + L"On first run, creates the file with all settings commented out at their defaults."}; +} + +void SettingsCommand::ExecuteInternal(CLIExecutionContext& context) const +{ + settings::User().PrepareToShellExecuteFile(); + + const auto& path = settings::User().SettingsFilePath(); + + // Some versions of windows will fail if no file extension association exists, other will pop up the dialog + // to make the user pick their default. + HINSTANCE res = ShellExecuteW(nullptr, nullptr, path.c_str(), nullptr, nullptr, SW_SHOW); + if (static_cast(reinterpret_cast(res)) <= 32) + { + // User doesn't have file type association. Default to notepad + // Quote the path so that Notepad treats it as a single argument even if it contains spaces. + std::filesystem::path notepadPath = std::filesystem::path{wil::GetSystemDirectoryW().get()} / L"notepad.exe"; + std::wstring quotedPath = L"\"" + path.wstring() + L"\""; + ShellExecuteW(nullptr, nullptr, notepadPath.c_str(), quotedPath.c_str(), nullptr, SW_SHOW); + } +} + +// SettingsResetCommand +std::vector SettingsResetCommand::GetArguments() const +{ + return {}; +} + +std::wstring SettingsResetCommand::ShortDescription() const +{ + return {L"Reset settings to built-in defaults."}; +} + +std::wstring SettingsResetCommand::LongDescription() const +{ + return {L"Overwrites the settings file with a commented-out defaults template."}; +} + +void SettingsResetCommand::ExecuteInternal(CLIExecutionContext& context) const +{ + // TODO: do we need prompt support? + settings::User().Reset(); + PrintMessage(L"Settings reset to defaults."); +} + +} // namespace wsl::windows::wslc diff --git a/src/windows/wslc/commands/SettingsCommand.h b/src/windows/wslc/commands/SettingsCommand.h new file mode 100644 index 000000000..5a26bdc20 --- /dev/null +++ b/src/windows/wslc/commands/SettingsCommand.h @@ -0,0 +1,54 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + SettingsCommand.h + +Abstract: + + Declaration of SettingsCommand command tree. + +--*/ +#pragma once +#include "Command.h" + +namespace wsl::windows::wslc { + +// Root settings command: opens the settings file in the user's default editor. +struct SettingsCommand final : public Command +{ + constexpr static std::wstring_view CommandName = L"settings"; + + SettingsCommand(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; +}; + +// Resets the settings file to built-in defaults. +struct SettingsResetCommand final : public Command +{ + constexpr static std::wstring_view CommandName = L"reset"; + + SettingsResetCommand(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/core/Main.cpp b/src/windows/wslc/core/Main.cpp index 86bf9ac69..810053730 100644 --- a/src/windows/wslc/core/Main.cpp +++ b/src/windows/wslc/core/Main.cpp @@ -20,6 +20,7 @@ Module Name: #include "CLIExecutionContext.h" #include "Invocation.h" #include "RootCommand.h" +#include "UserSettings.h" using namespace wsl::shared; using namespace wsl::windows::common; diff --git a/src/windows/wslc/services/SessionModel.cpp b/src/windows/wslc/services/SessionModel.cpp index a3bded053..789fcda3a 100644 --- a/src/windows/wslc/services/SessionModel.cpp +++ b/src/windows/wslc/services/SessionModel.cpp @@ -14,21 +14,24 @@ Module Name: #include #include "SessionModel.h" +#include "UserSettings.h" namespace wsl::windows::wslc::models { SessionOptions SessionOptions::Default() { // Use a function-local static to defer path initialization until first use. - static const std::filesystem::path defaultPath = {wsl::windows::common::filesystem::GetLocalAppDataPath(nullptr) / "wslc"}; + static const std::filesystem::path storagePath = + settings::User().Get().empty() + ? std::filesystem::path{wsl::windows::common::filesystem::GetLocalAppDataPath(nullptr) / L"wslc\\storage"} + : settings::User().Get().c_str(); - // TODO: Have a configuration file for those. SessionOptions options{}; options.m_sessionSettings.DisplayName = s_DefaultSessionName; - options.m_sessionSettings.CpuCount = 4; - options.m_sessionSettings.MemoryMb = 2048; + options.m_sessionSettings.CpuCount = settings::User().Get(); + options.m_sessionSettings.MemoryMb = settings::User().Get(); options.m_sessionSettings.BootTimeoutMs = 30 * 1000; - options.m_sessionSettings.StoragePath = defaultPath.c_str(); - options.m_sessionSettings.MaximumStorageSizeMb = 10000; // 10GB. + options.m_sessionSettings.StoragePath = storagePath.c_str(); + options.m_sessionSettings.MaximumStorageSizeMb = settings::User().Get(); options.m_sessionSettings.NetworkingMode = WSLCNetworkingModeVirtioProxy; return options; } diff --git a/src/windows/wslc/settings/UserSettings.cpp b/src/windows/wslc/settings/UserSettings.cpp new file mode 100644 index 000000000..6daed8c85 --- /dev/null +++ b/src/windows/wslc/settings/UserSettings.cpp @@ -0,0 +1,254 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + UserSettings.cpp + +Abstract: + + Implementation of UserSettings — YAML loading and validation. + +--*/ +#include "UserSettings.h" +#include "filesystem.hpp" +#include "string.hpp" +#include "wslutil.h" +#include +#include +#include +#include +#include + +using namespace wsl::windows::common::string; + +namespace wsl::windows::wslc::settings { + +// Default settings file template — written on first run. +// All entries are commented out; the values shown are the built-in defaults. +// TODO: localization for comments needed? +static constexpr std::string_view s_DefaultSettingsTemplate = + "# wslc user settings\n" + "# https://aka.ms/wslc-settings\n" + "\n" + "session:\n" + " # Number of virtual CPUs allocated to the session (default: 4)\n" + " # cpuCount: 4\n" + "\n" + " # Memory limit for the session in megabytes (default: 2GB)\n" + " # memorySize: 2GB\n" + "\n" + " # Maximum disk image size in megabytes (default: 100GB)\n" + " # maxStorageSize: 100GB\n" + "\n" + " # Default path for container storage (default: %LocalAppData%\\wslc\\storage)\n" + " # defaultStoragePath: \"\"\n"; + +// Validate individual setting specializations +namespace details { + + std::optional ParseSettingsMemoryValue(const std::string& value) + { + auto parsed = wsl::shared::string::ParseMemorySize(value.c_str()); + auto converted = parsed.has_value() ? *parsed / 1048576 : 0; // To Mb, and anything less than 1Mb is considered invalid. + return converted > 0 ? std::optional{static_cast(converted)} : std::nullopt; + } + +#define WSLC_VALIDATE_SETTING(_setting_) \ + std::optional::value_t> SettingMapping::Validate( \ + const SettingMapping::yaml_t& value) + + WSLC_VALIDATE_SETTING(SessionCpuCount) + { + return value > 0 ? std::optional{value} : std::nullopt; + } + + WSLC_VALIDATE_SETTING(SessionMemoryMb) + { + return ParseSettingsMemoryValue(value); + } + + WSLC_VALIDATE_SETTING(SessionStorageSizeMb) + { + return ParseSettingsMemoryValue(value); + } + + // yaml_t = std::string (UTF-8 from yaml-cpp), value_t = std::wstring + WSLC_VALIDATE_SETTING(SessionStoragePath) + { + return MultiByteToWide(value); + } + +#undef WSLC_VALIDATE_SETTING + +} // namespace details + +// Helpers +namespace { + + // Traverses a dot-separated path (e.g. "session.cpuCount") through a YAML node tree. + // Returns nullopt if any segment is invalid or missing. + std::optional NavigateYamlPath(const YAML::Node& root, std::string_view path) + { + YAML::Node current = root; + auto subPaths = wsl::shared::string::Split(std::string{path}, '.'); + for (auto const& subPath : subPaths) + { + if (current.IsDefined() && current.IsMap()) + { + // Use the const operator[] to avoid yaml-cpp's AssignNode/set_ref side-effect, + // which mutates the shared detail::node and corrupts subsequent lookups. + // Then use reset() to rebind 'current' without triggering set_ref. + auto child = static_cast(current)[subPath]; + if (!child.IsDefined()) + { + return std::nullopt; + } + current.reset(child); + } + else + { + return std::nullopt; + } + } + return current; + } + + // Validates and stores a single setting from the YAML document. + template + void ValidateSetting(const YAML::Node& root, SettingsMap& map, std::vector& warnings) + { + constexpr auto path = details::SettingMapping::YamlPath; + auto node = NavigateYamlPath(root, path); + + if (!node || !node->IsDefined() || node->IsNull()) + { + // Key absent — silently use the built-in default. + return; + } + + try + { + auto rawValue = node->as::yaml_t>(); + auto validated = details::SettingMapping::Validate(rawValue); + if (validated.has_value()) + { + map.Add(std::move(validated.value())); + } + else + { + const auto widePath = MultiByteToWide(path); + warnings.push_back({std::format(L"Warning: Invalid value for setting '{}'. Using default.", widePath), widePath}); + } + } + catch (...) + { + const auto widePath = MultiByteToWide(path); + warnings.push_back({std::format(L"Warning: Invalid type for setting '{}'. Using default.", widePath), widePath}); + } + } + + // Validates all settings via a fold over the Setting enum index sequence. + template + void ValidateAll(const YAML::Node& root, SettingsMap& map, std::vector& warnings, std::index_sequence) + { + (ValidateSetting(S)>(root, map, warnings), ...); + } + + // Attempts to parse a YAML document from the given file path. + // Returns an empty optional and pushes a warning if the file exists but fails to parse. + std::optional TryLoadYaml(const std::filesystem::path& path, std::vector& warnings) + { + std::ifstream stream(path); + if (!stream.is_open()) + { + auto err = errno; + // If the file exists but cannot be opened (permissions, sharing violation, etc.), + // emit a warning so the user understands why settings were ignored. + if (err != ENOENT) + { + warnings.push_back( + {std::format(L"Warning: Failed to open '{}', errno: {}. Using default settings.", path.filename().wstring(), err), {}}); + } + + return std::nullopt; + } + + try + { + return YAML::Load(stream); + } + catch (const std::exception& e) + { + warnings.push_back( + {std::format(L"Warning: '{}' could not be parsed: {}.", path.filename().wstring(), MultiByteToWide(e.what())), {}}); + return std::nullopt; + } + } + + const std::filesystem::path& SettingsDir() + { + static const std::filesystem::path dir = wsl::windows::common::filesystem::GetLocalAppDataPath(nullptr) / L"wslc"; + return dir; + } +} // namespace + +UserSettings const& UserSettings::Instance() +{ + static UserSettings instance; + return instance; +} + +UserSettings::UserSettings() : UserSettings(SettingsDir()) +{ +} + +UserSettings::UserSettings(const std::filesystem::path& settingsDir) +{ + m_settingsPath = settingsDir / L"settings.yaml"; + + auto root = TryLoadYaml(m_settingsPath, m_warnings); + if (root.has_value()) + { + m_type = UserSettingsType::Standard; + } + + if (root.has_value()) + { + constexpr auto settingCount = static_cast(Setting::Max); + ValidateAll(root.value(), m_settings, m_warnings, std::make_index_sequence()); + + // TODO: Iterate through all nodes and warn about unknown keys? + } + + // Emit any settings load warnings. + for (const auto& warning : m_warnings) + { + wsl::windows::common::wslutil::PrintMessage(warning.Message, stderr); + } +} + +void UserSettings::Reset() const +{ + std::filesystem::create_directories(m_settingsPath.parent_path()); + std::ofstream file(m_settingsPath); + THROW_HR_IF_MSG(E_UNEXPECTED, !file.is_open(), "Failed to create settings file"); + file << s_DefaultSettingsTemplate; +} + +void UserSettings::PrepareToShellExecuteFile() const +{ + if (m_type == UserSettingsType::Default) + { + // First run — create the directory and write the commented-out defaults template. + Reset(); + } +} + +std::filesystem::path UserSettings::SettingsFilePath() const +{ + return m_settingsPath; +} + +} // namespace wsl::windows::wslc::settings diff --git a/src/windows/wslc/settings/UserSettings.h b/src/windows/wslc/settings/UserSettings.h new file mode 100644 index 000000000..00705619e --- /dev/null +++ b/src/windows/wslc/settings/UserSettings.h @@ -0,0 +1,168 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + UserSettings.h + +Abstract: + + Declaration of UserSettings — the singleton that loads, validates, and + provides access to the wslc user settings file. + +--*/ +#pragma once +#include "defs.h" +#include "EnumVariantMap.h" +#include +#include +#include +#include +#include +#include + +// How to add a setting: +// 1 - Add an entry to the Setting enum. +// 2 - Add a DEFINE_SETTING_MAPPING specialization with yaml_t, value_t, default, and YAML path. +// 3 - Implement the Validate function in UserSettings.cpp if needed, otherwise use pass through. + +namespace wsl::windows::wslc::settings { + +// Enum of all user settings. +// Must start at 0 to enable direct variant indexing. +// Max must be last and unused. +enum class Setting : size_t +{ + SessionCpuCount = 0, + SessionMemoryMb, + SessionStorageSizeMb, + SessionStoragePath, + + Max +}; + +namespace details { + + template + struct SettingMapping + { + // yaml_t - the C++ type read from the YAML node via node.as() + // value_t - the native type stored in SettingsMap + // DefaultValue - used when the key is absent or fails validation + // YamlPath - dot-separated path into the YAML document (e.g. "session.cpuCount") + // Validate - semantic validation; returns nullopt to reject and fall back to default + }; + + // clang-format off +#define DEFINE_SETTING_MAPPING(_setting_, _yaml_t_, _value_t_, _default_, _path_) \ + template <> \ + struct SettingMapping \ + { \ + using yaml_t = _yaml_t_; \ + using value_t = _value_t_; \ + inline static const value_t DefaultValue = _default_; \ + static constexpr std::string_view YamlPath = _path_; \ + static std::optional Validate(const yaml_t& value); \ + }; + + DEFINE_SETTING_MAPPING(SessionCpuCount, uint32_t, uint32_t, 4, "session.cpuCount") + DEFINE_SETTING_MAPPING(SessionMemoryMb, std::string, uint32_t, 2048, "session.memorySize") + DEFINE_SETTING_MAPPING(SessionStorageSizeMb, std::string, uint32_t, 102400, "session.maxStorageSize") + DEFINE_SETTING_MAPPING(SessionStoragePath, std::string, std::wstring, {}, "session.defaultStoragePath") + +#undef DEFINE_SETTING_MAPPING + // clang-format on + +} // namespace details + +// Type-safe enum-indexed map of all settings values, backed by EnumBasedVariantMap. +struct SettingsMap : wsl::windows::wslc::EnumBasedVariantMap +{ + // Returns the stored value if present, otherwise the compile-time default. + template + typename details::SettingMapping::value_t GetOrDefault() const + { + if (Contains(S)) + { + return Get(); + } + return details::SettingMapping::DefaultValue; + } +}; + +// Indicates which source the settings were loaded from. +enum class UserSettingsType +{ + Default, // Settings file did not exist or failed to parse; built-in defaults are used. + Standard, // Settings file (settings.yaml) loaded successfully. +}; + +struct Warning +{ + std::wstring Message; + std::wstring SettingPath; // Empty for file-level warnings; key path for per-field warnings. +}; + +// Singleton that owns the parsed settings for the current process lifetime. +// Load order: +// 1. settings.yaml (Standard) +// 2. Built-in defaults (Default, if the file is absent or fails to parse) +class UserSettings +{ +public: + // Returns the singleton instance. Loaded on first call; subsequent calls are no-ops. + static UserSettings const& Instance(); + + NON_COPYABLE(UserSettings); + NON_MOVABLE(UserSettings); + + // Returns the value for setting S, or its built-in default if not present in the file. + template + typename details::SettingMapping::value_t Get() const + { + return m_settings.GetOrDefault(); + } + + std::vector const& GetWarnings() const + { + return m_warnings; + } + + UserSettingsType GetType() const + { + return m_type; + } + + // Called before opening the settings file in an editor. + // If type is Default, creates the file from the commented-out defaults template. + void PrepareToShellExecuteFile() const; + + std::filesystem::path SettingsFilePath() const; + + // Overwrites the settings file with the commented-out defaults template. + void Reset() const; + +protected: + // Loads settings from an explicit directory. Used by the singleton (via + // the private zero-arg constructor) and by test subclasses. + explicit UserSettings(const std::filesystem::path& settingsDir); + ~UserSettings() = default; + +private: + UserSettings(); + + SettingsMap m_settings; + std::vector m_warnings; + UserSettingsType m_type = UserSettingsType::Default; + std::filesystem::path m_settingsPath; +}; + +// Convenience free function — returns the singleton instance. +// Usage: settings::User().Get() +inline UserSettings const& User() +{ + return UserSettings::Instance(); +} + +} // namespace wsl::windows::wslc::settings diff --git a/test/windows/wslc/CMakeLists.txt b/test/windows/wslc/CMakeLists.txt index 23418d7cb..4e91a5411 100644 --- a/test/windows/wslc/CMakeLists.txt +++ b/test/windows/wslc/CMakeLists.txt @@ -28,4 +28,5 @@ target_include_directories(wsltests PRIVATE ${CMAKE_SOURCE_DIR}/src/windows/wslc/arguments ${CMAKE_SOURCE_DIR}/src/windows/wslc/services ${CMAKE_SOURCE_DIR}/src/windows/wslc/tasks + ${CMAKE_SOURCE_DIR}/src/windows/wslc/settings ) diff --git a/test/windows/wslc/CommandLineTestCases.h b/test/windows/wslc/CommandLineTestCases.h index 32c471461..04bd94c1c 100644 --- a/test/windows/wslc/CommandLineTestCases.h +++ b/test/windows/wslc/CommandLineTestCases.h @@ -111,6 +111,10 @@ COMMAND_LINE_TEST_CASE(L"image list -q", L"list", true) COMMAND_LINE_TEST_CASE(L"image pull ubuntu", L"pull", true) COMMAND_LINE_TEST_CASE(L"pull ubuntu", L"pull", true) +// Settings command +COMMAND_LINE_TEST_CASE(L"settings", L"settings", true) +COMMAND_LINE_TEST_CASE(L"settings reset", L"reset", true) + // Error cases COMMAND_LINE_TEST_CASE(L"invalid command", L"", false) COMMAND_LINE_TEST_CASE(L"CONTAINER list", L"list", false) // We are intentionally case-sensitive diff --git a/test/windows/wslc/WSLCCLISettingsUnitTests.cpp b/test/windows/wslc/WSLCCLISettingsUnitTests.cpp new file mode 100644 index 000000000..7a6c02b37 --- /dev/null +++ b/test/windows/wslc/WSLCCLISettingsUnitTests.cpp @@ -0,0 +1,295 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + WSLCCLISettingsUnitTests.cpp + +Abstract: + + Unit tests for the wslc UserSettings system: SettingsMap, YAML loading, + per-setting validation, fallback logic, and UserSettingsType detection. + +--*/ + +#include "precomp.h" +#include "windows/Common.h" +#include "WSLCCLITestHelpers.h" +#include "UserSettings.h" + +#include +#include + +using namespace wsl::windows::wslc::settings; +using namespace WSLCTestHelpers; +using namespace WEX::Logging; +using namespace WEX::Common; +using namespace WEX::TestExecution; + +namespace WSLCCLISettingsUnitTests { + +// Thin subclass that makes the protected constructor publicly accessible, +// allowing tests to load settings from an arbitrary directory without +// going through the singleton. +class UserSettingsTest : public UserSettings +{ +public: + explicit UserSettingsTest(const std::filesystem::path& settingsDir) : UserSettings(settingsDir) + { + } +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static std::atomic s_dirCounter{0}; + +static std::filesystem::path UniqueTempDir() +{ + auto dir = std::filesystem::temp_directory_path() / L"WSLCSettingsTests" / std::to_wstring(GetCurrentProcessId()) / + std::to_wstring(++s_dirCounter); + std::filesystem::create_directories(dir); + return dir; +} + +static void WriteFile(const std::filesystem::path& path, std::string_view content) +{ + std::filesystem::create_directories(path.parent_path()); + std::ofstream f(path, std::ios::binary); + VERIFY_IS_TRUE(f.is_open()); + f.write(content.data(), static_cast(content.size())); +} + +// --------------------------------------------------------------------------- +// Test class +// --------------------------------------------------------------------------- + +class WSLCCLISettingsUnitTests +{ + WSL_TEST_CLASS(WSLCCLISettingsUnitTests) + + TEST_CLASS_SETUP(TestClassSetup) + { + return true; + } + + TEST_CLASS_CLEANUP(TestClassCleanup) + { + std::error_code ec; + std::filesystem::remove_all(std::filesystem::temp_directory_path() / L"WSLCSettingsTests", ec); + return true; + } + + // ----------------------------------------------------------------------- + // SettingsMap — pure unit tests, no I/O + // ----------------------------------------------------------------------- + + // All four settings should return their compile-time defaults when the map + // is empty (no values have been inserted). + TEST_METHOD(SettingsMap_GetOrDefault_ReturnsBuiltInWhenAbsent) + { + SettingsMap map; + VERIFY_ARE_EQUAL(4u, map.GetOrDefault()); + VERIFY_ARE_EQUAL(2048u, map.GetOrDefault()); + VERIFY_ARE_EQUAL(102400u, map.GetOrDefault()); + VERIFY_ARE_EQUAL(std::wstring{}, map.GetOrDefault()); + } + + // After inserting a value, GetOrDefault must return it rather than the default. + TEST_METHOD(SettingsMap_GetOrDefault_ReturnsStoredWhenPresent) + { + SettingsMap map; + map.Add(16u); + VERIFY_ARE_EQUAL(16u, map.GetOrDefault()); + VERIFY_ARE_EQUAL(2048u, map.GetOrDefault()); + } + + // ----------------------------------------------------------------------- + // Default (setting file missing) + // ----------------------------------------------------------------------- + + // When settings file missing, the type must be Default, there + // must be no warnings, and all values must be at their built-in defaults. + TEST_METHOD(LoadSettings_NoFiles_YieldsDefaultTypeAndNoWarnings) + { + UserSettingsTest s{UniqueTempDir()}; + + VERIFY_ARE_EQUAL(static_cast(UserSettingsType::Default), static_cast(s.GetType())); + VERIFY_ARE_EQUAL(0u, s.GetWarnings().size()); + VERIFY_ARE_EQUAL(4u, s.Get()); + VERIFY_ARE_EQUAL(2048u, s.Get()); + VERIFY_ARE_EQUAL(102400u, s.Get()); + VERIFY_ARE_EQUAL(std::wstring{}, s.Get()); + } + + // ----------------------------------------------------------------------- + // Standard (valid settings) + // ----------------------------------------------------------------------- + + // A well-formed settings file must set the type to Standard with no + // warnings and the specified values loaded. + TEST_METHOD(LoadSettings_ValidSettings_YieldsStandardTypeAndValues) + { + auto dir = UniqueTempDir(); + WriteFile( + dir / L"settings.yaml", + "session:\n" + " cpuCount: 8\n" + " memorySize: 4GB\n" + " maxStorageSize: 20000MB\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(static_cast(UserSettingsType::Standard), static_cast(s.GetType())); + VERIFY_ARE_EQUAL(0u, s.GetWarnings().size()); + VERIFY_ARE_EQUAL(8u, s.Get()); + VERIFY_ARE_EQUAL(4096u, s.Get()); + VERIFY_ARE_EQUAL(20000u, s.Get()); + // Unspecified setting falls back to built-in default. + VERIFY_ARE_EQUAL(std::wstring{}, s.Get()); + } + + // An empty settings file is valid YAML (null document); all settings use + // their defaults with no warnings. + TEST_METHOD(LoadSettings_EmptySettings_AllDefaultsNoWarnings) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", ""); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(0u, s.GetWarnings().size()); + VERIFY_ARE_EQUAL(4u, s.Get()); + VERIFY_ARE_EQUAL(2048u, s.Get()); + VERIFY_ARE_EQUAL(102400u, s.Get()); + } + + // When the settings file fails to parse, the type is Default and a warning is emitted. + TEST_METHOD(LoadSettings_InvalidSettings_YieldsDefaultTypeWithWarning) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "session: [\n"); // broken YAML (unclosed flow seq) + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(static_cast(UserSettingsType::Default), static_cast(s.GetType())); + VERIFY_IS_TRUE(s.GetWarnings().size() >= 1u); + VERIFY_ARE_EQUAL(4u, s.Get()); + } + + // ----------------------------------------------------------------------- + // Per-setting validation + // ----------------------------------------------------------------------- + + // cpuCount: 0 must be rejected; the default (4) is used and a warning emitted. + TEST_METHOD(Validation_CpuCount_Zero_UsesDefaultAndWarns) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "session:\n cpuCount: 0\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(4u, s.Get()); + VERIFY_IS_TRUE(s.GetWarnings().size() >= 1u); + VERIFY_IS_FALSE(s.GetWarnings().front().SettingPath.empty()); + } + + // memorySize: 0 must be rejected; the default (2048) is used. + TEST_METHOD(Validation_MemoryMb_Zero_UsesDefaultAndWarns) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "session:\n memorySize: 0\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(2048u, s.Get()); + VERIFY_IS_TRUE(s.GetWarnings().size() >= 1u); + } + + // maxStorageSize: 0 must be rejected; the default (100GB) is used. + TEST_METHOD(Validation_StorageSizeMb_Zero_UsesDefaultAndWarns) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "session:\n maxStorageSize: 0\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(102400u, s.Get()); + VERIFY_IS_TRUE(s.GetWarnings().size() >= 1u); + } + + // A string where a uint32_t is expected must emit a type warning and fall + // back to the default. + TEST_METHOD(Validation_WrongType_UsesDefaultAndWarns) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "session:\n cpuCount: \"not-a-number\"\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(4u, s.Get()); + VERIFY_IS_TRUE(s.GetWarnings().size() >= 1u); + } + + // A valid defaultStoragePath string must survive the UTF-8 → wstring round-trip. + TEST_METHOD(Validation_StoragePath_NonEmpty_RoundTrips) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "session:\n defaultStoragePath: \"C:\\\\TestFolder\"\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(std::wstring(L"C:\\TestFolder"), s.Get()); + VERIFY_ARE_EQUAL(0u, s.GetWarnings().size()); + } + + // An empty defaultStoragePath string is valid. + TEST_METHOD(Validation_StoragePath_Empty_IsValid) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "session:\n defaultStoragePath: \"\"\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(std::wstring{}, s.Get()); + VERIFY_ARE_EQUAL(0u, s.GetWarnings().size()); + } + + // Absent keys must silently use defaults — no warnings emitted. + TEST_METHOD(Validation_AbsentKeys_NoWarningsAndDefaults) + { + auto dir = UniqueTempDir(); + WriteFile(dir / L"settings.yaml", "session:\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(0u, s.GetWarnings().size()); + VERIFY_ARE_EQUAL(4u, s.Get()); + VERIFY_ARE_EQUAL(2048u, s.Get()); + VERIFY_ARE_EQUAL(102400u, s.Get()); + VERIFY_ARE_EQUAL(std::wstring{}, s.Get()); + } + + // Extra unknown keys at any level must not cause errors or warnings. + TEST_METHOD(Validation_UnknownKeys_NoErrorsOrWarnings) + { + auto dir = UniqueTempDir(); + WriteFile( + dir / L"settings.yaml", + "session:\n" + " cpuCount: 4\n" + " unknownSetting: 99\n" + "unknownSection:\n" + " foo: bar\n"); + + UserSettingsTest s{dir}; + + VERIFY_ARE_EQUAL(static_cast(UserSettingsType::Standard), static_cast(s.GetType())); + VERIFY_ARE_EQUAL(0u, s.GetWarnings().size()); + VERIFY_ARE_EQUAL(4u, s.Get()); + } +}; + +} // namespace WSLCCLISettingsUnitTests diff --git a/test/windows/wslc/e2e/WSLCE2EGlobalTests.cpp b/test/windows/wslc/e2e/WSLCE2EGlobalTests.cpp index f297f7e75..064feed76 100644 --- a/test/windows/wslc/e2e/WSLCE2EGlobalTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EGlobalTests.cpp @@ -74,6 +74,7 @@ class WSLCE2EGlobalTests << L" container Container command.\r\n" << L" image Image command.\r\n" << L" session Session command.\r\n" + << L" settings Open the settings file in the default editor.\r\n" << L" attach Attach to a container.\r\n" << L" build Build an image from a Dockerfile.\r\n" << L" create Create a container.\r\n"