| 1 | /*++ |
| 2 | |
| 3 | Copyright (c) Microsoft. All rights reserved. |
| 4 | |
| 5 | Module Name: |
| 6 | |
| 7 | SpecParsing.cpp |
| 8 | |
| 9 | Abstract: |
| 10 | |
| 11 | Parsers that turn delimited command-line spec strings (e.g. --secret, |
| 12 | --ulimit, --label, --filter) into structured values. These share the |
| 13 | SplitKeyValue helper for consistent key/value splitting. |
| 14 | |
| 15 | --*/ |
| 16 | |
| 17 | #include "precomp.h" |
| 18 | #include "SpecParsing.h" |
| 19 | #include "ArgumentValidation.h" |
| 20 | #include "Exceptions.h" |
| 21 | #include "ImageService.h" |
| 22 | #include "JsonUtils.h" |
| 23 | #include "Localization.h" |
| 24 | #include <algorithm> |
| 25 | #include <charconv> |
| 26 | #include <chrono> |
| 27 | #include <cmath> |
| 28 | #include <filesystem> |
| 29 | #include <format> |
| 30 | #include <limits> |
| 31 | #include <optional> |
| 32 | #include <unordered_map> |
| 33 | #include <wslc.h> |
| 34 | |
| 35 | using namespace wsl::windows::common; |
| 36 | using namespace wsl::shared; |
| 37 | using namespace wsl::shared::string; |
| 38 | |
| 39 | namespace wsl::windows::wslc::validation { |
| 40 | |
| 41 | KeyValueSplit SplitKeyValue(const std::wstring& value, wchar_t separator) |
| 42 | { |
| 43 | const auto pos = value.find(separator); |
| 44 | if (pos == std::wstring::npos) |
| 45 | { |
| 46 | return {value, std::wstring{}, false}; |
| 47 | } |
| 48 | |
| 49 | return {value.substr(0, pos), value.substr(pos + 1), true}; |
| 50 | } |
| 51 | |
| 52 | services::BuildSecret ParseSecretSpec(const std::wstring& spec) |
| 53 | { |
| 54 | std::wstring id; |
| 55 | std::wstring type; |
| 56 | std::wstring envName; |
| 57 | std::wstring srcPath; |
| 58 | |
| 59 | // Docker parity: buildx parses --secret as a single CSV record (go-csvvalue), so a quoted field |
| 60 | // may contain commas (e.g. a 'src=' path). Malformed quoting is rejected like any other bad spec. |
| 61 | const auto parts = SplitCsvFields(spec); |
| 62 | if (!parts.has_value()) |
| 63 | { |
| 64 | throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"malformed quoting")); |
| 65 | } |
| 66 | |
| 67 | for (const auto& part : *parts) |
| 68 | { |
| 69 | const auto kv = SplitKeyValue(part); |
| 70 | if (!kv.HadSeparator || kv.Key.empty()) |
| 71 | { |
| 72 | throw ArgumentException( |
| 73 | Localization::MessageWslcSecretInvalidSpec(spec, L"expected key=value pairs separated by ','")); |
| 74 | } |
| 75 | const auto& key = kv.Key; |
| 76 | const auto& value = kv.Value; |
| 77 | |
| 78 | if (key == L"id") |
| 79 | { |
| 80 | id = value; |
| 81 | } |
| 82 | else if (key == L"type") |
| 83 | { |
| 84 | type = value; |
| 85 | } |
| 86 | else if (key == L"env") |
| 87 | { |
| 88 | envName = value; |
| 89 | } |
| 90 | else if (key == L"src" || key == L"source") |
| 91 | { |
| 92 | srcPath = value; |
| 93 | } |
| 94 | else |
| 95 | { |
| 96 | throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported key '{}'", key))); |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | if (id.empty()) |
| 101 | { |
| 102 | throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'id=' is required")); |
| 103 | } |
| 104 | |
| 105 | // Docker parity: 'id' may not start with '-' because that would be interpreted as a command-line option. |
| 106 | if (id[0] == L'-') |
| 107 | { |
| 108 | throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may not start with '-'")); |
| 109 | } |
| 110 | |
| 111 | // The id is forwarded into docker's comma/'='-delimited --secret spec, so reject any character |
| 112 | // that could break out of the id= field and inject additional options (e.g. ",src=/etc/passwd"). |
| 113 | for (auto ch : id) |
| 114 | { |
| 115 | const bool allowed = (ch >= L'a' && ch <= L'z') || (ch >= L'A' && ch <= L'Z') || (ch >= L'0' && ch <= L'9') || |
| 116 | ch == L'_' || ch == L'-' || ch == L'.'; |
| 117 | if (!allowed) |
| 118 | { |
| 119 | throw ArgumentException( |
| 120 | Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may only contain letters, digits, '_', '-' or '.'")); |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | if (!type.empty() && type != L"file" && type != L"env") |
| 125 | { |
| 126 | throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported secret type '{}'", type))); |
| 127 | } |
| 128 | |
| 129 | // Docker parity: 'type=file' names a source file, so it requires 'src='. Without it we would |
| 130 | // otherwise fall through to reading an environment variable, silently contradicting the type. |
| 131 | if (type == L"file" && srcPath.empty()) |
| 132 | { |
| 133 | throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'type=file' requires 'src='")); |
| 134 | } |
| 135 | |
| 136 | // Docker parity: with 'type=env', a bare 'src=' names the environment variable to read (rather |
| 137 | // than a file path), unless an explicit 'env=' was also given. |
| 138 | if (type == L"env" && envName.empty() && !srcPath.empty()) |
| 139 | { |
| 140 | envName = std::move(srcPath); |
| 141 | srcPath.clear(); |
| 142 | } |
| 143 | |
| 144 | if (!envName.empty() && !srcPath.empty()) |
| 145 | { |
| 146 | // Docker parity: 'env=' and 'src=' are not mutually exclusive; when both are given the |
| 147 | // environment variable wins and the file path is ignored. |
| 148 | srcPath.clear(); |
| 149 | } |
| 150 | if (envName.empty() && srcPath.empty()) |
| 151 | { |
| 152 | // Docker parity: with neither 'env=' nor 'src=', the secret value is read from the host |
| 153 | // environment variable whose name matches the id. Unlike an explicit 'env=', that variable |
| 154 | // must be set - Docker errors when the id-named variable is undefined. |
| 155 | envName = id; |
| 156 | if (!wsl::windows::common::wslutil::ReadEnvironmentVariable(envName.c_str()).has_value()) |
| 157 | { |
| 158 | throw ArgumentException( |
| 159 | Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"environment variable '{}' is not set", envName))); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | if (!srcPath.empty()) |
| 164 | { |
| 165 | std::error_code ec; |
| 166 | // Normalize to an absolute path (the service requires one to mount the file's directory) but do |
| 167 | // not verify the file exists or is a regular file here: that would be a TOCTOU race with the |
| 168 | // build, and the file may only be reachable from the service's context. Let the service/BuildKit |
| 169 | // reject an unmountable or unreadable file instead. GetCanonicalPath resolves a relative path |
| 170 | // against the current directory, collapses '..', and resolves symlinks for the portion of the |
| 171 | // path that exists; it succeeds for a missing file but still reports genuine errors. |
| 172 | auto absPath = wsl::windows::common::filesystem::GetCanonicalPath(srcPath, ec); |
| 173 | if (ec.value() != 0) |
| 174 | { |
| 175 | throw ArgumentException( |
| 176 | Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"could not resolve source path: {}", srcPath))); |
| 177 | } |
| 178 | |
| 179 | // Forward the resolved path rather than the bytes: the server mounts the file's parent directory |
| 180 | // into the build VM read-only and references the file in place with docker's --secret src=, so |
| 181 | // the secret is never copied off its original (possibly EFS-encrypted) location while still |
| 182 | // delivering arbitrary binary content byte-for-byte - matching Docker's type=file semantics. |
| 183 | return services::BuildSecret{ |
| 184 | .Id = std::move(id), |
| 185 | .SourcePath = absPath.wstring(), |
| 186 | }; |
| 187 | } |
| 188 | |
| 189 | // Docker parity: a referenced environment variable that is unset (or set but empty) yields an |
| 190 | // empty secret value rather than an error. ReadEnvironmentVariable returns nullopt for an |
| 191 | // undefined variable, which we collapse to an empty value. |
| 192 | const std::wstring value = wsl::windows::common::wslutil::ReadEnvironmentVariable(envName.c_str()).value_or(std::wstring{}); |
| 193 | |
| 194 | // The env value is delivered as UTF-8 bytes, matching how the guest exposes it at /run/secrets/<id>. |
| 195 | auto valueBytes = wsl::windows::common::string::WideToMultiByte(value); |
| 196 | return services::BuildSecret{ |
| 197 | .Id = std::move(id), |
| 198 | .Value = std::vector<BYTE>(valueBytes.begin(), valueBytes.end()), |
| 199 | }; |
| 200 | } |
| 201 | |
| 202 | services::BuildOutput ParseOutputSpec(const std::wstring& spec) |
| 203 | { |
| 204 | // Mirrors `docker buildx build --output`. A bare token is shorthand for a destination; otherwise |
| 205 | // the spec is a single CSV record of key=value pairs where 'type'/'dest' are structural and every |
| 206 | // other key is forwarded verbatim to buildx as an exporter attribute. |
| 207 | if (spec.empty()) |
| 208 | { |
| 209 | throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(spec, L"the value may not be empty")); |
| 210 | } |
| 211 | |
| 212 | // buildx parses the spec as one CSV record (go-csvvalue / encoding/csv): fields are comma |
| 213 | // separated, a field may be double-quoted, "" inside a quoted field is a literal quote, and a |
| 214 | // comma inside quotes is part of the value. This lets a value such as an annotation contain commas. |
| 215 | const auto fields = SplitCsvFields(spec); |
| 216 | if (!fields.has_value()) |
| 217 | { |
| 218 | throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(spec, L"malformed quoting")); |
| 219 | } |
| 220 | |
| 221 | // Keys are ASCII and matched case-insensitively, and buildx TrimSpace's each key so " dest=x" after |
| 222 | // a comma is accepted; values are left untouched. AsciiToLower/TrimAscii live in stringshared.h. |
| 223 | |
| 224 | // Shorthand: a single field that is exactly the input and does not start with "type=" names the |
| 225 | // destination. Matching Docker, '-' streams a tarball to stdout ('type=tar,dest=-'); anything else |
| 226 | // is the local (directory) exporter ('type=local,dest=<path>'), which is not supported. |
| 227 | if (fields->size() == 1 && fields->front() == spec && spec.compare(0, 5, L"type=") != 0) |
| 228 | { |
| 229 | if (fields->front() == L"-") |
| 230 | { |
| 231 | return services::BuildOutput{.Type = L"tar", .Dest = L"-"}; |
| 232 | } |
| 233 | |
| 234 | throw ArgumentException(Localization::MessageWslcOutputInvalidSpec( |
| 235 | spec, |
| 236 | L"directory exporters are not supported; write a single file with 'dest=<file>' (type=tar/oci/docker) " |
| 237 | L"or stream a tarball to stdout with 'type=tar,dest=-'")); |
| 238 | } |
| 239 | |
| 240 | services::BuildOutput output; |
| 241 | std::wstring rawType; |
| 242 | bool hasType = false; |
| 243 | |
| 244 | for (const auto& field : *fields) |
| 245 | { |
| 246 | // buildx splits each field on the FIRST '=' and requires two parts; the value is not trimmed. |
| 247 | const auto pos = field.find(L'='); |
| 248 | if (pos == std::wstring::npos) |
| 249 | { |
| 250 | throw ArgumentException( |
| 251 | Localization::MessageWslcOutputInvalidSpec(spec, L"expected key=value pairs separated by ','")); |
| 252 | } |
| 253 | |
| 254 | const auto key = AsciiToLower(TrimAscii(std::wstring_view(field).substr(0, pos))); |
| 255 | auto value = field.substr(pos + 1); |
| 256 | if (key.empty()) |
| 257 | { |
| 258 | throw ArgumentException( |
| 259 | Localization::MessageWslcOutputInvalidSpec(spec, L"expected key=value pairs separated by ','")); |
| 260 | } |
| 261 | |
| 262 | if (key == L"type") |
| 263 | { |
| 264 | rawType = value; |
| 265 | output.Type = AsciiToLower(std::wstring_view(value)); |
| 266 | hasType = true; |
| 267 | } |
| 268 | else if (key == L"dest") |
| 269 | { |
| 270 | output.Dest = std::move(value); |
| 271 | } |
| 272 | else |
| 273 | { |
| 274 | // Remaining attributes (name, push, compression, tar, annotations, ...) are matched |
| 275 | // case-insensitively by buildx, so their keys are already lowercased above. |
| 276 | output.Attributes[key] = std::move(value); |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | if (!hasType || output.Type.empty()) |
| 281 | { |
| 282 | throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(spec, L"type is required")); |
| 283 | } |
| 284 | |
| 285 | // buildx forwards the type to buildkit and only rejects it there; we route the exporter ourselves, |
| 286 | // so an unroutable type has to be rejected up front. Every real exporter is in this list. |
| 287 | const bool supportedType = output.Type == L"local" || output.Type == L"tar" || output.Type == L"oci" || |
| 288 | output.Type == L"docker" || output.Type == L"image" || output.Type == L"registry" || |
| 289 | output.Type == L"cacheonly"; |
| 290 | if (!supportedType) |
| 291 | { |
| 292 | throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(spec, std::format(L"unsupported output type '{}'", rawType))); |
| 293 | } |
| 294 | |
| 295 | // The 'tar' attribute selects a single tarball vs. an OCI layout directory for oci/docker, and it |
| 296 | // drives file-vs-directory routing here. buildx parses it with Go's ParseBool and errors on |
| 297 | // anything else, so reject an invalid value up front rather than failing confusingly in the VM. |
| 298 | if (output.Type == L"oci" || output.Type == L"docker") |
| 299 | { |
| 300 | const auto tarIt = output.Attributes.find(L"tar"); |
| 301 | if (tarIt != output.Attributes.end() && !ParseBool(tarIt->second.c_str(), true).has_value()) |
| 302 | { |
| 303 | throw ArgumentException( |
| 304 | Localization::MessageWslcOutputInvalidSpec(spec, std::format(L"invalid boolean value '{}' for 'tar'", tarIt->second))); |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // Destination resolution, mirroring `docker buildx build --output`: |
| 309 | if (OutputIsDirectory(output)) |
| 310 | { |
| 311 | // Directory exporters (local, or oci/docker with tar=false) write a Linux directory tree, which |
| 312 | // cannot be materialized faithfully on a Windows destination, so they are not supported. Point |
| 313 | // users at the single-file exporters instead. |
| 314 | throw ArgumentException(Localization::MessageWslcOutputInvalidSpec( |
| 315 | spec, |
| 316 | L"directory exporters are not supported; write a single file with 'dest=<file>' (type=tar/oci/docker) " |
| 317 | L"or stream a tarball to stdout with 'type=tar,dest=-'")); |
| 318 | } |
| 319 | |
| 320 | if (output.Type == L"tar" || output.Type == L"oci") |
| 321 | { |
| 322 | // Single-tarball exporters stream to stdout when no destination is given (buildx default). |
| 323 | if (output.Dest.empty()) |
| 324 | { |
| 325 | output.Dest = L"-"; |
| 326 | } |
| 327 | } |
| 328 | // docker: no dest -> load into the VM image store (leave empty); dest='-' streams a tarball to |
| 329 | // stdout; a path writes a file. image/registry/cacheonly run in the VM and ignore 'dest'. |
| 330 | |
| 331 | return output; |
| 332 | } |
| 333 | |
| 334 | bool OutputStreamsToClient(const services::BuildOutput& output) |
| 335 | { |
| 336 | if (output.Type == L"local" || output.Type == L"tar" || output.Type == L"oci") |
| 337 | { |
| 338 | return true; |
| 339 | } |
| 340 | |
| 341 | if (output.Type == L"docker") |
| 342 | { |
| 343 | // An omitted destination loads the image into the store in the VM; any destination (a file or |
| 344 | // stdout '-') is produced in the VM and streamed back to the client. |
| 345 | return !output.Dest.empty(); |
| 346 | } |
| 347 | |
| 348 | // image / registry / cacheonly run entirely in the build VM. |
| 349 | return false; |
| 350 | } |
| 351 | |
| 352 | bool OutputIsDirectory(const services::BuildOutput& output) |
| 353 | { |
| 354 | if (output.Type == L"local") |
| 355 | { |
| 356 | return true; |
| 357 | } |
| 358 | |
| 359 | if (output.Type == L"oci" || output.Type == L"docker") |
| 360 | { |
| 361 | // oci/docker default to a single tarball but export an OCI layout directory when tar is false. |
| 362 | const auto it = output.Attributes.find(L"tar"); |
| 363 | if (it != output.Attributes.end()) |
| 364 | { |
| 365 | const auto tar = ParseBool(it->second.c_str(), true); |
| 366 | return tar.has_value() && !tar.value(); |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | return false; |
| 371 | } |
| 372 | |
| 373 | std::wstring FormatOutputSpec(const services::BuildOutput& output) |
| 374 | { |
| 375 | // buildx consumes the same CSV key=value form we parsed, so we round-trip the parsed struct back |
| 376 | // into a canonical spec. This is what actually reaches `docker build --output <spec>`. Each token |
| 377 | // is CSV-escaped so a value containing a comma or quote survives the trip. |
| 378 | std::vector<std::wstring> fields; |
| 379 | fields.push_back(std::format(L"type={}", output.Type)); |
| 380 | if (!output.Dest.empty()) |
| 381 | { |
| 382 | fields.push_back(std::format(L"dest={}", output.Dest)); |
| 383 | } |
| 384 | |
| 385 | for (const auto& [key, value] : output.Attributes) |
| 386 | { |
| 387 | fields.push_back(std::format(L"{}={}", key, value)); |
| 388 | } |
| 389 | |
| 390 | return JoinCsvFields(fields); |
| 391 | } |
| 392 | |
| 393 | std::tuple<std::string, int64_t, int64_t> ParseUlimit(const std::wstring& input, const std::wstring& argName) |
| 394 | { |
| 395 | // Accepts <name>=<soft>[:<hard>]; if hard is omitted hard = soft. -1 means unlimited. |
| 396 | const auto nameValue = SplitKeyValue(input); |
| 397 | if (!nameValue.HadSeparator || nameValue.Key.empty()) |
| 398 | { |
| 399 | throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); |
| 400 | } |
| 401 | |
| 402 | const std::wstring& valuesPart = nameValue.Value; |
| 403 | const auto colonPos = valuesPart.find(L':'); |
| 404 | |
| 405 | auto parseLimit = [&](const std::wstring& limitStr) -> int64_t { |
| 406 | if (limitStr.empty()) |
| 407 | { |
| 408 | throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); |
| 409 | } |
| 410 | |
| 411 | try |
| 412 | { |
| 413 | return GetIntegerFromString<int64_t>(limitStr, argName, [](int64_t v) { return v >= -1; }); |
| 414 | } |
| 415 | catch (const ArgumentException&) |
| 416 | { |
| 417 | // Re-throw with the ulimit-specific error message so the user sees the full input. |
| 418 | throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); |
| 419 | } |
| 420 | }; |
| 421 | |
| 422 | const int64_t soft = parseLimit(colonPos == std::wstring::npos ? valuesPart : valuesPart.substr(0, colonPos)); |
| 423 | const int64_t hard = colonPos == std::wstring::npos ? soft : parseLimit(valuesPart.substr(colonPos + 1)); |
| 424 | |
| 425 | // This rejects "-1:1024" and "-1:<finite>" while allowing "<finite>:-1", "-1:-1", and "-1". |
| 426 | const bool invalidRange = (soft == -1) ? (hard != -1) : (hard != -1 && hard < soft); |
| 427 | if (invalidRange) |
| 428 | { |
| 429 | throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); |
| 430 | } |
| 431 | |
| 432 | return {WideToMultiByte(nameValue.Key), soft, hard}; |
| 433 | } |
| 434 | |
| 435 | std::pair<std::string, std::string> ParseLabel(const std::wstring& value) |
| 436 | { |
| 437 | const auto kv = SplitKeyValue(value); |
| 438 | auto key = WideToMultiByte(kv.Key); |
| 439 | THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_LabelKeyEmptyError(), key.empty()); |
| 440 | return {std::move(key), WideToMultiByte(kv.Value)}; |
| 441 | } |
| 442 | |
| 443 | std::pair<std::string, std::string> ParseDriverOption(const std::wstring& value) |
| 444 | { |
| 445 | const auto kv = SplitKeyValue(value); |
| 446 | return {WideToMultiByte(kv.Key), WideToMultiByte(kv.Value)}; |
| 447 | } |
| 448 | |
| 449 | std::pair<std::string, std::string> ParseFilter(const std::wstring& value) |
| 450 | { |
| 451 | const auto kv = SplitKeyValue(value); |
| 452 | if (!kv.HadSeparator) |
| 453 | { |
| 454 | throw ArgumentException(Localization::WSLCCLI_InvalidFilterError(value)); |
| 455 | } |
| 456 | |
| 457 | return {WideToMultiByte(kv.Key), WideToMultiByte(kv.Value)}; |
| 458 | } |
| 459 | |
| 460 | ParsedNetworkArgument ParseNetworkArgument(std::wstring_view value, const std::wstring& argName) |
| 461 | { |
| 462 | ParsedNetworkArgument result; |
| 463 | |
| 464 | auto parseOptions = [&](std::wstring_view options, bool requireName) { |
| 465 | bool parsedName = false; |
| 466 | for (const auto part : SplitPreserveEmpty(options, L',')) |
| 467 | { |
| 468 | const auto separator = part.find(L'='); |
| 469 | if (separator == std::wstring_view::npos || separator == 0) |
| 470 | { |
| 471 | throw ArgumentException(Localization::WSLCCLI_NetworkUnsupportedOptionError(argName, std::wstring{part})); |
| 472 | } |
| 473 | |
| 474 | const auto key = part.substr(0, separator); |
| 475 | const auto optionValue = part.substr(separator + 1); |
| 476 | if (key == L"name") |
| 477 | { |
| 478 | if (IsEmptyOrWhitespace(optionValue)) |
| 479 | { |
| 480 | throw ArgumentException(Localization::WSLCCLI_NetworkEmptyError(argName)); |
| 481 | } |
| 482 | |
| 483 | if (parsedName) |
| 484 | { |
| 485 | throw ArgumentException(Localization::WSLCCLI_NetworkDuplicateNameError(argName)); |
| 486 | } |
| 487 | |
| 488 | parsedName = true; |
| 489 | result.Name = WideToMultiByte(std::wstring{optionValue}); |
| 490 | } |
| 491 | else if (key == L"alias") |
| 492 | { |
| 493 | if (IsEmptyOrWhitespace(optionValue)) |
| 494 | { |
| 495 | throw ArgumentException(Localization::WSLCCLI_NetworkAliasEmptyError(argName)); |
| 496 | } |
| 497 | |
| 498 | result.Aliases.emplace_back(WideToMultiByte(std::wstring{optionValue})); |
| 499 | } |
| 500 | else |
| 501 | { |
| 502 | throw ArgumentException(Localization::WSLCCLI_NetworkUnsupportedOptionError(argName, std::wstring{key})); |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | if (requireName && !parsedName) |
| 507 | { |
| 508 | throw ArgumentException(Localization::WSLCCLI_NetworkEmptyError(argName)); |
| 509 | } |
| 510 | }; |
| 511 | |
| 512 | if (value.find(L'=') != std::wstring_view::npos) |
| 513 | { |
| 514 | parseOptions(value, true); |
| 515 | } |
| 516 | else |
| 517 | { |
| 518 | if (IsEmptyOrWhitespace(value)) |
| 519 | { |
| 520 | throw ArgumentException(Localization::WSLCCLI_NetworkEmptyError(argName)); |
| 521 | } |
| 522 | |
| 523 | result.Name = WideToMultiByte(std::wstring{value}); |
| 524 | } |
| 525 | |
| 526 | if (result.Name.empty()) |
| 527 | { |
| 528 | throw ArgumentException(Localization::WSLCCLI_NetworkEmptyError(argName)); |
| 529 | } |
| 530 | |
| 531 | return result; |
| 532 | } |
| 533 | |
| 534 | // Map of signal names to WSLCSignal enum values |
| 535 | static const std::unordered_map<std::wstring, WSLCSignal> SignalMap = { |
| 536 | {L"SIGHUP", WSLCSignalSIGHUP}, {L"SIGINT", WSLCSignalSIGINT}, {L"SIGQUIT", WSLCSignalSIGQUIT}, |
| 537 | {L"SIGILL", WSLCSignalSIGILL}, {L"SIGTRAP", WSLCSignalSIGTRAP}, {L"SIGABRT", WSLCSignalSIGABRT}, |
| 538 | {L"SIGIOT", WSLCSignalSIGIOT}, {L"SIGBUS", WSLCSignalSIGBUS}, {L"SIGFPE", WSLCSignalSIGFPE}, |
| 539 | {L"SIGKILL", WSLCSignalSIGKILL}, {L"SIGUSR1", WSLCSignalSIGUSR1}, {L"SIGSEGV", WSLCSignalSIGSEGV}, |
| 540 | {L"SIGUSR2", WSLCSignalSIGUSR2}, {L"SIGPIPE", WSLCSignalSIGPIPE}, {L"SIGALRM", WSLCSignalSIGALRM}, |
| 541 | {L"SIGTERM", WSLCSignalSIGTERM}, {L"SIGTKFLT", WSLCSignalSIGTKFLT}, {L"SIGCHLD", WSLCSignalSIGCHLD}, |
| 542 | {L"SIGCONT", WSLCSignalSIGCONT}, {L"SIGSTOP", WSLCSignalSIGSTOP}, {L"SIGTSTP", WSLCSignalSIGTSTP}, |
| 543 | {L"SIGTTIN", WSLCSignalSIGTTIN}, {L"SIGTTOU", WSLCSignalSIGTTOU}, {L"SIGURG", WSLCSignalSIGURG}, |
| 544 | {L"SIGXCPU", WSLCSignalSIGXCPU}, {L"SIGXFSZ", WSLCSignalSIGXFSZ}, {L"SIGVTALRM", WSLCSignalSIGVTALRM}, |
| 545 | {L"SIGPROF", WSLCSignalSIGPROF}, {L"SIGWINCH", WSLCSignalSIGWINCH}, {L"SIGIO", WSLCSignalSIGIO}, |
| 546 | {L"SIGPOLL", WSLCSignalSIGPOLL}, {L"SIGPWR", WSLCSignalSIGPWR}, {L"SIGSYS", WSLCSignalSIGSYS}, |
| 547 | }; |
| 548 | |
| 549 | // Convert string to WSLCSignal enum - accepts either signal name (e.g., "SIGKILL") or number (e.g., "9") |
| 550 | WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName) |
| 551 | { |
| 552 | constexpr int MIN_SIGNAL = WSLCSignalSIGHUP; |
| 553 | constexpr int MAX_SIGNAL = WSLCSignalSIGSYS; |
| 554 | constexpr std::wstring_view sigPrefix = L"SIG"; |
| 555 | |
| 556 | // Normalize input: ensure it has "SIG" prefix for map lookup |
| 557 | std::wstring normalizedInput; |
| 558 | if (IsEqual(input.substr(0, sigPrefix.size()), sigPrefix, true)) |
| 559 | { |
| 560 | normalizedInput = input; |
| 561 | } |
| 562 | else |
| 563 | { |
| 564 | normalizedInput = std::wstring(sigPrefix) + input; |
| 565 | } |
| 566 | |
| 567 | for (const auto& [signalName, signalValue] : SignalMap) |
| 568 | { |
| 569 | if (IsEqual(normalizedInput, signalName, true)) |
| 570 | { |
| 571 | return signalValue; |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | // User may have input an integer representation instead. |
| 576 | int signalValue{}; |
| 577 | try |
| 578 | { |
| 579 | signalValue = GetIntegerFromString<int>(input, argName); |
| 580 | } |
| 581 | // If it fails to be converted give a better user message than just the integer conversion |
| 582 | // failure since we also know it failed to be found in the map. |
| 583 | catch (ArgumentException) |
| 584 | { |
| 585 | throw ArgumentException(Localization::WSLCCLI_InvalidSignalError(argName, input)); |
| 586 | } |
| 587 | |
| 588 | if (signalValue < MIN_SIGNAL || signalValue > MAX_SIGNAL) |
| 589 | { |
| 590 | throw ArgumentException(Localization::WSLCCLI_SignalOutOfRangeError(argName, input, MIN_SIGNAL, MAX_SIGNAL)); |
| 591 | } |
| 592 | |
| 593 | return static_cast<WSLCSignal>(signalValue); |
| 594 | } |
| 595 | |
| 596 | LONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName) |
| 597 | { |
| 598 | std::string narrowValue = wsl::windows::common::string::WideToMultiByte(value); |
| 599 | |
| 600 | // Try integer (Unix epoch seconds) first |
| 601 | LONGLONG intValue{}; |
| 602 | const char* begin = narrowValue.c_str(); |
| 603 | const char* end = begin + narrowValue.size(); |
| 604 | auto result = std::from_chars(begin, end, intValue); |
| 605 | if (result.ec == std::errc() && result.ptr == end) |
| 606 | { |
| 607 | return intValue; |
| 608 | } |
| 609 | |
| 610 | if (const auto duration = wsl::windows::common::timestamp::TryParseDuration(narrowValue); duration.has_value()) |
| 611 | { |
| 612 | // Apply the duration at full precision and truncate once, so that a sub-second value keeps its sign. |
| 613 | const auto target = std::chrono::system_clock::now() - duration.value(); |
| 614 | |
| 615 | return std::chrono::floor<std::chrono::seconds>(target.time_since_epoch()).count(); |
| 616 | } |
| 617 | |
| 618 | try |
| 619 | { |
| 620 | return wsl::windows::common::timestamp::Rfc3339ToEpoch(wsl::windows::common::timestamp::ExpandToRfc3339(narrowValue)); |
| 621 | } |
| 622 | // Name the offending argument rather than surfacing the raw parse failure. |
| 623 | catch (...) |
| 624 | { |
| 625 | throw ArgumentException(Localization::WSLCCLI_InvalidTimestampArgumentError(argName, value)); |
| 626 | } |
| 627 | } |
| 628 | |
| 629 | models::FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName) |
| 630 | { |
| 631 | // Single source of truth for the accepted format values. It drives both parsing and the error |
| 632 | // message's supported-values list, so adding a type here updates both automatically. |
| 633 | static constexpr std::pair<std::wstring_view, models::FormatType> c_formatTypes[] = { |
| 634 | {L"json", models::FormatType::Json}, |
| 635 | {L"table", models::FormatType::Table}, |
| 636 | }; |
| 637 | |
| 638 | for (const auto& [name, type] : c_formatTypes) |
| 639 | { |
| 640 | if (IsEqual(input, name)) |
| 641 | { |
| 642 | return type; |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | std::wstring supportedValues; |
| 647 | for (const auto& formatType : c_formatTypes) |
| 648 | { |
| 649 | if (!supportedValues.empty()) |
| 650 | { |
| 651 | supportedValues += L", "; |
| 652 | } |
| 653 | |
| 654 | supportedValues += formatType.first; |
| 655 | } |
| 656 | |
| 657 | throw ArgumentException(Localization::WSLCCLI_InvalidFormatValueError(argName, input, supportedValues)); |
| 658 | } |
| 659 | |
| 660 | int GetInspectJsonIndentFromString(const std::wstring& input, const std::wstring& argName) |
| 661 | { |
| 662 | if (!IsEqual(input, L"json")) |
| 663 | { |
| 664 | constexpr std::wstring_view supportedValues = L"json"; |
| 665 | throw ArgumentException(Localization::WSLCCLI_InvalidFormatValueError(argName, input, supportedValues)); |
| 666 | } |
| 667 | |
| 668 | return wsl::shared::c_jsonCompactIndent; |
| 669 | } |
| 670 | |
| 671 | models::PullPolicy GetPullPolicyFromString(const std::wstring& input, const std::wstring& argName) |
| 672 | { |
| 673 | static constexpr std::pair<std::wstring_view, models::PullPolicy> c_pullPolicies[] = { |
| 674 | {L"always", models::PullPolicy::Always}, |
| 675 | {L"missing", models::PullPolicy::Missing}, |
| 676 | {L"never", models::PullPolicy::Never}, |
| 677 | }; |
| 678 | |
| 679 | for (const auto& [name, policy] : c_pullPolicies) |
| 680 | { |
| 681 | if (IsEqual(input, name)) |
| 682 | { |
| 683 | return policy; |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | std::wstring supportedValues; |
| 688 | for (const auto& pullPolicy : c_pullPolicies) |
| 689 | { |
| 690 | if (!supportedValues.empty()) |
| 691 | { |
| 692 | supportedValues += L", "; |
| 693 | } |
| 694 | |
| 695 | supportedValues += pullPolicy.first; |
| 696 | } |
| 697 | |
| 698 | throw ArgumentException(Localization::WSLCCLI_InvalidPullPolicyError(argName, input, supportedValues)); |
| 699 | } |
| 700 | |
| 701 | models::ProgressMode GetProgressModeFromString(const std::wstring& input, const std::wstring& argName) |
| 702 | { |
| 703 | static constexpr std::pair<std::wstring_view, models::ProgressMode> c_progressModes[] = { |
| 704 | {L"auto", models::ProgressMode::Auto}, |
| 705 | {L"tty", models::ProgressMode::Tty}, |
| 706 | {L"plain", models::ProgressMode::Plain}, |
| 707 | {L"quiet", models::ProgressMode::Quiet}, |
| 708 | }; |
| 709 | |
| 710 | for (const auto& [name, mode] : c_progressModes) |
| 711 | { |
| 712 | if (IsEqual(input, name)) |
| 713 | { |
| 714 | return mode; |
| 715 | } |
| 716 | } |
| 717 | |
| 718 | std::wstring supportedValues; |
| 719 | for (const auto& progressMode : c_progressModes) |
| 720 | { |
| 721 | if (!supportedValues.empty()) |
| 722 | { |
| 723 | supportedValues += L", "; |
| 724 | } |
| 725 | |
| 726 | supportedValues += progressMode.first; |
| 727 | } |
| 728 | |
| 729 | throw ArgumentException(Localization::WSLCCLI_InvalidProgressTypeError(argName, input, supportedValues)); |
| 730 | } |
| 731 | |
| 732 | models::InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName) |
| 733 | { |
| 734 | if (IsEqual(input, L"image")) |
| 735 | { |
| 736 | return models::InspectType::Image; |
| 737 | } |
| 738 | else if (IsEqual(input, L"container")) |
| 739 | { |
| 740 | return models::InspectType::Container; |
| 741 | } |
| 742 | else if (IsEqual(input, L"network")) |
| 743 | { |
| 744 | return models::InspectType::Network; |
| 745 | } |
| 746 | else if (IsEqual(input, L"volume")) |
| 747 | { |
| 748 | return models::InspectType::Volume; |
| 749 | } |
| 750 | else |
| 751 | { |
| 752 | constexpr std::wstring_view supportedValues = L"image, container, network, volume"; |
| 753 | throw ArgumentException(Localization::WSLCCLI_InvalidInspectError(argName, input, supportedValues)); |
| 754 | } |
| 755 | } |
| 756 | |
| 757 | int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName) |
| 758 | { |
| 759 | const auto bytes = |
| 760 | wsl::windows::common::string::ParseStorageSize(std::wstring_view{input}, wsl::windows::common::string::StorageSizeUnit::Binary); |
| 761 | if (!bytes.has_value() || bytes.value() > static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) |
| 762 | { |
| 763 | throw ArgumentException(Localization::WSLCCLI_InvalidMemorySizeError(argName, input)); |
| 764 | } |
| 765 | |
| 766 | return static_cast<int64_t>(bytes.value()); |
| 767 | } |
| 768 | |
| 769 | int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName) |
| 770 | { |
| 771 | const std::string narrow = WideToMultiByte(input); |
| 772 | const auto parsed = wsl::windows::common::timestamp::TryParseDuration(narrow); |
| 773 | |
| 774 | if (!parsed.has_value() || parsed.value() < std::chrono::nanoseconds::zero()) |
| 775 | { |
| 776 | throw ArgumentException(Localization::WSLCCLI_InvalidDurationError(argName, input)); |
| 777 | } |
| 778 | |
| 779 | return parsed.value().count(); |
| 780 | } |
| 781 | |
| 782 | int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName) |
| 783 | { |
| 784 | constexpr double NanosPerCpu = 1'000'000'000.0; |
| 785 | constexpr double MaxCpus = static_cast<double>(std::numeric_limits<int64_t>::max()) / NanosPerCpu; |
| 786 | |
| 787 | const std::string narrow = WideToMultiByte(input); |
| 788 | const char* begin = narrow.c_str(); |
| 789 | const char* end = begin + narrow.size(); |
| 790 | |
| 791 | double cpus{}; |
| 792 | const auto result = std::from_chars(begin, end, cpus, std::chars_format::fixed); |
| 793 | if (result.ec != std::errc() || result.ptr != end || cpus <= 0.0 || cpus > MaxCpus) |
| 794 | { |
| 795 | throw ArgumentException(Localization::WSLCCLI_InvalidCpusError(argName, input)); |
| 796 | } |
| 797 | |
| 798 | return static_cast<int64_t>(cpus * NanosPerCpu); |
| 799 | } |
| 800 | |
| 801 | } // namespace wsl::windows::wslc::validation |