diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index aa0f5bf72c..e47268122a 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -2327,6 +2327,10 @@ For privacy information about this product please visit https://aka.ms/privacy.< Image '{}' not found. {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated + + Invalid --secret value '{}': {} + {FixedPlaceholder="{}"}{Locked="--secret "}Command line arguments, file names and string inserts should not be translated + Missing required option: '{}' {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated @@ -2868,6 +2872,10 @@ On first run, creates the file with all settings commented out at their defaults Set the target build stage to build + + Expose secret to the build (id=NAME[,type=env|file][,env=VAR|,src=PATH]; defaults to env var NAME) + {Locked="id=NAME"}{Locked="type=env|file"}{Locked="env=VAR"}{Locked="src=PATH"}Command line arguments should not be translated + The command to run diff --git a/src/windows/common/wslutil.cpp b/src/windows/common/wslutil.cpp index 9fd5091905..b8de61f4ce 100644 --- a/src/windows/common/wslutil.cpp +++ b/src/windows/common/wslutil.cpp @@ -1457,6 +1457,19 @@ void wsl::windows::common::wslutil::PrintMessage(_In_ const std::wstring& messag fwprintf(stream, L"%ls\n", message.c_str()); } +std::optional wsl::windows::common::wslutil::ReadEnvironmentVariable(_In_ LPCWSTR Name) +{ + std::wstring value; + const HRESULT hr = wil::GetEnvironmentVariableW(Name, value); + if (hr == HRESULT_FROM_WIN32(ERROR_ENVVAR_NOT_FOUND)) + { + return std::nullopt; + } + + THROW_IF_FAILED(hr); + return value; +} + void wsl::windows::common::wslutil::SetCrtEncoding(int Mode) { // Configure the CRT to manipulate text as the specified mode. diff --git a/src/windows/common/wslutil.h b/src/windows/common/wslutil.h index 2deb0809a1..47db93e907 100644 --- a/src/windows/common/wslutil.h +++ b/src/windows/common/wslutil.h @@ -311,6 +311,10 @@ void PrintMessage(_In_ const std::wstring& message, _Inout_ FILE* const stream = PrintMessageImpl(message, stream, std::forward(args)...); } +// Reads an environment variable. Returns nullopt iff the variable is not defined; an engaged +// (possibly empty) string otherwise. +std::optional ReadEnvironmentVariable(_In_ LPCWSTR Name); + void SetCrtEncoding(int Mode); void SetThreadDescription(LPCWSTR Name); diff --git a/src/windows/service/inc/wslc.idl b/src/windows/service/inc/wslc.idl index 2c9abc1aa3..6b92e7a391 100644 --- a/src/windows/service/inc/wslc.idl +++ b/src/windows/service/inc/wslc.idl @@ -169,6 +169,22 @@ typedef struct _WSLCStringArray ULONG Count; } WSLCStringArray; +typedef struct _WSLCBuildSecret +{ + [string] LPCSTR Id; // Value for docker's --secret id= field. + // Raw secret bytes (never cross into argv). Carried as a counted byte array so arbitrary binary + // content - including embedded NULs - round-trips losslessly; the server materializes it into a + // root-only tmpfs file inside the VM and references it with docker's --secret src=. + [unique, size_is(ValueSize)] const byte* Value; + ULONG ValueSize; +} WSLCBuildSecret; + +typedef struct _WSLCBuildSecretArray +{ + [unique, size_is(Count)] const WSLCBuildSecret* Values; + ULONG Count; +} WSLCBuildSecretArray; + typedef struct _WSLCProcessOptions { [unique] LPCSTR CurrentDirectory; @@ -523,6 +539,7 @@ typedef struct _WSLCBuildImageOptions LPCSTR Target; // Target build stage name passed as --target to docker. WSLCBuildImageFlags Flags; // WSLCBuildImageFlags WSLCStringArray Labels; // KEY=VALUE pairs passed as --label to docker. + WSLCBuildSecretArray Secrets; // --secret entries; the server writes each secret's bytes to a root-only tmpfs file in the VM and emits the corresponding id=...,src=... spec. } WSLCBuildImageOptions; typedef struct _WSLCTagImageOptions diff --git a/src/windows/wslc/arguments/ArgumentDefinitions.h b/src/windows/wslc/arguments/ArgumentDefinitions.h index 268ab69ff8..5a012f530a 100644 --- a/src/windows/wslc/arguments/ArgumentDefinitions.h +++ b/src/windows/wslc/arguments/ArgumentDefinitions.h @@ -105,6 +105,7 @@ _(PublishAll, "publish-all", L"P", Kind::Flag, L _(Quiet, "quiet", L"q", Kind::Flag, Localization::WSLCCLI_QuietArgDescription()) \ _(Remove, "rm", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_RemoveArgDescription()) \ /*_(Scheme, "scheme", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SchemeArgDescription())*/ \ +_(Secret, "secret", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SecretArgDescription()) \ _(Server, "server", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_LoginServerArgDescription()) \ _(Session, "session", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SessionIdArgDescription()) \ _(ShmSize, "shm-size", NO_ALIAS, Kind::Value, Localization::WSLCCLI_ShmSizeArgDescription()) \ diff --git a/src/windows/wslc/arguments/ArgumentValidation.cpp b/src/windows/wslc/arguments/ArgumentValidation.cpp index 4b2995e8ea..cca569d9b3 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.cpp +++ b/src/windows/wslc/arguments/ArgumentValidation.cpp @@ -18,14 +18,9 @@ Module Name: #include "ArgumentValidation.h" #include "ContainerModel.h" #include "Exceptions.h" +#include "ImageService.h" #include "Localization.h" #include -#include -#include -#include -#include -#include -#include #include using namespace wsl::windows::common; @@ -104,6 +99,15 @@ void Argument::Validate(const ArgMap& execArgs) const validation::ValidateIntegerFromString(execArgs.GetAll(), m_name); break; + case ArgType::Secret: + { + for (const auto& spec : execArgs.GetAll()) + { + std::ignore = validation::ParseSecretSpec(spec); + } + break; + } + case ArgType::Since: validation::ValidateTimestamp(execArgs.GetAll(), m_name); break; @@ -178,21 +182,6 @@ void Argument::Validate(const ArgMap& execArgs) const namespace wsl::windows::wslc::validation { -// Map of signal names to WSLCSignal enum values -static const std::unordered_map SignalMap = { - {L"SIGHUP", WSLCSignalSIGHUP}, {L"SIGINT", WSLCSignalSIGINT}, {L"SIGQUIT", WSLCSignalSIGQUIT}, - {L"SIGILL", WSLCSignalSIGILL}, {L"SIGTRAP", WSLCSignalSIGTRAP}, {L"SIGABRT", WSLCSignalSIGABRT}, - {L"SIGIOT", WSLCSignalSIGIOT}, {L"SIGBUS", WSLCSignalSIGBUS}, {L"SIGFPE", WSLCSignalSIGFPE}, - {L"SIGKILL", WSLCSignalSIGKILL}, {L"SIGUSR1", WSLCSignalSIGUSR1}, {L"SIGSEGV", WSLCSignalSIGSEGV}, - {L"SIGUSR2", WSLCSignalSIGUSR2}, {L"SIGPIPE", WSLCSignalSIGPIPE}, {L"SIGALRM", WSLCSignalSIGALRM}, - {L"SIGTERM", WSLCSignalSIGTERM}, {L"SIGTKFLT", WSLCSignalSIGTKFLT}, {L"SIGCHLD", WSLCSignalSIGCHLD}, - {L"SIGCONT", WSLCSignalSIGCONT}, {L"SIGSTOP", WSLCSignalSIGSTOP}, {L"SIGTSTP", WSLCSignalSIGTSTP}, - {L"SIGTTIN", WSLCSignalSIGTTIN}, {L"SIGTTOU", WSLCSignalSIGTTOU}, {L"SIGURG", WSLCSignalSIGURG}, - {L"SIGXCPU", WSLCSignalSIGXCPU}, {L"SIGXFSZ", WSLCSignalSIGXFSZ}, {L"SIGVTALRM", WSLCSignalSIGVTALRM}, - {L"SIGPROF", WSLCSignalSIGPROF}, {L"SIGWINCH", WSLCSignalSIGWINCH}, {L"SIGIO", WSLCSignalSIGIO}, - {L"SIGPOLL", WSLCSignalSIGPOLL}, {L"SIGPWR", WSLCSignalSIGPWR}, {L"SIGSYS", WSLCSignalSIGSYS}, -}; - void ValidateWSLCSignalFromString(const std::vector& values, const std::wstring& argName) { for (const auto& value : values) @@ -219,119 +208,6 @@ void ValidateFilter(const std::vector& values) } } -// Convert string to WSLCSignal enum - accepts either signal name (e.g., "SIGKILL") or number (e.g., "9") -WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName) -{ - constexpr int MIN_SIGNAL = WSLCSignalSIGHUP; - constexpr int MAX_SIGNAL = WSLCSignalSIGSYS; - constexpr std::wstring_view sigPrefix = L"SIG"; - - // Normalize input: ensure it has "SIG" prefix for map lookup - std::wstring normalizedInput; - if (IsEqual(input.substr(0, sigPrefix.size()), sigPrefix, true)) - { - normalizedInput = input; - } - else - { - normalizedInput = std::wstring(sigPrefix) + input; - } - - for (const auto& [signalName, signalValue] : SignalMap) - { - if (IsEqual(normalizedInput, signalName, true)) - { - return signalValue; - } - } - - // User may have input an integer representation instead. - int signalValue{}; - try - { - signalValue = GetIntegerFromString(input, argName); - } - // If it fails to be converted give a better user message than just the integer conversion - // failure since we also know it failed to be found in the map. - catch (ArgumentException) - { - throw ArgumentException(Localization::WSLCCLI_InvalidSignalError(argName, input)); - } - - if (signalValue < MIN_SIGNAL || signalValue > MAX_SIGNAL) - { - throw ArgumentException(Localization::WSLCCLI_SignalOutOfRangeError(argName, input, MIN_SIGNAL, MAX_SIGNAL)); - } - - return static_cast(signalValue); -} - -// Parses an RFC3339 timestamp (e.g. "2024-01-15T10:30:00Z" or "2024-01-15T10:30:00+05:30") -// into a ULONGLONG Unix epoch seconds value using std::chrono::parse. -// Note: +HHMM (no colon) offsets are not supported; use +HH:MM format. -static std::optional TryParseRfc3339(const std::string& input) -{ - std::string normalized = input; - - // Normalize trailing 'Z'/'z' to '+00:00' so %Ez can parse it uniformly. - if (!normalized.empty() && (normalized.back() == 'Z' || normalized.back() == 'z')) - { - normalized.pop_back(); - normalized += "+00:00"; - } - - // Reject bare dot with no fractional digits (e.g. "10:30:00.+00:00") since - // std::chrono::parse is lenient about this. - auto dotPos = normalized.find('.'); - if (dotPos != std::string::npos && (dotPos + 1 >= normalized.size() || !std::isdigit(normalized[dotPos + 1]))) - { - return std::nullopt; - } - - // Pre-validate day-of-month since std::chrono::parse silently wraps invalid dates (e.g. Feb 31 → Mar 2). - if (normalized.size() >= 10 && normalized[4] == '-' && normalized[7] == '-') - { - int year = 0, month = 0, day = 0; - auto yResult = std::from_chars(normalized.data(), normalized.data() + 4, year); - auto mResult = std::from_chars(normalized.data() + 5, normalized.data() + 7, month); - auto dResult = std::from_chars(normalized.data() + 8, normalized.data() + 10, day); - - if (yResult.ec == std::errc() && mResult.ec == std::errc() && dResult.ec == std::errc()) - { - auto ymd = std::chrono::year{year} / std::chrono::month{static_cast(month)} / - std::chrono::day{static_cast(day)}; - if (!ymd.ok()) - { - return std::nullopt; - } - } - } - - // Parse into nanosecond precision so fractional seconds (e.g. ".123456789") are consumed - // by std::chrono::parse rather than requiring manual stripping. - std::chrono::sys_time utcTime; - std::istringstream stream(normalized); - stream >> std::chrono::parse("%FT%T%Ez", utcTime); - if (stream.fail()) - { - return std::nullopt; - } - - // Reject if there are trailing characters after the parsed timestamp - if (stream.peek() != std::istringstream::traits_type::eof()) - { - return std::nullopt; - } - - auto epochSeconds = std::chrono::duration_cast(utcTime.time_since_epoch()).count(); - if (epochSeconds < 0) - { - return std::nullopt; - } - - return static_cast(epochSeconds); -} - void ValidateTimestamp(const std::vector& values, const std::wstring& argName) { for (const auto& value : values) @@ -340,30 +216,6 @@ void ValidateTimestamp(const std::vector& values, const std::wstri } } -ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName) -{ - std::string narrowValue = wsl::windows::common::string::WideToMultiByte(value); - - // Try integer (Unix epoch seconds) first - ULONGLONG intValue{}; - const char* begin = narrowValue.c_str(); - const char* end = begin + narrowValue.size(); - auto result = std::from_chars(begin, end, intValue); - if (result.ec == std::errc() && result.ptr == end) - { - return intValue; - } - - // Try RFC3339 timestamp - auto rfc3339Value = TryParseRfc3339(narrowValue); - if (rfc3339Value.has_value()) - { - return rfc3339Value.value(); - } - - throw ArgumentException(Localization::WSLCCLI_InvalidTimestampArgumentError(argName, value)); -} - void ValidateFormatTypeFromString(const std::vector& values, const std::wstring& argName) { for (const auto& value : values) @@ -372,48 +224,6 @@ void ValidateFormatTypeFromString(const std::vector& values, const } } -FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName) -{ - if (IsEqual(input, L"json")) - { - return FormatType::Json; - } - else if (IsEqual(input, L"table")) - { - return FormatType::Table; - } - else - { - throw ArgumentException(std::format( - L"Invalid {} value: {} is not a recognized format type. Supported format types are: json, table.", argName, input)); - } -} - -InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName) -{ - if (IsEqual(input, L"image")) - { - return InspectType::Image; - } - else if (IsEqual(input, L"container")) - { - return InspectType::Container; - } - else if (IsEqual(input, L"network")) - { - return InspectType::Network; - } - else if (IsEqual(input, L"volume")) - { - return InspectType::Volume; - } - else - { - constexpr std::wstring_view supportedValues = L"image, container, network, volume"; - throw ArgumentException(Localization::WSLCCLI_InvalidInspectError(argName, input, supportedValues)); - } -} - void ValidateGpus(const std::vector& values, const std::wstring& argName) { for (const auto& value : values) @@ -433,134 +243,6 @@ void ValidateMemorySize(const std::vector& values, const std::wstr } } -int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName) -{ - auto parsed = wsl::shared::string::ParseMemorySize(input.c_str()); - if (!parsed.has_value()) - { - throw ArgumentException(Localization::WSLCCLI_InvalidMemorySizeError(argName, input)); - } - - return static_cast(parsed.value()); -} - -// Parses duration string into nanoseconds. -static std::optional TryParseDuration(const std::string& input) -{ - if (input.empty()) - { - return std::nullopt; - } - - size_t pos = 0; - bool negative = false; - if (input[pos] == '+' || input[pos] == '-') - { - negative = input[pos] == '-'; - pos++; - } - - // Special case: a bare "0" (with optional sign) is a valid zero duration. - if (input.substr(pos) == "0") - { - return 0; - } - - // Accumulate in a long double so fractional units (e.g. "1.5h") are handled, then round. - long double totalNanos = 0.0L; - bool sawValue = false; - - while (pos < input.size()) - { - // Parse the numeric part (integer and/or fraction). - const size_t numberStart = pos; - while (pos < input.size() && (std::isdigit(static_cast(input[pos])) || input[pos] == '.')) - { - pos++; - } - - const std::string numberStr = input.substr(numberStart, pos - numberStart); - if (numberStr.empty() || numberStr == "." || std::count(numberStr.begin(), numberStr.end(), '.') > 1) - { - return std::nullopt; - } - - // Parse the unit (everything up to the next digit or '.'). - const size_t unitStart = pos; - while (pos < input.size() && !std::isdigit(static_cast(input[pos])) && input[pos] != '.') - { - pos++; - } - - const std::string unit = input.substr(unitStart, pos - unitStart); - - long double multiplier{}; - if (unit == "ns") - { - multiplier = 1.0L; - } - else if (unit == "us" || unit == "\xC2\xB5s" /* µs (U+00B5) */ || unit == "\xCE\xBCs" /* μs (U+03BC) */) - { - multiplier = 1000L; - } - else if (unit == "ms") - { - multiplier = 1000000L; - } - else if (unit == "s") - { - multiplier = 1000000000L; - } - else if (unit == "m") - { - multiplier = 60000000000L; - } - else if (unit == "h") - { - multiplier = 3600000000000L; - } - else - { - return std::nullopt; - } - - long double value{}; - try - { - auto [ptr, ec] = std::from_chars(numberStr.data(), numberStr.data() + numberStr.size(), value, std::chars_format::fixed); - if (ptr != numberStr.data() + numberStr.size() || ec != std::errc()) - { - return std::nullopt; - } - } - catch (...) - { - return std::nullopt; - } - - totalNanos += value * multiplier; - sawValue = true; - } - - if (!sawValue) - { - return std::nullopt; - } - - if (negative) - { - totalNanos = -totalNanos; - } - - if (totalNanos > static_cast(std::numeric_limits::max()) || - totalNanos < static_cast(std::numeric_limits::min())) - { - return std::nullopt; - } - - return static_cast(std::llroundl(totalNanos)); -} - void ValidateDuration(const std::vector& values, const std::wstring& argName) { for (const auto& value : values) @@ -569,19 +251,6 @@ void ValidateDuration(const std::vector& values, const std::wstrin } } -int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName) -{ - const std::string narrow = WideToMultiByte(input); - const auto parsed = TryParseDuration(narrow); - - if (!parsed.has_value() || parsed.value() < 0) - { - throw ArgumentException(Localization::WSLCCLI_InvalidDurationError(argName, input)); - } - - return parsed.value(); -} - void ValidateNanoCpus(const std::vector& values, const std::wstring& argName) { for (const auto& value : values) @@ -590,25 +259,6 @@ void ValidateNanoCpus(const std::vector& values, const std::wstrin } } -int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName) -{ - constexpr double NanosPerCpu = 1'000'000'000.0; - constexpr double MaxCpus = static_cast(std::numeric_limits::max()) / NanosPerCpu; - - const std::string narrow = WideToMultiByte(input); - const char* begin = narrow.c_str(); - const char* end = begin + narrow.size(); - - double cpus{}; - const auto result = std::from_chars(begin, end, cpus, std::chars_format::fixed); - if (result.ec != std::errc() || result.ptr != end || cpus <= 0.0 || cpus > MaxCpus) - { - throw ArgumentException(Localization::WSLCCLI_InvalidCpusError(argName, input)); - } - - return static_cast(cpus * NanosPerCpu); -} - void ValidateUlimit(const std::vector& values, const std::wstring& argName) { for (const auto& value : values) @@ -617,86 +267,4 @@ void ValidateUlimit(const std::vector& values, const std::wstring& } } -std::tuple ParseUlimit(const std::wstring& input, const std::wstring& argName) -{ - // Accepts =[:]; if hard is omitted hard = soft. -1 means unlimited. - const auto equalsPos = input.find(L'='); - if (equalsPos == std::wstring::npos || equalsPos == 0) - { - throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); - } - - const std::wstring valuesPart = input.substr(equalsPos + 1); - const auto colonPos = valuesPart.find(L':'); - - auto parseLimit = [&](const std::wstring& limitStr) -> int64_t { - if (limitStr.empty()) - { - throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); - } - - try - { - return GetIntegerFromString(limitStr, argName, [](int64_t v) { return v >= -1; }); - } - catch (const ArgumentException&) - { - // Re-throw with the ulimit-specific error message so the user sees the full input. - throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); - } - }; - - const int64_t soft = parseLimit(colonPos == std::wstring::npos ? valuesPart : valuesPart.substr(0, colonPos)); - const int64_t hard = colonPos == std::wstring::npos ? soft : parseLimit(valuesPart.substr(colonPos + 1)); - - // This rejects "-1:1024" and "-1:" while allowing ":-1", "-1:-1", and "-1". - const bool invalidRange = (soft == -1) ? (hard != -1) : (hard != -1 && hard < soft); - if (invalidRange) - { - throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); - } - - return {WideToMultiByte(input.substr(0, equalsPos)), soft, hard}; -} - -std::pair ParseLabel(const std::wstring& value) -{ - std::pair result{}; - auto pos = value.find('='); - if (pos == std::wstring::npos) - { - result.first = WideToMultiByte(value); - } - else - { - result.first = WideToMultiByte(value.substr(0, pos)); - result.second = WideToMultiByte(value.substr(pos + 1)); - } - - THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_LabelKeyEmptyError(), result.first.empty()); - return result; -} - -std::pair ParseDriverOption(const std::wstring& value) -{ - auto pos = value.find('='); - if (pos == std::wstring::npos) - { - return {WideToMultiByte(value), std::string{}}; - } - - return {WideToMultiByte(value.substr(0, pos)), WideToMultiByte(value.substr(pos + 1))}; -} - -std::pair ParseFilter(const std::wstring& value) -{ - auto pos = value.find(L'='); - if (pos == std::wstring::npos) - { - throw ArgumentException(Localization::WSLCCLI_InvalidFilterError(value)); - } - - return {WideToMultiByte(value.substr(0, pos)), WideToMultiByte(value.substr(pos + 1))}; -} - } // namespace wsl::windows::wslc::validation diff --git a/src/windows/wslc/arguments/ArgumentValidation.h b/src/windows/wslc/arguments/ArgumentValidation.h index b25a807e03..860ade8690 100644 --- a/src/windows/wslc/arguments/ArgumentValidation.h +++ b/src/windows/wslc/arguments/ArgumentValidation.h @@ -16,6 +16,7 @@ Module Name: #include "Exceptions.h" #include "ContainerModel.h" #include "InspectModel.h" +#include "SpecParsing.h" #include #include #include @@ -61,33 +62,21 @@ T GetIntegerFromString( } void ValidateWSLCSignalFromString(const std::vector& values, const std::wstring& argName); -WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName = {}); void ValidateMemorySize(const std::vector& values, const std::wstring& argName); -int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName = {}); void ValidateDuration(const std::vector& values, const std::wstring& argName); -int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName = {}); void ValidateTimestamp(const std::vector& values, const std::wstring& argName); -ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName = {}); void ValidateNanoCpus(const std::vector& values, const std::wstring& argName); -int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName = {}); void ValidateUlimit(const std::vector& values, const std::wstring& argName); -std::tuple ParseUlimit(const std::wstring& input, const std::wstring& argName = {}); void ValidateFormatTypeFromString(const std::vector& values, const std::wstring& argName); -FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName = {}); - -InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName); void ValidateGpus(const std::vector& values, const std::wstring& argName); + void ValidateVolumeMount(const std::vector& values); void ValidateFilter(const std::vector& values); -std::pair ParseLabel(const std::wstring& value); -std::pair ParseDriverOption(const std::wstring& value); -std::pair ParseFilter(const std::wstring& value); - } // namespace wsl::windows::wslc::validation diff --git a/src/windows/wslc/arguments/SpecParsing.cpp b/src/windows/wslc/arguments/SpecParsing.cpp new file mode 100644 index 0000000000..54e750fff0 --- /dev/null +++ b/src/windows/wslc/arguments/SpecParsing.cpp @@ -0,0 +1,640 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + SpecParsing.cpp + +Abstract: + + Parsers that turn delimited command-line spec strings (e.g. --secret, + --ulimit, --label, --filter) into structured values. These share the + SplitKeyValue helper for consistent key/value splitting. + +--*/ + +#include "precomp.h" +#include "SpecParsing.h" +#include "ArgumentValidation.h" +#include "Exceptions.h" +#include "ImageService.h" +#include "Localization.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace wsl::windows::common; +using namespace wsl::shared; +using namespace wsl::shared::string; + +namespace wsl::windows::wslc::validation { + +KeyValueSplit SplitKeyValue(const std::wstring& value, wchar_t separator) +{ + const auto pos = value.find(separator); + if (pos == std::wstring::npos) + { + return {value, std::wstring{}, false}; + } + + return {value.substr(0, pos), value.substr(pos + 1), true}; +} + +services::BuildSecret ParseSecretSpec(const std::wstring& spec) +{ + std::wstring id; + std::wstring type; + std::wstring envName; + std::wstring srcPath; + + for (const auto& part : Split(spec, L',')) + { + const auto kv = SplitKeyValue(part); + if (!kv.HadSeparator || kv.Key.empty()) + { + throw ArgumentException( + Localization::MessageWslcSecretInvalidSpec(spec, L"expected key=value pairs separated by ','")); + } + const auto& key = kv.Key; + const auto& value = kv.Value; + + if (key == L"id") + { + id = value; + } + else if (key == L"type") + { + type = value; + } + else if (key == L"env") + { + envName = value; + } + else if (key == L"src" || key == L"source") + { + srcPath = value; + } + else + { + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported key '{}'", key))); + } + } + + if (id.empty()) + { + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'id=' is required")); + } + + // Docker parity: 'id' may not start with '-' because that would be interpreted as a command-line option. + if (id[0] == L'-') + { + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may not start with '-'")); + } + + // The id is forwarded into docker's comma/'='-delimited --secret spec, so reject any character + // that could break out of the id= field and inject additional options (e.g. ",src=/etc/passwd"). + for (auto ch : id) + { + const bool allowed = (ch >= L'a' && ch <= L'z') || (ch >= L'A' && ch <= L'Z') || (ch >= L'0' && ch <= L'9') || + ch == L'_' || ch == L'-' || ch == L'.'; + if (!allowed) + { + throw ArgumentException( + Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may only contain letters, digits, '_', '-' or '.'")); + } + } + + if (!type.empty() && type != L"file" && type != L"env") + { + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported secret type '{}'", type))); + } + + // Docker parity: 'type=file' names a source file, so it requires 'src='. Without it we would + // otherwise fall through to reading an environment variable, silently contradicting the type. + if (type == L"file" && srcPath.empty()) + { + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'type=file' requires 'src='")); + } + + // Docker parity: with 'type=env', a bare 'src=' names the environment variable to read (rather + // than a file path), unless an explicit 'env=' was also given. + if (type == L"env" && envName.empty() && !srcPath.empty()) + { + envName = std::move(srcPath); + srcPath.clear(); + } + + if (!envName.empty() && !srcPath.empty()) + { + // Docker parity: 'env=' and 'src=' are not mutually exclusive; when both are given the + // environment variable wins and the file path is ignored. + srcPath.clear(); + } + if (envName.empty() && srcPath.empty()) + { + // Docker parity: with neither 'env=' nor 'src=', the secret value is read from the host + // environment variable whose name matches the id. Unlike an explicit 'env=', that variable + // must be set - Docker errors when the id-named variable is undefined. + envName = id; + if (!wsl::windows::common::wslutil::ReadEnvironmentVariable(envName.c_str()).has_value()) + { + throw ArgumentException( + Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"environment variable '{}' is not set", envName))); + } + } + + if (!srcPath.empty()) + { + std::error_code ec; + // Resolve symlinks (and normalize '..') so we read the file that actually holds the secret's + // bytes rather than the link node itself. + auto absPath = std::filesystem::weakly_canonical(std::filesystem::absolute(srcPath), ec); + if (ec.value() != 0 || !std::filesystem::is_regular_file(absPath, ec)) + { + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec( + spec, std::format(L"source file not found or not a regular file: {}", absPath.wstring()))); + } + + // Read the file's raw bytes and forward them verbatim. The server materializes them into a + // root-only tmpfs file inside the VM, so file secrets are byte-exact (binary, embedded NULs, + // and arbitrary size all round-trip) - matching Docker's type=file semantics - without ever + // mounting a host directory into the VM. + std::ifstream file(absPath, std::ios::binary | std::ios::ate); + THROW_HR_WITH_USER_ERROR_IF( + E_INVALIDARG, + Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unable to open source file: {}", absPath.wstring())), + !file); + + // Read a known number of bytes rather than draining via istreambuf_iterator: that iterator + // cannot distinguish EOF from a mid-stream read error, so a transient I/O failure would + // silently truncate the secret. Size the buffer from the stream, read exactly that many + // bytes, then verify the full contents were delivered so a short read is surfaced as an + // error instead of forwarding a partial secret to the build. + const std::streamoff size = file.tellg(); + THROW_HR_WITH_USER_ERROR_IF( + E_INVALIDARG, + Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unable to determine size of source file: {}", absPath.wstring())), + size < 0); + + std::vector value(static_cast(size)); + if (size > 0) + { + file.seekg(0); + file.read(reinterpret_cast(value.data()), size); + THROW_HR_WITH_USER_ERROR_IF( + E_UNEXPECTED, + Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"failed to read source file: {}", absPath.wstring())), + file.bad() || file.gcount() != size); + } + + return services::BuildSecret{ + .Id = std::move(id), + .Value = std::move(value), + }; + } + + // Docker parity: a referenced environment variable that is unset (or set but empty) yields an + // empty secret value rather than an error. ReadEnvironmentVariable returns nullopt for an + // undefined variable, which we collapse to an empty value. + const std::wstring value = wsl::windows::common::wslutil::ReadEnvironmentVariable(envName.c_str()).value_or(std::wstring{}); + + // The env value is delivered as UTF-8 bytes, matching how the guest exposes it at /run/secrets/. + auto valueBytes = wsl::windows::common::string::WideToMultiByte(value); + return services::BuildSecret{ + .Id = std::move(id), + .Value = std::vector(valueBytes.begin(), valueBytes.end()), + }; +} + +std::tuple ParseUlimit(const std::wstring& input, const std::wstring& argName) +{ + // Accepts =[:]; if hard is omitted hard = soft. -1 means unlimited. + const auto nameValue = SplitKeyValue(input); + if (!nameValue.HadSeparator || nameValue.Key.empty()) + { + throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); + } + + const std::wstring& valuesPart = nameValue.Value; + const auto colonPos = valuesPart.find(L':'); + + auto parseLimit = [&](const std::wstring& limitStr) -> int64_t { + if (limitStr.empty()) + { + throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); + } + + try + { + return GetIntegerFromString(limitStr, argName, [](int64_t v) { return v >= -1; }); + } + catch (const ArgumentException&) + { + // Re-throw with the ulimit-specific error message so the user sees the full input. + throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); + } + }; + + const int64_t soft = parseLimit(colonPos == std::wstring::npos ? valuesPart : valuesPart.substr(0, colonPos)); + const int64_t hard = colonPos == std::wstring::npos ? soft : parseLimit(valuesPart.substr(colonPos + 1)); + + // This rejects "-1:1024" and "-1:" while allowing ":-1", "-1:-1", and "-1". + const bool invalidRange = (soft == -1) ? (hard != -1) : (hard != -1 && hard < soft); + if (invalidRange) + { + throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input)); + } + + return {WideToMultiByte(nameValue.Key), soft, hard}; +} + +std::pair ParseLabel(const std::wstring& value) +{ + const auto kv = SplitKeyValue(value); + auto key = WideToMultiByte(kv.Key); + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_LabelKeyEmptyError(), key.empty()); + return {std::move(key), WideToMultiByte(kv.Value)}; +} + +std::pair ParseDriverOption(const std::wstring& value) +{ + const auto kv = SplitKeyValue(value); + return {WideToMultiByte(kv.Key), WideToMultiByte(kv.Value)}; +} + +std::pair ParseFilter(const std::wstring& value) +{ + const auto kv = SplitKeyValue(value); + if (!kv.HadSeparator) + { + throw ArgumentException(Localization::WSLCCLI_InvalidFilterError(value)); + } + + return {WideToMultiByte(kv.Key), WideToMultiByte(kv.Value)}; +} + +// Map of signal names to WSLCSignal enum values +static const std::unordered_map SignalMap = { + {L"SIGHUP", WSLCSignalSIGHUP}, {L"SIGINT", WSLCSignalSIGINT}, {L"SIGQUIT", WSLCSignalSIGQUIT}, + {L"SIGILL", WSLCSignalSIGILL}, {L"SIGTRAP", WSLCSignalSIGTRAP}, {L"SIGABRT", WSLCSignalSIGABRT}, + {L"SIGIOT", WSLCSignalSIGIOT}, {L"SIGBUS", WSLCSignalSIGBUS}, {L"SIGFPE", WSLCSignalSIGFPE}, + {L"SIGKILL", WSLCSignalSIGKILL}, {L"SIGUSR1", WSLCSignalSIGUSR1}, {L"SIGSEGV", WSLCSignalSIGSEGV}, + {L"SIGUSR2", WSLCSignalSIGUSR2}, {L"SIGPIPE", WSLCSignalSIGPIPE}, {L"SIGALRM", WSLCSignalSIGALRM}, + {L"SIGTERM", WSLCSignalSIGTERM}, {L"SIGTKFLT", WSLCSignalSIGTKFLT}, {L"SIGCHLD", WSLCSignalSIGCHLD}, + {L"SIGCONT", WSLCSignalSIGCONT}, {L"SIGSTOP", WSLCSignalSIGSTOP}, {L"SIGTSTP", WSLCSignalSIGTSTP}, + {L"SIGTTIN", WSLCSignalSIGTTIN}, {L"SIGTTOU", WSLCSignalSIGTTOU}, {L"SIGURG", WSLCSignalSIGURG}, + {L"SIGXCPU", WSLCSignalSIGXCPU}, {L"SIGXFSZ", WSLCSignalSIGXFSZ}, {L"SIGVTALRM", WSLCSignalSIGVTALRM}, + {L"SIGPROF", WSLCSignalSIGPROF}, {L"SIGWINCH", WSLCSignalSIGWINCH}, {L"SIGIO", WSLCSignalSIGIO}, + {L"SIGPOLL", WSLCSignalSIGPOLL}, {L"SIGPWR", WSLCSignalSIGPWR}, {L"SIGSYS", WSLCSignalSIGSYS}, +}; + +// Convert string to WSLCSignal enum - accepts either signal name (e.g., "SIGKILL") or number (e.g., "9") +WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName) +{ + constexpr int MIN_SIGNAL = WSLCSignalSIGHUP; + constexpr int MAX_SIGNAL = WSLCSignalSIGSYS; + constexpr std::wstring_view sigPrefix = L"SIG"; + + // Normalize input: ensure it has "SIG" prefix for map lookup + std::wstring normalizedInput; + if (IsEqual(input.substr(0, sigPrefix.size()), sigPrefix, true)) + { + normalizedInput = input; + } + else + { + normalizedInput = std::wstring(sigPrefix) + input; + } + + for (const auto& [signalName, signalValue] : SignalMap) + { + if (IsEqual(normalizedInput, signalName, true)) + { + return signalValue; + } + } + + // User may have input an integer representation instead. + int signalValue{}; + try + { + signalValue = GetIntegerFromString(input, argName); + } + // If it fails to be converted give a better user message than just the integer conversion + // failure since we also know it failed to be found in the map. + catch (ArgumentException) + { + throw ArgumentException(Localization::WSLCCLI_InvalidSignalError(argName, input)); + } + + if (signalValue < MIN_SIGNAL || signalValue > MAX_SIGNAL) + { + throw ArgumentException(Localization::WSLCCLI_SignalOutOfRangeError(argName, input, MIN_SIGNAL, MAX_SIGNAL)); + } + + return static_cast(signalValue); +} + +// Parses an RFC3339 timestamp (e.g. "2024-01-15T10:30:00Z" or "2024-01-15T10:30:00+05:30") +// into a ULONGLONG Unix epoch seconds value using std::chrono::parse. +// Note: +HHMM (no colon) offsets are not supported; use +HH:MM format. +static std::optional TryParseRfc3339(const std::string& input) +{ + std::string normalized = input; + + // Normalize trailing 'Z'/'z' to '+00:00' so %Ez can parse it uniformly. + if (!normalized.empty() && (normalized.back() == 'Z' || normalized.back() == 'z')) + { + normalized.pop_back(); + normalized += "+00:00"; + } + + // Reject bare dot with no fractional digits (e.g. "10:30:00.+00:00") since + // std::chrono::parse is lenient about this. + auto dotPos = normalized.find('.'); + if (dotPos != std::string::npos && (dotPos + 1 >= normalized.size() || !std::isdigit(normalized[dotPos + 1]))) + { + return std::nullopt; + } + + // Pre-validate day-of-month since std::chrono::parse silently wraps invalid dates (e.g. Feb 31 → Mar 2). + if (normalized.size() >= 10 && normalized[4] == '-' && normalized[7] == '-') + { + int year = 0, month = 0, day = 0; + auto yResult = std::from_chars(normalized.data(), normalized.data() + 4, year); + auto mResult = std::from_chars(normalized.data() + 5, normalized.data() + 7, month); + auto dResult = std::from_chars(normalized.data() + 8, normalized.data() + 10, day); + + if (yResult.ec == std::errc() && mResult.ec == std::errc() && dResult.ec == std::errc()) + { + auto ymd = std::chrono::year{year} / std::chrono::month{static_cast(month)} / + std::chrono::day{static_cast(day)}; + if (!ymd.ok()) + { + return std::nullopt; + } + } + } + + // Parse into nanosecond precision so fractional seconds (e.g. ".123456789") are consumed + // by std::chrono::parse rather than requiring manual stripping. + std::chrono::sys_time utcTime; + std::istringstream stream(normalized); + stream >> std::chrono::parse("%FT%T%Ez", utcTime); + if (stream.fail()) + { + return std::nullopt; + } + + // Reject if there are trailing characters after the parsed timestamp + if (stream.peek() != std::istringstream::traits_type::eof()) + { + return std::nullopt; + } + + auto epochSeconds = std::chrono::duration_cast(utcTime.time_since_epoch()).count(); + if (epochSeconds < 0) + { + return std::nullopt; + } + + return static_cast(epochSeconds); +} + +ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName) +{ + std::string narrowValue = wsl::windows::common::string::WideToMultiByte(value); + + // Try integer (Unix epoch seconds) first + ULONGLONG intValue{}; + const char* begin = narrowValue.c_str(); + const char* end = begin + narrowValue.size(); + auto result = std::from_chars(begin, end, intValue); + if (result.ec == std::errc() && result.ptr == end) + { + return intValue; + } + + // Try RFC3339 timestamp + auto rfc3339Value = TryParseRfc3339(narrowValue); + if (rfc3339Value.has_value()) + { + return rfc3339Value.value(); + } + + throw ArgumentException(Localization::WSLCCLI_InvalidTimestampArgumentError(argName, value)); +} + +models::FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName) +{ + if (IsEqual(input, L"json")) + { + return models::FormatType::Json; + } + else if (IsEqual(input, L"table")) + { + return models::FormatType::Table; + } + else + { + throw ArgumentException(std::format( + L"Invalid {} value: {} is not a recognized format type. Supported format types are: json, table.", argName, input)); + } +} + +models::InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName) +{ + if (IsEqual(input, L"image")) + { + return models::InspectType::Image; + } + else if (IsEqual(input, L"container")) + { + return models::InspectType::Container; + } + else if (IsEqual(input, L"network")) + { + return models::InspectType::Network; + } + else if (IsEqual(input, L"volume")) + { + return models::InspectType::Volume; + } + else + { + constexpr std::wstring_view supportedValues = L"image, container, network, volume"; + throw ArgumentException(Localization::WSLCCLI_InvalidInspectError(argName, input, supportedValues)); + } +} + +int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName) +{ + auto parsed = wsl::shared::string::ParseMemorySize(input.c_str()); + if (!parsed.has_value()) + { + throw ArgumentException(Localization::WSLCCLI_InvalidMemorySizeError(argName, input)); + } + + return static_cast(parsed.value()); +} + +// Parses duration string into nanoseconds. +static std::optional TryParseDuration(const std::string& input) +{ + if (input.empty()) + { + return std::nullopt; + } + + size_t pos = 0; + bool negative = false; + if (input[pos] == '+' || input[pos] == '-') + { + negative = input[pos] == '-'; + pos++; + } + + // Special case: a bare "0" (with optional sign) is a valid zero duration. + if (input.substr(pos) == "0") + { + return 0; + } + + // Accumulate in a long double so fractional units (e.g. "1.5h") are handled, then round. + long double totalNanos = 0.0L; + bool sawValue = false; + + while (pos < input.size()) + { + // Parse the numeric part (integer and/or fraction). + const size_t numberStart = pos; + while (pos < input.size() && (std::isdigit(static_cast(input[pos])) || input[pos] == '.')) + { + pos++; + } + + const std::string numberStr = input.substr(numberStart, pos - numberStart); + if (numberStr.empty() || numberStr == "." || std::count(numberStr.begin(), numberStr.end(), '.') > 1) + { + return std::nullopt; + } + + // Parse the unit (everything up to the next digit or '.'). + const size_t unitStart = pos; + while (pos < input.size() && !std::isdigit(static_cast(input[pos])) && input[pos] != '.') + { + pos++; + } + + const std::string unit = input.substr(unitStart, pos - unitStart); + + long double multiplier{}; + if (unit == "ns") + { + multiplier = 1.0L; + } + else if (unit == "us" || unit == "\xC2\xB5s" /* µs (U+00B5) */ || unit == "\xCE\xBCs" /* μs (U+03BC) */) + { + multiplier = 1000L; + } + else if (unit == "ms") + { + multiplier = 1000000L; + } + else if (unit == "s") + { + multiplier = 1000000000L; + } + else if (unit == "m") + { + multiplier = 60000000000L; + } + else if (unit == "h") + { + multiplier = 3600000000000L; + } + else + { + return std::nullopt; + } + + long double value{}; + try + { + auto [ptr, ec] = std::from_chars(numberStr.data(), numberStr.data() + numberStr.size(), value, std::chars_format::fixed); + if (ptr != numberStr.data() + numberStr.size() || ec != std::errc()) + { + return std::nullopt; + } + } + catch (...) + { + return std::nullopt; + } + + totalNanos += value * multiplier; + sawValue = true; + } + + if (!sawValue) + { + return std::nullopt; + } + + if (negative) + { + totalNanos = -totalNanos; + } + + if (totalNanos > static_cast(std::numeric_limits::max()) || + totalNanos < static_cast(std::numeric_limits::min())) + { + return std::nullopt; + } + + return static_cast(std::llroundl(totalNanos)); +} + +int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName) +{ + const std::string narrow = WideToMultiByte(input); + const auto parsed = TryParseDuration(narrow); + + if (!parsed.has_value() || parsed.value() < 0) + { + throw ArgumentException(Localization::WSLCCLI_InvalidDurationError(argName, input)); + } + + return parsed.value(); +} + +int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName) +{ + constexpr double NanosPerCpu = 1'000'000'000.0; + constexpr double MaxCpus = static_cast(std::numeric_limits::max()) / NanosPerCpu; + + const std::string narrow = WideToMultiByte(input); + const char* begin = narrow.c_str(); + const char* end = begin + narrow.size(); + + double cpus{}; + const auto result = std::from_chars(begin, end, cpus, std::chars_format::fixed); + if (result.ec != std::errc() || result.ptr != end || cpus <= 0.0 || cpus > MaxCpus) + { + throw ArgumentException(Localization::WSLCCLI_InvalidCpusError(argName, input)); + } + + return static_cast(cpus * NanosPerCpu); +} + +} // namespace wsl::windows::wslc::validation diff --git a/src/windows/wslc/arguments/SpecParsing.h b/src/windows/wslc/arguments/SpecParsing.h new file mode 100644 index 0000000000..4d60b88738 --- /dev/null +++ b/src/windows/wslc/arguments/SpecParsing.h @@ -0,0 +1,79 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + SpecParsing.h + +Abstract: + + Declarations for parsers that turn delimited command-line spec strings + (e.g. --secret, --ulimit, --label, --filter) into structured values. + +--*/ +#pragma once + +#include "ContainerModel.h" +#include "InspectModel.h" +#include +#include +#include +#include + +namespace wsl::windows::wslc::services { +struct BuildSecret; +} + +namespace wsl::windows::wslc::validation { + +// The two halves of a spec token split at its first separator (default '='). +// HadSeparator distinguishes "key" (no separator) from "key=" (empty value). +struct KeyValueSplit +{ + std::wstring Key; + std::wstring Value; + bool HadSeparator; +}; + +// Splits value at the first occurrence of separator. When no separator is present the whole +// string is returned as Key, Value is empty, and HadSeparator is false. +KeyValueSplit SplitKeyValue(const std::wstring& value, wchar_t separator = L'='); + +// Parses a docker-style --secret spec ("id=...,type=...,src=...") and resolves its value bytes. +services::BuildSecret ParseSecretSpec(const std::wstring& spec); + +// Parses a --ulimit spec ("=[:]") into (name, soft, hard). -1 means unlimited. +std::tuple ParseUlimit(const std::wstring& input, const std::wstring& argName = {}); + +// Parses a --label spec ("key[=value]"); the key must be non-empty. +std::pair ParseLabel(const std::wstring& value); + +// Parses a driver option spec ("key[=value]"); a missing value yields an empty string. +std::pair ParseDriverOption(const std::wstring& value); + +// Parses a --filter spec ("key=value"); the separator is required. +std::pair ParseFilter(const std::wstring& value); + +// Parses a signal by name ("SIGKILL"/"KILL", case-insensitive) or number ("9") into a WSLCSignal. +WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName = {}); + +// Parses a timestamp given as Unix epoch seconds or an RFC3339 string into epoch seconds. +ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName = {}); + +// Parses an output format ("json"/"table") into a FormatType. +models::FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName = {}); + +// Parses an inspect target ("image"/"container"/"network"/"volume") into an InspectType. +models::InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName); + +// Parses a memory size (e.g. "512m", "1g") into a byte count. +int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName = {}); + +// Parses a Go-style duration (e.g. "1.5h", "500ms") into nanoseconds. +int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName = {}); + +// Parses a fractional CPU count into nano-CPUs (cpus * 1e9). +int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName = {}); + +} // namespace wsl::windows::wslc::validation diff --git a/src/windows/wslc/commands/ImageBuildCommand.cpp b/src/windows/wslc/commands/ImageBuildCommand.cpp index 68020f645a..0f452dd840 100644 --- a/src/windows/wslc/commands/ImageBuildCommand.cpp +++ b/src/windows/wslc/commands/ImageBuildCommand.cpp @@ -34,6 +34,7 @@ std::vector ImageBuildCommand::GetArguments() const Argument::Create(ArgType::File), Argument::Create(ArgType::Label, false, NO_LIMIT), Argument::Create(ArgType::NoCache), + Argument::Create(ArgType::Secret, false, NO_LIMIT), Argument::Create(ArgType::Tag, false, NO_LIMIT), Argument::Create(ArgType::Verbose), }; diff --git a/src/windows/wslc/core/EnvironmentOptions.cpp b/src/windows/wslc/core/EnvironmentOptions.cpp index 8e6ace0d7c..72506146a6 100644 --- a/src/windows/wslc/core/EnvironmentOptions.cpp +++ b/src/windows/wslc/core/EnvironmentOptions.cpp @@ -11,23 +11,6 @@ Module Name: #include "EnvironmentOptions.h" namespace wsl::windows::wslc { -namespace { - - // nullopt iff the variable is not defined; engaged (possibly empty) otherwise. - std::optional ReadEnv(const wchar_t* name) - { - std::wstring value; - const HRESULT hr = wil::GetEnvironmentVariableW(name, value); - if (hr == HRESULT_FROM_WIN32(ERROR_ENVVAR_NOT_FOUND)) - { - return std::nullopt; - } - - THROW_IF_FAILED(hr); - return value; - } - -} // namespace void ApplyEnvironmentOptions(argument::ArgMap& target, const std::vector& definedArgs) noexcept try @@ -47,7 +30,7 @@ try continue; } - auto value = ReadEnv(binding.Name); + auto value = wsl::windows::common::wslutil::ReadEnvironmentVariable(binding.Name); if (!value.has_value()) { continue; diff --git a/src/windows/wslc/services/ImageService.cpp b/src/windows/wslc/services/ImageService.cpp index dd1c4e36fa..9d20d0b72e 100644 --- a/src/windows/wslc/services/ImageService.cpp +++ b/src/windows/wslc/services/ImageService.cpp @@ -120,6 +120,7 @@ void ImageService::Build( const std::vector& tags, const std::vector& buildArgs, const std::vector& labels, + const std::vector& secrets, const std::wstring& dockerfilePath, const std::wstring& target, WSLCBuildImageFlags flags, @@ -172,6 +173,23 @@ void ImageService::Build( std::vector labelPointers; toMultiByte(labels, labelStrings, labelPointers); + // Keep narrow-encoded id strings alive for the duration of the COM call. The raw secret bytes are + // referenced in place from the caller's BuildSecret objects (which outlive this call), so they are + // never copied or NUL-truncated. + std::vector secretIdStrings; + std::vector secretEntries; + secretIdStrings.reserve(secrets.size()); + secretEntries.reserve(secrets.size()); + for (const auto& secret : secrets) + { + secretIdStrings.push_back(wsl::windows::common::string::WideToMultiByte(secret.Id)); + secretEntries.push_back(WSLCBuildSecret{ + .Id = secretIdStrings.back().c_str(), + .Value = secret.Value.empty() ? nullptr : secret.Value.data(), + .ValueSize = static_cast(secret.Value.size()), + }); + } + auto targetStr = wsl::windows::common::string::WideToMultiByte(target); auto contextPathStr = absolutePath.wstring(); @@ -183,6 +201,7 @@ void ImageService::Build( .Target = targetStr.empty() ? nullptr : targetStr.c_str(), .Flags = flags, .Labels = {labelPointers.data(), static_cast(labelPointers.size())}, + .Secrets = {secretEntries.data(), static_cast(secretEntries.size())}, }; THROW_IF_FAILED(session.Get()->BuildImage(&options, callback, cancelEvent)); diff --git a/src/windows/wslc/services/ImageService.h b/src/windows/wslc/services/ImageService.h index 2d5fade374..2666df2787 100644 --- a/src/windows/wslc/services/ImageService.h +++ b/src/windows/wslc/services/ImageService.h @@ -19,6 +19,13 @@ Module Name: #include namespace wsl::windows::wslc::services { + +struct BuildSecret +{ + std::wstring Id; // value for docker's --secret id= field + std::vector Value; // raw secret bytes (may contain NULs); materialized into a VM tmpfs file server-side +}; + class ImageService { public: @@ -28,6 +35,7 @@ class ImageService const std::vector& tags, const std::vector& buildArgs, const std::vector& labels, + const std::vector& secrets, const std::wstring& dockerfilePath, const std::wstring& target, WSLCBuildImageFlags flags, diff --git a/src/windows/wslc/tasks/ImageTasks.cpp b/src/windows/wslc/tasks/ImageTasks.cpp index 726e9a2ab8..477ad6c5ff 100644 --- a/src/windows/wslc/tasks/ImageTasks.cpp +++ b/src/windows/wslc/tasks/ImageTasks.cpp @@ -105,6 +105,15 @@ void BuildImage(CLIExecutionContext& context) validation::ParseLabel(label); } + std::vector secrets; + if (context.Args.Contains(ArgType::Secret)) + { + for (const auto& spec : context.Args.GetAll()) + { + secrets.push_back(validation::ParseSecretSpec(spec)); + } + } + std::wstring dockerfilePath; if (context.Args.Contains(ArgType::File)) { @@ -124,7 +133,7 @@ void BuildImage(CLIExecutionContext& context) auto cancelEvent = context.CreateCancelEvent(); BuildImageCallback callback(context.Reporter, cancelEvent, context.Args.Contains(ArgType::Verbose)); - services::ImageService::Build(session, contextPath, tags, buildArgs, labels, dockerfilePath, target, flags, &callback, cancelEvent); + services::ImageService::Build(session, contextPath, tags, buildArgs, labels, secrets, dockerfilePath, target, flags, &callback, cancelEvent); } void GetImages(CLIExecutionContext& context) diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index 6c82d61a48..63317555a6 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -880,6 +880,7 @@ try RETURN_HR_IF(E_INVALIDARG, Options->Tags.Count > 0 && Options->Tags.Values == nullptr); RETURN_HR_IF(E_INVALIDARG, Options->BuildArgs.Count > 0 && Options->BuildArgs.Values == nullptr); RETURN_HR_IF(E_INVALIDARG, Options->Labels.Count > 0 && Options->Labels.Values == nullptr); + RETURN_HR_IF(E_INVALIDARG, Options->Secrets.Count > 0 && Options->Secrets.Values == nullptr); THROW_HR_IF_MSG( E_INVALIDARG, WI_IsAnyFlagSet(static_cast(Options->Flags), ~WSLCBuildImageFlagsValid), @@ -907,12 +908,30 @@ try THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_virtualMachine); - GUID volumeId{}; - THROW_IF_FAILED(CoCreateGuid(&volumeId)); - auto mountPath = std::format("/mnt/{}", wsl::shared::string::GuidToString(volumeId)); - THROW_IF_FAILED(m_virtualMachine->MountWindowsFolder(Options->ContextPath, mountPath.c_str(), TRUE)); - auto unmountFolder = - wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { m_virtualMachine->UnmountWindowsFolder(mountPath.c_str()); }); + // Track every Windows folder we mount into the VM during this build so a single scope_exit + // unmounts them all on success or on any throw partway through the loop below. + std::vector mountedPaths; + auto unmountAll = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { + for (const auto& path : mountedPaths) + { + m_virtualMachine->UnmountWindowsFolder(path.c_str()); + } + }); + auto mountInVm = [&](LPCWSTR windowsPath, BOOL readOnly) -> std::string { + GUID id{}; + THROW_IF_FAILED(CoCreateGuid(&id)); + auto vmPath = std::format("/mnt/{}", wsl::shared::string::GuidToString(id)); + THROW_IF_FAILED(m_virtualMachine->MountWindowsFolder(windowsPath, vmPath.c_str(), readOnly)); + mountedPaths.push_back(std::move(vmPath)); + return mountedPaths.back(); + }; + + // Reserve up front so mountInVm's push_back can never reallocate-and-throw after a successful + // MountWindowsFolder, which would leak a mount the scope_exit hasn't recorded yet. Only the build + // context is mounted; secrets are streamed into a VM tmpfs file (below) and never mount. + mountedPaths.reserve(1); + + auto mountPath = mountInVm(Options->ContextPath, TRUE); std::vector buildArgs{"/usr/bin/docker", "build", "--progress=rawjson"}; if (WI_IsFlagSet(Options->Flags, WSLCBuildImageFlagsNoCache)) @@ -950,6 +969,89 @@ try buildArgs.push_back(Options->Labels.Values[i]); } + // Materialize each secret into a root-only tmpfs file inside the VM and hand docker a file + // reference (id=,src=). The bytes are streamed to a `cat > file` helper's stdin - the + // same relay used to install trusted roots - so arbitrary binary content (embedded NULs, keys, + // certificates of any size) round-trips exactly and stays re-readable across RUN steps, matching + // Docker's type=file semantics, without mounting any host directory into the VM. The value never + // reaches argv/telemetry. + // + // Each build gets its own random subdirectory so concurrent builds (which only hold a shared lock) + // never read or delete one another's secrets; the scope_exit removes just this build's directory. + std::string secretDir; + if (Options->Secrets.Count > 0) + { + GUID dirId{}; + THROW_IF_FAILED(CoCreateGuid(&dirId)); + secretDir = std::format("/run/wslc-secrets/{}", wsl::shared::string::GuidToString(dirId)); + } + auto removeSecrets = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { + if (!secretDir.empty()) + { + ServiceProcessLauncher cleanup("/bin/sh", {"/bin/sh", "--norc", "-c", std::format("rm -rf '{}'", secretDir)}, {}, WSLCProcessFlagsNone); + auto cleanupResult = cleanup.LaunchNoThrow(*m_virtualMachine); + auto& process = std::get<2>(cleanupResult); + if (process) + { + // Best-effort wipe: we cannot recover from within the scope_exit, but a failed + // removal leaves the secret bytes readable in the VM tmpfs for the session lifetime, + // so surface it for diagnosis/security auditing. The rm output carries only error + // text about the directory path, never secret contents, so it is safe to log. + const auto result = process->WaitAndCaptureOutput(60000UL); + if (result.Code != 0) + { + WSL_LOG( + "BuildSecretCleanupFailed", + TraceLoggingLevel(WINEVENT_LEVEL_WARNING), + TraceLoggingValue(result.Code, "ExitCode"), + TraceLoggingValue(cleanup.FormatResult(result).c_str(), "Output")); + } + } + else + { + WSL_LOG( + "BuildSecretCleanupLaunchFailed", + TraceLoggingLevel(WINEVENT_LEVEL_WARNING), + TraceLoggingValue(std::get<0>(cleanupResult), "Result")); + } + } + }); + + for (ULONG i = 0; i < Options->Secrets.Count; i++) + { + const auto& secret = Options->Secrets.Values[i]; + RETURN_HR_IF_NULL(E_INVALIDARG, secret.Id); + RETURN_HR_IF(E_INVALIDARG, secret.Id[0] == '\0'); + RETURN_HR_IF(E_INVALIDARG, secret.Id[0] == '-'); + // Id is interpolated into docker's comma/'='-delimited --secret spec below, so reject any ',' + // or '=' a malicious caller could use to inject extra options. + RETURN_HR_IF(E_INVALIDARG, std::string_view(secret.Id).find_first_of(",=") != std::string_view::npos); + RETURN_HR_IF(E_INVALIDARG, secret.ValueSize != 0 && secret.Value == nullptr); + + // Use a random filename (not the id) so nothing about the id can influence the path, and place + // it in a 0700 directory with a 0600 file (umask 077) so only root inside the VM can read it. + GUID fileId{}; + THROW_IF_FAILED(CoCreateGuid(&fileId)); + auto secretPath = std::format("{}/{}", secretDir, wsl::shared::string::GuidToString(fileId)); + auto script = std::format("umask 077 && mkdir -p '{}' && cat > '{}'", secretDir, secretPath); + + ServiceProcessLauncher writer("/bin/sh", {"/bin/sh", "--norc", "-c", script}, {}, WSLCProcessFlagsStdin); + auto writerProcess = writer.Launch(*m_virtualMachine); + + std::vector bytes; + if (secret.ValueSize > 0) + { + bytes.assign(reinterpret_cast(secret.Value), reinterpret_cast(secret.Value) + secret.ValueSize); + } + std::vector> extraHandles; + extraHandles.emplace_back(std::make_unique(writerProcess.GetStdHandle(WSLCFDStdin), std::move(bytes))); + const auto result = writerProcess.WaitAndCaptureOutput(60000UL, std::move(extraHandles)); + THROW_HR_IF_MSG(E_FAIL, result.Code != 0, "failed to stage build secret: %hs", writer.FormatResult(result).c_str()); + + buildArgs.push_back("--secret"); + buildArgs.push_back(std::format("id={},src={}", secret.Id, secretPath)); + } + buildArgs.push_back("-f"); buildArgs.push_back("-"); buildArgs.push_back(mountPath); diff --git a/test/windows/wslc/WSLCCLIEnvVarParserUnitTests.cpp b/test/windows/wslc/WSLCCLIEnvVarParserUnitTests.cpp index 86aac4a3de..63d2d8c328 100644 --- a/test/windows/wslc/WSLCCLIEnvVarParserUnitTests.cpp +++ b/test/windows/wslc/WSLCCLIEnvVarParserUnitTests.cpp @@ -50,9 +50,7 @@ class WSLCCLIEnvVarParserUnitTests TEST_METHOD(WSLCCLIEnvVarParser_UsesProcessEnvWhenValueMissing) { constexpr const auto key = L"WSLC_TEST_ENV_FROM_PROCESS"; - VERIFY_IS_TRUE(SetEnvironmentVariableW(key, L"process_value")); - - auto cleanup = wil::scope_exit([&] { SetEnvironmentVariableW(key, nullptr); }); + ScopedEnvVariable env(key, L"process_value"); const auto parsed = models::EnvironmentVariable::Parse(key); VERIFY_IS_TRUE(parsed.has_value()); @@ -64,7 +62,7 @@ class WSLCCLIEnvVarParserUnitTests const auto whitespaceOnly = models::EnvironmentVariable::Parse(L" \t "); VERIFY_IS_FALSE(whitespaceOnly.has_value()); - SetEnvironmentVariableA("WSLC_TEST_ENV_UNSET", nullptr); + ScopedEnvVariable env(L"WSLC_TEST_ENV_UNSET"); const auto missingFromProcess = models::EnvironmentVariable::Parse(L"WSLC_TEST_ENV_UNSET"); VERIFY_IS_FALSE(missingFromProcess.has_value()); } @@ -96,9 +94,7 @@ class WSLCCLIEnvVarParserUnitTests TEST_METHOD(WSLCCLIEnvVarParser_ParseFileParsesAndSkipsExpectedLines) { constexpr const auto key = L"WSLC_TEST_ENV_FROM_FILE"; - VERIFY_IS_TRUE(SetEnvironmentVariableW(key, L"file_process_value") == TRUE); - - auto envCleanup = wil::scope_exit([&] { SetEnvironmentVariableW(key, nullptr); }); + ScopedEnvVariable env(key, L"file_process_value"); std::ofstream file(EnvTestFile); VERIFY_IS_TRUE(file.is_open()); @@ -158,9 +154,7 @@ class WSLCCLIEnvVarParserUnitTests TEST_METHOD(WSLCCLIEnvVarParser_UsesProcessEnvWhenValueIsExplicitlyEmpty) { constexpr const auto key = L"WSLC_TEST_ENV_EMPTY_VALUE"; - VERIFY_IS_TRUE(SetEnvironmentVariableW(key, L"")); - - auto cleanup = wil::scope_exit([&] { SetEnvironmentVariableW(key, nullptr); }); + ScopedEnvVariable env(key, L""); const auto parsed = models::EnvironmentVariable::Parse(key); VERIFY_IS_TRUE(parsed.has_value()); diff --git a/test/windows/wslc/WSLCCLISecretParserUnitTests.cpp b/test/windows/wslc/WSLCCLISecretParserUnitTests.cpp new file mode 100644 index 0000000000..56125be3fc --- /dev/null +++ b/test/windows/wslc/WSLCCLISecretParserUnitTests.cpp @@ -0,0 +1,257 @@ +/*++ + +Copyright (c) Microsoft. All rights reserved. + +Module Name: + + WSLCCLISecretParserUnitTests.cpp + +Abstract: + + This file contains unit tests for WSLC CLI --secret spec validation and parsing (validation::ParseSecretSpec). + +--*/ + +#include "precomp.h" +#include "windows/Common.h" +#include "WSLCCLITestHelpers.h" +#include "ArgumentValidation.h" +#include "ImageService.h" +#include "Exceptions.h" +#include +#include +#include +#include + +using namespace wsl::windows::wslc; + +namespace WSLCCLISecretParserUnitTests { + +// RAII helper: writes the given bytes to a uniquely named temp file and deletes it on destruction. +class ScopedTempFile +{ +public: + explicit ScopedTempFile(const std::vector& bytes) + { + const auto dir = std::filesystem::temp_directory_path(); + m_path = + dir / (L"wslc_ut_secret_" + std::to_wstring(GetCurrentProcessId()) + L"_" + std::to_wstring(++s_counter) + L".bin"); + std::ofstream file(m_path, std::ios::binary | std::ios::trunc); + if (!bytes.empty()) + { + file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + } + } + + ~ScopedTempFile() + { + std::error_code ec; + std::filesystem::remove(m_path, ec); + } + + ScopedTempFile(const ScopedTempFile&) = delete; + ScopedTempFile& operator=(const ScopedTempFile&) = delete; + + std::wstring wpath() const + { + return m_path.wstring(); + } + +private: + std::filesystem::path m_path; + static inline int s_counter = 0; +}; + +class WSLCCLISecretParserUnitTests +{ + WSLC_TEST_CLASS(WSLCCLISecretParserUnitTests) + + static std::vector ToBytes(std::string_view text) + { + return std::vector(text.begin(), text.end()); + } + + // Parses a spec expected to be valid and asserts the resolved id and bytes. + static void VerifyValid(const std::wstring& spec, const std::wstring& expectedId, const std::vector& expectedValue) + { + auto secret = validation::ParseSecretSpec(spec); + VERIFY_ARE_EQUAL(expectedId, secret.Id); + VERIFY_ARE_EQUAL(expectedValue.size(), secret.Value.size()); + VERIFY_IS_TRUE(expectedValue == secret.Value); + } + + // Parses a spec expected to be rejected and asserts it throws an ArgumentException whose message is + // the standard "Invalid --secret value '': " wrapper and contains the expected reason. + static void VerifyInvalid(const std::wstring& spec, const std::wstring& expectedReasonSubstr) + { + try + { + (void)validation::ParseSecretSpec(spec); + VERIFY_FAIL(L"Expected ArgumentException for invalid secret spec"); + } + catch (const ArgumentException& ex) + { + const std::wstring& message = ex.Message(); + VERIFY_IS_TRUE(message.find(L"Invalid --secret value") != std::wstring::npos); + VERIFY_IS_TRUE(message.find(expectedReasonSubstr) != std::wstring::npos); + } + } + + // --- Valid: environment-variable backed secrets --- + + TEST_METHOD(Secret_Env_BareIdReadsIdNamedVariable) + { + ScopedEnvVariable env(L"WSLC_UT_SECRET_BARE", L"bare-value"); + VerifyValid(L"id=WSLC_UT_SECRET_BARE", L"WSLC_UT_SECRET_BARE", ToBytes("bare-value")); + } + + TEST_METHOD(Secret_Env_ExplicitEnvName) + { + ScopedEnvVariable env(L"WSLC_UT_SECRET_ENV", L"explicit-env"); + VerifyValid(L"id=my.secret,env=WSLC_UT_SECRET_ENV", L"my.secret", ToBytes("explicit-env")); + } + + TEST_METHOD(Secret_Env_TypeEnvBareSrcIsVariableName) + { + ScopedEnvVariable env(L"WSLC_UT_SECRET_TYPEENV", L"type-env-src"); + VerifyValid(L"id=s,type=env,src=WSLC_UT_SECRET_TYPEENV", L"s", ToBytes("type-env-src")); + } + + TEST_METHOD(Secret_Env_WinsOverSrcWhenBothPresent) + { + ScopedEnvVariable env(L"WSLC_UT_SECRET_ENVWINS", L"env-wins"); + // A non-existent src path is provided but must be ignored because env= takes precedence. + VerifyValid(L"id=s,env=WSLC_UT_SECRET_ENVWINS,src=C:\\wslc-ut\\does-not-exist.txt", L"s", ToBytes("env-wins")); + } + + TEST_METHOD(Secret_Env_ExplicitEnvUnsetYieldsEmptyValue) + { + // Ensure the variable is not set. + ScopedEnvVariable env(L"WSLC_UT_SECRET_EXPLICIT_UNSET"); + VerifyValid(L"id=s,env=WSLC_UT_SECRET_EXPLICIT_UNSET", L"s", {}); + } + + TEST_METHOD(Secret_Env_EmptyVariableYieldsEmptyValue) + { + ScopedEnvVariable env(L"WSLC_UT_SECRET_EMPTY", L""); + VerifyValid(L"id=WSLC_UT_SECRET_EMPTY", L"WSLC_UT_SECRET_EMPTY", {}); + } + + TEST_METHOD(Secret_Env_ValueEncodedAsUtf8) + { + // 'é' (U+00E9) encodes to the two UTF-8 bytes 0xC3 0xA9. + ScopedEnvVariable env(L"WSLC_UT_SECRET_UTF8", L"h\u00e9llo"); + VerifyValid(L"id=WSLC_UT_SECRET_UTF8", L"WSLC_UT_SECRET_UTF8", {0x68, 0xC3, 0xA9, 0x6C, 0x6C, 0x6F}); + } + + TEST_METHOD(Secret_Env_IdAllowedCharacters) + { + ScopedEnvVariable env(L"WSLC_UT_SECRET_IDCHARS", L"ok"); + VerifyValid(L"id=Ab.9_-x,env=WSLC_UT_SECRET_IDCHARS", L"Ab.9_-x", ToBytes("ok")); + } + + // --- Valid: file backed secrets --- + + TEST_METHOD(Secret_File_BareSrcReadsFileBytes) + { + ScopedTempFile file(ToBytes("file-content")); + VerifyValid(L"id=s,src=" + file.wpath(), L"s", ToBytes("file-content")); + } + + TEST_METHOD(Secret_File_TypeFileReadsFileBytes) + { + ScopedTempFile file(ToBytes("typed-file-content")); + VerifyValid(L"id=s,type=file,src=" + file.wpath(), L"s", ToBytes("typed-file-content")); + } + + TEST_METHOD(Secret_File_SourceKeyAlias) + { + ScopedTempFile file(ToBytes("aliased")); + VerifyValid(L"id=s,source=" + file.wpath(), L"s", ToBytes("aliased")); + } + + TEST_METHOD(Secret_File_EmptyFileYieldsEmptyValue) + { + ScopedTempFile file({}); + VerifyValid(L"id=s,src=" + file.wpath(), L"s", {}); + } + + TEST_METHOD(Secret_File_BinaryContentRoundTripsExactly) + { + const std::vector bytes = {0x00, 0x01, 0x02, 0xFF, 0x00, 0x41, 0x00, 0x7F, 0x80}; + ScopedTempFile file(bytes); + VerifyValid(L"id=s,src=" + file.wpath(), L"s", bytes); + } + + // --- Invalid: spec structure --- + + TEST_METHOD(Secret_Invalid_EmptyId) + { + VerifyInvalid(L"id=", L"'id=' is required"); + } + + TEST_METHOD(Secret_Invalid_MissingIdKey) + { + VerifyInvalid(L"env=WSLC_UT_SECRET_ANY", L"'id=' is required"); + } + + TEST_METHOD(Secret_Invalid_PartWithoutEquals) + { + VerifyInvalid(L"id=s,garbage", L"expected key=value pairs separated by ','"); + } + + TEST_METHOD(Secret_Invalid_PartWithLeadingEquals) + { + VerifyInvalid(L"=value", L"expected key=value pairs separated by ','"); + } + + TEST_METHOD(Secret_Invalid_UnsupportedKey) + { + VerifyInvalid(L"id=s,bogus=1", L"unsupported key 'bogus'"); + } + + // --- Invalid: id constraints --- + + TEST_METHOD(Secret_Invalid_IdStartsWithDash) + { + VerifyInvalid(L"id=-secret", L"'id' may not start with '-'"); + } + + TEST_METHOD(Secret_Invalid_IdContainsDisallowedCharacter) + { + VerifyInvalid(L"id=bad$id", L"'id' may only contain letters, digits"); + } + + TEST_METHOD(Secret_Invalid_IdContainsSlash) + { + VerifyInvalid(L"id=a/b", L"'id' may only contain letters, digits"); + } + + // --- Invalid: type constraints --- + + TEST_METHOD(Secret_Invalid_UnsupportedType) + { + VerifyInvalid(L"id=s,type=bogus", L"unsupported secret type 'bogus'"); + } + + TEST_METHOD(Secret_Invalid_TypeFileRequiresSrc) + { + VerifyInvalid(L"id=s,type=file", L"'type=file' requires 'src='"); + } + + // --- Invalid: value resolution --- + + TEST_METHOD(Secret_Invalid_SourceFileMissing) + { + VerifyInvalid(L"id=s,src=C:\\wslc-ut\\definitely-missing-secret-file.txt", L"source file not found or not a regular file"); + } + + TEST_METHOD(Secret_Invalid_BareIdVariableNotSet) + { + // A bare id whose matching environment variable is undefined must be rejected (Docker parity). + ScopedEnvVariable env(L"WSLC_UT_SECRET_BARE_UNSET"); + VerifyInvalid(L"id=WSLC_UT_SECRET_BARE_UNSET", L"environment variable 'WSLC_UT_SECRET_BARE_UNSET' is not set"); + } +}; + +} // namespace WSLCCLISecretParserUnitTests diff --git a/test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp b/test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp index 3ee542c07b..214cb646c5 100644 --- a/test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp +++ b/test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp @@ -55,6 +55,22 @@ class WSLCE2EImageBuildTests }); } + // All secret tests build from this single shared (empty) context directory. The session never + // releases virtiofs shares (see WSLCVirtualMachine::UnmountWindowsFolder), so every distinct + // mounted directory permanently consumes one of a small number of share slots for the session's + // lifetime. Giving each secret test its own context directory would exhaust that budget; reusing + // one path keeps all secret tests to a single shared slot. The per-test Dockerfile and any secret + // source files live under each test's own testRoot and are never mounted (the Dockerfile is + // streamed via -f and file secrets are read client-side). + static std::filesystem::path SharedSecretBuildContext() + { + auto dir = std::filesystem::current_path() / L"wslc-e2e-build-secret-context"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::is_directory(dir)); + return dir; + } + WSLC_TEST_METHOD(WSLCE2E_Image_Build_EmptyContextDirectory_Success) { auto imageCleanup = DeleteImageOnExit(BuiltImage); @@ -261,6 +277,427 @@ class WSLCE2EImageBuildTests VERIFY_ARE_EQUAL(std::string("from-cli"), it->second); } + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_Env_Success) + { + // Set the env var the --secret will reference; ensure cleanup so we don't leak into other tests. + constexpr auto envName = L"WSLC_E2E_SECRET_VALUE"; + constexpr auto envValue = L"expected-secret-content-12345"; + ScopedEnvVariable envVar(envName, envValue); + + auto imageCleanup = DeleteImageOnExit(BuiltImageSecret); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-env"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + + // RUN with type=secret asserts the secret value matches; if mismatched, RUN exits non-zero and the build fails. + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret " + "[ \"$(cat /run/secrets/mysecret)\" = \"expected-secret-content-12345\" ]\n" + "CMD [\"echo\", \"secret-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_SECRET_VALUE", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecret.NameAndTag())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecret.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BareId_UsesEnvNamedById_Success) + { + // Docker parity: '--secret id=NAME' with no env=/src= reads the host env var named NAME. + constexpr auto envName = L"WSLC_E2E_BARE_SECRET"; + constexpr auto envValue = L"bare-id-secret-content-67890"; + ScopedEnvVariable envVar(envName, envValue); + + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretBareId); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-bare-id"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + + // The docker secret id equals the env var name, so the mount reads /run/secrets/. + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=WSLC_E2E_BARE_SECRET " + "[ \"$(cat /run/secrets/WSLC_E2E_BARE_SECRET)\" = \"bare-id-secret-content-67890\" ]\n" + "CMD [\"echo\", \"secret-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret id=WSLC_E2E_BARE_SECRET", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretBareId.NameAndTag())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretBareId.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BareIdUnsetVar_Fails) + { + // Docker parity: '--secret id=NAME' with no env=/src= reads the host env var named NAME, and + // errors when that variable is unset (unlike an explicit 'env=', which yields an empty value). + constexpr auto envName = L"WSLC_E2E_SECRET_BARE_ID_UNSET"; + ScopedEnvVariable envVar(envName); // Clears it (restoring any prior value on exit) so a leaked value can't taint the test. + + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-bare-id-unset"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = testRoot / L"context"; + std::error_code ec; + std::filesystem::create_directories(contextDir, ec); + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir)); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" --secret id=WSLC_E2E_SECRET_BARE_ID_UNSET", contextDir.wstring(), dockerfilePath.wstring())); + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u)); + VERIFY_IS_TRUE(buildResult.Stderr.has_value()); + VERIFY_IS_FALSE(buildResult.Stderr->empty()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_MissingEnvVar_EmptyValue_Success) + { + // Docker parity: an unset environment variable yields an empty secret value, not an error. + constexpr auto envName = L"WSLC_E2E_SECRET_UNSET_VAR"; + ScopedEnvVariable envVar(envName); // Clears it (restoring any prior value on exit) so a leaked value can't taint the test. + + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretMissingEnv); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-missing"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret [ -z \"$(cat /run/secrets/mysecret)\" ]\n" + "CMD [\"echo\", \"secret-empty-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_SECRET_UNSET_VAR", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretMissingEnv.NameAndTag())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretMissingEnv.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_Src_Success) + { + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretSrc); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + std::error_code ec; + + // Place the secret OUTSIDE the build context; the client reads its bytes and the server writes + // them to a tmpfs file inside the VM, so the secret's directory is never mounted. + auto secretDir = testRoot / L"secrets"; + std::filesystem::create_directories(secretDir, ec); + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(secretDir)); + auto secretFile = secretDir / L"token.txt"; + WriteTestFileContent(secretFile, "file-secret-content-67890"); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret " + "[ \"$(cat /run/secrets/mysecret)\" = \"file-secret-content-67890\" ]\n" + "CMD [\"echo\", \"secret-src-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretSrc.NameAndTag(), + secretFile.wstring())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretSrc.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_SrcSymlink_Success) + { + // A symlink whose target lives in a separate directory must resolve to the target's content. + // The client reads the resolved file's bytes and the server writes them to a tmpfs file inside + // the VM, so neither the link's nor the target's directory is ever mounted. + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretSrcSymlink); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src-symlink"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + std::error_code ec; + + auto targetDir = testRoot / L"target"; + std::filesystem::create_directories(targetDir, ec); + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(targetDir)); + auto targetFile = targetDir / L"real-secret.txt"; + WriteTestFileContent(targetFile, "symlinked-secret-content-44444"); + + auto linkDir = testRoot / L"links"; + std::filesystem::create_directories(linkDir, ec); + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(linkDir)); + auto linkFile = linkDir / L"token.txt"; + std::filesystem::create_symlink(targetFile, linkFile); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret " + "[ \"$(cat /run/secrets/mysecret)\" = \"symlinked-secret-content-44444\" ]\n" + "CMD [\"echo\", \"secret-symlink-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretSrcSymlink.NameAndTag(), + linkFile.wstring())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretSrcSymlink.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_SrcFileMissing_Fails) + { + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src-missing"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = testRoot / L"context"; + std::error_code ec; + std::filesystem::create_directories(contextDir, ec); + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir)); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n"); + + auto missingFile = testRoot / L"does-not-exist.txt"; + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" --secret id=x,src=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), missingFile.wstring())); + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u)); + VERIFY_IS_TRUE(buildResult.Stderr.has_value()); + VERIFY_IS_TRUE(buildResult.Stderr->find(L"source file not found or not a regular file") != std::wstring::npos); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_EnvAndSrc_EnvWins_Success) + { + // Docker parity: when both 'env=' and 'src=' are given, the environment variable wins and + // the file path is ignored (no error). + constexpr auto envName = L"WSLC_E2E_ENV_WINS_VALUE"; + constexpr auto envValue = L"env-wins-content-55555"; + ScopedEnvVariable envVar(envName, envValue); + + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretEnvWins); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-both"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + + // The src file holds different content; it must be ignored in favor of the env value. + auto secretFile = testRoot / L"ignored.txt"; + WriteTestFileContent(secretFile, "this-file-should-be-ignored"); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret " + "[ \"$(cat /run/secrets/mysecret)\" = \"env-wins-content-55555\" ]\n" + "CMD [\"echo\", \"secret-env-wins-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_ENV_WINS_VALUE,src=\"{}\"", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretEnvWins.NameAndTag(), + secretFile.wstring())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretEnvWins.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeEnv_Success) + { + constexpr auto envName = L"WSLC_E2E_TYPE_ENV_VALUE"; + constexpr auto envValue = L"type-env-content-11111"; + ScopedEnvVariable envVar(envName, envValue); + + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeEnv); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-env"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret " + "[ \"$(cat /run/secrets/mysecret)\" = \"type-env-content-11111\" ]\n" + "CMD [\"echo\", \"secret-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret type=env,id=mysecret,env=WSLC_E2E_TYPE_ENV_VALUE", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretTypeEnv.NameAndTag())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretTypeEnv.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeEnvSrcIsEnvName_Success) + { + // Docker parity: with type=env, a bare src= names the env var to read (not a file path). + constexpr auto envName = L"WSLC_E2E_TYPE_ENV_SRC_VALUE"; + constexpr auto envValue = L"type-env-src-content-22222"; + ScopedEnvVariable envVar(envName, envValue); + + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeEnvSrc); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-env-src"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret " + "[ \"$(cat /run/secrets/mysecret)\" = \"type-env-src-content-22222\" ]\n" + "CMD [\"echo\", \"secret-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret type=env,id=mysecret,src=WSLC_E2E_TYPE_ENV_SRC_VALUE", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretTypeEnvSrc.NameAndTag())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretTypeEnvSrc.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeFile_Success) + { + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeFile); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-file"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + + auto secretFile = testRoot / L"token.txt"; + WriteTestFileContent(secretFile, "type-file-content-33333"); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret " + "[ \"$(cat /run/secrets/mysecret)\" = \"type-file-content-33333\" ]\n" + "CMD [\"echo\", \"secret-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret type=file,id=mysecret,src=\"{}\"", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretTypeFile.NameAndTag(), + secretFile.wstring())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretTypeFile.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BinaryFile_Success) + { + // A file secret must be delivered byte-for-byte, including an embedded NUL and high bytes that + // an environment-variable (NUL-terminated, text-only) transport could never carry. The content + // below is 13 bytes with a NUL at offset 6; the in-container checks assert both the exact byte + // count (proving no NUL truncation) and that the bytes on either side of the NUL survived. + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretBinary); + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-binary"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = SharedSecretBuildContext(); + + auto secretFile = testRoot / L"blob.bin"; + WriteTestFileContent(secretFile, std::string("before\0after\xff", 13)); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent( + dockerfilePath, + "# syntax=docker/dockerfile:1\n" + "FROM debian:latest\n" + "RUN --mount=type=secret,id=mysecret " + "[ \"$(wc -c < /run/secrets/mysecret)\" = \"13\" ] && " + "[ \"$(tr -d '\\000' < /run/secrets/mysecret | tr -d '\\377')\" = \"beforeafter\" ]\n" + "CMD [\"echo\", \"secret-binary-ok\"]\n"); + + auto buildResult = RunWslc(std::format( + L"build \"{}\" -f \"{}\" -t {} --secret type=file,id=mysecret,src=\"{}\"", + contextDir.wstring(), + dockerfilePath.wstring(), + BuiltImageSecretBinary.NameAndTag(), + secretFile.wstring())); + buildResult.Verify({.ExitCode = 0}); + + auto inspectData = InspectImage(BuiltImageSecretBinary.NameAndTag()); + VERIFY_IS_TRUE(inspectData.RepoTags.has_value()); + } + + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_UnknownType_Fails) + { + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-bad"; + auto cleanup = SetupTestDirectory(testRoot); + + auto contextDir = testRoot / L"context"; + std::error_code ec; + std::filesystem::create_directories(contextDir, ec); + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir)); + + auto dockerfilePath = testRoot / L"Dockerfile"; + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n"); + + auto buildResult = + RunWslc(std::format(L"build \"{}\" -f \"{}\" --secret id=x,type=bogus", contextDir.wstring(), dockerfilePath.wstring())); + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u)); + VERIFY_IS_TRUE(buildResult.Stderr.has_value()); + VERIFY_IS_TRUE(buildResult.Stderr->find(L"Invalid --secret value 'id=x,type=bogus': unsupported secret type 'bogus'") != std::wstring::npos); + } + WSLC_TEST_METHOD(WSLCE2E_Image_Build_DockerfileInContextDir_Success) { auto imageCleanup = DeleteImageOnExit(BuiltImageDockerfile); @@ -373,6 +810,16 @@ class WSLCE2EImageBuildTests const TestImage BuiltImageNoCache{L"wslc-e2e-build-no-cache", L"latest", L""}; const TestImage BuiltImageLabel{L"wslc-e2e-build-label", L"latest", L""}; const TestImage BuiltImageLabelOverride{L"wslc-e2e-build-label-override", L"latest", L""}; + const TestImage BuiltImageSecret{L"wslc-e2e-build-secret-env", L"latest", L""}; + const TestImage BuiltImageSecretBareId{L"wslc-e2e-build-secret-bare-id", L"latest", L""}; + const TestImage BuiltImageSecretMissingEnv{L"wslc-e2e-build-secret-missing-env", L"latest", L""}; + const TestImage BuiltImageSecretEnvWins{L"wslc-e2e-build-secret-env-wins", L"latest", L""}; + const TestImage BuiltImageSecretTypeEnv{L"wslc-e2e-build-secret-type-env", L"latest", L""}; + const TestImage BuiltImageSecretTypeEnvSrc{L"wslc-e2e-build-secret-type-env-src", L"latest", L""}; + const TestImage BuiltImageSecretTypeFile{L"wslc-e2e-build-secret-type-file", L"latest", L""}; + const TestImage BuiltImageSecretSrc{L"wslc-e2e-build-secret-src", L"latest", L""}; + const TestImage BuiltImageSecretSrcSymlink{L"wslc-e2e-build-secret-src-symlink", L"latest", L""}; + const TestImage BuiltImageSecretBinary{L"wslc-e2e-build-secret-binary", L"latest", L""}; void BuildFromContextFile(const std::wstring& fileName, const TestImage& image) {