Add wslc build --secret support

Add a --secret flag to `wslc image build` that forwards build secrets to docker/BuildKit. File secrets are materialized in the VM via a per-session host virtiofs mount (no host bytes copied), and env secrets are delivered through BuildKit's env source. Includes spec parsing/validation (ParseSecretSpec in SpecParsing), crash-safe secret-dir sweep and teardown cleanup, and parser + e2e tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

ggarzia-MSFT committed Jul 28, 2026 at 17:22 UTC bd278ebe3aae9cce3ae5680714627e0dd4936a2b
19 files changed +1857 -491
localization/strings/en-US/Resources.resw
+8
@@ -2326,6 +2326,10 @@ For privacy information about this product please visit https://aka.ms/privacy.<
2326 <value>Image '{}' not found.</value>
2327 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
2328 </data>
2329 + <data name="MessageWslcSecretInvalidSpec" xml:space="preserve">
2330 + <value>Invalid --secret value '{}': {}</value>
2331 + <comment>{FixedPlaceholder="{}"}{Locked="--secret "}Command line arguments, file names and string inserts should not be translated</comment>
2332 + </data>
2333 <data name = "MessageWslcMissingVolumeOption" xml:space = "preserve" >
2334 <value>Missing required option: '{}'</value>
2335 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
@@ -2890,6 +2894,10 @@ On first run, creates the file with all settings commented out at their defaults
2894 <data name="WSLCCLI_BuildTargetArgDescription" xml:space="preserve">
2895 <value>Set the target build stage to build</value>
2896 </data>
2897 + <data name="WSLCCLI_SecretArgDescription" xml:space="preserve">
2898 + <value>Expose secret to the build (id=NAME[,type=env|file][,env=VAR|,src=PATH]; defaults to env var NAME)</value>
2899 + <comment>{Locked="id=NAME"}{Locked="type=env|file"}{Locked="env=VAR"}{Locked="src=PATH"}Command line arguments should not be translated</comment>
2900 + </data>
2901 <data name="WSLCCLI_CommandArgDescription" xml:space="preserve">
2902 <value>The command to run</value>
2903 </data>
src/windows/common/wslutil.cpp
+13
@@ -1480,6 +1480,19 @@ void wsl::windows::common::wslutil::PrintMessage(_In_ const std::wstring& messag
1480 fwprintf(stream, L"%ls\n", message.c_str());
1481 }
1482
1483 +std::optional<std::wstring> wsl::windows::common::wslutil::ReadEnvironmentVariable(_In_ LPCWSTR Name)
1484 +{
1485 + std::wstring value;
1486 + const HRESULT hr = wil::GetEnvironmentVariableW(Name, value);
1487 + if (hr == HRESULT_FROM_WIN32(ERROR_ENVVAR_NOT_FOUND))
1488 + {
1489 + return std::nullopt;
1490 + }
1491 +
1492 + THROW_IF_FAILED(hr);
1493 + return value;
1494 +}
1495 +
1496 void wsl::windows::common::wslutil::SetCrtEncoding(int Mode)
1497 {
1498 // Configure the CRT to manipulate text as the specified mode.
src/windows/common/wslutil.h
+4
@@ -348,6 +348,10 @@ void PrintMessage(_In_ const std::wstring& message, _Inout_ FILE* const stream =
348 PrintMessageImpl(message, stream, std::forward<Args>(args)...);
349 }
350
351 +// Reads an environment variable. Returns nullopt iff the variable is not defined; an engaged
352 +// (possibly empty) string otherwise.
353 +std::optional<std::wstring> ReadEnvironmentVariable(_In_ LPCWSTR Name);
354 +
355 void SetCrtEncoding(int Mode);
356
357 void SetThreadDescription(LPCWSTR Name);
src/windows/service/inc/wslc.idl
+23
@@ -169,6 +169,28 @@ typedef struct _WSLCStringArray
169 ULONG Count;
170 } WSLCStringArray;
171
172 +typedef struct _WSLCBuildSecret
173 +{
174 + [string] LPCSTR Id; // Value for docker's --secret id= field.
175 + // For file (src=) secrets: the resolved absolute host path of the secret file. The server mounts the
176 + // file's parent directory into the build VM read-only over virtiofs and references the file in place,
177 + // so the secret bytes are never copied off their original (possibly EFS-encrypted) location. Null for
178 + // env/in-memory secrets.
179 + [string, unique] LPCWSTR SourcePath;
180 + // For env/in-memory secrets: raw secret bytes (never cross into argv). Carried as a counted byte array
181 + // so arbitrary binary content - including embedded NULs - round-trips losslessly; the server writes it
182 + // to a host file exposed to the VM read-only over virtiofs and references it with docker's --secret
183 + // src=. Null for file secrets.
184 + [unique, size_is(ValueSize)] const byte* Value;
185 + ULONG ValueSize;
186 +} WSLCBuildSecret;
187 +
188 +typedef struct _WSLCBuildSecretArray
189 +{
190 + [unique, size_is(Count)] const WSLCBuildSecret* Values;
191 + ULONG Count;
192 +} WSLCBuildSecretArray;
193 +
194 typedef struct _WSLCProcessOptions
195 {
196 [unique] LPCSTR CurrentDirectory;
@@ -526,6 +548,7 @@ typedef struct _WSLCBuildImageOptions
548 LPCSTR Target; // Target build stage name passed as --target to docker.
549 WSLCBuildImageFlags Flags; // WSLCBuildImageFlags
550 WSLCStringArray Labels; // KEY=VALUE pairs passed as --label to docker.
551 + WSLCBuildSecretArray Secrets; // --secret entries; the server writes each secret's bytes to a host file exposed to the VM read-only over virtiofs and emits the corresponding id=...,src=... spec.
552 } WSLCBuildImageOptions;
553
554 typedef struct _WSLCTagImageOptions
src/windows/wslc/arguments/ArgumentDefinitions.h
+1
@@ -110,6 +110,7 @@ _(PublishAll, "publish-all", L"P", Kind::Flag, L
110 _(Quiet, "quiet", L"q", Kind::Flag, Localization::WSLCCLI_QuietArgDescription()) \
111 _(Remove, "rm", NO_ALIAS, Kind::Flag, Localization::WSLCCLI_RemoveArgDescription()) \
112 /*_(Scheme, "scheme", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SchemeArgDescription())*/ \
113 +_(Secret, "secret", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SecretArgDescription()) \
114 _(Server, "server", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_LoginServerArgDescription()) \
115 _(Session, "session", NO_ALIAS, Kind::Value, Localization::WSLCCLI_SessionIdArgDescription()) \
116 _(ShmSize, "shm-size", NO_ALIAS, Kind::Value, Localization::WSLCCLI_ShmSizeArgDescription()) \
src/windows/wslc/arguments/ArgumentValidation.cpp
+10 -442
@@ -18,14 +18,9 @@ Abstract:
18 #include "ArgumentValidation.h"
19 #include "ContainerModel.h"
20 #include "Exceptions.h"
21 +#include "ImageService.h"
22 #include "Localization.h"
23 #include <algorithm>
23 -#include <charconv>
24 -#include <chrono>
25 -#include <cmath>
26 -#include <format>
27 -#include <sstream>
28 -#include <unordered_map>
24 #include <wslc.h>
25
26 using namespace wsl::windows::common;
@@ -104,6 +99,15 @@ void Argument::Validate(const ArgMap& execArgs) const
99 validation::ValidateIntegerFromString<LONGLONG>(execArgs.GetAll<ArgType::Time>(), m_name);
100 break;
101
102 + case ArgType::Secret:
103 + {
104 + for (const auto& spec : execArgs.GetAll<ArgType::Secret>())
105 + {
106 + std::ignore = validation::ParseSecretSpec(spec);
107 + }
108 + break;
109 + }
110 +
111 case ArgType::Since:
112 validation::ValidateTimestamp(execArgs.GetAll<ArgType::Since>(), m_name);
113 break;
@@ -178,21 +182,6 @@ void Argument::Validate(const ArgMap& execArgs) const
182
183 namespace wsl::windows::wslc::validation {
184
181 -// Map of signal names to WSLCSignal enum values
182 -static const std::unordered_map<std::wstring, WSLCSignal> SignalMap = {
183 - {L"SIGHUP", WSLCSignalSIGHUP}, {L"SIGINT", WSLCSignalSIGINT}, {L"SIGQUIT", WSLCSignalSIGQUIT},
184 - {L"SIGILL", WSLCSignalSIGILL}, {L"SIGTRAP", WSLCSignalSIGTRAP}, {L"SIGABRT", WSLCSignalSIGABRT},
185 - {L"SIGIOT", WSLCSignalSIGIOT}, {L"SIGBUS", WSLCSignalSIGBUS}, {L"SIGFPE", WSLCSignalSIGFPE},
186 - {L"SIGKILL", WSLCSignalSIGKILL}, {L"SIGUSR1", WSLCSignalSIGUSR1}, {L"SIGSEGV", WSLCSignalSIGSEGV},
187 - {L"SIGUSR2", WSLCSignalSIGUSR2}, {L"SIGPIPE", WSLCSignalSIGPIPE}, {L"SIGALRM", WSLCSignalSIGALRM},
188 - {L"SIGTERM", WSLCSignalSIGTERM}, {L"SIGTKFLT", WSLCSignalSIGTKFLT}, {L"SIGCHLD", WSLCSignalSIGCHLD},
189 - {L"SIGCONT", WSLCSignalSIGCONT}, {L"SIGSTOP", WSLCSignalSIGSTOP}, {L"SIGTSTP", WSLCSignalSIGTSTP},
190 - {L"SIGTTIN", WSLCSignalSIGTTIN}, {L"SIGTTOU", WSLCSignalSIGTTOU}, {L"SIGURG", WSLCSignalSIGURG},
191 - {L"SIGXCPU", WSLCSignalSIGXCPU}, {L"SIGXFSZ", WSLCSignalSIGXFSZ}, {L"SIGVTALRM", WSLCSignalSIGVTALRM},
192 - {L"SIGPROF", WSLCSignalSIGPROF}, {L"SIGWINCH", WSLCSignalSIGWINCH}, {L"SIGIO", WSLCSignalSIGIO},
193 - {L"SIGPOLL", WSLCSignalSIGPOLL}, {L"SIGPWR", WSLCSignalSIGPWR}, {L"SIGSYS", WSLCSignalSIGSYS},
194 -};
195 -
185 void ValidateWSLCSignalFromString(const std::vector<std::wstring>& values, const std::wstring& argName)
186 {
187 for (const auto& value : values)
@@ -219,119 +208,6 @@ void ValidateFilter(const std::vector<std::wstring>& values)
208 }
209 }
210
222 -// Convert string to WSLCSignal enum - accepts either signal name (e.g., "SIGKILL") or number (e.g., "9")
223 -WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName)
224 -{
225 - constexpr int MIN_SIGNAL = WSLCSignalSIGHUP;
226 - constexpr int MAX_SIGNAL = WSLCSignalSIGSYS;
227 - constexpr std::wstring_view sigPrefix = L"SIG";
228 -
229 - // Normalize input: ensure it has "SIG" prefix for map lookup
230 - std::wstring normalizedInput;
231 - if (IsEqual(input.substr(0, sigPrefix.size()), sigPrefix, true))
232 - {
233 - normalizedInput = input;
234 - }
235 - else
236 - {
237 - normalizedInput = std::wstring(sigPrefix) + input;
238 - }
239 -
240 - for (const auto& [signalName, signalValue] : SignalMap)
241 - {
242 - if (IsEqual(normalizedInput, signalName, true))
243 - {
244 - return signalValue;
245 - }
246 - }
247 -
248 - // User may have input an integer representation instead.
249 - int signalValue{};
250 - try
251 - {
252 - signalValue = GetIntegerFromString<int>(input, argName);
253 - }
254 - // If it fails to be converted give a better user message than just the integer conversion
255 - // failure since we also know it failed to be found in the map.
256 - catch (ArgumentException)
257 - {
258 - throw ArgumentException(Localization::WSLCCLI_InvalidSignalError(argName, input));
259 - }
260 -
261 - if (signalValue < MIN_SIGNAL || signalValue > MAX_SIGNAL)
262 - {
263 - throw ArgumentException(Localization::WSLCCLI_SignalOutOfRangeError(argName, input, MIN_SIGNAL, MAX_SIGNAL));
264 - }
265 -
266 - return static_cast<WSLCSignal>(signalValue);
267 -}
268 -
269 -// Parses an RFC3339 timestamp (e.g. "2024-01-15T10:30:00Z" or "2024-01-15T10:30:00+05:30")
270 -// into a ULONGLONG Unix epoch seconds value using std::chrono::parse.
271 -// Note: +HHMM (no colon) offsets are not supported; use +HH:MM format.
272 -static std::optional<ULONGLONG> TryParseRfc3339(const std::string& input)
273 -{
274 - std::string normalized = input;
275 -
276 - // Normalize trailing 'Z'/'z' to '+00:00' so %Ez can parse it uniformly.
277 - if (!normalized.empty() && (normalized.back() == 'Z' || normalized.back() == 'z'))
278 - {
279 - normalized.pop_back();
280 - normalized += "+00:00";
281 - }
282 -
283 - // Reject bare dot with no fractional digits (e.g. "10:30:00.+00:00") since
284 - // std::chrono::parse is lenient about this.
285 - auto dotPos = normalized.find('.');
286 - if (dotPos != std::string::npos && (dotPos + 1 >= normalized.size() || !std::isdigit(normalized[dotPos + 1])))
287 - {
288 - return std::nullopt;
289 - }
290 -
291 - // Pre-validate day-of-month since std::chrono::parse silently wraps invalid dates (e.g. Feb 31 → Mar 2).
292 - if (normalized.size() >= 10 && normalized[4] == '-' && normalized[7] == '-')
293 - {
294 - int year = 0, month = 0, day = 0;
295 - auto yResult = std::from_chars(normalized.data(), normalized.data() + 4, year);
296 - auto mResult = std::from_chars(normalized.data() + 5, normalized.data() + 7, month);
297 - auto dResult = std::from_chars(normalized.data() + 8, normalized.data() + 10, day);
298 -
299 - if (yResult.ec == std::errc() && mResult.ec == std::errc() && dResult.ec == std::errc())
300 - {
301 - auto ymd = std::chrono::year{year} / std::chrono::month{static_cast<unsigned>(month)} /
302 - std::chrono::day{static_cast<unsigned>(day)};
303 - if (!ymd.ok())
304 - {
305 - return std::nullopt;
306 - }
307 - }
308 - }
309 -
310 - // Parse into nanosecond precision so fractional seconds (e.g. ".123456789") are consumed
311 - // by std::chrono::parse rather than requiring manual stripping.
312 - std::chrono::sys_time<std::chrono::nanoseconds> utcTime;
313 - std::istringstream stream(normalized);
314 - stream >> std::chrono::parse("%FT%T%Ez", utcTime);
315 - if (stream.fail())
316 - {
317 - return std::nullopt;
318 - }
319 -
320 - // Reject if there are trailing characters after the parsed timestamp
321 - if (stream.peek() != std::istringstream::traits_type::eof())
322 - {
323 - return std::nullopt;
324 - }
325 -
326 - auto epochSeconds = std::chrono::duration_cast<std::chrono::seconds>(utcTime.time_since_epoch()).count();
327 - if (epochSeconds < 0)
328 - {
329 - return std::nullopt;
330 - }
331 -
332 - return static_cast<ULONGLONG>(epochSeconds);
333 -}
334 -
211 void ValidateTimestamp(const std::vector<std::wstring>& values, const std::wstring& argName)
212 {
213 for (const auto& value : values)
@@ -340,30 +216,6 @@ void ValidateTimestamp(const std::vector<std::wstring>& values, const std::wstri
216 }
217 }
218
343 -ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName)
344 -{
345 - std::string narrowValue = wsl::windows::common::string::WideToMultiByte(value);
346 -
347 - // Try integer (Unix epoch seconds) first
348 - ULONGLONG intValue{};
349 - const char* begin = narrowValue.c_str();
350 - const char* end = begin + narrowValue.size();
351 - auto result = std::from_chars(begin, end, intValue);
352 - if (result.ec == std::errc() && result.ptr == end)
353 - {
354 - return intValue;
355 - }
356 -
357 - // Try RFC3339 timestamp
358 - auto rfc3339Value = TryParseRfc3339(narrowValue);
359 - if (rfc3339Value.has_value())
360 - {
361 - return rfc3339Value.value();
362 - }
363 -
364 - throw ArgumentException(Localization::WSLCCLI_InvalidTimestampArgumentError(argName, value));
365 -}
366 -
219 void ValidateFormatTypeFromString(const std::vector<std::wstring>& values, const std::wstring& argName)
220 {
221 for (const auto& value : values)
@@ -372,48 +224,6 @@ void ValidateFormatTypeFromString(const std::vector<std::wstring>& values, const
224 }
225 }
226
375 -FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName)
376 -{
377 - if (IsEqual(input, L"json"))
378 - {
379 - return FormatType::Json;
380 - }
381 - else if (IsEqual(input, L"table"))
382 - {
383 - return FormatType::Table;
384 - }
385 - else
386 - {
387 - throw ArgumentException(std::format(
388 - L"Invalid {} value: {} is not a recognized format type. Supported format types are: json, table.", argName, input));
389 - }
390 -}
391 -
392 -InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName)
393 -{
394 - if (IsEqual(input, L"image"))
395 - {
396 - return InspectType::Image;
397 - }
398 - else if (IsEqual(input, L"container"))
399 - {
400 - return InspectType::Container;
401 - }
402 - else if (IsEqual(input, L"network"))
403 - {
404 - return InspectType::Network;
405 - }
406 - else if (IsEqual(input, L"volume"))
407 - {
408 - return InspectType::Volume;
409 - }
410 - else
411 - {
412 - constexpr std::wstring_view supportedValues = L"image, container, network, volume";
413 - throw ArgumentException(Localization::WSLCCLI_InvalidInspectError(argName, input, supportedValues));
414 - }
415 -}
416 -
227 void ValidateGpus(const std::vector<std::wstring>& values, const std::wstring& argName)
228 {
229 for (const auto& value : values)
@@ -433,134 +243,6 @@ void ValidateMemorySize(const std::vector<std::wstring>& values, const std::wstr
243 }
244 }
245
436 -int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName)
437 -{
438 - auto parsed = wsl::shared::string::ParseMemorySize(input.c_str());
439 - if (!parsed.has_value())
440 - {
441 - throw ArgumentException(Localization::WSLCCLI_InvalidMemorySizeError(argName, input));
442 - }
443 -
444 - return static_cast<int64_t>(parsed.value());
445 -}
446 -
447 -// Parses duration string into nanoseconds.
448 -static std::optional<int64_t> TryParseDuration(const std::string& input)
449 -{
450 - if (input.empty())
451 - {
452 - return std::nullopt;
453 - }
454 -
455 - size_t pos = 0;
456 - bool negative = false;
457 - if (input[pos] == '+' || input[pos] == '-')
458 - {
459 - negative = input[pos] == '-';
460 - pos++;
461 - }
462 -
463 - // Special case: a bare "0" (with optional sign) is a valid zero duration.
464 - if (input.substr(pos) == "0")
465 - {
466 - return 0;
467 - }
468 -
469 - // Accumulate in a long double so fractional units (e.g. "1.5h") are handled, then round.
470 - long double totalNanos = 0.0L;
471 - bool sawValue = false;
472 -
473 - while (pos < input.size())
474 - {
475 - // Parse the numeric part (integer and/or fraction).
476 - const size_t numberStart = pos;
477 - while (pos < input.size() && (std::isdigit(static_cast<unsigned char>(input[pos])) || input[pos] == '.'))
478 - {
479 - pos++;
480 - }
481 -
482 - const std::string numberStr = input.substr(numberStart, pos - numberStart);
483 - if (numberStr.empty() || numberStr == "." || std::count(numberStr.begin(), numberStr.end(), '.') > 1)
484 - {
485 - return std::nullopt;
486 - }
487 -
488 - // Parse the unit (everything up to the next digit or '.').
489 - const size_t unitStart = pos;
490 - while (pos < input.size() && !std::isdigit(static_cast<unsigned char>(input[pos])) && input[pos] != '.')
491 - {
492 - pos++;
493 - }
494 -
495 - const std::string unit = input.substr(unitStart, pos - unitStart);
496 -
497 - long double multiplier{};
498 - if (unit == "ns")
499 - {
500 - multiplier = 1.0L;
501 - }
502 - else if (unit == "us" || unit == "\xC2\xB5s" /* µs (U+00B5) */ || unit == "\xCE\xBCs" /* μs (U+03BC) */)
503 - {
504 - multiplier = 1000L;
505 - }
506 - else if (unit == "ms")
507 - {
508 - multiplier = 1000000L;
509 - }
510 - else if (unit == "s")
511 - {
512 - multiplier = 1000000000L;
513 - }
514 - else if (unit == "m")
515 - {
516 - multiplier = 60000000000L;
517 - }
518 - else if (unit == "h")
519 - {
520 - multiplier = 3600000000000L;
521 - }
522 - else
523 - {
524 - return std::nullopt;
525 - }
526 -
527 - long double value{};
528 - try
529 - {
530 - auto [ptr, ec] = std::from_chars(numberStr.data(), numberStr.data() + numberStr.size(), value, std::chars_format::fixed);
531 - if (ptr != numberStr.data() + numberStr.size() || ec != std::errc())
532 - {
533 - return std::nullopt;
534 - }
535 - }
536 - catch (...)
537 - {
538 - return std::nullopt;
539 - }
540 -
541 - totalNanos += value * multiplier;
542 - sawValue = true;
543 - }
544 -
545 - if (!sawValue)
546 - {
547 - return std::nullopt;
548 - }
549 -
550 - if (negative)
551 - {
552 - totalNanos = -totalNanos;
553 - }
554 -
555 - if (totalNanos > static_cast<long double>(std::numeric_limits<int64_t>::max()) ||
556 - totalNanos < static_cast<long double>(std::numeric_limits<int64_t>::min()))
557 - {
558 - return std::nullopt;
559 - }
560 -
561 - return static_cast<int64_t>(std::llroundl(totalNanos));
562 -}
563 -
246 void ValidateDuration(const std::vector<std::wstring>& values, const std::wstring& argName)
247 {
248 for (const auto& value : values)
@@ -569,19 +251,6 @@ void ValidateDuration(const std::vector<std::wstring>& values, const std::wstrin
251 }
252 }
253
572 -int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName)
573 -{
574 - const std::string narrow = WideToMultiByte(input);
575 - const auto parsed = TryParseDuration(narrow);
576 -
577 - if (!parsed.has_value() || parsed.value() < 0)
578 - {
579 - throw ArgumentException(Localization::WSLCCLI_InvalidDurationError(argName, input));
580 - }
581 -
582 - return parsed.value();
583 -}
584 -
254 void ValidateNanoCpus(const std::vector<std::wstring>& values, const std::wstring& argName)
255 {
256 for (const auto& value : values)
@@ -590,25 +259,6 @@ void ValidateNanoCpus(const std::vector<std::wstring>& values, const std::wstrin
259 }
260 }
261
593 -int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName)
594 -{
595 - constexpr double NanosPerCpu = 1'000'000'000.0;
596 - constexpr double MaxCpus = static_cast<double>(std::numeric_limits<int64_t>::max()) / NanosPerCpu;
597 -
598 - const std::string narrow = WideToMultiByte(input);
599 - const char* begin = narrow.c_str();
600 - const char* end = begin + narrow.size();
601 -
602 - double cpus{};
603 - const auto result = std::from_chars(begin, end, cpus, std::chars_format::fixed);
604 - if (result.ec != std::errc() || result.ptr != end || cpus <= 0.0 || cpus > MaxCpus)
605 - {
606 - throw ArgumentException(Localization::WSLCCLI_InvalidCpusError(argName, input));
607 - }
608 -
609 - return static_cast<int64_t>(cpus * NanosPerCpu);
610 -}
611 -
262 void ValidateUlimit(const std::vector<std::wstring>& values, const std::wstring& argName)
263 {
264 for (const auto& value : values)
@@ -617,86 +267,4 @@ void ValidateUlimit(const std::vector<std::wstring>& values, const std::wstring&
267 }
268 }
269
620 -std::tuple<std::string, int64_t, int64_t> ParseUlimit(const std::wstring& input, const std::wstring& argName)
621 -{
622 - // Accepts <name>=<soft>[:<hard>]; if hard is omitted hard = soft. -1 means unlimited.
623 - const auto equalsPos = input.find(L'=');
624 - if (equalsPos == std::wstring::npos || equalsPos == 0)
625 - {
626 - throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input));
627 - }
628 -
629 - const std::wstring valuesPart = input.substr(equalsPos + 1);
630 - const auto colonPos = valuesPart.find(L':');
631 -
632 - auto parseLimit = [&](const std::wstring& limitStr) -> int64_t {
633 - if (limitStr.empty())
634 - {
635 - throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input));
636 - }
637 -
638 - try
639 - {
640 - return GetIntegerFromString<int64_t>(limitStr, argName, [](int64_t v) { return v >= -1; });
641 - }
642 - catch (const ArgumentException&)
643 - {
644 - // Re-throw with the ulimit-specific error message so the user sees the full input.
645 - throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input));
646 - }
647 - };
648 -
649 - const int64_t soft = parseLimit(colonPos == std::wstring::npos ? valuesPart : valuesPart.substr(0, colonPos));
650 - const int64_t hard = colonPos == std::wstring::npos ? soft : parseLimit(valuesPart.substr(colonPos + 1));
651 -
652 - // This rejects "-1:1024" and "-1:<finite>" while allowing "<finite>:-1", "-1:-1", and "-1".
653 - const bool invalidRange = (soft == -1) ? (hard != -1) : (hard != -1 && hard < soft);
654 - if (invalidRange)
655 - {
656 - throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input));
657 - }
658 -
659 - return {WideToMultiByte(input.substr(0, equalsPos)), soft, hard};
660 -}
661 -
662 -std::pair<std::string, std::string> ParseLabel(const std::wstring& value)
663 -{
664 - std::pair<std::string, std::string> result{};
665 - auto pos = value.find('=');
666 - if (pos == std::wstring::npos)
667 - {
668 - result.first = WideToMultiByte(value);
669 - }
670 - else
671 - {
672 - result.first = WideToMultiByte(value.substr(0, pos));
673 - result.second = WideToMultiByte(value.substr(pos + 1));
674 - }
675 -
676 - THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_LabelKeyEmptyError(), result.first.empty());
677 - return result;
678 -}
679 -
680 -std::pair<std::string, std::string> ParseDriverOption(const std::wstring& value)
681 -{
682 - auto pos = value.find('=');
683 - if (pos == std::wstring::npos)
684 - {
685 - return {WideToMultiByte(value), std::string{}};
686 - }
687 -
688 - return {WideToMultiByte(value.substr(0, pos)), WideToMultiByte(value.substr(pos + 1))};
689 -}
690 -
691 -std::pair<std::string, std::string> ParseFilter(const std::wstring& value)
692 -{
693 - auto pos = value.find(L'=');
694 - if (pos == std::wstring::npos)
695 - {
696 - throw ArgumentException(Localization::WSLCCLI_InvalidFilterError(value));
697 - }
698 -
699 - return {WideToMultiByte(value.substr(0, pos)), WideToMultiByte(value.substr(pos + 1))};
700 -}
701 -
270 } // namespace wsl::windows::wslc::validation
src/windows/wslc/arguments/ArgumentValidation.h
+2 -13
@@ -16,6 +16,7 @@ Abstract:
16 #include "Exceptions.h"
17 #include "ContainerModel.h"
18 #include "InspectModel.h"
19 +#include "SpecParsing.h"
20 #include <string>
21 #include <tuple>
22 #include <vector>
@@ -61,33 +62,21 @@ T GetIntegerFromString(
62 }
63
64 void ValidateWSLCSignalFromString(const std::vector<std::wstring>& values, const std::wstring& argName);
64 -WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName = {});
65
66 void ValidateMemorySize(const std::vector<std::wstring>& values, const std::wstring& argName);
67 -int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName = {});
67
68 void ValidateDuration(const std::vector<std::wstring>& values, const std::wstring& argName);
70 -int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName = {});
69
70 void ValidateTimestamp(const std::vector<std::wstring>& values, const std::wstring& argName);
73 -ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName = {});
71 void ValidateNanoCpus(const std::vector<std::wstring>& values, const std::wstring& argName);
75 -int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName = {});
72
73 void ValidateUlimit(const std::vector<std::wstring>& values, const std::wstring& argName);
78 -std::tuple<std::string, int64_t, int64_t> ParseUlimit(const std::wstring& input, const std::wstring& argName = {});
74
75 void ValidateFormatTypeFromString(const std::vector<std::wstring>& values, const std::wstring& argName);
81 -FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName = {});
82 -
83 -InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName);
76
77 void ValidateGpus(const std::vector<std::wstring>& values, const std::wstring& argName);
78 +
79 void ValidateVolumeMount(const std::vector<std::wstring>& values);
80 void ValidateFilter(const std::vector<std::wstring>& values);
81
89 -std::pair<std::string, std::string> ParseLabel(const std::wstring& value);
90 -std::pair<std::string, std::string> ParseDriverOption(const std::wstring& value);
91 -std::pair<std::string, std::string> ParseFilter(const std::wstring& value);
92 -
82 } // namespace wsl::windows::wslc::validation
src/windows/wslc/arguments/SpecParsing.cpp new
+615
@@ -0,0 +1,615 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + SpecParsing.cpp
8 +
9 +Abstract:
10 +
11 + Parsers that turn delimited command-line spec strings (e.g. --secret,
12 + --ulimit, --label, --filter) into structured values. These share the
13 + SplitKeyValue helper for consistent key/value splitting.
14 +
15 +--*/
16 +
17 +#include "precomp.h"
18 +#include "SpecParsing.h"
19 +#include "ArgumentValidation.h"
20 +#include "Exceptions.h"
21 +#include "ImageService.h"
22 +#include "Localization.h"
23 +#include <algorithm>
24 +#include <charconv>
25 +#include <chrono>
26 +#include <cmath>
27 +#include <filesystem>
28 +#include <format>
29 +#include <limits>
30 +#include <optional>
31 +#include <sstream>
32 +#include <unordered_map>
33 +#include <wslc.h>
34 +
35 +using namespace wsl::windows::common;
36 +using namespace wsl::shared;
37 +using namespace wsl::shared::string;
38 +
39 +namespace wsl::windows::wslc::validation {
40 +
41 +KeyValueSplit SplitKeyValue(const std::wstring& value, wchar_t separator)
42 +{
43 + const auto pos = value.find(separator);
44 + if (pos == std::wstring::npos)
45 + {
46 + return {value, std::wstring{}, false};
47 + }
48 +
49 + return {value.substr(0, pos), value.substr(pos + 1), true};
50 +}
51 +
52 +services::BuildSecret ParseSecretSpec(const std::wstring& spec)
53 +{
54 + std::wstring id;
55 + std::wstring type;
56 + std::wstring envName;
57 + std::wstring srcPath;
58 +
59 + for (const auto& part : Split(spec, L','))
60 + {
61 + const auto kv = SplitKeyValue(part);
62 + if (!kv.HadSeparator || kv.Key.empty())
63 + {
64 + throw ArgumentException(
65 + Localization::MessageWslcSecretInvalidSpec(spec, L"expected key=value pairs separated by ','"));
66 + }
67 + const auto& key = kv.Key;
68 + const auto& value = kv.Value;
69 +
70 + if (key == L"id")
71 + {
72 + id = value;
73 + }
74 + else if (key == L"type")
75 + {
76 + type = value;
77 + }
78 + else if (key == L"env")
79 + {
80 + envName = value;
81 + }
82 + else if (key == L"src" || key == L"source")
83 + {
84 + srcPath = value;
85 + }
86 + else
87 + {
88 + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported key '{}'", key)));
89 + }
90 + }
91 +
92 + if (id.empty())
93 + {
94 + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'id=' is required"));
95 + }
96 +
97 + // Docker parity: 'id' may not start with '-' because that would be interpreted as a command-line option.
98 + if (id[0] == L'-')
99 + {
100 + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may not start with '-'"));
101 + }
102 +
103 + // The id is forwarded into docker's comma/'='-delimited --secret spec, so reject any character
104 + // that could break out of the id= field and inject additional options (e.g. ",src=/etc/passwd").
105 + for (auto ch : id)
106 + {
107 + const bool allowed = (ch >= L'a' && ch <= L'z') || (ch >= L'A' && ch <= L'Z') || (ch >= L'0' && ch <= L'9') ||
108 + ch == L'_' || ch == L'-' || ch == L'.';
109 + if (!allowed)
110 + {
111 + throw ArgumentException(
112 + Localization::MessageWslcSecretInvalidSpec(spec, L"'id' may only contain letters, digits, '_', '-' or '.'"));
113 + }
114 + }
115 +
116 + if (!type.empty() && type != L"file" && type != L"env")
117 + {
118 + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"unsupported secret type '{}'", type)));
119 + }
120 +
121 + // Docker parity: 'type=file' names a source file, so it requires 'src='. Without it we would
122 + // otherwise fall through to reading an environment variable, silently contradicting the type.
123 + if (type == L"file" && srcPath.empty())
124 + {
125 + throw ArgumentException(Localization::MessageWslcSecretInvalidSpec(spec, L"'type=file' requires 'src='"));
126 + }
127 +
128 + // Docker parity: with 'type=env', a bare 'src=' names the environment variable to read (rather
129 + // than a file path), unless an explicit 'env=' was also given.
130 + if (type == L"env" && envName.empty() && !srcPath.empty())
131 + {
132 + envName = std::move(srcPath);
133 + srcPath.clear();
134 + }
135 +
136 + if (!envName.empty() && !srcPath.empty())
137 + {
138 + // Docker parity: 'env=' and 'src=' are not mutually exclusive; when both are given the
139 + // environment variable wins and the file path is ignored.
140 + srcPath.clear();
141 + }
142 + if (envName.empty() && srcPath.empty())
143 + {
144 + // Docker parity: with neither 'env=' nor 'src=', the secret value is read from the host
145 + // environment variable whose name matches the id. Unlike an explicit 'env=', that variable
146 + // must be set - Docker errors when the id-named variable is undefined.
147 + envName = id;
148 + if (!wsl::windows::common::wslutil::ReadEnvironmentVariable(envName.c_str()).has_value())
149 + {
150 + throw ArgumentException(
151 + Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"environment variable '{}' is not set", envName)));
152 + }
153 + }
154 +
155 + if (!srcPath.empty())
156 + {
157 + std::error_code ec;
158 + // Normalize to an absolute path (the service requires one to mount the file's directory) but do
159 + // not verify the file exists or is a regular file here: that would be a TOCTOU race with the
160 + // build, and the file may only be reachable from the service's context. Let the service/BuildKit
161 + // reject an unmountable or unreadable file instead. weakly_canonical resolves a relative path
162 + // against the current directory, collapses '..', and resolves symlinks for the portion of the
163 + // path that exists; it succeeds for a missing file but still reports genuine errors.
164 + auto absPath = std::filesystem::weakly_canonical(srcPath, ec);
165 + if (ec.value() != 0)
166 + {
167 + throw ArgumentException(
168 + Localization::MessageWslcSecretInvalidSpec(spec, std::format(L"could not resolve source path: {}", srcPath)));
169 + }
170 +
171 + // Forward the resolved path rather than the bytes: the server mounts the file's parent directory
172 + // into the build VM read-only and references the file in place with docker's --secret src=, so
173 + // the secret is never copied off its original (possibly EFS-encrypted) location while still
174 + // delivering arbitrary binary content byte-for-byte - matching Docker's type=file semantics.
175 + return services::BuildSecret{
176 + .Id = std::move(id),
177 + .SourcePath = absPath.wstring(),
178 + };
179 + }
180 +
181 + // Docker parity: a referenced environment variable that is unset (or set but empty) yields an
182 + // empty secret value rather than an error. ReadEnvironmentVariable returns nullopt for an
183 + // undefined variable, which we collapse to an empty value.
184 + const std::wstring value = wsl::windows::common::wslutil::ReadEnvironmentVariable(envName.c_str()).value_or(std::wstring{});
185 +
186 + // The env value is delivered as UTF-8 bytes, matching how the guest exposes it at /run/secrets/<id>.
187 + auto valueBytes = wsl::windows::common::string::WideToMultiByte(value);
188 + return services::BuildSecret{
189 + .Id = std::move(id),
190 + .Value = std::vector<BYTE>(valueBytes.begin(), valueBytes.end()),
191 + };
192 +}
193 +
194 +std::tuple<std::string, int64_t, int64_t> ParseUlimit(const std::wstring& input, const std::wstring& argName)
195 +{
196 + // Accepts <name>=<soft>[:<hard>]; if hard is omitted hard = soft. -1 means unlimited.
197 + const auto nameValue = SplitKeyValue(input);
198 + if (!nameValue.HadSeparator || nameValue.Key.empty())
199 + {
200 + throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input));
201 + }
202 +
203 + const std::wstring& valuesPart = nameValue.Value;
204 + const auto colonPos = valuesPart.find(L':');
205 +
206 + auto parseLimit = [&](const std::wstring& limitStr) -> int64_t {
207 + if (limitStr.empty())
208 + {
209 + throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input));
210 + }
211 +
212 + try
213 + {
214 + return GetIntegerFromString<int64_t>(limitStr, argName, [](int64_t v) { return v >= -1; });
215 + }
216 + catch (const ArgumentException&)
217 + {
218 + // Re-throw with the ulimit-specific error message so the user sees the full input.
219 + throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input));
220 + }
221 + };
222 +
223 + const int64_t soft = parseLimit(colonPos == std::wstring::npos ? valuesPart : valuesPart.substr(0, colonPos));
224 + const int64_t hard = colonPos == std::wstring::npos ? soft : parseLimit(valuesPart.substr(colonPos + 1));
225 +
226 + // This rejects "-1:1024" and "-1:<finite>" while allowing "<finite>:-1", "-1:-1", and "-1".
227 + const bool invalidRange = (soft == -1) ? (hard != -1) : (hard != -1 && hard < soft);
228 + if (invalidRange)
229 + {
230 + throw ArgumentException(Localization::WSLCCLI_InvalidUlimitError(argName, input));
231 + }
232 +
233 + return {WideToMultiByte(nameValue.Key), soft, hard};
234 +}
235 +
236 +std::pair<std::string, std::string> ParseLabel(const std::wstring& value)
237 +{
238 + const auto kv = SplitKeyValue(value);
239 + auto key = WideToMultiByte(kv.Key);
240 + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::WSLCCLI_LabelKeyEmptyError(), key.empty());
241 + return {std::move(key), WideToMultiByte(kv.Value)};
242 +}
243 +
244 +std::pair<std::string, std::string> ParseDriverOption(const std::wstring& value)
245 +{
246 + const auto kv = SplitKeyValue(value);
247 + return {WideToMultiByte(kv.Key), WideToMultiByte(kv.Value)};
248 +}
249 +
250 +std::pair<std::string, std::string> ParseFilter(const std::wstring& value)
251 +{
252 + const auto kv = SplitKeyValue(value);
253 + if (!kv.HadSeparator)
254 + {
255 + throw ArgumentException(Localization::WSLCCLI_InvalidFilterError(value));
256 + }
257 +
258 + return {WideToMultiByte(kv.Key), WideToMultiByte(kv.Value)};
259 +}
260 +
261 +// Map of signal names to WSLCSignal enum values
262 +static const std::unordered_map<std::wstring, WSLCSignal> SignalMap = {
263 + {L"SIGHUP", WSLCSignalSIGHUP}, {L"SIGINT", WSLCSignalSIGINT}, {L"SIGQUIT", WSLCSignalSIGQUIT},
264 + {L"SIGILL", WSLCSignalSIGILL}, {L"SIGTRAP", WSLCSignalSIGTRAP}, {L"SIGABRT", WSLCSignalSIGABRT},
265 + {L"SIGIOT", WSLCSignalSIGIOT}, {L"SIGBUS", WSLCSignalSIGBUS}, {L"SIGFPE", WSLCSignalSIGFPE},
266 + {L"SIGKILL", WSLCSignalSIGKILL}, {L"SIGUSR1", WSLCSignalSIGUSR1}, {L"SIGSEGV", WSLCSignalSIGSEGV},
267 + {L"SIGUSR2", WSLCSignalSIGUSR2}, {L"SIGPIPE", WSLCSignalSIGPIPE}, {L"SIGALRM", WSLCSignalSIGALRM},
268 + {L"SIGTERM", WSLCSignalSIGTERM}, {L"SIGTKFLT", WSLCSignalSIGTKFLT}, {L"SIGCHLD", WSLCSignalSIGCHLD},
269 + {L"SIGCONT", WSLCSignalSIGCONT}, {L"SIGSTOP", WSLCSignalSIGSTOP}, {L"SIGTSTP", WSLCSignalSIGTSTP},
270 + {L"SIGTTIN", WSLCSignalSIGTTIN}, {L"SIGTTOU", WSLCSignalSIGTTOU}, {L"SIGURG", WSLCSignalSIGURG},
271 + {L"SIGXCPU", WSLCSignalSIGXCPU}, {L"SIGXFSZ", WSLCSignalSIGXFSZ}, {L"SIGVTALRM", WSLCSignalSIGVTALRM},
272 + {L"SIGPROF", WSLCSignalSIGPROF}, {L"SIGWINCH", WSLCSignalSIGWINCH}, {L"SIGIO", WSLCSignalSIGIO},
273 + {L"SIGPOLL", WSLCSignalSIGPOLL}, {L"SIGPWR", WSLCSignalSIGPWR}, {L"SIGSYS", WSLCSignalSIGSYS},
274 +};
275 +
276 +// Convert string to WSLCSignal enum - accepts either signal name (e.g., "SIGKILL") or number (e.g., "9")
277 +WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName)
278 +{
279 + constexpr int MIN_SIGNAL = WSLCSignalSIGHUP;
280 + constexpr int MAX_SIGNAL = WSLCSignalSIGSYS;
281 + constexpr std::wstring_view sigPrefix = L"SIG";
282 +
283 + // Normalize input: ensure it has "SIG" prefix for map lookup
284 + std::wstring normalizedInput;
285 + if (IsEqual(input.substr(0, sigPrefix.size()), sigPrefix, true))
286 + {
287 + normalizedInput = input;
288 + }
289 + else
290 + {
291 + normalizedInput = std::wstring(sigPrefix) + input;
292 + }
293 +
294 + for (const auto& [signalName, signalValue] : SignalMap)
295 + {
296 + if (IsEqual(normalizedInput, signalName, true))
297 + {
298 + return signalValue;
299 + }
300 + }
301 +
302 + // User may have input an integer representation instead.
303 + int signalValue{};
304 + try
305 + {
306 + signalValue = GetIntegerFromString<int>(input, argName);
307 + }
308 + // If it fails to be converted give a better user message than just the integer conversion
309 + // failure since we also know it failed to be found in the map.
310 + catch (ArgumentException)
311 + {
312 + throw ArgumentException(Localization::WSLCCLI_InvalidSignalError(argName, input));
313 + }
314 +
315 + if (signalValue < MIN_SIGNAL || signalValue > MAX_SIGNAL)
316 + {
317 + throw ArgumentException(Localization::WSLCCLI_SignalOutOfRangeError(argName, input, MIN_SIGNAL, MAX_SIGNAL));
318 + }
319 +
320 + return static_cast<WSLCSignal>(signalValue);
321 +}
322 +
323 +// Parses an RFC3339 timestamp (e.g. "2024-01-15T10:30:00Z" or "2024-01-15T10:30:00+05:30")
324 +// into a ULONGLONG Unix epoch seconds value using std::chrono::parse.
325 +// Note: +HHMM (no colon) offsets are not supported; use +HH:MM format.
326 +static std::optional<ULONGLONG> TryParseRfc3339(const std::string& input)
327 +{
328 + std::string normalized = input;
329 +
330 + // Normalize trailing 'Z'/'z' to '+00:00' so %Ez can parse it uniformly.
331 + if (!normalized.empty() && (normalized.back() == 'Z' || normalized.back() == 'z'))
332 + {
333 + normalized.pop_back();
334 + normalized += "+00:00";
335 + }
336 +
337 + // Reject bare dot with no fractional digits (e.g. "10:30:00.+00:00") since
338 + // std::chrono::parse is lenient about this.
339 + auto dotPos = normalized.find('.');
340 + if (dotPos != std::string::npos && (dotPos + 1 >= normalized.size() || !std::isdigit(normalized[dotPos + 1])))
341 + {
342 + return std::nullopt;
343 + }
344 +
345 + // Pre-validate day-of-month since std::chrono::parse silently wraps invalid dates (e.g. Feb 31 → Mar 2).
346 + if (normalized.size() >= 10 && normalized[4] == '-' && normalized[7] == '-')
347 + {
348 + int year = 0, month = 0, day = 0;
349 + auto yResult = std::from_chars(normalized.data(), normalized.data() + 4, year);
350 + auto mResult = std::from_chars(normalized.data() + 5, normalized.data() + 7, month);
351 + auto dResult = std::from_chars(normalized.data() + 8, normalized.data() + 10, day);
352 +
353 + if (yResult.ec == std::errc() && mResult.ec == std::errc() && dResult.ec == std::errc())
354 + {
355 + auto ymd = std::chrono::year{year} / std::chrono::month{static_cast<unsigned>(month)} /
356 + std::chrono::day{static_cast<unsigned>(day)};
357 + if (!ymd.ok())
358 + {
359 + return std::nullopt;
360 + }
361 + }
362 + }
363 +
364 + // Parse into nanosecond precision so fractional seconds (e.g. ".123456789") are consumed
365 + // by std::chrono::parse rather than requiring manual stripping.
366 + std::chrono::sys_time<std::chrono::nanoseconds> utcTime;
367 + std::istringstream stream(normalized);
368 + stream >> std::chrono::parse("%FT%T%Ez", utcTime);
369 + if (stream.fail())
370 + {
371 + return std::nullopt;
372 + }
373 +
374 + // Reject if there are trailing characters after the parsed timestamp
375 + if (stream.peek() != std::istringstream::traits_type::eof())
376 + {
377 + return std::nullopt;
378 + }
379 +
380 + auto epochSeconds = std::chrono::duration_cast<std::chrono::seconds>(utcTime.time_since_epoch()).count();
381 + if (epochSeconds < 0)
382 + {
383 + return std::nullopt;
384 + }
385 +
386 + return static_cast<ULONGLONG>(epochSeconds);
387 +}
388 +
389 +ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName)
390 +{
391 + std::string narrowValue = wsl::windows::common::string::WideToMultiByte(value);
392 +
393 + // Try integer (Unix epoch seconds) first
394 + ULONGLONG intValue{};
395 + const char* begin = narrowValue.c_str();
396 + const char* end = begin + narrowValue.size();
397 + auto result = std::from_chars(begin, end, intValue);
398 + if (result.ec == std::errc() && result.ptr == end)
399 + {
400 + return intValue;
401 + }
402 +
403 + // Try RFC3339 timestamp
404 + auto rfc3339Value = TryParseRfc3339(narrowValue);
405 + if (rfc3339Value.has_value())
406 + {
407 + return rfc3339Value.value();
408 + }
409 +
410 + throw ArgumentException(Localization::WSLCCLI_InvalidTimestampArgumentError(argName, value));
411 +}
412 +
413 +models::FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName)
414 +{
415 + if (IsEqual(input, L"json"))
416 + {
417 + return models::FormatType::Json;
418 + }
419 + else if (IsEqual(input, L"table"))
420 + {
421 + return models::FormatType::Table;
422 + }
423 + else
424 + {
425 + throw ArgumentException(std::format(
426 + L"Invalid {} value: {} is not a recognized format type. Supported format types are: json, table.", argName, input));
427 + }
428 +}
429 +
430 +models::InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName)
431 +{
432 + if (IsEqual(input, L"image"))
433 + {
434 + return models::InspectType::Image;
435 + }
436 + else if (IsEqual(input, L"container"))
437 + {
438 + return models::InspectType::Container;
439 + }
440 + else if (IsEqual(input, L"network"))
441 + {
442 + return models::InspectType::Network;
443 + }
444 + else if (IsEqual(input, L"volume"))
445 + {
446 + return models::InspectType::Volume;
447 + }
448 + else
449 + {
450 + constexpr std::wstring_view supportedValues = L"image, container, network, volume";
451 + throw ArgumentException(Localization::WSLCCLI_InvalidInspectError(argName, input, supportedValues));
452 + }
453 +}
454 +
455 +int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName)
456 +{
457 + auto parsed = wsl::shared::string::ParseMemorySize(input.c_str());
458 + if (!parsed.has_value())
459 + {
460 + throw ArgumentException(Localization::WSLCCLI_InvalidMemorySizeError(argName, input));
461 + }
462 +
463 + return static_cast<int64_t>(parsed.value());
464 +}
465 +
466 +// Parses duration string into nanoseconds.
467 +static std::optional<int64_t> TryParseDuration(const std::string& input)
468 +{
469 + if (input.empty())
470 + {
471 + return std::nullopt;
472 + }
473 +
474 + size_t pos = 0;
475 + bool negative = false;
476 + if (input[pos] == '+' || input[pos] == '-')
477 + {
478 + negative = input[pos] == '-';
479 + pos++;
480 + }
481 +
482 + // Special case: a bare "0" (with optional sign) is a valid zero duration.
483 + if (input.substr(pos) == "0")
484 + {
485 + return 0;
486 + }
487 +
488 + // Accumulate in a long double so fractional units (e.g. "1.5h") are handled, then round.
489 + long double totalNanos = 0.0L;
490 + bool sawValue = false;
491 +
492 + while (pos < input.size())
493 + {
494 + // Parse the numeric part (integer and/or fraction).
495 + const size_t numberStart = pos;
496 + while (pos < input.size() && (std::isdigit(static_cast<unsigned char>(input[pos])) || input[pos] == '.'))
497 + {
498 + pos++;
499 + }
500 +
501 + const std::string numberStr = input.substr(numberStart, pos - numberStart);
502 + if (numberStr.empty() || numberStr == "." || std::count(numberStr.begin(), numberStr.end(), '.') > 1)
503 + {
504 + return std::nullopt;
505 + }
506 +
507 + // Parse the unit (everything up to the next digit or '.').
508 + const size_t unitStart = pos;
509 + while (pos < input.size() && !std::isdigit(static_cast<unsigned char>(input[pos])) && input[pos] != '.')
510 + {
511 + pos++;
512 + }
513 +
514 + const std::string unit = input.substr(unitStart, pos - unitStart);
515 +
516 + long double multiplier{};
517 + if (unit == "ns")
518 + {
519 + multiplier = 1.0L;
520 + }
521 + else if (unit == "us" || unit == "\xC2\xB5s" /* µs (U+00B5) */ || unit == "\xCE\xBCs" /* μs (U+03BC) */)
522 + {
523 + multiplier = 1000L;
524 + }
525 + else if (unit == "ms")
526 + {
527 + multiplier = 1000000L;
528 + }
529 + else if (unit == "s")
530 + {
531 + multiplier = 1000000000L;
532 + }
533 + else if (unit == "m")
534 + {
535 + multiplier = 60000000000L;
536 + }
537 + else if (unit == "h")
538 + {
539 + multiplier = 3600000000000L;
540 + }
541 + else
542 + {
543 + return std::nullopt;
544 + }
545 +
546 + long double value{};
547 + try
548 + {
549 + auto [ptr, ec] = std::from_chars(numberStr.data(), numberStr.data() + numberStr.size(), value, std::chars_format::fixed);
550 + if (ptr != numberStr.data() + numberStr.size() || ec != std::errc())
551 + {
552 + return std::nullopt;
553 + }
554 + }
555 + catch (...)
556 + {
557 + return std::nullopt;
558 + }
559 +
560 + totalNanos += value * multiplier;
561 + sawValue = true;
562 + }
563 +
564 + if (!sawValue)
565 + {
566 + return std::nullopt;
567 + }
568 +
569 + if (negative)
570 + {
571 + totalNanos = -totalNanos;
572 + }
573 +
574 + if (totalNanos > static_cast<long double>(std::numeric_limits<int64_t>::max()) ||
575 + totalNanos < static_cast<long double>(std::numeric_limits<int64_t>::min()))
576 + {
577 + return std::nullopt;
578 + }
579 +
580 + return static_cast<int64_t>(std::llroundl(totalNanos));
581 +}
582 +
583 +int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName)
584 +{
585 + const std::string narrow = WideToMultiByte(input);
586 + const auto parsed = TryParseDuration(narrow);
587 +
588 + if (!parsed.has_value() || parsed.value() < 0)
589 + {
590 + throw ArgumentException(Localization::WSLCCLI_InvalidDurationError(argName, input));
591 + }
592 +
593 + return parsed.value();
594 +}
595 +
596 +int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName)
597 +{
598 + constexpr double NanosPerCpu = 1'000'000'000.0;
599 + constexpr double MaxCpus = static_cast<double>(std::numeric_limits<int64_t>::max()) / NanosPerCpu;
600 +
601 + const std::string narrow = WideToMultiByte(input);
602 + const char* begin = narrow.c_str();
603 + const char* end = begin + narrow.size();
604 +
605 + double cpus{};
606 + const auto result = std::from_chars(begin, end, cpus, std::chars_format::fixed);
607 + if (result.ec != std::errc() || result.ptr != end || cpus <= 0.0 || cpus > MaxCpus)
608 + {
609 + throw ArgumentException(Localization::WSLCCLI_InvalidCpusError(argName, input));
610 + }
611 +
612 + return static_cast<int64_t>(cpus * NanosPerCpu);
613 +}
614 +
615 +} // namespace wsl::windows::wslc::validation
src/windows/wslc/arguments/SpecParsing.h new
+79
@@ -0,0 +1,79 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + SpecParsing.h
8 +
9 +Abstract:
10 +
11 + Declarations for parsers that turn delimited command-line spec strings
12 + (e.g. --secret, --ulimit, --label, --filter) into structured values.
13 +
14 +--*/
15 +#pragma once
16 +
17 +#include "ContainerModel.h"
18 +#include "InspectModel.h"
19 +#include <string>
20 +#include <tuple>
21 +#include <utility>
22 +#include <wslc.h>
23 +
24 +namespace wsl::windows::wslc::services {
25 +struct BuildSecret;
26 +}
27 +
28 +namespace wsl::windows::wslc::validation {
29 +
30 +// The two halves of a spec token split at its first separator (default '=').
31 +// HadSeparator distinguishes "key" (no separator) from "key=" (empty value).
32 +struct KeyValueSplit
33 +{
34 + std::wstring Key;
35 + std::wstring Value;
36 + bool HadSeparator;
37 +};
38 +
39 +// Splits value at the first occurrence of separator. When no separator is present the whole
40 +// string is returned as Key, Value is empty, and HadSeparator is false.
41 +KeyValueSplit SplitKeyValue(const std::wstring& value, wchar_t separator = L'=');
42 +
43 +// Parses a docker-style --secret spec ("id=...,type=...,src=...") and resolves its value bytes.
44 +services::BuildSecret ParseSecretSpec(const std::wstring& spec);
45 +
46 +// Parses a --ulimit spec ("<name>=<soft>[:<hard>]") into (name, soft, hard). -1 means unlimited.
47 +std::tuple<std::string, int64_t, int64_t> ParseUlimit(const std::wstring& input, const std::wstring& argName = {});
48 +
49 +// Parses a --label spec ("key[=value]"); the key must be non-empty.
50 +std::pair<std::string, std::string> ParseLabel(const std::wstring& value);
51 +
52 +// Parses a driver option spec ("key[=value]"); a missing value yields an empty string.
53 +std::pair<std::string, std::string> ParseDriverOption(const std::wstring& value);
54 +
55 +// Parses a --filter spec ("key=value"); the separator is required.
56 +std::pair<std::string, std::string> ParseFilter(const std::wstring& value);
57 +
58 +// Parses a signal by name ("SIGKILL"/"KILL", case-insensitive) or number ("9") into a WSLCSignal.
59 +WSLCSignal GetWSLCSignalFromString(const std::wstring& input, const std::wstring& argName = {});
60 +
61 +// Parses a timestamp given as Unix epoch seconds or an RFC3339 string into epoch seconds.
62 +ULONGLONG GetTimestampFromString(const std::wstring& value, const std::wstring& argName = {});
63 +
64 +// Parses an output format ("json"/"table") into a FormatType.
65 +models::FormatType GetFormatTypeFromString(const std::wstring& input, const std::wstring& argName = {});
66 +
67 +// Parses an inspect target ("image"/"container"/"network"/"volume") into an InspectType.
68 +models::InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName);
69 +
70 +// Parses a memory size (e.g. "512m", "1g") into a byte count.
71 +int64_t GetMemorySizeFromString(const std::wstring& input, const std::wstring& argName = {});
72 +
73 +// Parses a Go-style duration (e.g. "1.5h", "500ms") into nanoseconds.
74 +int64_t GetDurationNanosFromString(const std::wstring& input, const std::wstring& argName = {});
75 +
76 +// Parses a fractional CPU count into nano-CPUs (cpus * 1e9).
77 +int64_t GetNanoCpusFromString(const std::wstring& input, const std::wstring& argName = {});
78 +
79 +} // namespace wsl::windows::wslc::validation
src/windows/wslc/commands/ImageBuildCommand.cpp
+1
@@ -34,6 +34,7 @@ std::vector<Argument> ImageBuildCommand::GetArguments() const
34 Argument::Create(ArgType::File),
35 Argument::Create(ArgType::Label, false, Limit::Unlimited),
36 Argument::Create(ArgType::NoCache),
37 + Argument::Create(ArgType::Secret, false, Limit::Unlimited),
38 Argument::Create(ArgType::Tag, false, Limit::Unlimited),
39 Argument::Create(ArgType::Verbose),
40 };
src/windows/wslc/core/EnvironmentOptions.cpp
+1 -18
@@ -11,23 +11,6 @@ Module Name:
11 #include "EnvironmentOptions.h"
12
13 namespace wsl::windows::wslc {
14 -namespace {
15 -
16 - // nullopt iff the variable is not defined; engaged (possibly empty) otherwise.
17 - std::optional<std::wstring> ReadEnv(const wchar_t* name)
18 - {
19 - std::wstring value;
20 - const HRESULT hr = wil::GetEnvironmentVariableW(name, value);
21 - if (hr == HRESULT_FROM_WIN32(ERROR_ENVVAR_NOT_FOUND))
22 - {
23 - return std::nullopt;
24 - }
25 -
26 - THROW_IF_FAILED(hr);
27 - return value;
28 - }
29 -
30 -} // namespace
14
15 void ApplyEnvironmentOptions(argument::ArgMap& target, const std::vector<Argument>& definedArgs) noexcept
16 try
@@ -47,7 +30,7 @@ try
30 continue;
31 }
32
50 - auto value = ReadEnv(binding.Name);
33 + auto value = wsl::windows::common::wslutil::ReadEnvironmentVariable(binding.Name);
34 if (!value.has_value())
35 {
36 continue;
src/windows/wslc/services/ImageService.cpp
+20
@@ -118,6 +118,7 @@ void ImageService::Build(
118 const std::vector<std::wstring>& tags,
119 const std::vector<std::wstring>& buildArgs,
120 const std::vector<std::wstring>& labels,
121 + const std::vector<BuildSecret>& secrets,
122 const std::wstring& dockerfilePath,
123 const std::wstring& target,
124 WSLCBuildImageFlags flags,
@@ -170,6 +171,24 @@ void ImageService::Build(
171 std::vector<LPCSTR> labelPointers;
172 toMultiByte(labels, labelStrings, labelPointers);
173
174 + // Keep narrow-encoded id strings alive for the duration of the COM call. The source path and raw
175 + // secret bytes are referenced in place from the caller's BuildSecret objects (which outlive this
176 + // call), so they are never copied or NUL-truncated.
177 + std::vector<std::string> secretIdStrings;
178 + std::vector<WSLCBuildSecret> secretEntries;
179 + secretIdStrings.reserve(secrets.size());
180 + secretEntries.reserve(secrets.size());
181 + for (const auto& secret : secrets)
182 + {
183 + secretIdStrings.push_back(wsl::windows::common::string::WideToMultiByte(secret.Id));
184 + secretEntries.push_back(WSLCBuildSecret{
185 + .Id = secretIdStrings.back().c_str(),
186 + .SourcePath = secret.SourcePath.empty() ? nullptr : secret.SourcePath.c_str(),
187 + .Value = secret.Value.empty() ? nullptr : secret.Value.data(),
188 + .ValueSize = static_cast<ULONG>(secret.Value.size()),
189 + });
190 + }
191 +
192 auto targetStr = wsl::windows::common::string::WideToMultiByte(target);
193
194 auto contextPathStr = absolutePath.wstring();
@@ -181,6 +200,7 @@ void ImageService::Build(
200 .Target = targetStr.empty() ? nullptr : targetStr.c_str(),
201 .Flags = flags,
202 .Labels = {labelPointers.data(), static_cast<ULONG>(labelPointers.size())},
203 + .Secrets = {secretEntries.data(), static_cast<ULONG>(secretEntries.size())},
204 };
205
206 THROW_IF_FAILED(session.Get()->BuildImage(&options, callback, cancelEvent));
src/windows/wslc/services/ImageService.h
+14
@@ -19,6 +19,19 @@ Abstract:
19 #include <wslc_schema.h>
20
21 namespace wsl::windows::wslc::services {
22 +
23 +struct BuildSecret
24 +{
25 + std::wstring Id; // value for docker's --secret id= field
26 + // For file (src=) secrets: the resolved absolute host path. The service mounts the file's parent
27 + // directory into the build VM read-only and references the file in place, so the bytes are never
28 + // copied off their original (possibly EFS-encrypted) location. Empty for env/in-memory secrets.
29 + std::wstring SourcePath;
30 + // For env/in-memory secrets: the raw secret bytes (may contain NULs), materialized into a host-side
31 + // file mounted read-only into the VM during the build. Empty for file secrets.
32 + std::vector<BYTE> Value;
33 +};
34 +
35 class ImageService
36 {
37 public:
@@ -28,6 +41,7 @@ public:
41 const std::vector<std::wstring>& tags,
42 const std::vector<std::wstring>& buildArgs,
43 const std::vector<std::wstring>& labels,
44 + const std::vector<BuildSecret>& secrets,
45 const std::wstring& dockerfilePath,
46 const std::wstring& target,
47 WSLCBuildImageFlags flags,
src/windows/wslc/tasks/ImageTasks.cpp
+10 -1
@@ -105,6 +105,15 @@ void BuildImage(CLIExecutionContext& context)
105 validation::ParseLabel(label);
106 }
107
108 + std::vector<services::BuildSecret> secrets;
109 + if (context.Args.Contains(ArgType::Secret))
110 + {
111 + for (const auto& spec : context.Args.GetAll<ArgType::Secret>())
112 + {
113 + secrets.push_back(validation::ParseSecretSpec(spec));
114 + }
115 + }
116 +
117 std::wstring dockerfilePath;
118 if (context.Args.Contains(ArgType::File))
119 {
@@ -124,7 +133,7 @@ void BuildImage(CLIExecutionContext& context)
133
134 auto cancelEvent = context.CreateCancelEvent();
135 BuildImageCallback callback(context.Reporter, cancelEvent, context.Args.GetFlag<ArgType::Verbose>());
127 - services::ImageService::Build(session, contextPath, tags, buildArgs, labels, dockerfilePath, target, flags, &callback, cancelEvent);
136 + services::ImageService::Build(session, contextPath, tags, buildArgs, labels, secrets, dockerfilePath, target, flags, &callback, cancelEvent);
137 }
138
139 void GetImages(CLIExecutionContext& context)
src/windows/wslcsession/WSLCSession.cpp
+123 -7
@@ -881,6 +881,7 @@ try
881 RETURN_HR_IF(E_INVALIDARG, Options->Tags.Count > 0 && Options->Tags.Values == nullptr);
882 RETURN_HR_IF(E_INVALIDARG, Options->BuildArgs.Count > 0 && Options->BuildArgs.Values == nullptr);
883 RETURN_HR_IF(E_INVALIDARG, Options->Labels.Count > 0 && Options->Labels.Values == nullptr);
884 + RETURN_HR_IF(E_INVALIDARG, Options->Secrets.Count > 0 && Options->Secrets.Values == nullptr);
885 THROW_HR_IF_MSG(
886 E_INVALIDARG,
887 WI_IsAnyFlagSet(static_cast<WSLCBuildImageFlags>(Options->Flags), ~WSLCBuildImageFlagsValid),
@@ -908,14 +909,37 @@ try
909
910 THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_virtualMachine);
911
911 - GUID volumeId{};
912 - THROW_IF_FAILED(CoCreateGuid(&volumeId));
913 - auto mountPath = std::format("/mnt/{}", wsl::shared::string::GuidToString<char>(volumeId));
914 - THROW_IF_FAILED(m_virtualMachine->MountWindowsFolder(Options->ContextPath, mountPath.c_str(), TRUE));
915 - auto unmountFolder =
916 - wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { m_virtualMachine->UnmountWindowsFolder(mountPath.c_str()); });
912 + // Track every Windows folder we mount into the VM during this build so a single scope_exit
913 + // unmounts them all on success or on any throw partway through the loop below.
914 + std::vector<std::string> mountedPaths;
915 + auto unmountAll = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
916 + for (const auto& path : mountedPaths)
917 + {
918 + // Best-effort but not silent: a failed unmount can leave a file-secret share mounted in the
919 + // guest, so log it. Never throw here.
920 + LOG_IF_FAILED(m_virtualMachine->UnmountWindowsFolder(path.c_str()));
921 + }
922 + });
923 + auto mountInVm = [&](LPCWSTR windowsPath, BOOL readOnly, std::string_view guestBase = "/mnt") -> std::string {
924 + GUID id{};
925 + THROW_IF_FAILED(CoCreateGuid(&id));
926 + auto vmPath = std::format("{}/{}", guestBase, wsl::shared::string::GuidToString<char>(id));
927 + THROW_IF_FAILED(m_virtualMachine->MountWindowsFolder(windowsPath, vmPath.c_str(), readOnly));
928 + mountedPaths.push_back(std::move(vmPath));
929 + return mountedPaths.back();
930 + };
931 +
932 + // Reserve up front so mountInVm's push_back can never reallocate-and-throw after a successful
933 + // MountWindowsFolder, which would leak a mount the scope_exit hasn't recorded yet. At most the build
934 + // context (1) and one parent directory per file secret are mounted.
935 + mountedPaths.reserve(static_cast<size_t>(1) + Options->Secrets.Count);
936 +
937 + auto mountPath = mountInVm(Options->ContextPath, TRUE);
938
939 std::vector<std::string> buildArgs{"/usr/bin/docker", "buildx", "build", "--builder", "default", "--progress=rawjson"};
940 + // Environment for the docker process. Env/in-memory secrets are delivered as variables here so their
941 + // values never touch disk; kept off telemetry (only buildArgs is logged).
942 + std::vector<std::string> buildEnv;
943 if (WI_IsFlagSet(Options->Flags, WSLCBuildImageFlagsNoCache))
944 {
945 buildArgs.push_back("--no-cache");
@@ -951,13 +975,105 @@ try
975 buildArgs.push_back(Options->Labels.Values[i]);
976 }
977
978 + // Deliver each secret to the build without ever writing the value to disk on host or guest, keeping
979 + // it off argv/telemetry and re-readable across RUN steps - matching Docker's secret semantics. Two
980 + // kinds of secret are handled:
981 + //
982 + // * File (src=) secrets carry the resolved host path. We mount the file's *parent directory* into
983 + // the VM read-only and reference the file in place, so the bytes are never copied off their
984 + // original (possibly EFS-encrypted) location. Secrets sharing a directory reuse one mount.
985 + //
986 + // * Env/in-memory secrets carry raw bytes (there is no source file). We hand the value to BuildKit
987 + // through an environment variable of the docker process (id=<id>,env=<var>); nothing is written
988 + // to disk, so there is nothing to clean up.
989 + if (Options->Secrets.Count > 0)
990 + {
991 + // Guest tmpfs base for file-secret directory mounts: keeping them under /run means the secret
992 + // contents never hit the guest disk and leave nothing to clean up if the session crashes.
993 + constexpr std::string_view c_secretMountBase = "/run/build-secrets";
994 +
995 + // (id, source spec) pairs - the source spec is docker's "src=<path>" or "env=<var>" token -
996 + // emitted as --secret arguments once every secret is prepared.
997 + std::vector<std::pair<std::string, std::string>> secretArgs;
998 + secretArgs.reserve(Options->Secrets.Count);
999 +
1000 + // Dedup file-secret parent-directory mounts: secrets from the same host directory share a mount.
1001 + std::map<std::filesystem::path, std::string> fileSecretDirMounts;
1002 +
1003 + for (ULONG i = 0; i < Options->Secrets.Count; i++)
1004 + {
1005 + const auto& secret = Options->Secrets.Values[i];
1006 + RETURN_HR_IF_MSG(E_INVALIDARG, secret.Id == nullptr, "Secret %u has a null id", i);
1007 + RETURN_HR_IF_MSG(E_INVALIDARG, secret.Id[0] == '\0', "Secret %u has an empty id", i);
1008 + RETURN_HR_IF_MSG(E_INVALIDARG, secret.Id[0] == '-', "Invalid secret id '%hs'", secret.Id);
1009 + // Id is interpolated into docker's comma/'='-delimited --secret spec below, so reject any
1010 + // ',' or '=' a malicious caller could use to inject extra options.
1011 + RETURN_HR_IF_MSG(
1012 + E_INVALIDARG,
1013 + std::string_view(secret.Id).find_first_of(",=") != std::string_view::npos,
1014 + "Invalid secret id '%hs'",
1015 + secret.Id);
1016 +
1017 + if (secret.SourcePath != nullptr)
1018 + {
1019 + // File secret: mount the file's parent directory read-only and reference the file in
1020 + // place - the bytes are never copied. Mounting the whole directory (not just the file) is
1021 + // inherent to virtiofs sharing a directory tree; sibling files are exposed to this user's
1022 + // own build VM read-only for the build's duration only.
1023 + std::filesystem::path sourcePath(secret.SourcePath);
1024 + // The client and server may have different current directories, so a relative path is
1025 + // ambiguous - require an absolute path. An empty SourcePath is not absolute, so a
1026 + // malformed file secret fails here rather than being treated as an env secret.
1027 + THROW_HR_WITH_USER_ERROR_IF(E_INVALIDARG, Localization::MessagePathNotAbsolute(secret.SourcePath), !sourcePath.is_absolute());
1028 + auto parent = sourcePath.parent_path();
1029 + auto fileNameUtf8 = sourcePath.filename().string();
1030 + RETURN_HR_IF(E_INVALIDARG, parent.empty() || fileNameUtf8.empty());
1031 + // The filename is interpolated into the CSV --secret spec; a ',' or '"' would corrupt it.
1032 + RETURN_HR_IF(E_INVALIDARG, fileNameUtf8.find_first_of(",\"") != std::string::npos);
1033 +
1034 + auto it = fileSecretDirMounts.find(parent);
1035 + if (it == fileSecretDirMounts.end())
1036 + {
1037 + it = fileSecretDirMounts.emplace(parent, mountInVm(parent.c_str(), TRUE, c_secretMountBase)).first;
1038 + }
1039 + secretArgs.emplace_back(secret.Id, std::format("src={}/{}", it->second, fileNameUtf8));
1040 + }
1041 + else
1042 + {
1043 + // Env/in-memory secret: hand the value to BuildKit through an environment variable of the
1044 + // docker process. BuildKit reads it (id=<id>,env=<var>) and streams it to the daemon, so
1045 + // the value never touches disk on host or guest and needs no cleanup.
1046 + RETURN_HR_IF(E_INVALIDARG, secret.ValueSize != 0 && secret.Value == nullptr);
1047 + std::string_view value;
1048 + if (secret.ValueSize != 0)
1049 + {
1050 + value = std::string_view(reinterpret_cast<const char*>(secret.Value), secret.ValueSize);
1051 + }
1052 + // An environment variable value cannot contain a NUL; reject rather than silently
1053 + // truncate the secret.
1054 + RETURN_HR_IF(E_INVALIDARG, value.find('\0') != std::string_view::npos);
1055 +
1056 + auto varName = std::format("WSLC_SECRET_{}", std::to_string(i));
1057 +
1058 + buildEnv.push_back(std::format("{}={}", varName, value));
1059 + secretArgs.emplace_back(secret.Id, std::format("env={}", varName));
1060 + }
1061 + }
1062 +
1063 + for (const auto& [id, source] : secretArgs)
1064 + {
1065 + buildArgs.push_back("--secret");
1066 + buildArgs.push_back(std::format("id={},{}", id, source));
1067 + }
1068 + }
1069 +
1070 buildArgs.push_back("-f");
1071 buildArgs.push_back("-");
1072 buildArgs.push_back(mountPath);
1073
1074 WSL_LOG("BuildImageStart", TraceLoggingValue(wsl::shared::string::Join(buildArgs, ' ').c_str(), "Command"));
1075
960 - ServiceProcessLauncher buildLauncher(buildArgs[0], buildArgs, {}, WSLCProcessFlagsStdin);
1076 + ServiceProcessLauncher buildLauncher(buildArgs[0], buildArgs, buildEnv, WSLCProcessFlagsStdin);
1077 auto buildProcess = buildLauncher.Launch(*m_virtualMachine);
1078
1079 auto io = CreateIOContext();
src/windows/wslcsession/WSLCSession.h
+1
@@ -269,6 +269,7 @@ private:
269 void CreateContainerImpl(const WSLCContainerOptions* Options, IWSLCContainer** Container);
270
271 void ConfigureStorage(const WSLCSessionInitSettings& Settings, PSID UserSid);
272 +
273 void Ext4Format(const std::string& Device);
274 _Requires_shared_lock_held_(m_lock)
275 std::string InspectImageLockHeld(const std::string& Id);
test/windows/wslc/WSLCCLIEnvVarParserUnitTests.cpp
+4 -10
@@ -50,9 +50,7 @@ class WSLCCLIEnvVarParserUnitTests
50 TEST_METHOD(WSLCCLIEnvVarParser_UsesProcessEnvWhenValueMissing)
51 {
52 constexpr const auto key = L"WSLC_TEST_ENV_FROM_PROCESS";
53 - VERIFY_IS_TRUE(SetEnvironmentVariableW(key, L"process_value"));
54 -
55 - auto cleanup = wil::scope_exit([&] { SetEnvironmentVariableW(key, nullptr); });
53 + ScopedEnvVariable env(key, L"process_value");
54
55 const auto parsed = models::EnvironmentVariable::Parse(key);
56 VERIFY_IS_TRUE(parsed.has_value());
@@ -64,7 +62,7 @@ class WSLCCLIEnvVarParserUnitTests
62 const auto whitespaceOnly = models::EnvironmentVariable::Parse(L" \t ");
63 VERIFY_IS_FALSE(whitespaceOnly.has_value());
64
67 - SetEnvironmentVariableA("WSLC_TEST_ENV_UNSET", nullptr);
65 + ScopedEnvVariable env(L"WSLC_TEST_ENV_UNSET");
66 const auto missingFromProcess = models::EnvironmentVariable::Parse(L"WSLC_TEST_ENV_UNSET");
67 VERIFY_IS_FALSE(missingFromProcess.has_value());
68 }
@@ -96,9 +94,7 @@ class WSLCCLIEnvVarParserUnitTests
94 TEST_METHOD(WSLCCLIEnvVarParser_ParseFileParsesAndSkipsExpectedLines)
95 {
96 constexpr const auto key = L"WSLC_TEST_ENV_FROM_FILE";
99 - VERIFY_IS_TRUE(SetEnvironmentVariableW(key, L"file_process_value") == TRUE);
100 -
101 - auto envCleanup = wil::scope_exit([&] { SetEnvironmentVariableW(key, nullptr); });
97 + ScopedEnvVariable env(key, L"file_process_value");
98
99 std::ofstream file(EnvTestFile);
100 VERIFY_IS_TRUE(file.is_open());
@@ -158,9 +154,7 @@ class WSLCCLIEnvVarParserUnitTests
154 TEST_METHOD(WSLCCLIEnvVarParser_UsesProcessEnvWhenValueIsExplicitlyEmpty)
155 {
156 constexpr const auto key = L"WSLC_TEST_ENV_EMPTY_VALUE";
161 - VERIFY_IS_TRUE(SetEnvironmentVariableW(key, L""));
162 -
163 - auto cleanup = wil::scope_exit([&] { SetEnvironmentVariableW(key, nullptr); });
157 + ScopedEnvVariable env(key, L"");
158
159 const auto parsed = models::EnvironmentVariable::Parse(key);
160 VERIFY_IS_TRUE(parsed.has_value());
test/windows/wslc/WSLCCLISecretParserUnitTests.cpp new
+314
@@ -0,0 +1,314 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + WSLCCLISecretParserUnitTests.cpp
8 +
9 +Abstract:
10 +
11 + This file contains unit tests for WSLC CLI --secret spec validation and parsing (validation::ParseSecretSpec).
12 +
13 +--*/
14 +
15 +#include "precomp.h"
16 +#include "windows/Common.h"
17 +#include "WSLCCLITestHelpers.h"
18 +#include "ArgumentValidation.h"
19 +#include "ImageService.h"
20 +#include "Exceptions.h"
21 +#include <filesystem>
22 +#include <fstream>
23 +#include <string>
24 +#include <vector>
25 +
26 +using namespace wsl::windows::wslc;
27 +
28 +namespace WSLCCLISecretParserUnitTests {
29 +
30 +// RAII helper: writes the given bytes to a uniquely named temp file and deletes it on destruction.
31 +class ScopedTempFile
32 +{
33 +public:
34 + explicit ScopedTempFile(const std::vector<BYTE>& bytes)
35 + {
36 + m_path = std::filesystem::temp_directory_path() /
37 + (L"wslc_ut_secret_" + std::to_wstring(GetCurrentProcessId()) + L"_" + std::to_wstring(++s_counter) + L".bin");
38 + std::ofstream file(m_path, std::ios::binary | std::ios::trunc);
39 + if (!bytes.empty())
40 + {
41 + file.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
42 + }
43 + }
44 +
45 + ~ScopedTempFile()
46 + {
47 + std::error_code ec;
48 + std::filesystem::remove(m_path, ec);
49 + }
50 +
51 + ScopedTempFile(const ScopedTempFile&) = delete;
52 + ScopedTempFile& operator=(const ScopedTempFile&) = delete;
53 +
54 + std::wstring wpath() const
55 + {
56 + return m_path.wstring();
57 + }
58 +
59 +private:
60 + std::filesystem::path m_path;
61 + static inline int s_counter = 0;
62 +};
63 +
64 +class WSLCCLISecretParserUnitTests
65 +{
66 + WSLC_TEST_CLASS(WSLCCLISecretParserUnitTests)
67 +
68 + static std::vector<BYTE> ToBytes(std::string_view text)
69 + {
70 + return std::vector<BYTE>(text.begin(), text.end());
71 + }
72 +
73 + // Parses a spec expected to be valid and asserts the resolved id and bytes.
74 + static void VerifyValid(const std::wstring& spec, const std::wstring& expectedId, const std::vector<BYTE>& expectedValue)
75 + {
76 + auto secret = validation::ParseSecretSpec(spec);
77 + VERIFY_ARE_EQUAL(expectedId, secret.Id);
78 + VERIFY_ARE_EQUAL(expectedValue.size(), secret.Value.size());
79 + VERIFY_IS_TRUE(expectedValue == secret.Value);
80 + }
81 +
82 + // Parses a spec expected to be rejected and asserts it throws an ArgumentException whose message is
83 + // the standard "Invalid --secret value '<spec>': <reason>" wrapper and contains the expected reason.
84 + static void VerifyInvalid(const std::wstring& spec, const std::wstring& expectedReasonSubstr)
85 + {
86 + try
87 + {
88 + (void)validation::ParseSecretSpec(spec);
89 + VERIFY_FAIL(L"Expected ArgumentException for invalid secret spec");
90 + }
91 + catch (const ArgumentException& ex)
92 + {
93 + const std::wstring& message = ex.Message();
94 + VERIFY_IS_TRUE(message.find(L"Invalid --secret value") != std::wstring::npos);
95 + VERIFY_IS_TRUE(message.find(expectedReasonSubstr) != std::wstring::npos);
96 + }
97 + }
98 +
99 + // Parses a spec expected to resolve to a file-backed secret and asserts the resolved id, that no
100 + // bytes were read (Value is empty), and that the canonicalized source path is forwarded in place -
101 + // file secrets are delivered by mounting the file's directory into the VM, never by copying bytes.
102 + static void VerifyValidFileSecret(const std::wstring& spec, const std::wstring& expectedId, const std::wstring& expectedPath)
103 + {
104 + auto secret = validation::ParseSecretSpec(spec);
105 + VERIFY_ARE_EQUAL(expectedId, secret.Id);
106 + VERIFY_IS_TRUE(secret.Value.empty());
107 + std::error_code ec;
108 + const auto expectedCanonical = std::filesystem::weakly_canonical(std::filesystem::absolute(expectedPath), ec);
109 + VERIFY_ARE_EQUAL(expectedCanonical.wstring(), secret.SourcePath);
110 + }
111 +
112 + // --- Valid: environment-variable backed secrets ---
113 +
114 + TEST_METHOD(Secret_Env_BareIdReadsIdNamedVariable)
115 + {
116 + ScopedEnvVariable env(L"WSLC_UT_SECRET_BARE", L"bare-value");
117 + VerifyValid(L"id=WSLC_UT_SECRET_BARE", L"WSLC_UT_SECRET_BARE", ToBytes("bare-value"));
118 + }
119 +
120 + TEST_METHOD(Secret_Env_ExplicitEnvName)
121 + {
122 + ScopedEnvVariable env(L"WSLC_UT_SECRET_ENV", L"explicit-env");
123 + VerifyValid(L"id=my.secret,env=WSLC_UT_SECRET_ENV", L"my.secret", ToBytes("explicit-env"));
124 + }
125 +
126 + TEST_METHOD(Secret_Env_TypeEnvBareSrcIsVariableName)
127 + {
128 + ScopedEnvVariable env(L"WSLC_UT_SECRET_TYPEENV", L"type-env-src");
129 + VerifyValid(L"id=s,type=env,src=WSLC_UT_SECRET_TYPEENV", L"s", ToBytes("type-env-src"));
130 + }
131 +
132 + TEST_METHOD(Secret_Env_WinsOverSrcWhenBothPresent)
133 + {
134 + ScopedEnvVariable env(L"WSLC_UT_SECRET_ENVWINS", L"env-wins");
135 + // A non-existent src path is provided but must be ignored because env= takes precedence.
136 + VerifyValid(L"id=s,env=WSLC_UT_SECRET_ENVWINS,src=C:\\wslc-ut\\does-not-exist.txt", L"s", ToBytes("env-wins"));
137 + }
138 +
139 + TEST_METHOD(Secret_Env_ExplicitEnvUnsetYieldsEmptyValue)
140 + {
141 + // Ensure the variable is not set.
142 + ScopedEnvVariable env(L"WSLC_UT_SECRET_EXPLICIT_UNSET");
143 + VerifyValid(L"id=s,env=WSLC_UT_SECRET_EXPLICIT_UNSET", L"s", {});
144 + }
145 +
146 + TEST_METHOD(Secret_Env_EmptyVariableYieldsEmptyValue)
147 + {
148 + ScopedEnvVariable env(L"WSLC_UT_SECRET_EMPTY", L"");
149 + VerifyValid(L"id=WSLC_UT_SECRET_EMPTY", L"WSLC_UT_SECRET_EMPTY", {});
150 + }
151 +
152 + TEST_METHOD(Secret_Env_ValueEncodedAsUtf8)
153 + {
154 + // 'é' (U+00E9) encodes to the two UTF-8 bytes 0xC3 0xA9.
155 + ScopedEnvVariable env(L"WSLC_UT_SECRET_UTF8", L"h\u00e9llo");
156 + VerifyValid(L"id=WSLC_UT_SECRET_UTF8", L"WSLC_UT_SECRET_UTF8", {0x68, 0xC3, 0xA9, 0x6C, 0x6C, 0x6F});
157 + }
158 +
159 + TEST_METHOD(Secret_Env_IdAllowedCharacters)
160 + {
161 + ScopedEnvVariable env(L"WSLC_UT_SECRET_IDCHARS", L"ok");
162 + VerifyValid(L"id=Ab.9_-x,env=WSLC_UT_SECRET_IDCHARS", L"Ab.9_-x", ToBytes("ok"));
163 + }
164 +
165 + // --- Valid: file backed secrets ---
166 +
167 + TEST_METHOD(Secret_File_BareSrcForwardsPath)
168 + {
169 + ScopedTempFile file(ToBytes("file-content"));
170 + VerifyValidFileSecret(L"id=s,src=" + file.wpath(), L"s", file.wpath());
171 + }
172 +
173 + TEST_METHOD(Secret_File_TypeFileForwardsPath)
174 + {
175 + ScopedTempFile file(ToBytes("typed-file-content"));
176 + VerifyValidFileSecret(L"id=s,type=file,src=" + file.wpath(), L"s", file.wpath());
177 + }
178 +
179 + TEST_METHOD(Secret_File_SourceKeyAlias)
180 + {
181 + ScopedTempFile file(ToBytes("aliased"));
182 + VerifyValidFileSecret(L"id=s,source=" + file.wpath(), L"s", file.wpath());
183 + }
184 +
185 + TEST_METHOD(Secret_File_EmptyFileForwardsPath)
186 + {
187 + // An empty file is still a valid file secret: its path is forwarded and mounted (docker delivers
188 + // an empty /run/secrets/<id>); no bytes are carried in Value.
189 + ScopedTempFile file({});
190 + VerifyValidFileSecret(L"id=s,src=" + file.wpath(), L"s", file.wpath());
191 + }
192 +
193 + TEST_METHOD(Secret_File_BinaryFileForwardsPath)
194 + {
195 + // Binary content does not affect parsing: the file is referenced by path, not read, so arbitrary
196 + // bytes (including embedded NULs) are irrelevant to the client and delivered verbatim via mount.
197 + const std::vector<BYTE> bytes = {0x00, 0x01, 0x02, 0xFF, 0x00, 0x41, 0x00, 0x7F, 0x80};
198 + ScopedTempFile file(bytes);
199 + VerifyValidFileSecret(L"id=s,src=" + file.wpath(), L"s", file.wpath());
200 + }
201 +
202 + TEST_METHOD(Secret_File_LargeFileSucceeds)
203 + {
204 + // A large file must parse into a valid file secret.
205 + const std::vector<BYTE> bytes(512000, 0x41);
206 + ScopedTempFile file(bytes);
207 + VerifyValidFileSecret(L"id=s,src=" + file.wpath(), L"s", file.wpath());
208 + }
209 +
210 + TEST_METHOD(Secret_File_RelativeSrcResolvedToAbsolutePath)
211 + {
212 + // A relative src= must be resolved to an absolute SourcePath. The server rejects non-absolute
213 + // secret paths (the client and server may have different current directories), so the parser is
214 + // responsible for producing an absolute path before the spec is forwarded.
215 + ScopedTempFile file(ToBytes("relative-src"));
216 + const std::filesystem::path absPath = file.wpath();
217 +
218 + auto originalDir = std::filesystem::current_path();
219 + auto restoreDir = wil::scope_exit([&]() {
220 + std::error_code ec;
221 + std::filesystem::current_path(originalDir, ec);
222 + });
223 + std::filesystem::current_path(absPath.parent_path());
224 +
225 + const auto relativeSrc = absPath.filename().wstring();
226 + VERIFY_IS_FALSE(std::filesystem::path(relativeSrc).is_absolute());
227 +
228 + auto secret = validation::ParseSecretSpec(L"id=s,src=" + relativeSrc);
229 + VERIFY_ARE_EQUAL(std::wstring(L"s"), secret.Id);
230 + VERIFY_IS_TRUE(std::filesystem::path(secret.SourcePath).is_absolute());
231 +
232 + std::error_code ec;
233 + const auto expectedCanonical = std::filesystem::weakly_canonical(absPath, ec);
234 + VERIFY_ARE_EQUAL(expectedCanonical.wstring(), secret.SourcePath);
235 + }
236 +
237 + // --- Invalid: spec structure ---
238 +
239 + TEST_METHOD(Secret_Invalid_EmptyId)
240 + {
241 + VerifyInvalid(L"id=", L"'id=' is required");
242 + }
243 +
244 + TEST_METHOD(Secret_Invalid_MissingIdKey)
245 + {
246 + VerifyInvalid(L"env=WSLC_UT_SECRET_ANY", L"'id=' is required");
247 + }
248 +
249 + TEST_METHOD(Secret_Invalid_PartWithoutEquals)
250 + {
251 + VerifyInvalid(L"id=s,garbage", L"expected key=value pairs separated by ','");
252 + }
253 +
254 + TEST_METHOD(Secret_Invalid_PartWithLeadingEquals)
255 + {
256 + VerifyInvalid(L"=value", L"expected key=value pairs separated by ','");
257 + }
258 +
259 + TEST_METHOD(Secret_Invalid_UnsupportedKey)
260 + {
261 + VerifyInvalid(L"id=s,bogus=1", L"unsupported key 'bogus'");
262 + }
263 +
264 + // --- Invalid: id constraints ---
265 +
266 + TEST_METHOD(Secret_Invalid_IdStartsWithDash)
267 + {
268 + VerifyInvalid(L"id=-secret", L"'id' may not start with '-'");
269 + }
270 +
271 + TEST_METHOD(Secret_Invalid_IdContainsDisallowedCharacter)
272 + {
273 + VerifyInvalid(L"id=bad$id", L"'id' may only contain letters, digits");
274 + }
275 +
276 + TEST_METHOD(Secret_Invalid_IdContainsSlash)
277 + {
278 + VerifyInvalid(L"id=a/b", L"'id' may only contain letters, digits");
279 + }
280 +
281 + // --- Invalid: type constraints ---
282 +
283 + TEST_METHOD(Secret_Invalid_UnsupportedType)
284 + {
285 + VerifyInvalid(L"id=s,type=bogus", L"unsupported secret type 'bogus'");
286 + }
287 +
288 + TEST_METHOD(Secret_Invalid_TypeFileRequiresSrc)
289 + {
290 + VerifyInvalid(L"id=s,type=file", L"'type=file' requires 'src='");
291 + }
292 +
293 + // --- Invalid: value resolution ---
294 +
295 + TEST_METHOD(Secret_File_MissingSourceForwardsPath)
296 + {
297 + // A missing source file is not rejected client-side. The path is forwarded as
298 + // an absolute SourcePath and the service/BuildKit reports if it can't be mounted or read.
299 + const std::wstring missing = L"C:\\wslc-ut\\definitely-missing-secret-file.txt";
300 + auto secret = validation::ParseSecretSpec(L"id=s,src=" + missing);
301 + VERIFY_ARE_EQUAL(std::wstring(L"s"), secret.Id);
302 + VERIFY_IS_TRUE(secret.Value.empty());
303 + VERIFY_IS_TRUE(std::filesystem::path(secret.SourcePath).is_absolute());
304 + }
305 +
306 + TEST_METHOD(Secret_Invalid_BareIdVariableNotSet)
307 + {
308 + // A bare id whose matching environment variable is undefined must be rejected (Docker parity).
309 + ScopedEnvVariable env(L"WSLC_UT_SECRET_BARE_UNSET");
310 + VerifyInvalid(L"id=WSLC_UT_SECRET_BARE_UNSET", L"environment variable 'WSLC_UT_SECRET_BARE_UNSET' is not set");
311 + }
312 +};
313 +
314 +} // namespace WSLCCLISecretParserUnitTests
test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp
+614
@@ -55,6 +55,21 @@ class WSLCE2EImageBuildTests
55 });
56 }
57
58 + // All secret tests build from this single shared (empty) context directory. Each distinct mounted
59 + // directory consumes a virtiofs share slot while it is mounted, so reusing a single context path
60 + // helps keep secret tests from exhausting the per-session share budget.
61 + //
62 + // The per-test Dockerfile is streamed via -f (never mounted); each file secret causes the server to
63 + // mount that secret file's parent directory read-only for the duration of that build.
64 + static std::filesystem::path SharedSecretBuildContext()
65 + {
66 + auto dir = std::filesystem::current_path() / L"wslc-e2e-build-secret-context";
67 + std::error_code ec;
68 + std::filesystem::create_directories(dir, ec);
69 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::is_directory(dir));
70 + return dir;
71 + }
72 +
73 WSLC_TEST_METHOD(WSLCE2E_Image_Build_EmptyContextDirectory_Success)
74 {
75 auto imageCleanup = DeleteImageOnExit(BuiltImage);
@@ -261,6 +276,588 @@ class WSLCE2EImageBuildTests
276 VERIFY_ARE_EQUAL(std::string("from-cli"), it->second);
277 }
278
279 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_Env_Success)
280 + {
281 + // Set the env var the --secret will reference; ensure cleanup so we don't leak into other tests.
282 + constexpr auto envName = L"WSLC_E2E_SECRET_VALUE";
283 + constexpr auto envValue = L"expected-secret-content-12345";
284 + ScopedEnvVariable envVar(envName, envValue);
285 +
286 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecret);
287 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-env";
288 + auto cleanup = SetupTestDirectory(testRoot);
289 +
290 + auto contextDir = SharedSecretBuildContext();
291 +
292 + // RUN with type=secret asserts the secret value matches; if mismatched, RUN exits non-zero and the build fails.
293 + auto dockerfilePath = testRoot / L"Dockerfile";
294 + WriteTestFileContent(
295 + dockerfilePath,
296 + "# syntax=docker/dockerfile:1\n"
297 + "FROM debian:latest\n"
298 + "RUN --mount=type=secret,id=mysecret "
299 + "[ \"$(cat /run/secrets/mysecret)\" = \"expected-secret-content-12345\" ]\n"
300 + "CMD [\"echo\", \"secret-ok\"]\n");
301 +
302 + auto buildResult = RunWslc(std::format(
303 + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_SECRET_VALUE",
304 + contextDir.wstring(),
305 + dockerfilePath.wstring(),
306 + BuiltImageSecret.NameAndTag()));
307 + buildResult.Verify({.ExitCode = 0});
308 +
309 + auto inspectData = InspectImage(BuiltImageSecret.NameAndTag());
310 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
311 + }
312 +
313 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BareId_UsesEnvNamedById_Success)
314 + {
315 + // Docker parity: '--secret id=NAME' with no env=/src= reads the host env var named NAME.
316 + constexpr auto envName = L"WSLC_E2E_BARE_SECRET";
317 + constexpr auto envValue = L"bare-id-secret-content-67890";
318 + ScopedEnvVariable envVar(envName, envValue);
319 +
320 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretBareId);
321 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-bare-id";
322 + auto cleanup = SetupTestDirectory(testRoot);
323 +
324 + auto contextDir = SharedSecretBuildContext();
325 +
326 + // The docker secret id equals the env var name, so the mount reads /run/secrets/<envName>.
327 + auto dockerfilePath = testRoot / L"Dockerfile";
328 + WriteTestFileContent(
329 + dockerfilePath,
330 + "# syntax=docker/dockerfile:1\n"
331 + "FROM debian:latest\n"
332 + "RUN --mount=type=secret,id=WSLC_E2E_BARE_SECRET "
333 + "[ \"$(cat /run/secrets/WSLC_E2E_BARE_SECRET)\" = \"bare-id-secret-content-67890\" ]\n"
334 + "CMD [\"echo\", \"secret-ok\"]\n");
335 +
336 + auto buildResult = RunWslc(std::format(
337 + L"build \"{}\" -f \"{}\" -t {} --secret id=WSLC_E2E_BARE_SECRET",
338 + contextDir.wstring(),
339 + dockerfilePath.wstring(),
340 + BuiltImageSecretBareId.NameAndTag()));
341 + buildResult.Verify({.ExitCode = 0});
342 +
343 + auto inspectData = InspectImage(BuiltImageSecretBareId.NameAndTag());
344 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
345 + }
346 +
347 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BareIdUnsetVar_Fails)
348 + {
349 + // Docker parity: '--secret id=NAME' with no env=/src= reads the host env var named NAME, and
350 + // errors when that variable is unset (unlike an explicit 'env=', which yields an empty value).
351 + constexpr auto envName = L"WSLC_E2E_SECRET_BARE_ID_UNSET";
352 + ScopedEnvVariable envVar(envName); // Clears it (restoring any prior value on exit) so a leaked value can't taint the test.
353 +
354 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-bare-id-unset";
355 + auto cleanup = SetupTestDirectory(testRoot);
356 +
357 + auto contextDir = testRoot / L"context";
358 + std::error_code ec;
359 + std::filesystem::create_directories(contextDir, ec);
360 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
361 +
362 + auto dockerfilePath = testRoot / L"Dockerfile";
363 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
364 +
365 + auto buildResult = RunWslc(std::format(
366 + L"build \"{}\" -f \"{}\" --secret id=WSLC_E2E_SECRET_BARE_ID_UNSET", contextDir.wstring(), dockerfilePath.wstring()));
367 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
368 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
369 + VERIFY_IS_FALSE(buildResult.Stderr->empty());
370 + }
371 +
372 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_MissingEnvVar_EmptyValue_Success)
373 + {
374 + // Docker parity: an unset environment variable yields an empty secret value, not an error.
375 + constexpr auto envName = L"WSLC_E2E_SECRET_UNSET_VAR";
376 + ScopedEnvVariable envVar(envName); // Clears it (restoring any prior value on exit) so a leaked value can't taint the test.
377 +
378 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretMissingEnv);
379 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-missing";
380 + auto cleanup = SetupTestDirectory(testRoot);
381 +
382 + auto contextDir = SharedSecretBuildContext();
383 +
384 + auto dockerfilePath = testRoot / L"Dockerfile";
385 + WriteTestFileContent(
386 + dockerfilePath,
387 + "# syntax=docker/dockerfile:1\n"
388 + "FROM debian:latest\n"
389 + "RUN --mount=type=secret,id=mysecret [ -z \"$(cat /run/secrets/mysecret)\" ]\n"
390 + "CMD [\"echo\", \"secret-empty-ok\"]\n");
391 +
392 + auto buildResult = RunWslc(std::format(
393 + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_SECRET_UNSET_VAR",
394 + contextDir.wstring(),
395 + dockerfilePath.wstring(),
396 + BuiltImageSecretMissingEnv.NameAndTag()));
397 + buildResult.Verify({.ExitCode = 0});
398 +
399 + auto inspectData = InspectImage(BuiltImageSecretMissingEnv.NameAndTag());
400 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
401 + }
402 +
403 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_Src_Success)
404 + {
405 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretSrc);
406 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src";
407 + auto cleanup = SetupTestDirectory(testRoot);
408 +
409 + auto contextDir = SharedSecretBuildContext();
410 + std::error_code ec;
411 +
412 + // Place the secret OUTSIDE the build context; the server mounts the secret file's parent
413 + // directory read-only and references the file in place, so its bytes are never copied.
414 + auto secretDir = testRoot / L"secrets";
415 + std::filesystem::create_directories(secretDir, ec);
416 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(secretDir));
417 + auto secretFile = secretDir / L"token.txt";
418 + WriteTestFileContent(secretFile, "file-secret-content-67890");
419 +
420 + auto dockerfilePath = testRoot / L"Dockerfile";
421 + WriteTestFileContent(
422 + dockerfilePath,
423 + "# syntax=docker/dockerfile:1\n"
424 + "FROM debian:latest\n"
425 + "RUN --mount=type=secret,id=mysecret "
426 + "[ \"$(cat /run/secrets/mysecret)\" = \"file-secret-content-67890\" ]\n"
427 + "CMD [\"echo\", \"secret-src-ok\"]\n");
428 +
429 + auto buildResult = RunWslc(std::format(
430 + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"",
431 + contextDir.wstring(),
432 + dockerfilePath.wstring(),
433 + BuiltImageSecretSrc.NameAndTag(),
434 + secretFile.wstring()));
435 + buildResult.Verify({.ExitCode = 0});
436 +
437 + auto inspectData = InspectImage(BuiltImageSecretSrc.NameAndTag());
438 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
439 + }
440 +
441 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_SrcSymlink_Success)
442 + {
443 + // A symlink whose target lives in a separate directory must resolve to the target's content.
444 + // The client canonicalizes the link to its target; the server mounts the *target's* parent
445 + // directory read-only and references the resolved file in place, so its bytes are never copied.
446 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretSrcSymlink);
447 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src-symlink";
448 + auto cleanup = SetupTestDirectory(testRoot);
449 +
450 + auto contextDir = SharedSecretBuildContext();
451 + std::error_code ec;
452 +
453 + auto targetDir = testRoot / L"target";
454 + std::filesystem::create_directories(targetDir, ec);
455 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(targetDir));
456 + auto targetFile = targetDir / L"real-secret.txt";
457 + WriteTestFileContent(targetFile, "symlinked-secret-content-44444");
458 +
459 + auto linkDir = testRoot / L"links";
460 + std::filesystem::create_directories(linkDir, ec);
461 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(linkDir));
462 + auto linkFile = linkDir / L"token.txt";
463 + std::filesystem::create_symlink(targetFile, linkFile);
464 +
465 + auto dockerfilePath = testRoot / L"Dockerfile";
466 + WriteTestFileContent(
467 + dockerfilePath,
468 + "# syntax=docker/dockerfile:1\n"
469 + "FROM debian:latest\n"
470 + "RUN --mount=type=secret,id=mysecret "
471 + "[ \"$(cat /run/secrets/mysecret)\" = \"symlinked-secret-content-44444\" ]\n"
472 + "CMD [\"echo\", \"secret-symlink-ok\"]\n");
473 +
474 + auto buildResult = RunWslc(std::format(
475 + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"",
476 + contextDir.wstring(),
477 + dockerfilePath.wstring(),
478 + BuiltImageSecretSrcSymlink.NameAndTag(),
479 + linkFile.wstring()));
480 + buildResult.Verify({.ExitCode = 0});
481 +
482 + auto inspectData = InspectImage(BuiltImageSecretSrcSymlink.NameAndTag());
483 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
484 + }
485 +
486 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_SrcFileMissing_Fails)
487 + {
488 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-src-missing";
489 + auto cleanup = SetupTestDirectory(testRoot);
490 +
491 + auto contextDir = testRoot / L"context";
492 + std::error_code ec;
493 + std::filesystem::create_directories(contextDir, ec);
494 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
495 +
496 + auto dockerfilePath = testRoot / L"Dockerfile";
497 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
498 +
499 + // Build should fail if the src file does not exist
500 + auto missingFile = testRoot / L"does-not-exist.txt";
501 + auto buildResult = RunWslc(std::format(
502 + L"build \"{}\" -f \"{}\" --secret id=x,src=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), missingFile.wstring()));
503 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
504 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
505 + VERIFY_IS_FALSE(buildResult.Stderr->empty());
506 + }
507 +
508 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_EnvAndSrc_EnvWins_Success)
509 + {
510 + // Docker parity: when both 'env=' and 'src=' are given, the environment variable wins and
511 + // the file path is ignored (no error).
512 + constexpr auto envName = L"WSLC_E2E_ENV_WINS_VALUE";
513 + constexpr auto envValue = L"env-wins-content-55555";
514 + ScopedEnvVariable envVar(envName, envValue);
515 +
516 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretEnvWins);
517 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-both";
518 + auto cleanup = SetupTestDirectory(testRoot);
519 +
520 + auto contextDir = SharedSecretBuildContext();
521 +
522 + // The src file holds different content; it must be ignored in favor of the env value.
523 + auto secretFile = testRoot / L"ignored.txt";
524 + WriteTestFileContent(secretFile, "this-file-should-be-ignored");
525 +
526 + auto dockerfilePath = testRoot / L"Dockerfile";
527 + WriteTestFileContent(
528 + dockerfilePath,
529 + "# syntax=docker/dockerfile:1\n"
530 + "FROM debian:latest\n"
531 + "RUN --mount=type=secret,id=mysecret "
532 + "[ \"$(cat /run/secrets/mysecret)\" = \"env-wins-content-55555\" ]\n"
533 + "CMD [\"echo\", \"secret-env-wins-ok\"]\n");
534 +
535 + auto buildResult = RunWslc(std::format(
536 + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,env=WSLC_E2E_ENV_WINS_VALUE,src=\"{}\"",
537 + contextDir.wstring(),
538 + dockerfilePath.wstring(),
539 + BuiltImageSecretEnvWins.NameAndTag(),
540 + secretFile.wstring()));
541 + buildResult.Verify({.ExitCode = 0});
542 +
543 + auto inspectData = InspectImage(BuiltImageSecretEnvWins.NameAndTag());
544 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
545 + }
546 +
547 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeEnv_Success)
548 + {
549 + constexpr auto envName = L"WSLC_E2E_TYPE_ENV_VALUE";
550 + constexpr auto envValue = L"type-env-content-11111";
551 + ScopedEnvVariable envVar(envName, envValue);
552 +
553 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeEnv);
554 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-env";
555 + auto cleanup = SetupTestDirectory(testRoot);
556 +
557 + auto contextDir = SharedSecretBuildContext();
558 +
559 + auto dockerfilePath = testRoot / L"Dockerfile";
560 + WriteTestFileContent(
561 + dockerfilePath,
562 + "# syntax=docker/dockerfile:1\n"
563 + "FROM debian:latest\n"
564 + "RUN --mount=type=secret,id=mysecret "
565 + "[ \"$(cat /run/secrets/mysecret)\" = \"type-env-content-11111\" ]\n"
566 + "CMD [\"echo\", \"secret-ok\"]\n");
567 +
568 + auto buildResult = RunWslc(std::format(
569 + L"build \"{}\" -f \"{}\" -t {} --secret type=env,id=mysecret,env=WSLC_E2E_TYPE_ENV_VALUE",
570 + contextDir.wstring(),
571 + dockerfilePath.wstring(),
572 + BuiltImageSecretTypeEnv.NameAndTag()));
573 + buildResult.Verify({.ExitCode = 0});
574 +
575 + auto inspectData = InspectImage(BuiltImageSecretTypeEnv.NameAndTag());
576 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
577 + }
578 +
579 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeEnvSrcIsEnvName_Success)
580 + {
581 + // Docker parity: with type=env, a bare src= names the env var to read (not a file path).
582 + constexpr auto envName = L"WSLC_E2E_TYPE_ENV_SRC_VALUE";
583 + constexpr auto envValue = L"type-env-src-content-22222";
584 + ScopedEnvVariable envVar(envName, envValue);
585 +
586 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeEnvSrc);
587 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-env-src";
588 + auto cleanup = SetupTestDirectory(testRoot);
589 +
590 + auto contextDir = SharedSecretBuildContext();
591 +
592 + auto dockerfilePath = testRoot / L"Dockerfile";
593 + WriteTestFileContent(
594 + dockerfilePath,
595 + "# syntax=docker/dockerfile:1\n"
596 + "FROM debian:latest\n"
597 + "RUN --mount=type=secret,id=mysecret "
598 + "[ \"$(cat /run/secrets/mysecret)\" = \"type-env-src-content-22222\" ]\n"
599 + "CMD [\"echo\", \"secret-ok\"]\n");
600 +
601 + auto buildResult = RunWslc(std::format(
602 + L"build \"{}\" -f \"{}\" -t {} --secret type=env,id=mysecret,src=WSLC_E2E_TYPE_ENV_SRC_VALUE",
603 + contextDir.wstring(),
604 + dockerfilePath.wstring(),
605 + BuiltImageSecretTypeEnvSrc.NameAndTag()));
606 + buildResult.Verify({.ExitCode = 0});
607 +
608 + auto inspectData = InspectImage(BuiltImageSecretTypeEnvSrc.NameAndTag());
609 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
610 + }
611 +
612 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_TypeFile_Success)
613 + {
614 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretTypeFile);
615 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-file";
616 + auto cleanup = SetupTestDirectory(testRoot);
617 +
618 + auto contextDir = SharedSecretBuildContext();
619 +
620 + auto secretFile = testRoot / L"token.txt";
621 + WriteTestFileContent(secretFile, "type-file-content-33333");
622 +
623 + auto dockerfilePath = testRoot / L"Dockerfile";
624 + WriteTestFileContent(
625 + dockerfilePath,
626 + "# syntax=docker/dockerfile:1\n"
627 + "FROM debian:latest\n"
628 + "RUN --mount=type=secret,id=mysecret "
629 + "[ \"$(cat /run/secrets/mysecret)\" = \"type-file-content-33333\" ]\n"
630 + "CMD [\"echo\", \"secret-ok\"]\n");
631 +
632 + auto buildResult = RunWslc(std::format(
633 + L"build \"{}\" -f \"{}\" -t {} --secret type=file,id=mysecret,src=\"{}\"",
634 + contextDir.wstring(),
635 + dockerfilePath.wstring(),
636 + BuiltImageSecretTypeFile.NameAndTag(),
637 + secretFile.wstring()));
638 + buildResult.Verify({.ExitCode = 0});
639 +
640 + auto inspectData = InspectImage(BuiltImageSecretTypeFile.NameAndTag());
641 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
642 + }
643 +
644 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_BinaryFile_Success)
645 + {
646 + // A file secret must be delivered byte-for-byte, including an embedded NUL and high bytes that
647 + // an environment-variable (NUL-terminated, text-only) transport could never carry. The content
648 + // below is 13 bytes with a NUL at offset 6; the in-container checks assert both the exact byte
649 + // count (proving no NUL truncation) and that the bytes on either side of the NUL survived.
650 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretBinary);
651 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-binary";
652 + auto cleanup = SetupTestDirectory(testRoot);
653 +
654 + auto contextDir = SharedSecretBuildContext();
655 +
656 + auto secretFile = testRoot / L"blob.bin";
657 + WriteTestFileContent(secretFile, std::string("before\0after\xff", 13));
658 +
659 + auto dockerfilePath = testRoot / L"Dockerfile";
660 + WriteTestFileContent(
661 + dockerfilePath,
662 + "# syntax=docker/dockerfile:1\n"
663 + "FROM debian:latest\n"
664 + "RUN --mount=type=secret,id=mysecret "
665 + "[ \"$(wc -c < /run/secrets/mysecret)\" = \"13\" ] && "
666 + "[ \"$(tr -d '\\000' < /run/secrets/mysecret | tr -d '\\377')\" = \"beforeafter\" ]\n"
667 + "CMD [\"echo\", \"secret-binary-ok\"]\n");
668 +
669 + auto buildResult = RunWslc(std::format(
670 + L"build \"{}\" -f \"{}\" -t {} --secret type=file,id=mysecret,src=\"{}\"",
671 + contextDir.wstring(),
672 + dockerfilePath.wstring(),
673 + BuiltImageSecretBinary.NameAndTag(),
674 + secretFile.wstring()));
675 + buildResult.Verify({.ExitCode = 0});
676 +
677 + auto inspectData = InspectImage(BuiltImageSecretBinary.NameAndTag());
678 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
679 + }
680 +
681 + // Builds a file secret of the given size (filled with a single repeated byte) and asserts, inside the
682 + // container, both the exact byte count and that every byte survived intact. Verifies the client->service
683 + // transport carries the secret byte-for-byte regardless of size.
684 + void RunSizedFileSecretSuccess(const TestImage& image, const std::wstring& subdir, size_t size)
685 + {
686 + auto imageCleanup = DeleteImageOnExit(image);
687 + auto testRoot = std::filesystem::current_path() / subdir;
688 + auto cleanup = SetupTestDirectory(testRoot);
689 +
690 + auto contextDir = SharedSecretBuildContext();
691 +
692 + auto secretFile = testRoot / L"secret.bin";
693 + WriteTestFileContent(secretFile, std::string(size, 'A'));
694 +
695 + auto dockerfilePath = testRoot / L"Dockerfile";
696 + WriteTestFileContent(
697 + dockerfilePath,
698 + std::format(
699 + "# syntax=docker/dockerfile:1\n"
700 + "FROM debian:latest\n"
701 + "RUN --mount=type=secret,id=mysecret "
702 + "[ \"$(wc -c < /run/secrets/mysecret)\" = \"{}\" ] && "
703 + "[ -z \"$(tr -d 'A' < /run/secrets/mysecret)\" ]\n"
704 + "CMD [\"echo\", \"secret-size-ok\"]\n",
705 + size));
706 +
707 + auto buildResult = RunWslc(std::format(
708 + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"",
709 + contextDir.wstring(),
710 + dockerfilePath.wstring(),
711 + image.NameAndTag(),
712 + secretFile.wstring()));
713 + buildResult.Verify({.ExitCode = 0});
714 +
715 + auto inspectData = InspectImage(image.NameAndTag());
716 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
717 + }
718 +
719 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_EmptyFile_Success)
720 + {
721 + // A zero-byte file secret must mount as an empty (but present) file.
722 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretEmptyFile);
723 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-empty-file";
724 + auto cleanup = SetupTestDirectory(testRoot);
725 +
726 + auto contextDir = SharedSecretBuildContext();
727 +
728 + auto secretFile = testRoot / L"empty.bin";
729 + WriteTestFileContent(secretFile, "");
730 +
731 + auto dockerfilePath = testRoot / L"Dockerfile";
732 + WriteTestFileContent(
733 + dockerfilePath,
734 + "# syntax=docker/dockerfile:1\n"
735 + "FROM debian:latest\n"
736 + "RUN --mount=type=secret,id=mysecret "
737 + "[ -f /run/secrets/mysecret ] && [ \"$(wc -c < /run/secrets/mysecret)\" = \"0\" ]\n"
738 + "CMD [\"echo\", \"secret-empty-ok\"]\n");
739 +
740 + auto buildResult = RunWslc(std::format(
741 + L"build \"{}\" -f \"{}\" -t {} --secret id=mysecret,src=\"{}\"",
742 + contextDir.wstring(),
743 + dockerfilePath.wstring(),
744 + BuiltImageSecretEmptyFile.NameAndTag(),
745 + secretFile.wstring()));
746 + buildResult.Verify({.ExitCode = 0});
747 +
748 + auto inspectData = InspectImage(BuiltImageSecretEmptyFile.NameAndTag());
749 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
750 + }
751 +
752 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_LargeFile_Success)
753 + {
754 + // A mid-size (256 KiB) secret is well within BuildKit's cap and exercises a multi-page transport.
755 + RunSizedFileSecretSuccess(BuiltImageSecretLarge, L"wslc-e2e-build-secret-large", 256 * 1024);
756 + }
757 +
758 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_MaxSizeFile_Success)
759 + {
760 + // Exactly BuildKit's per-secret cap (500 KiB == 512000 bytes) must still succeed.
761 + RunSizedFileSecretSuccess(BuiltImageSecretMaxSize, L"wslc-e2e-build-secret-max-size", c_maxSecretSize);
762 + }
763 +
764 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_OversizeFile_Fails)
765 + {
766 + // One byte over BuildKit's per-secret cap (500 KiB + 1). The file is forwarded and mounted, and
767 + // BuildKit enforces its MaxSecretSize limit when the secret is consumed, so the build fails.
768 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-oversize";
769 + auto cleanup = SetupTestDirectory(testRoot);
770 +
771 + auto contextDir = SharedSecretBuildContext();
772 +
773 + auto secretFile = testRoot / L"secret.bin";
774 + WriteTestFileContent(secretFile, std::string(c_maxSecretSize + 1, 'A'));
775 +
776 + auto dockerfilePath = testRoot / L"Dockerfile";
777 + WriteTestFileContent(
778 + dockerfilePath,
779 + "# syntax=docker/dockerfile:1\n"
780 + "FROM debian:latest\n"
781 + "RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret > /dev/null\n"
782 + "CMD [\"echo\", \"secret-oversize\"]\n");
783 +
784 + auto buildResult = RunWslc(std::format(
785 + L"build \"{}\" -f \"{}\" --secret id=mysecret,src=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), secretFile.wstring()));
786 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
787 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
788 + VERIFY_IS_FALSE(buildResult.Stderr->empty());
789 + }
790 +
791 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_MultipleFiles_Success)
792 + {
793 + // Several file secrets in one build: two share a directory (the server mounts it once, deduped)
794 + // and a third lives elsewhere (a second mount). All three must be delivered with their own
795 + // content, exercising the multi-mount/dedup path for in-place file secrets.
796 + auto imageCleanup = DeleteImageOnExit(BuiltImageSecretMultiple);
797 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-multi";
798 + auto cleanup = SetupTestDirectory(testRoot);
799 +
800 + auto contextDir = SharedSecretBuildContext();
801 + std::error_code ec;
802 +
803 + auto dirA = testRoot / L"a";
804 + auto dirB = testRoot / L"b";
805 + std::filesystem::create_directories(dirA, ec);
806 + std::filesystem::create_directories(dirB, ec);
807 + THROW_HR_IF(E_FAIL, !std::filesystem::exists(dirA) || !std::filesystem::exists(dirB));
808 +
809 + auto secret1 = dirA / L"s1.txt";
810 + auto secret2 = dirA / L"s2.txt";
811 + auto secret3 = dirB / L"s3.txt";
812 + WriteTestFileContent(secret1, "multi-secret-one-11111");
813 + WriteTestFileContent(secret2, "multi-secret-two-22222");
814 + WriteTestFileContent(secret3, "multi-secret-three-33333");
815 +
816 + auto dockerfilePath = testRoot / L"Dockerfile";
817 + WriteTestFileContent(
818 + dockerfilePath,
819 + "# syntax=docker/dockerfile:1\n"
820 + "FROM debian:latest\n"
821 + "RUN --mount=type=secret,id=s1 --mount=type=secret,id=s2 --mount=type=secret,id=s3 "
822 + "[ \"$(cat /run/secrets/s1)\" = \"multi-secret-one-11111\" ] && "
823 + "[ \"$(cat /run/secrets/s2)\" = \"multi-secret-two-22222\" ] && "
824 + "[ \"$(cat /run/secrets/s3)\" = \"multi-secret-three-33333\" ]\n"
825 + "CMD [\"echo\", \"secret-multi-ok\"]\n");
826 +
827 + auto buildResult = RunWslc(std::format(
828 + L"build \"{}\" -f \"{}\" -t {} --secret id=s1,src=\"{}\" --secret id=s2,src=\"{}\" --secret id=s3,src=\"{}\"",
829 + contextDir.wstring(),
830 + dockerfilePath.wstring(),
831 + BuiltImageSecretMultiple.NameAndTag(),
832 + secret1.wstring(),
833 + secret2.wstring(),
834 + secret3.wstring()));
835 + buildResult.Verify({.ExitCode = 0});
836 +
837 + auto inspectData = InspectImage(BuiltImageSecretMultiple.NameAndTag());
838 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
839 + }
840 +
841 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Secret_UnknownType_Fails)
842 + {
843 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-secret-type-bad";
844 + auto cleanup = SetupTestDirectory(testRoot);
845 +
846 + auto contextDir = testRoot / L"context";
847 + std::error_code ec;
848 + std::filesystem::create_directories(contextDir, ec);
849 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
850 +
851 + auto dockerfilePath = testRoot / L"Dockerfile";
852 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
853 +
854 + auto buildResult =
855 + RunWslc(std::format(L"build \"{}\" -f \"{}\" --secret id=x,type=bogus", contextDir.wstring(), dockerfilePath.wstring()));
856 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
857 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
858 + VERIFY_IS_TRUE(buildResult.Stderr->find(L"Invalid --secret value 'id=x,type=bogus': unsupported secret type 'bogus'") != std::wstring::npos);
859 + }
860 +
861 WSLC_TEST_METHOD(WSLCE2E_Image_Build_DockerfileInContextDir_Success)
862 {
863 auto imageCleanup = DeleteImageOnExit(BuiltImageDockerfile);
@@ -373,6 +970,23 @@ private:
970 const TestImage BuiltImageNoCache{L"wslc-e2e-build-no-cache", L"latest", L""};
971 const TestImage BuiltImageLabel{L"wslc-e2e-build-label", L"latest", L""};
972 const TestImage BuiltImageLabelOverride{L"wslc-e2e-build-label-override", L"latest", L""};
973 + const TestImage BuiltImageSecret{L"wslc-e2e-build-secret-env", L"latest", L""};
974 + const TestImage BuiltImageSecretBareId{L"wslc-e2e-build-secret-bare-id", L"latest", L""};
975 + const TestImage BuiltImageSecretMissingEnv{L"wslc-e2e-build-secret-missing-env", L"latest", L""};
976 + const TestImage BuiltImageSecretEnvWins{L"wslc-e2e-build-secret-env-wins", L"latest", L""};
977 + const TestImage BuiltImageSecretTypeEnv{L"wslc-e2e-build-secret-type-env", L"latest", L""};
978 + const TestImage BuiltImageSecretTypeEnvSrc{L"wslc-e2e-build-secret-type-env-src", L"latest", L""};
979 + const TestImage BuiltImageSecretTypeFile{L"wslc-e2e-build-secret-type-file", L"latest", L""};
980 + const TestImage BuiltImageSecretSrc{L"wslc-e2e-build-secret-src", L"latest", L""};
981 + const TestImage BuiltImageSecretSrcSymlink{L"wslc-e2e-build-secret-src-symlink", L"latest", L""};
982 + const TestImage BuiltImageSecretBinary{L"wslc-e2e-build-secret-binary", L"latest", L""};
983 + const TestImage BuiltImageSecretEmptyFile{L"wslc-e2e-build-secret-empty-file", L"latest", L""};
984 + const TestImage BuiltImageSecretLarge{L"wslc-e2e-build-secret-large", L"latest", L""};
985 + const TestImage BuiltImageSecretMaxSize{L"wslc-e2e-build-secret-max-size", L"latest", L""};
986 + const TestImage BuiltImageSecretMultiple{L"wslc-e2e-build-secret-multi", L"latest", L""};
987 +
988 + // Maximum secret size allowed by BuildKit (500kb)
989 + static constexpr size_t c_maxSecretSize = 500 * 1024;
990
991 void BuildFromContextFile(const std::wstring& fileName, const TestImage& image)
992 {