| 1 | /*++ |
| 2 | |
| 3 | Copyright (c) Microsoft. All rights reserved. |
| 4 | |
| 5 | Module Name: |
| 6 | |
| 7 | WSLCCLIArgumentUnitTests.cpp |
| 8 | |
| 9 | Abstract: |
| 10 | |
| 11 | This file contains unit tests for WSLC CLI argument parsing and validation. |
| 12 | |
| 13 | --*/ |
| 14 | |
| 15 | #include "precomp.h" |
| 16 | #include "windows/Common.h" |
| 17 | #include "WSLCCLITestHelpers.h" |
| 18 | |
| 19 | #include "Argument.h" |
| 20 | #include "ArgMap.h" |
| 21 | #include "ArgumentValidation.h" |
| 22 | #include "ImageService.h" |
| 23 | #include "JsonUtils.h" |
| 24 | #include "Exceptions.h" |
| 25 | #include <chrono> |
| 26 | #include <wslc.h> |
| 27 | |
| 28 | using namespace wsl::windows::wslc; |
| 29 | using namespace wsl::windows::wslc::argument; |
| 30 | |
| 31 | using namespace WSLCTestHelpers; |
| 32 | using namespace WEX::Logging; |
| 33 | using namespace WEX::Common; |
| 34 | using namespace WEX::TestExecution; |
| 35 | |
| 36 | namespace WSLCCLIArgumentUnitTests { |
| 37 | namespace mount = wsl::windows::common::mount; |
| 38 | |
| 39 | using RawArgMapBase = EnumBasedVariantMap<ArgType, wsl::windows::wslc::argument::details::ArgDataMapping, &ArgMapInvalidateValidatedCache>; |
| 40 | |
| 41 | static_assert(!std::is_convertible_v<ArgMap*, RawArgMapBase*>); |
| 42 | static_assert(!std::is_copy_assignable_v<ArgMap>); |
| 43 | static_assert(!std::is_move_assignable_v<ArgMap>); |
| 44 | |
| 45 | class WSLCCLIArgumentUnitTests |
| 46 | { |
| 47 | WSLC_TEST_CLASS(WSLCCLIArgumentUnitTests) |
| 48 | |
| 49 | TEST_CLASS_SETUP(TestClassSetup) |
| 50 | { |
| 51 | // Add any necessary setup for argument tests |
| 52 | return true; |
| 53 | } |
| 54 | |
| 55 | TEST_CLASS_CLEANUP(TestClassCleanup) |
| 56 | { |
| 57 | // Add any necessary cleanup for argument tests |
| 58 | return true; |
| 59 | } |
| 60 | |
| 61 | TEST_METHOD(ArgumentException_OptionalArgumentHelp) |
| 62 | { |
| 63 | const ArgumentException withoutArgument{L"error"}; |
| 64 | VERIFY_IS_TRUE(withoutArgument.Arguments().empty()); |
| 65 | |
| 66 | const auto argument = Argument::Create(ArgType::Verbose); |
| 67 | const ArgumentException withArgument{L"error", argument}; |
| 68 | VERIFY_ARE_EQUAL(1u, withArgument.Arguments().size()); |
| 69 | VERIFY_ARE_EQUAL(ArgType::Verbose, withArgument.Arguments().front().Type()); |
| 70 | } |
| 71 | |
| 72 | TEST_METHOD(ArgumentCreate_DefaultsAndOverrides) |
| 73 | { |
| 74 | const auto defaults = Argument::Create(ArgType::Quiet); |
| 75 | VERIFY_ARE_EQUAL(std::wstring{L"quiet"}, defaults.Name()); |
| 76 | VERIFY_ARE_EQUAL(std::wstring{L"q"}, defaults.Alias()); |
| 77 | |
| 78 | const auto noAlias = Argument::Create(ArgType::Quiet, {.Alias = NO_ALIAS}); |
| 79 | VERIFY_IS_TRUE(noAlias.Alias().empty()); |
| 80 | VERIFY_ARE_EQUAL(defaults.Description(), noAlias.Description()); |
| 81 | |
| 82 | const auto overrides = Argument::Create( |
| 83 | ArgType::Filter, {.Name = L"where", .Alias = L"x", .Required = true, .Limit = Limit::Unlimited, .Desc = L"Custom description"}); |
| 84 | VERIFY_ARE_EQUAL(std::wstring{L"where"}, overrides.Name()); |
| 85 | VERIFY_ARE_EQUAL(std::wstring{L"x"}, overrides.Alias()); |
| 86 | VERIFY_IS_TRUE(overrides.Required()); |
| 87 | VERIFY_ARE_EQUAL(Limit::Unlimited, overrides.Limit()); |
| 88 | VERIFY_ARE_EQUAL(std::wstring{L"Custom description"}, overrides.Description()); |
| 89 | } |
| 90 | |
| 91 | // Test: Verify Argument::Create() successfully creates arguments for all ArgType enum values |
| 92 | TEST_METHOD(ArgumentCreate_AllArguments) |
| 93 | { |
| 94 | // ArgMap is the container for processed args. |
| 95 | ArgMap args; |
| 96 | |
| 97 | // Iterate through all ArgType enum values except Max |
| 98 | auto allArgTypes = std::vector<ArgType>{}; |
| 99 | for (int i = 0; i < static_cast<int>(ArgType::Max); ++i) |
| 100 | { |
| 101 | ArgType argType = static_cast<ArgType>(i); |
| 102 | |
| 103 | // Create argument using Create |
| 104 | Argument arg = Argument::Create(argType); |
| 105 | |
| 106 | // Verify the argument was created successfully by checking its type matches |
| 107 | VERIFY_ARE_EQUAL(static_cast<int>(arg.Type()), i); |
| 108 | |
| 109 | // Verify the argument has basic properties set |
| 110 | // (Name should not be empty for valid argument types) |
| 111 | VERIFY_IS_FALSE(arg.Name().empty()); |
| 112 | LogComment(L"Verified Argument::Create() creates argument with name: " + arg.Name()); |
| 113 | |
| 114 | // Add the argument to the ArgMap with a test value based on its type. |
| 115 | VERIFY_IS_FALSE(args.Contains(argType)); |
| 116 | switch (arg.Kind()) |
| 117 | { |
| 118 | case Kind::Value: |
| 119 | case Kind::Positional: |
| 120 | args.Add(argType, std::wstring(L"test")); |
| 121 | break; |
| 122 | case Kind::Forward: |
| 123 | args.Add(argType, std::vector<std::wstring>{L"forward1", L"forward2"}); |
| 124 | break; |
| 125 | case Kind::Flag: |
| 126 | args.Add(argType, true); |
| 127 | break; |
| 128 | default: |
| 129 | VERIFY_FAIL(L"Unhandled ValueType in test"); |
| 130 | } |
| 131 | |
| 132 | allArgTypes.push_back(argType); |
| 133 | VERIFY_IS_TRUE(args.Contains(argType)); |
| 134 | } |
| 135 | |
| 136 | // We do not have a runtime Get for argument values, so we will instead use the keys |
| 137 | // in the argmap. The fact that the keys exist and can be used to retrieve values |
| 138 | // verifies that Argument::Create() created arguments that are compatible with ArgMap. |
| 139 | // Verify all created argument types are in the ArgMap keys |
| 140 | auto argMapKeys = args.GetKeys(); |
| 141 | VERIFY_ARE_EQUAL(argMapKeys.size(), allArgTypes.size()); |
| 142 | for (const auto& argType : allArgTypes) |
| 143 | { |
| 144 | VERIFY_IS_TRUE(std::find(argMapKeys.begin(), argMapKeys.end(), argType) != argMapKeys.end()); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // Test: Verify Argument::Create() successfully creates arguments for all ArgType enum values |
| 149 | TEST_METHOD(ArgumentValidation_ValueValidation) |
| 150 | { |
| 151 | // Verify integer conversion for supported types. |
| 152 | auto longlong = validation::GetIntegerFromString<LONGLONG>(L"1234567890123"); |
| 153 | VERIFY_ARE_EQUAL(longlong, 1234567890123LL); |
| 154 | VERIFY_THROWS(validation::GetIntegerFromString<LONGLONG>(L"abc"), ArgumentException); // Not a number |
| 155 | VERIFY_THROWS(validation::GetIntegerFromString<LONGLONG>(L"-92233720369999854775808"), ArgumentException); // Out of range |
| 156 | VERIFY_NO_THROW(validation::ValidateIntegerFromString<LONGLONG>({L"1234", L"-1234567890123"}, L"testArg")); |
| 157 | VERIFY_THROWS(validation::ValidateIntegerFromString<LONGLONG>({L"1234", L"-92233720369999854775808"}, L"testArg"), ArgumentException); |
| 158 | |
| 159 | // Verify --tail validation rejects 0 (mirrors ArgType::Tail validation) |
| 160 | VERIFY_THROWS(validation::ValidateIntegerFromString<ULONGLONG>({L"0"}, L"tail", [](auto value) { return value != 0; }), ArgumentException); |
| 161 | VERIFY_NO_THROW(validation::ValidateIntegerFromString<ULONGLONG>({L"10"}, L"tail", [](auto value) { return value != 0; })); |
| 162 | VERIFY_NO_THROW(validation::ValidateIntegerFromString<ULONGLONG>({L"1"}, L"tail", [](auto value) { return value != 0; })); |
| 163 | |
| 164 | // Verify WSLCSignal conversion |
| 165 | auto validSignal = validation::GetWSLCSignalFromString(L"SIGTERM"); |
| 166 | VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM); |
| 167 | validSignal = validation::GetWSLCSignalFromString(L"TERM"); // No prefix |
| 168 | VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM); |
| 169 | validSignal = validation::GetWSLCSignalFromString(L"sIgTerm"); // Case-insensitive |
| 170 | VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM); |
| 171 | validSignal = validation::GetWSLCSignalFromString(L"term"); // Case-insensitive no prefix |
| 172 | VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM); |
| 173 | VERIFY_THROWS(validation::GetWSLCSignalFromString(L"INVALID_SIGNAL"), ArgumentException); |
| 174 | validSignal = validation::GetWSLCSignalFromString(L"15"); // SIGTERM is 15 |
| 175 | VERIFY_ARE_EQUAL(validSignal, WSLCSignalSIGTERM); |
| 176 | VERIFY_THROWS(validation::GetWSLCSignalFromString(L"999"), ArgumentException); // Out of range |
| 177 | VERIFY_NO_THROW(validation::ValidateWSLCSignalFromString({L"HUP", L"9", L"SIGKILL", L"stop"}, L"signalArg")); |
| 178 | VERIFY_THROWS(validation::ValidateWSLCSignalFromString({L"SIGHUP", L"999"}, L"signalArg"), ArgumentException); // 999 is out of range |
| 179 | |
| 180 | // Verify format type |
| 181 | auto format = validation::GetFormatTypeFromString(L"json"); |
| 182 | VERIFY_ARE_EQUAL(format, FormatType::Json); |
| 183 | format = validation::GetFormatTypeFromString(L"table"); |
| 184 | VERIFY_ARE_EQUAL(format, FormatType::Table); |
| 185 | VERIFY_THROWS(validation::GetFormatTypeFromString(L"xml"), ArgumentException); |
| 186 | VERIFY_NO_THROW(validation::ValidateFormatTypeFromString({L"json", L"table"}, L"formatArg")); |
| 187 | VERIFY_THROWS(validation::ValidateFormatTypeFromString({L"JSON", L"TABLE", L"csv"}, L"formatArg"), ArgumentException); |
| 188 | |
| 189 | // Verify image pull policy |
| 190 | auto pullPolicy = validation::GetPullPolicyFromString(L"always"); |
| 191 | VERIFY_ARE_EQUAL(pullPolicy, PullPolicy::Always); |
| 192 | pullPolicy = validation::GetPullPolicyFromString(L"missing"); |
| 193 | VERIFY_ARE_EQUAL(pullPolicy, PullPolicy::Missing); |
| 194 | pullPolicy = validation::GetPullPolicyFromString(L"never"); |
| 195 | VERIFY_ARE_EQUAL(pullPolicy, PullPolicy::Never); |
| 196 | VERIFY_THROWS(validation::GetPullPolicyFromString(L"invalid"), ArgumentException); |
| 197 | |
| 198 | // Verify build progress mode |
| 199 | VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"auto"), ProgressMode::Auto); |
| 200 | VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"tty"), ProgressMode::Tty); |
| 201 | VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"plain"), ProgressMode::Plain); |
| 202 | VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"quiet"), ProgressMode::Quiet); |
| 203 | VERIFY_THROWS(validation::GetProgressModeFromString(L"TTY"), ArgumentException); // Case-sensitive: only lowercase accepted |
| 204 | VERIFY_THROWS(validation::GetProgressModeFromString(L"fancy"), ArgumentException); |
| 205 | |
| 206 | // Verify Docker-style memory size conversion. |
| 207 | VERIFY_ARE_EQUAL(static_cast<int64_t>(1'610'612'736), validation::GetMemorySizeFromString(L"1.5G")); |
| 208 | VERIFY_ARE_EQUAL(static_cast<int64_t>(314'572), validation::GetMemorySizeFromString(L"0.3MiB")); |
| 209 | VERIFY_ARE_EQUAL(static_cast<int64_t>(32), validation::GetMemorySizeFromString(L"32.3")); |
| 210 | VERIFY_ARE_EQUAL(static_cast<int64_t>(9'007'199'254'740'993), validation::GetMemorySizeFromString(L"9007199254740993")); |
| 211 | VERIFY_ARE_EQUAL(std::numeric_limits<int64_t>::max(), validation::GetMemorySizeFromString(L"9223372036854775807")); |
| 212 | VERIFY_THROWS(validation::GetMemorySizeFromString(L"-1.5G"), ArgumentException); |
| 213 | VERIFY_THROWS(validation::GetMemorySizeFromString(L"9223372036854775808"), ArgumentException); |
| 214 | |
| 215 | // Verify GPU device argument |
| 216 | VERIFY_NO_THROW(validation::ValidateGpus({L"all"}, L"gpusArg")); |
| 217 | VERIFY_THROWS(validation::ValidateGpus({L"none"}, L"gpusArg"), ArgumentException); |
| 218 | VERIFY_THROWS(validation::ValidateGpus({L"0"}, L"gpusArg"), ArgumentException); |
| 219 | VERIFY_THROWS(validation::ValidateGpus({L"gpu0"}, L"gpusArg"), ArgumentException); |
| 220 | VERIFY_THROWS(validation::ValidateGpus({L""}, L"gpusArg"), ArgumentException); |
| 221 | } |
| 222 | |
| 223 | // Test: Verify EnumVariantMap behavior with ArgTypes. |
| 224 | TEST_METHOD(EnumVariantMap_AllDataTypes) |
| 225 | { |
| 226 | // ArgMap is an EnumVariantMap |
| 227 | ArgMap argsContainer; |
| 228 | |
| 229 | // Verify basic add |
| 230 | argsContainer.Add<ArgType::Help>(true); |
| 231 | VERIFY_IS_TRUE(argsContainer.Contains(ArgType::Help)); |
| 232 | argsContainer.Add<ArgType::ContainerId>(std::wstring(L"test")); |
| 233 | VERIFY_IS_TRUE(argsContainer.Contains(ArgType::ContainerId)); |
| 234 | argsContainer.Add<ArgType::ForwardArgs>(std::vector<std::wstring>{L"test1", L"test2"}); |
| 235 | VERIFY_IS_TRUE(argsContainer.Contains(ArgType::ForwardArgs)); |
| 236 | |
| 237 | // Verify basic retrieval |
| 238 | auto retrievedBool = argsContainer.GetValue<ArgType::Help>(); |
| 239 | VERIFY_ARE_EQUAL(retrievedBool, true); |
| 240 | auto retrievedString = argsContainer.GetValue<ArgType::ContainerId>(); |
| 241 | VERIFY_ARE_EQUAL(retrievedString, std::wstring(L"test")); |
| 242 | auto retrievedStringSet = argsContainer.GetValue<ArgType::ForwardArgs>(); |
| 243 | VERIFY_ARE_EQUAL(retrievedStringSet[0], std::wstring(L"test1")); |
| 244 | VERIFY_ARE_EQUAL(retrievedStringSet[1], std::wstring(L"test2")); |
| 245 | |
| 246 | // Verify multimap functionality and Runtime Add |
| 247 | argsContainer.Add(ArgType::Publish, std::wstring(L"test1")); |
| 248 | argsContainer.Add(ArgType::Publish, std::wstring(L"test2")); |
| 249 | argsContainer.Add(ArgType::Publish, std::wstring(L"test3")); |
| 250 | VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::Publish), 3); |
| 251 | auto publishArgs = argsContainer.GetAllValues<ArgType::Publish>(); |
| 252 | VERIFY_ARE_EQUAL(publishArgs.size(), 3); |
| 253 | VERIFY_ARE_EQUAL(publishArgs[0], std::wstring(L"test1")); |
| 254 | VERIFY_ARE_EQUAL(publishArgs[1], std::wstring(L"test2")); |
| 255 | VERIFY_ARE_EQUAL(publishArgs[2], std::wstring(L"test3")); |
| 256 | |
| 257 | // Verify Remove |
| 258 | ArgMap removeArgs; |
| 259 | removeArgs.Add<ArgType::Publish>(L"test"); |
| 260 | removeArgs.Remove(ArgType::Publish); |
| 261 | VERIFY_ARE_EQUAL(removeArgs.Count(ArgType::Publish), 0); |
| 262 | |
| 263 | // Verify compile time add works like runtime add for multimap types. |
| 264 | ArgMap compileTimeArgs; |
| 265 | compileTimeArgs.Add<ArgType::Publish>(L"test1"); |
| 266 | compileTimeArgs.Add<ArgType::Publish>(L"test2"); |
| 267 | compileTimeArgs.Add<ArgType::Publish>(L"test3"); |
| 268 | VERIFY_ARE_EQUAL(compileTimeArgs.Count(ArgType::Publish), 3); |
| 269 | publishArgs = compileTimeArgs.GetAllValues<ArgType::Publish>(); |
| 270 | VERIFY_ARE_EQUAL(publishArgs.size(), 3); |
| 271 | VERIFY_ARE_EQUAL(publishArgs[0], std::wstring(L"test1")); |
| 272 | VERIFY_ARE_EQUAL(publishArgs[1], std::wstring(L"test2")); |
| 273 | VERIFY_ARE_EQUAL(publishArgs[2], std::wstring(L"test3")); |
| 274 | |
| 275 | // Verify Keys |
| 276 | auto allArgTypes = argsContainer.GetKeys(); |
| 277 | VERIFY_ARE_EQUAL(allArgTypes.size(), 4); |
| 278 | VERIFY_IS_TRUE(std::find(allArgTypes.begin(), allArgTypes.end(), ArgType::Help) != allArgTypes.end()); |
| 279 | VERIFY_IS_TRUE(std::find(allArgTypes.begin(), allArgTypes.end(), ArgType::ContainerId) != allArgTypes.end()); |
| 280 | VERIFY_IS_TRUE(std::find(allArgTypes.begin(), allArgTypes.end(), ArgType::Publish) != allArgTypes.end()); |
| 281 | VERIFY_IS_TRUE(std::find(allArgTypes.begin(), allArgTypes.end(), ArgType::ForwardArgs) != allArgTypes.end()); |
| 282 | |
| 283 | // Verify count |
| 284 | VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::Help), 1); |
| 285 | VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::ContainerId), 1); |
| 286 | VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::Publish), 3); |
| 287 | VERIFY_ARE_EQUAL(argsContainer.Count(ArgType::ForwardArgs), 1); |
| 288 | VERIFY_ARE_EQUAL(argsContainer.GetCount(), 6); // 1 Help + 1 ContainerId + 3 Publish + 1 ForwardArgs |
| 289 | } |
| 290 | |
| 291 | // Test: Verify the validated-value cache stores and returns converted results so that a |
| 292 | // conversion performed during validation is reused during execution. Access is by a compile-time |
| 293 | // ArgType, so the value type is fixed by the argument's ConvertedType and cannot be mismatched. |
| 294 | TEST_METHOD(ValidatedCache_StoresAndRetrievesConvertedValues) |
| 295 | { |
| 296 | ArgMap args; |
| 297 | |
| 298 | // Populate raw arguments so the validated-cache invariant (raw count == validated count, |
| 299 | // enforced by a debug assert in the cache readers) holds when values are read below. |
| 300 | args.Add(ArgType::StopTimeout, std::wstring(L"30")); |
| 301 | args.Add(ArgType::Filter, std::wstring(L"status=running")); |
| 302 | args.Add(ArgType::Filter, std::wstring(L"label=env=prod")); |
| 303 | |
| 304 | // Nothing cached yet. |
| 305 | VERIFY_IS_FALSE(args.ContainsValidated(ArgType::StopTimeout)); |
| 306 | |
| 307 | // A conversion that produces a non-string type (string -> int). The value type is fixed by |
| 308 | // ArgType::StopTimeout's ConvertedType (int), so no type is supplied by the caller. |
| 309 | args.AddValidated<ArgType::StopTimeout>(30); |
| 310 | VERIFY_IS_TRUE(args.ContainsValidated(ArgType::StopTimeout)); |
| 311 | VERIFY_ARE_EQUAL(args.CountValidated(ArgType::StopTimeout), static_cast<size_t>(1)); |
| 312 | VERIFY_ARE_EQUAL(args.GetValue<ArgType::StopTimeout>(), 30); |
| 313 | |
| 314 | // Multiple cached values for one argument preserve insertion order. |
| 315 | args.AddValidated<ArgType::Filter>(std::pair<std::string, std::string>{"status", "running"}); |
| 316 | args.AddValidated<ArgType::Filter>(std::pair<std::string, std::string>{"label", "env=prod"}); |
| 317 | VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Filter), static_cast<size_t>(2)); |
| 318 | auto filters = args.GetAllValues<ArgType::Filter>(); |
| 319 | VERIFY_ARE_EQUAL(filters.size(), static_cast<size_t>(2)); |
| 320 | VERIFY_ARE_EQUAL(filters[0].first, std::string("status")); |
| 321 | VERIFY_ARE_EQUAL(filters[0].second, std::string("running")); |
| 322 | VERIFY_ARE_EQUAL(filters[1].first, std::string("label")); |
| 323 | VERIFY_ARE_EQUAL(filters[1].second, std::string("env=prod")); |
| 324 | |
| 325 | // GetAllValidated returns empty when nothing is cached for the argument. |
| 326 | auto empty = args.GetAllValues<ArgType::Signal>(); |
| 327 | VERIFY_IS_TRUE(empty.empty()); |
| 328 | |
| 329 | // An absent argument resolves to its value type's default-constructed value. |
| 330 | VERIFY_IS_FALSE(args.ContainsValidated(ArgType::Memory)); |
| 331 | VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Memory), static_cast<size_t>(0)); |
| 332 | VERIFY_ARE_EQUAL(args.GetValue<ArgType::Memory>(), int64_t{}); |
| 333 | VERIFY_IS_FALSE(args.Contains(ArgType::Memory)); |
| 334 | } |
| 335 | |
| 336 | // Helper: run validation for a single-value argument and return the converted result (type fixed |
| 337 | // by the argument's ConvertedType). Drives both paths for every converted ArgType its callers |
| 338 | // exercise: the eager path (an explicit validation pass) and the on-demand path (a converted read |
| 339 | // with no prior validation pass, which must self-validate). The returned value is the on-demand |
| 340 | // result, so callers' expected-value assertions verify the on-demand output equals what the |
| 341 | // validation pass produces. Both paths run the same Argument::Validate, so their results match by |
| 342 | // construction; this asserts the on-demand trigger fires and caches an equal number of values. |
| 343 | template <ArgType E> |
| 344 | static auto ValidateAndGetCached(const std::wstring& raw) |
| 345 | { |
| 346 | ArgMap eager; |
| 347 | eager.Add(E, std::wstring(raw)); |
| 348 | Argument::Create(E).Validate(eager); |
| 349 | VERIFY_IS_TRUE(eager.ContainsValidated(E)); |
| 350 | |
| 351 | ArgMap onDemand; |
| 352 | onDemand.Add(E, std::wstring(raw)); |
| 353 | VERIFY_IS_FALSE(onDemand.ContainsValidated(E)); // no validation pass ran |
| 354 | auto value = onDemand.GetValue<E>(); // triggers on-demand validation |
| 355 | VERIFY_IS_TRUE(onDemand.ContainsValidated(E)); |
| 356 | VERIFY_ARE_EQUAL(onDemand.CountValidated(E), eager.CountValidated(E)); |
| 357 | return value; |
| 358 | } |
| 359 | |
| 360 | // Helper: as ValidateAndGetCached, for an argument that appears multiple times (ArgMap is a |
| 361 | // multimap). Runs the eager and on-demand paths and returns every on-demand converted value in |
| 362 | // insertion order. |
| 363 | template <ArgType E> |
| 364 | static auto ValidateAndGetAllCached(const std::vector<std::wstring>& raws) |
| 365 | { |
| 366 | ArgMap eager; |
| 367 | for (const auto& raw : raws) |
| 368 | { |
| 369 | eager.Add(E, std::wstring(raw)); |
| 370 | } |
| 371 | |
| 372 | Argument::Create(E).Validate(eager); |
| 373 | |
| 374 | // The cache must hold exactly one converted value per raw value in the map. |
| 375 | VERIFY_ARE_EQUAL(eager.CountValidated(E), eager.Count(E)); |
| 376 | VERIFY_ARE_EQUAL(eager.CountValidated(E), raws.size()); |
| 377 | |
| 378 | ArgMap onDemand; |
| 379 | for (const auto& raw : raws) |
| 380 | { |
| 381 | onDemand.Add(E, std::wstring(raw)); |
| 382 | } |
| 383 | |
| 384 | VERIFY_IS_FALSE(onDemand.ContainsValidated(E)); // no validation pass ran |
| 385 | auto values = onDemand.GetAllValues<E>(); // triggers on-demand validation |
| 386 | VERIFY_ARE_EQUAL(onDemand.CountValidated(E), eager.CountValidated(E)); |
| 387 | return values; |
| 388 | } |
| 389 | |
| 390 | // Test: Every ArgType whose validation converts its raw string into a typed value must cache |
| 391 | // that value on the ArgMap during Argument::Validate, so execution reads it back without |
| 392 | // re-converting. This drives the real validation + caching path for each converted ArgType. |
| 393 | TEST_METHOD(ArgumentValidate_ConvertsAndCachesEveryConvertedArgType) |
| 394 | { |
| 395 | // string -> FormatType |
| 396 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Format>(L"json"), FormatType::Json); |
| 397 | |
| 398 | // string -> json::dump() indentation |
| 399 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::InspectFormat>(L"json"), wsl::shared::c_jsonCompactIndent); |
| 400 | |
| 401 | // string -> PullPolicy |
| 402 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Pull>(L"missing"), PullPolicy::Missing); |
| 403 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Pull>(L"always"), PullPolicy::Always); |
| 404 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Pull>(L"never"), PullPolicy::Never); |
| 405 | |
| 406 | // string -> WSLCSignal (Signal and StopSignal share the converter) |
| 407 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Signal>(L"SIGTERM"), WSLCSignalSIGTERM); |
| 408 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::StopSignal>(L"SIGKILL"), WSLCSignalSIGKILL); |
| 409 | |
| 410 | // string -> int |
| 411 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::StopTimeout>(L"30"), 30); |
| 412 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::HealthRetries>(L"3"), 3); |
| 413 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Last>(L"5"), 5); |
| 414 | |
| 415 | // string -> LONG (Time and Timeout share the converter) |
| 416 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Time>(L"5"), 5L); |
| 417 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Timeout>(L"5"), 5L); |
| 418 | |
| 419 | // string -> ULONGLONG (Tail is a raw integer; Since/Until go through the timestamp parser) |
| 420 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Tail>(L"10"), 10ULL); |
| 421 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Since>(L"100"), validation::GetTimestampFromString(L"100")); |
| 422 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Until>(L"200"), validation::GetTimestampFromString(L"200")); |
| 423 | |
| 424 | // string -> int64_t (memory sizes). The cached value matches the converter's result. |
| 425 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Memory>(L"512M"), validation::GetMemorySizeFromString(L"512M")); |
| 426 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::ShmSize>(L"1.5G"), static_cast<int64_t>(1'610'612'736)); |
| 427 | |
| 428 | // string -> int64_t (durations, in nanoseconds) |
| 429 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::HealthInterval>(L"30s"), validation::GetDurationNanosFromString(L"30s")); |
| 430 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::HealthTimeout>(L"30s"), validation::GetDurationNanosFromString(L"30s")); |
| 431 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::HealthStartPeriod>(L"30s"), validation::GetDurationNanosFromString(L"30s")); |
| 432 | |
| 433 | // string -> int64_t (nano CPUs) |
| 434 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Cpus>(L"1.5"), validation::GetNanoCpusFromString(L"1.5")); |
| 435 | |
| 436 | // mount strings -> mount::Spec |
| 437 | { |
| 438 | const auto volume = ValidateAndGetCached<ArgType::Volume>(LR"(C:\hostPath:/containerPath)"); |
| 439 | VERIFY_ARE_EQUAL(static_cast<int>(WSLCMountTypeBind), static_cast<int>(volume.MountType)); |
| 440 | VERIFY_ARE_EQUAL(static_cast<int>(mount::BindSourcePolicy::CreateIfMissing), static_cast<int>(volume.BindSource)); |
| 441 | |
| 442 | const auto tmpfs = ValidateAndGetCached<ArgType::TMPFS>(L"/tmp:size=64k"); |
| 443 | VERIFY_ARE_EQUAL(static_cast<int>(WSLCMountTypeTmpfs), static_cast<int>(tmpfs.MountType)); |
| 444 | VERIFY_IS_TRUE(tmpfs.TmpfsOptions.has_value()); |
| 445 | VERIFY_ARE_EQUAL(std::string("size=64k"), tmpfs.TmpfsOptions.value()); |
| 446 | |
| 447 | const auto structured = ValidateAndGetCached<ArgType::Mount>(L"type=volume,source=data-volume,target=/data"); |
| 448 | VERIFY_ARE_EQUAL(static_cast<int>(WSLCMountTypeVolume), static_cast<int>(structured.MountType)); |
| 449 | VERIFY_ARE_EQUAL(std::wstring(L"data-volume"), structured.Source); |
| 450 | } |
| 451 | |
| 452 | // string -> tuple<name, soft, hard> (ulimit) |
| 453 | auto ulimit = ValidateAndGetCached<ArgType::Ulimit>(L"nofile=1024:2048"); |
| 454 | VERIFY_ARE_EQUAL(std::get<0>(ulimit), std::string("nofile")); |
| 455 | VERIFY_ARE_EQUAL(std::get<1>(ulimit), 1024LL); |
| 456 | VERIFY_ARE_EQUAL(std::get<2>(ulimit), 2048LL); |
| 457 | |
| 458 | // string -> pair<key, value> (filter). A single Validate call caches every raw value in order. |
| 459 | { |
| 460 | ArgMap args; |
| 461 | args.Add(ArgType::Filter, std::wstring(L"status=running")); |
| 462 | args.Add(ArgType::Filter, std::wstring(L"label=env=prod")); // split on first '=' |
| 463 | Argument::Create(ArgType::Filter).Validate(args); |
| 464 | auto filters = args.GetAllValues<ArgType::Filter>(); |
| 465 | VERIFY_ARE_EQUAL(filters.size(), static_cast<size_t>(2)); |
| 466 | VERIFY_ARE_EQUAL(filters[0].first, std::string("status")); |
| 467 | VERIFY_ARE_EQUAL(filters[0].second, std::string("running")); |
| 468 | VERIFY_ARE_EQUAL(filters[1].first, std::string("label")); |
| 469 | VERIFY_ARE_EQUAL(filters[1].second, std::string("env=prod")); |
| 470 | } |
| 471 | |
| 472 | // string -> InspectType (inspect object type) |
| 473 | VERIFY_ARE_EQUAL(ValidateAndGetCached<ArgType::Type>(L"container"), InspectType::Container); |
| 474 | |
| 475 | // string -> BuildOutput (docker-style build exporter spec) |
| 476 | { |
| 477 | auto output = ValidateAndGetCached<ArgType::BuildOutput>(L"type=tar,dest=-"); |
| 478 | VERIFY_ARE_EQUAL(output.Type, std::wstring(L"tar")); |
| 479 | VERIFY_ARE_EQUAL(output.Dest, std::wstring(L"-")); |
| 480 | } |
| 481 | |
| 482 | // string -> pair<key, value> (label and driver option share the key=value shape) |
| 483 | { |
| 484 | auto label = ValidateAndGetCached<ArgType::Label>(L"env=prod"); |
| 485 | VERIFY_ARE_EQUAL(label.first, std::string("env")); |
| 486 | VERIFY_ARE_EQUAL(label.second, std::string("prod")); |
| 487 | |
| 488 | auto option = ValidateAndGetCached<ArgType::Options>(L"com.docker.network.bridge.name=br0"); |
| 489 | VERIFY_ARE_EQUAL(option.first, std::string("com.docker.network.bridge.name")); |
| 490 | VERIFY_ARE_EQUAL(option.second, std::string("br0")); |
| 491 | } |
| 492 | |
| 493 | // string -> BuildSecret (docker-style --secret spec resolved to an id and value bytes) |
| 494 | { |
| 495 | ScopedEnvVariable env(L"WSLC_UT_CONV_SECRET", L"conv-value"); |
| 496 | auto secret = ValidateAndGetCached<ArgType::Secret>(L"id=convtest,env=WSLC_UT_CONV_SECRET"); |
| 497 | VERIFY_ARE_EQUAL(secret.Id, std::wstring(L"convtest")); |
| 498 | const std::string expected = "conv-value"; |
| 499 | VERIFY_IS_TRUE(std::vector<BYTE>(expected.begin(), expected.end()) == secret.Value); |
| 500 | } |
| 501 | |
| 502 | // string -> ParsedNetworkArgument (docker-style network name and aliases) |
| 503 | { |
| 504 | auto network = ValidateAndGetCached<ArgType::Network>(L"name=custom,alias=web"); |
| 505 | VERIFY_ARE_EQUAL(network.Name, std::string("custom")); |
| 506 | VERIFY_ARE_EQUAL(network.Aliases.size(), static_cast<size_t>(1)); |
| 507 | VERIFY_ARE_EQUAL(network.Aliases[0], std::string("web")); |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | // Test: Because ArgMap is a multimap and any command may allow an argument to repeat, a single |
| 512 | // Argument::Validate call must convert and cache every occurrence, in order. Covers the |
| 513 | // different converter result shapes (integer, enum, tuple, pair). The helper also asserts the |
| 514 | // cached count matches the number of raw values in the map. |
| 515 | TEST_METHOD(ArgumentValidate_CachesEveryValueForRepeatedArg) |
| 516 | { |
| 517 | // Integer converter, multiple values -> all cached in order. |
| 518 | auto retries = ValidateAndGetAllCached<ArgType::HealthRetries>({L"1", L"2", L"3"}); |
| 519 | VERIFY_ARE_EQUAL(retries.size(), static_cast<size_t>(3)); |
| 520 | VERIFY_ARE_EQUAL(retries[0], 1); |
| 521 | VERIFY_ARE_EQUAL(retries[1], 2); |
| 522 | VERIFY_ARE_EQUAL(retries[2], 3); |
| 523 | |
| 524 | // int64_t converter (memory sizes), multiple values -> all cached in order. |
| 525 | auto memories = ValidateAndGetAllCached<ArgType::Memory>({L"128M", L"256M"}); |
| 526 | VERIFY_ARE_EQUAL(memories.size(), static_cast<size_t>(2)); |
| 527 | VERIFY_ARE_EQUAL(memories[0], validation::GetMemorySizeFromString(L"128M")); |
| 528 | VERIFY_ARE_EQUAL(memories[1], validation::GetMemorySizeFromString(L"256M")); |
| 529 | |
| 530 | // Enum converter, multiple values -> all cached in order. |
| 531 | auto signals = ValidateAndGetAllCached<ArgType::Signal>({L"SIGTERM", L"SIGKILL", L"SIGHUP"}); |
| 532 | VERIFY_ARE_EQUAL(signals.size(), static_cast<size_t>(3)); |
| 533 | VERIFY_ARE_EQUAL(signals[0], WSLCSignalSIGTERM); |
| 534 | VERIFY_ARE_EQUAL(signals[1], WSLCSignalSIGKILL); |
| 535 | VERIFY_ARE_EQUAL(signals[2], WSLCSignalSIGHUP); |
| 536 | |
| 537 | // Tuple converter (ulimit), multiple values -> all cached in order. |
| 538 | auto ulimits = ValidateAndGetAllCached<ArgType::Ulimit>({L"nofile=1024:2048", L"nproc=512:1024"}); |
| 539 | VERIFY_ARE_EQUAL(ulimits.size(), static_cast<size_t>(2)); |
| 540 | VERIFY_ARE_EQUAL(std::get<0>(ulimits[0]), std::string("nofile")); |
| 541 | VERIFY_ARE_EQUAL(std::get<1>(ulimits[0]), 1024LL); |
| 542 | VERIFY_ARE_EQUAL(std::get<2>(ulimits[0]), 2048LL); |
| 543 | VERIFY_ARE_EQUAL(std::get<0>(ulimits[1]), std::string("nproc")); |
| 544 | VERIFY_ARE_EQUAL(std::get<1>(ulimits[1]), 512LL); |
| 545 | VERIFY_ARE_EQUAL(std::get<2>(ulimits[1]), 1024LL); |
| 546 | |
| 547 | // Pair converter (filter), multiple values -> all cached in order. |
| 548 | auto filters = ValidateAndGetAllCached<ArgType::Filter>({L"status=running", L"name=web", L"label=env=prod"}); |
| 549 | VERIFY_ARE_EQUAL(filters.size(), static_cast<size_t>(3)); |
| 550 | VERIFY_ARE_EQUAL(filters[0].first, std::string("status")); |
| 551 | VERIFY_ARE_EQUAL(filters[0].second, std::string("running")); |
| 552 | VERIFY_ARE_EQUAL(filters[1].first, std::string("name")); |
| 553 | VERIFY_ARE_EQUAL(filters[1].second, std::string("web")); |
| 554 | VERIFY_ARE_EQUAL(filters[2].first, std::string("label")); |
| 555 | VERIFY_ARE_EQUAL(filters[2].second, std::string("env=prod")); |
| 556 | |
| 557 | // BuildSecret converter (secret specs), multiple values -> all cached in order. |
| 558 | { |
| 559 | ScopedEnvVariable envA(L"WSLC_UT_CONV_SECRET_A", L"value-a"); |
| 560 | ScopedEnvVariable envB(L"WSLC_UT_CONV_SECRET_B", L"value-b"); |
| 561 | auto secrets = ValidateAndGetAllCached<ArgType::Secret>( |
| 562 | {L"id=seca,env=WSLC_UT_CONV_SECRET_A", L"id=secb,env=WSLC_UT_CONV_SECRET_B"}); |
| 563 | VERIFY_ARE_EQUAL(secrets.size(), static_cast<size_t>(2)); |
| 564 | VERIFY_ARE_EQUAL(secrets[0].Id, std::wstring(L"seca")); |
| 565 | VERIFY_ARE_EQUAL(secrets[1].Id, std::wstring(L"secb")); |
| 566 | const std::string expectedA = "value-a"; |
| 567 | const std::string expectedB = "value-b"; |
| 568 | VERIFY_IS_TRUE(std::vector<BYTE>(expectedA.begin(), expectedA.end()) == secrets[0].Value); |
| 569 | VERIFY_IS_TRUE(std::vector<BYTE>(expectedB.begin(), expectedB.end()) == secrets[1].Value); |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | // Test: Every validate-only ArgType (checked during validation but not converted into a |
| 574 | // distinct typed value that execution consumes) must NOT populate the cache. Execution reads |
| 575 | // the raw value for these instead. |
| 576 | TEST_METHOD(ArgumentValidate_ValidateOnlyArgsAreNotCached) |
| 577 | { |
| 578 | struct Case |
| 579 | { |
| 580 | ArgType Type; |
| 581 | std::wstring Value; |
| 582 | }; |
| 583 | |
| 584 | const std::vector<Case> cases = { |
| 585 | {ArgType::Gpus, L"all"}, |
| 586 | {ArgType::WorkDir, L"/app"}, |
| 587 | {ArgType::NetworkAlias, L"myalias"}, |
| 588 | }; |
| 589 | |
| 590 | for (const auto& c : cases) |
| 591 | { |
| 592 | ArgMap args; |
| 593 | args.Add(c.Type, std::wstring(c.Value)); |
| 594 | Argument::Create(c.Type).Validate(args); |
| 595 | VERIFY_IS_FALSE(args.ContainsValidated(c.Type)); |
| 596 | } |
| 597 | |
| 598 | // NoHealthcheck is a flag whose validation only rejects conflicting health options. With |
| 599 | // no conflicts present it passes and caches nothing. |
| 600 | ArgMap noHealthcheck; |
| 601 | noHealthcheck.Add(ArgType::NoHealthcheck, true); |
| 602 | Argument::Create(ArgType::NoHealthcheck).Validate(noHealthcheck); |
| 603 | VERIFY_IS_FALSE(noHealthcheck.ContainsValidated(ArgType::NoHealthcheck)); |
| 604 | } |
| 605 | |
| 606 | // Test: When conversion fails during validation, Validate throws and nothing is cached. |
| 607 | TEST_METHOD(ArgumentValidate_InvalidValueThrowsAndCachesNothing) |
| 608 | { |
| 609 | ArgMap args; |
| 610 | args.Add(ArgType::Format, std::wstring(L"xml")); |
| 611 | VERIFY_THROWS(Argument::Create(ArgType::Format).Validate(args), ArgumentException); |
| 612 | VERIFY_IS_FALSE(args.ContainsValidated(ArgType::Format)); |
| 613 | } |
| 614 | |
| 615 | // Note: on-demand validation for every converted ArgType (reading with no prior validation pass |
| 616 | // and getting the same result the pass produces) is covered by the tests above: |
| 617 | // ValidateAndGetCached / ValidateAndGetAllCached drive both the eager and on-demand paths and |
| 618 | // return the on-demand value, so those tests' expected-value assertions verify on-demand output |
| 619 | // for all converted shapes. The tests below cover the behaviors unique to the on-demand trigger: |
| 620 | // a bad value fails the same way as on the command line, a value added after the validation pass |
| 621 | // is re-validated, and validate-only arguments (no converted value) are checked on demand too. |
| 622 | |
| 623 | // Test: An invalid value read on demand (no prior validation pass) throws ArgumentException -- |
| 624 | // the same failure the up-front validation pass raises for that value. This proves an argument |
| 625 | // populated during execution routes to the same user error path as a bad command-line value, |
| 626 | // and that a failed on-demand validation leaves nothing cached. |
| 627 | TEST_METHOD(ArgumentValidate_OnDemandInvalidValueThrows) |
| 628 | { |
| 629 | ArgMap args; |
| 630 | args.Add(ArgType::Format, std::wstring(L"xml")); // not a valid FormatType |
| 631 | VERIFY_IS_FALSE(args.ContainsValidated(ArgType::Format)); |
| 632 | VERIFY_THROWS(args.GetValue<ArgType::Format>(), ArgumentException); |
| 633 | VERIFY_IS_FALSE(args.ContainsValidated(ArgType::Format)); |
| 634 | } |
| 635 | |
| 636 | // Test: Raw values can change after the up-front validation pass until the argument is read. |
| 637 | // The mutation invalidates the cache, and the first read validates the final values. |
| 638 | TEST_METHOD(ArgumentValidate_PostValidationAddBeforeReadRevalidates) |
| 639 | { |
| 640 | ArgMap args; |
| 641 | args.Add(ArgType::Signal, std::wstring(L"SIGTERM")); |
| 642 | Argument::Create(ArgType::Signal).Validate(args); |
| 643 | VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(1)); |
| 644 | |
| 645 | // Add a second raw value before the first read. The map-action callback drops the cache. |
| 646 | args.Add(ArgType::Signal, std::wstring(L"SIGKILL")); |
| 647 | VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(0)); |
| 648 | |
| 649 | // The first read re-validates both raw values on demand, in insertion order. |
| 650 | auto signals = args.GetAllValues<ArgType::Signal>(); |
| 651 | VERIFY_ARE_EQUAL(signals.size(), static_cast<size_t>(2)); |
| 652 | VERIFY_ARE_EQUAL(signals[0], WSLCSignalSIGTERM); |
| 653 | VERIFY_ARE_EQUAL(signals[1], WSLCSignalSIGKILL); |
| 654 | VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(2)); |
| 655 | } |
| 656 | |
| 657 | // Test: Arguments are validated on demand when read, so a value added after the up-front pass is |
| 658 | // checked exactly as a command-line value. |
| 659 | TEST_METHOD(ArgumentValidate_OnDemandArgIsChecked) |
| 660 | { |
| 661 | ArgMap labels; |
| 662 | labels.Add(ArgType::BuildLabel, std::wstring(L"foo")); |
| 663 | labels.Add(ArgType::BuildLabel, std::wstring(L"foo=")); |
| 664 | auto labelValues = labels.GetAllValues<ArgType::BuildLabel>(); |
| 665 | VERIFY_ARE_EQUAL(labelValues.size(), static_cast<size_t>(2)); |
| 666 | VERIFY_ARE_EQUAL(labelValues[0], std::wstring(L"foo")); |
| 667 | VERIFY_ARE_EQUAL(labelValues[1], std::wstring(L"foo=")); |
| 668 | |
| 669 | ArgMap invalidLabel; |
| 670 | invalidLabel.Add(ArgType::BuildLabel, std::wstring(L"=value")); |
| 671 | VERIFY_THROWS(invalidLabel.GetAllValues<ArgType::BuildLabel>(), wil::ResultException); |
| 672 | |
| 673 | // Valid value, no prior validation pass: the read validates and converts on demand. |
| 674 | ArgMap valid; |
| 675 | valid.Add(ArgType::Network, std::wstring(L"name=custom,alias=web")); |
| 676 | auto networks = valid.GetAllValues<ArgType::Network>(); |
| 677 | VERIFY_ARE_EQUAL(networks.size(), static_cast<size_t>(1)); |
| 678 | VERIFY_ARE_EQUAL(networks[0].Name, std::string("custom")); |
| 679 | VERIFY_ARE_EQUAL(networks[0].Aliases.size(), static_cast<size_t>(1)); |
| 680 | VERIFY_ARE_EQUAL(networks[0].Aliases[0], std::string("web")); |
| 681 | |
| 682 | // Invalid value, no prior validation pass: the read validates on demand and throws, matching |
| 683 | // the failure the up-front pass raises for the same value. |
| 684 | ArgMap invalid; |
| 685 | invalid.Add(ArgType::Network, std::wstring(L"host")); |
| 686 | VERIFY_THROWS(invalid.GetAllValues<ArgType::Network>(), ExecutionException); |
| 687 | |
| 688 | // Valid up-front, then an unsupported value added before the first read: the map-action |
| 689 | // callback clears the validated record, so the read re-validates on demand and throws. |
| 690 | ArgMap added; |
| 691 | added.Add(ArgType::Network, std::wstring(L"bridge")); |
| 692 | Argument::Create(ArgType::Network).Validate(added); |
| 693 | added.Add(ArgType::Network, std::wstring(L"host")); |
| 694 | VERIFY_THROWS(added.GetAllValues<ArgType::Network>(), ExecutionException); |
| 695 | } |
| 696 | |
| 697 | TEST_METHOD(ArgumentValidate_ReadMakesArgumentImmutable) |
| 698 | { |
| 699 | ArgMap args; |
| 700 | args.Add(ArgType::Signal, std::wstring(L"SIGTERM")); |
| 701 | VERIFY_ARE_EQUAL(args.GetValue<ArgType::Signal>(), WSLCSignalSIGTERM); |
| 702 | VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(1)); |
| 703 | |
| 704 | Argument::Create(ArgType::Signal).Validate(args); |
| 705 | args.MarkValidated(ArgType::Signal); |
| 706 | VERIFY_ARE_EQUAL(args.CountValidated(ArgType::Signal), static_cast<size_t>(1)); |
| 707 | |
| 708 | const auto verifyImmutableFailure = [](const auto& operation) { |
| 709 | VERIFY_THROWS_SPECIFIC(operation(), wil::ResultException, [](const wil::ResultException& e) { |
| 710 | return e.GetErrorCode() == E_ILLEGAL_METHOD_CALL; |
| 711 | }); |
| 712 | }; |
| 713 | |
| 714 | verifyImmutableFailure([&] { args.Add(ArgType::Signal, std::wstring(L"SIGKILL")); }); |
| 715 | verifyImmutableFailure([&] { args.Remove(ArgType::Signal); }); |
| 716 | verifyImmutableFailure([&] { args.InvalidateValidated(ArgType::Signal); }); |
| 717 | verifyImmutableFailure([&] { args.AddValidated<ArgType::Signal>(WSLCSignalSIGKILL); }); |
| 718 | |
| 719 | // Immutability is per argument; other arguments remain writable until they are read. |
| 720 | args.Add(ArgType::StopTimeout, std::wstring(L"30")); |
| 721 | VERIFY_ARE_EQUAL(args.GetValue<ArgType::StopTimeout>(), 30); |
| 722 | } |
| 723 | |
| 724 | TEST_METHOD(ArgumentValidate_FlagReadValidatesAndMakesArgumentImmutable) |
| 725 | { |
| 726 | const auto verifyImmutableFailure = [](const auto& operation) { |
| 727 | VERIFY_THROWS_SPECIFIC(operation(), wil::ResultException, [](const wil::ResultException& e) { |
| 728 | return e.GetErrorCode() == E_ILLEGAL_METHOD_CALL; |
| 729 | }); |
| 730 | }; |
| 731 | |
| 732 | ArgMap absent; |
| 733 | VERIFY_IS_FALSE(absent.GetValue<ArgType::Quiet>()); |
| 734 | VERIFY_IS_FALSE(absent.GetValue<ArgType::Quiet>(true)); |
| 735 | VERIFY_IS_FALSE(absent.GetValue<ArgType::Quiet>()); |
| 736 | VERIFY_IS_FALSE(absent.Contains(ArgType::Quiet)); |
| 737 | verifyImmutableFailure([&] { absent.Add(ArgType::Quiet, true); }); |
| 738 | |
| 739 | ArgMap present; |
| 740 | present.Add(ArgType::NoHealthcheck, true); |
| 741 | present.Add(ArgType::HealthCmd, std::wstring(L"CMD echo healthy")); |
| 742 | VERIFY_THROWS(present.GetValue<ArgType::NoHealthcheck>(), ArgumentException); |
| 743 | |
| 744 | // A failed read does not freeze the argument, so correcting the conflicting input permits |
| 745 | // a subsequent successful read. |
| 746 | present.Remove(ArgType::HealthCmd); |
| 747 | VERIFY_IS_TRUE(present.GetValue<ArgType::NoHealthcheck>()); |
| 748 | verifyImmutableFailure([&] { present.Remove(ArgType::NoHealthcheck); }); |
| 749 | } |
| 750 | |
| 751 | // Timestamp parsing unit tests (exercises TryParseRfc3339 and integer path via GetTimestampFromString) |
| 752 | |
| 753 | TEST_METHOD(ValidateTimestamp_ValidUnixEpochSeconds) |
| 754 | { |
| 755 | // Integer timestamps should parse directly |
| 756 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"0"), 0LL); |
| 757 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1700000000"), 1700000000LL); |
| 758 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1"), 1LL); |
| 759 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"9999999999"), 9999999999LL); |
| 760 | } |
| 761 | |
| 762 | TEST_METHOD(ValidateTimestamp_ValidRfc3339_UTC) |
| 763 | { |
| 764 | // Basic UTC timestamps with Z suffix |
| 765 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00Z"), 1705314600LL); |
| 766 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1970-01-01T00:00:00Z"), 0LL); |
| 767 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00z"), 1705314600LL); // lowercase z |
| 768 | } |
| 769 | |
| 770 | TEST_METHOD(ValidateTimestamp_ValidRfc3339_WithOffset) |
| 771 | { |
| 772 | // Timestamps with timezone offsets (+HH:MM / -HH:MM) |
| 773 | // 2024-01-15T10:30:00+05:30 = 2024-01-15T05:00:00Z = 1705294800 |
| 774 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00+05:30"), 1705294800LL); |
| 775 | // 2024-01-15T10:30:00-05:00 = 2024-01-15T15:30:00Z = 1705332600 |
| 776 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00-05:00"), 1705332600LL); |
| 777 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00+00:00"), 1705314600LL); |
| 778 | } |
| 779 | |
| 780 | TEST_METHOD(ValidateTimestamp_ValidRfc3339_FractionalSeconds) |
| 781 | { |
| 782 | // Fractional seconds should be consumed (truncated to seconds) |
| 783 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00.123Z"), 1705314600LL); |
| 784 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00.123456789Z"), 1705314600LL); |
| 785 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00.1+05:30"), 1705294800LL); |
| 786 | } |
| 787 | |
| 788 | TEST_METHOD(ValidateTimestamp_ValidPreEpoch) |
| 789 | { |
| 790 | // No lower bound is applied, so pre-1970 values convert to a negative epoch. |
| 791 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1960-01-15T10:30:00Z"), -314371800LL); |
| 792 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"0001-01-01T00:00:00Z"), -62135596800LL); |
| 793 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"-314371800"), -314371800LL); |
| 794 | } |
| 795 | |
| 796 | TEST_METHOD(ValidateTimestamp_OutsideNanosecondRange) |
| 797 | { |
| 798 | // Values beyond the range of a nanosecond representation still convert exactly. |
| 799 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"1600-01-01T00:00:00Z"), -11676096000LL); |
| 800 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2300-01-01T00:00:00Z"), 10413792000LL); |
| 801 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"9999-12-31T23:59:59Z"), 253402300799LL); |
| 802 | } |
| 803 | |
| 804 | TEST_METHOD(ValidateTimestamp_ValidZoneLessLocalTime) |
| 805 | { |
| 806 | // A value with no zone designator is resolved against the local UTC offset. |
| 807 | const auto offset = std::chrono::duration_cast<std::chrono::seconds>( |
| 808 | std::chrono::current_zone()->get_info(std::chrono::system_clock::now()).offset) |
| 809 | .count(); |
| 810 | const auto expected = 1705314600LL - offset; |
| 811 | |
| 812 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00"), expected); |
| 813 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30:00.123"), expected); |
| 814 | } |
| 815 | |
| 816 | TEST_METHOD(ValidateTimestamp_ValidPartialAndDateOnly) |
| 817 | { |
| 818 | // Hour-only, minute-only and date-only values are padded out to a full time. |
| 819 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15"), validation::GetTimestampFromString(L"2024-01-15T00:00:00")); |
| 820 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10"), validation::GetTimestampFromString(L"2024-01-15T10:00:00")); |
| 821 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30"), validation::GetTimestampFromString(L"2024-01-15T10:30:00")); |
| 822 | |
| 823 | // The same padding applies when an explicit zone is present. |
| 824 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10Z"), 1705312800LL); |
| 825 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15T10:30Z"), 1705314600LL); |
| 826 | VERIFY_ARE_EQUAL(validation::GetTimestampFromString(L"2024-01-15Z"), 1705276800LL); |
| 827 | } |
| 828 | |
| 829 | TEST_METHOD(ValidateTimestamp_ValidGoDuration) |
| 830 | { |
| 831 | const auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(); |
| 832 | |
| 833 | // Durations are measured back from the current time, so allow a small window for the clock |
| 834 | // to advance while the test runs. |
| 835 | auto verifyDuration = [&](LPCWSTR value, LONGLONG expectedOffset) { |
| 836 | const auto parsed = validation::GetTimestampFromString(value); |
| 837 | VERIFY_IS_GREATER_THAN_OR_EQUAL(parsed, now - expectedOffset); |
| 838 | VERIFY_IS_LESS_THAN_OR_EQUAL(parsed, now - expectedOffset + 30); |
| 839 | }; |
| 840 | |
| 841 | verifyDuration(L"10m", 600LL); |
| 842 | verifyDuration(L"1h30m", 5400LL); |
| 843 | verifyDuration(L"90s", 90LL); |
| 844 | verifyDuration(L"1.5h", 5400LL); |
| 845 | verifyDuration(L"2h45m30s", 9930LL); |
| 846 | |
| 847 | // A negative duration selects a time in the future. |
| 848 | verifyDuration(L"-1h", -3600LL); |
| 849 | } |
| 850 | |
| 851 | TEST_METHOD(ValidateTimestamp_SubSecondGoDuration) |
| 852 | { |
| 853 | // A sub-second duration is applied before the value is truncated, so a negative one still |
| 854 | // resolves at or after the current second rather than being rounded away. |
| 855 | const auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(); |
| 856 | |
| 857 | const auto future = validation::GetTimestampFromString(L"-500ms"); |
| 858 | VERIFY_IS_GREATER_THAN_OR_EQUAL(future, now); |
| 859 | VERIFY_IS_LESS_THAN_OR_EQUAL(future, now + 30); |
| 860 | |
| 861 | const auto past = validation::GetTimestampFromString(L"500ms"); |
| 862 | VERIFY_IS_GREATER_THAN_OR_EQUAL(past, now - 1); |
| 863 | VERIFY_IS_LESS_THAN_OR_EQUAL(past, now + 30); |
| 864 | } |
| 865 | |
| 866 | TEST_METHOD(ValidateTimestamp_InvalidRfc3339_Rejected) |
| 867 | { |
| 868 | // Invalid month |
| 869 | VERIFY_THROWS(validation::GetTimestampFromString(L"2024-13-15T10:30:00Z"), ArgumentException); |
| 870 | // Invalid hour |
| 871 | VERIFY_THROWS(validation::GetTimestampFromString(L"2024-01-15T25:30:00Z"), ArgumentException); |
| 872 | // Invalid day (Feb 31) |
| 873 | VERIFY_THROWS(validation::GetTimestampFromString(L"2024-02-31T10:30:00Z"), ArgumentException); |
| 874 | // Trailing characters |
| 875 | VERIFY_THROWS(validation::GetTimestampFromString(L"2024-01-15T10:30:00Zextra"), ArgumentException); |
| 876 | // +HHMM without colon (not supported by %Ez) |
| 877 | VERIFY_THROWS(validation::GetTimestampFromString(L"2024-01-15T10:30:00+0530"), ArgumentException); |
| 878 | // Dot with no fractional digits |
| 879 | VERIFY_THROWS(validation::GetTimestampFromString(L"2024-01-15T10:30:00.Z"), ArgumentException); |
| 880 | // Random text |
| 881 | VERIFY_THROWS(validation::GetTimestampFromString(L"abc"), ArgumentException); |
| 882 | VERIFY_THROWS(validation::GetTimestampFromString(L"not-a-timestamp"), ArgumentException); |
| 883 | // Duration with no unit |
| 884 | VERIFY_THROWS(validation::GetTimestampFromString(L"10x"), ArgumentException); |
| 885 | VERIFY_THROWS(validation::GetTimestampFromString(L"1h30"), ArgumentException); |
| 886 | } |
| 887 | }; |
| 888 | } // namespace WSLCCLIArgumentUnitTests |