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
{