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)
{