Add --output to wslc image build for docker buildx exporter specs (#41157)

Accepts a docker buildx --output spec and routes the exporter result to a Windows destination. The client parses the spec using buildx's CSV grammar and picks one of three paths: dest=- relays the build process's stdout straight to the caller's handle, a real file destination is written in place through a read-write virtiofs mount of its parent directory, and everything else (type=image, type=cacheonly) is forwarded verbatim and stays in the VM. Directory exporters (type=local, or oci/docker with tar=false) are rejected at parse time, since a Linux directory tree cannot be materialized faithfully on a Windows destination. Writing a binary exporter stream to an interactive console is rejected as well. Adds unit tests covering the spec grammar and rejection cases, plus e2e coverage for each exporter type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

ggarzia-MSFT committed Aug 4, 2026 at 11:50 UTC d90a4ed67335779792a290a0f28cecbefe563f09
14 files changed +1213 -8
localization/strings/en-US/Resources.resw
+12
@@ -2330,6 +2330,14 @@ For privacy information about this product please visit https://aka.ms/privacy.<
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="MessageWslcOutputInvalidSpec" xml:space="preserve">
2334 + <value>Invalid --output value '{}': {}</value>
2335 + <comment>{FixedPlaceholder="{}"}{Locked="--output "}Command line arguments, file names and string inserts should not be translated</comment>
2336 + </data>
2337 + <data name="MessageWslcOutputConsoleNotSupported" xml:space="preserve">
2338 + <value>Cannot write build output to the console for --output value '{}'.</value>
2339 + <comment>{FixedPlaceholder="{}"}{Locked="--output "}Command line arguments, file names and string inserts should not be translated</comment>
2340 + </data>
2341 <data name = "MessageWslcMissingVolumeOption" xml:space = "preserve" >
2342 <value>Missing required option: '{}'</value>
2343 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
@@ -2891,6 +2899,10 @@ On first run, creates the file with all settings commented out at their defaults
2899 <data name="WSLCCLI_BuildPullArgDescription" xml:space="preserve">
2900 <value>Always attempt to pull a newer version of the image</value>
2901 </data>
2902 + <data name="WSLCCLI_BuildOutputArgDescription" xml:space="preserve">
2903 + <value>Build output destination (docker buildx --output spec; e.g. type=local,dest=path or type=tar,dest=out.tar)</value>
2904 + <comment>{Locked="--output "}Command line arguments, file names and string inserts should not be translated</comment>
2905 + </data>
2906 <data name="WSLCCLI_BuildTargetArgDescription" xml:space="preserve">
2907 <value>Set the target build stage to build</value>
2908 </data>
src/shared/inc/stringshared.h
+37
@@ -65,6 +65,43 @@ inline bool EndsWith(const std::basic_string<T>& String, const std::basic_string
65 return std::equal(Suffix.rbegin(), Suffix.rend(), String.rbegin());
66 }
67
68 +// Lowercases ASCII 'A'-'Z' only, leaving every other code unit untouched. Unlike std::tolower this is
69 +// locale-independent (no Turkish-'I' surprises) and has no signed-char UB, which is what you want when
70 +// normalizing ASCII protocol tokens such as buildx CSV keys.
71 +template <class T>
72 +inline std::basic_string<T> AsciiToLower(const std::basic_string_view<T>& String)
73 +{
74 + std::basic_string<T> Result(String);
75 + for (auto& Ch : Result)
76 + {
77 + if (Ch >= static_cast<T>('A') && Ch <= static_cast<T>('Z'))
78 + {
79 + Ch = static_cast<T>(Ch - static_cast<T>('A') + static_cast<T>('a'));
80 + }
81 + }
82 +
83 + return Result;
84 +}
85 +
86 +// Trims leading and trailing ASCII whitespace (space, tab, CR, LF, vertical tab, form feed), matching
87 +// the ASCII subset of Go's strings.TrimSpace. Returns a view into the input, so the input must outlive
88 +// the result. The stdlib has no trim, so this centralizes the find_first/last_not_of idiom.
89 +template <class T>
90 +inline std::basic_string_view<T> TrimAscii(const std::basic_string_view<T>& String)
91 +{
92 + constexpr T Whitespace[] = {
93 + static_cast<T>(' '), static_cast<T>('\t'), static_cast<T>('\r'), static_cast<T>('\n'), static_cast<T>('\v'), static_cast<T>('\f'), static_cast<T>('\0')};
94 +
95 + const auto First = String.find_first_not_of(Whitespace);
96 + if (First == std::basic_string_view<T>::npos)
97 + {
98 + return {};
99 + }
100 +
101 + const auto Last = String.find_last_not_of(Whitespace);
102 + return String.substr(First, Last - First + 1);
103 +}
104 +
105 template <class T, class TInput>
106 inline std::basic_string<T> Join(const std::vector<TInput>& Input, T Separator)
107 {
src/windows/service/inc/wslc.idl
+4
@@ -549,6 +549,10 @@ typedef struct _WSLCBuildImageOptions
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 + [unique, string] LPCSTR Output; // buildx exporter spec passed as --output to docker build (e.g. type=tar, type=oci). The client omits dest= for exporters with a client destination; the server rewrites it to a VM temp path (streamed back over OutputHandle) or into the OutputMountPath mount (single-file exporters with a real destination). Directory exporters (type=local, or oci/docker with tar=false) are not supported.
553 + WSLCHandle OutputHandle; // When Type != WSLCHandleTypeUnknown, the server streams the single-file exporter output (a tar/oci/docker tarball, or dest=- stdout) to this handle after a successful build.
554 + [unique, string] LPCWSTR OutputMountPath; // When set, the server mounts this Windows directory read-write into the VM and points the exporter's dest at it (plus OutputMountFile). Used for single-file exporters with a real destination; mutually exclusive with OutputHandle.
555 + [unique, string] LPCWSTR OutputMountFile; // Leaf filename within OutputMountPath that a single-file exporter writes to.
556 } WSLCBuildImageOptions;
557
558 typedef struct _WSLCTagImageOptions
src/windows/wslc/arguments/SpecParsing.cpp
+191
@@ -199,6 +199,197 @@ services::BuildSecret ParseSecretSpec(const std::wstring& spec)
199 };
200 }
201
202 +services::BuildOutput ParseOutputSpec(const std::wstring& spec)
203 +{
204 + // Mirrors `docker buildx build --output`. A bare token is shorthand for a destination; otherwise
205 + // the spec is a single CSV record of key=value pairs where 'type'/'dest' are structural and every
206 + // other key is forwarded verbatim to buildx as an exporter attribute.
207 + if (spec.empty())
208 + {
209 + throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(spec, L"the value may not be empty"));
210 + }
211 +
212 + // buildx parses the spec as one CSV record (go-csvvalue / encoding/csv): fields are comma
213 + // separated, a field may be double-quoted, "" inside a quoted field is a literal quote, and a
214 + // comma inside quotes is part of the value. This lets a value such as an annotation contain commas.
215 + const auto fields = SplitCsvFields(spec);
216 + if (!fields.has_value())
217 + {
218 + throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(spec, L"malformed quoting"));
219 + }
220 +
221 + // Keys are ASCII and matched case-insensitively, and buildx TrimSpace's each key so " dest=x" after
222 + // a comma is accepted; values are left untouched. AsciiToLower/TrimAscii live in stringshared.h.
223 +
224 + // Shorthand: a single field that is exactly the input and does not start with "type=" names the
225 + // destination. Matching Docker, '-' streams a tarball to stdout ('type=tar,dest=-'); anything else
226 + // is the local (directory) exporter ('type=local,dest=<path>'), which is not supported.
227 + if (fields->size() == 1 && fields->front() == spec && spec.compare(0, 5, L"type=") != 0)
228 + {
229 + if (fields->front() == L"-")
230 + {
231 + return services::BuildOutput{.Type = L"tar", .Dest = L"-"};
232 + }
233 +
234 + throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(
235 + spec,
236 + L"directory exporters are not supported; write a single file with 'dest=<file>' (type=tar/oci/docker) "
237 + L"or stream a tarball to stdout with 'type=tar,dest=-'"));
238 + }
239 +
240 + services::BuildOutput output;
241 + std::wstring rawType;
242 + bool hasType = false;
243 +
244 + for (const auto& field : *fields)
245 + {
246 + // buildx splits each field on the FIRST '=' and requires two parts; the value is not trimmed.
247 + const auto pos = field.find(L'=');
248 + if (pos == std::wstring::npos)
249 + {
250 + throw ArgumentException(
251 + Localization::MessageWslcOutputInvalidSpec(spec, L"expected key=value pairs separated by ','"));
252 + }
253 +
254 + const auto key = AsciiToLower(TrimAscii(std::wstring_view(field).substr(0, pos)));
255 + auto value = field.substr(pos + 1);
256 + if (key.empty())
257 + {
258 + throw ArgumentException(
259 + Localization::MessageWslcOutputInvalidSpec(spec, L"expected key=value pairs separated by ','"));
260 + }
261 +
262 + if (key == L"type")
263 + {
264 + rawType = value;
265 + output.Type = AsciiToLower(std::wstring_view(value));
266 + hasType = true;
267 + }
268 + else if (key == L"dest")
269 + {
270 + output.Dest = std::move(value);
271 + }
272 + else
273 + {
274 + // Remaining attributes (name, push, compression, tar, annotations, ...) are matched
275 + // case-insensitively by buildx, so their keys are already lowercased above.
276 + output.Attributes[key] = std::move(value);
277 + }
278 + }
279 +
280 + if (!hasType || output.Type.empty())
281 + {
282 + throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(spec, L"type is required"));
283 + }
284 +
285 + // buildx forwards the type to buildkit and only rejects it there; we route the exporter ourselves,
286 + // so an unroutable type has to be rejected up front. Every real exporter is in this list.
287 + const bool supportedType = output.Type == L"local" || output.Type == L"tar" || output.Type == L"oci" ||
288 + output.Type == L"docker" || output.Type == L"image" || output.Type == L"registry" ||
289 + output.Type == L"cacheonly";
290 + if (!supportedType)
291 + {
292 + throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(spec, std::format(L"unsupported output type '{}'", rawType)));
293 + }
294 +
295 + // The 'tar' attribute selects a single tarball vs. an OCI layout directory for oci/docker, and it
296 + // drives file-vs-directory routing here. buildx parses it with Go's ParseBool and errors on
297 + // anything else, so reject an invalid value up front rather than failing confusingly in the VM.
298 + if (output.Type == L"oci" || output.Type == L"docker")
299 + {
300 + const auto tarIt = output.Attributes.find(L"tar");
301 + if (tarIt != output.Attributes.end() && !ParseBool(tarIt->second.c_str(), true).has_value())
302 + {
303 + throw ArgumentException(
304 + Localization::MessageWslcOutputInvalidSpec(spec, std::format(L"invalid boolean value '{}' for 'tar'", tarIt->second)));
305 + }
306 + }
307 +
308 + // Destination resolution, mirroring `docker buildx build --output`:
309 + if (OutputIsDirectory(output))
310 + {
311 + // Directory exporters (local, or oci/docker with tar=false) write a Linux directory tree, which
312 + // cannot be materialized faithfully on a Windows destination, so they are not supported. Point
313 + // users at the single-file exporters instead.
314 + throw ArgumentException(Localization::MessageWslcOutputInvalidSpec(
315 + spec,
316 + L"directory exporters are not supported; write a single file with 'dest=<file>' (type=tar/oci/docker) "
317 + L"or stream a tarball to stdout with 'type=tar,dest=-'"));
318 + }
319 +
320 + if (output.Type == L"tar" || output.Type == L"oci")
321 + {
322 + // Single-tarball exporters stream to stdout when no destination is given (buildx default).
323 + if (output.Dest.empty())
324 + {
325 + output.Dest = L"-";
326 + }
327 + }
328 + // docker: no dest -> load into the VM image store (leave empty); dest='-' streams a tarball to
329 + // stdout; a path writes a file. image/registry/cacheonly run in the VM and ignore 'dest'.
330 +
331 + return output;
332 +}
333 +
334 +bool OutputStreamsToClient(const services::BuildOutput& output)
335 +{
336 + if (output.Type == L"local" || output.Type == L"tar" || output.Type == L"oci")
337 + {
338 + return true;
339 + }
340 +
341 + if (output.Type == L"docker")
342 + {
343 + // An omitted destination loads the image into the store in the VM; any destination (a file or
344 + // stdout '-') is produced in the VM and streamed back to the client.
345 + return !output.Dest.empty();
346 + }
347 +
348 + // image / registry / cacheonly run entirely in the build VM.
349 + return false;
350 +}
351 +
352 +bool OutputIsDirectory(const services::BuildOutput& output)
353 +{
354 + if (output.Type == L"local")
355 + {
356 + return true;
357 + }
358 +
359 + if (output.Type == L"oci" || output.Type == L"docker")
360 + {
361 + // oci/docker default to a single tarball but export an OCI layout directory when tar is false.
362 + const auto it = output.Attributes.find(L"tar");
363 + if (it != output.Attributes.end())
364 + {
365 + const auto tar = ParseBool(it->second.c_str(), true);
366 + return tar.has_value() && !tar.value();
367 + }
368 + }
369 +
370 + return false;
371 +}
372 +
373 +std::wstring FormatOutputSpec(const services::BuildOutput& output)
374 +{
375 + // buildx consumes the same CSV key=value form we parsed, so we round-trip the parsed struct back
376 + // into a canonical spec. This is what actually reaches `docker build --output <spec>`. Each token
377 + // is CSV-escaped so a value containing a comma or quote survives the trip.
378 + std::vector<std::wstring> fields;
379 + fields.push_back(std::format(L"type={}", output.Type));
380 + if (!output.Dest.empty())
381 + {
382 + fields.push_back(std::format(L"dest={}", output.Dest));
383 + }
384 +
385 + for (const auto& [key, value] : output.Attributes)
386 + {
387 + fields.push_back(std::format(L"{}={}", key, value));
388 + }
389 +
390 + return JoinCsvFields(fields);
391 +}
392 +
393 std::tuple<std::string, int64_t, int64_t> ParseUlimit(const std::wstring& input, const std::wstring& argName)
394 {
395 // Accepts <name>=<soft>[:<hard>]; if hard is omitted hard = soft. -1 means unlimited.
src/windows/wslc/arguments/SpecParsing.h
+18 -1
@@ -23,7 +23,8 @@ Abstract:
23
24 namespace wsl::windows::wslc::services {
25 struct BuildSecret;
26 -}
26 +struct BuildOutput;
27 +} // namespace wsl::windows::wslc::services
28
29 namespace wsl::windows::wslc::validation {
30
@@ -43,6 +44,22 @@ KeyValueSplit SplitKeyValue(const std::wstring& value, wchar_t separator = L'=')
44 // Parses a docker-style --secret spec ("id=...,type=...,src=...") and resolves its value bytes.
45 services::BuildSecret ParseSecretSpec(const std::wstring& spec);
46
47 +// Parses a docker-style --output spec ("type=...,dest=...,<attr>=...") into a BuildOutput.
48 +services::BuildOutput ParseOutputSpec(const std::wstring& spec);
49 +
50 +// Serializes a BuildOutput back into a canonical buildx --output spec ("type=...,dest=...,<attr>=...").
51 +std::wstring FormatOutputSpec(const services::BuildOutput& output);
52 +
53 +// True when the exporter produces a destination (file, directory, or stdout stream) that the client
54 +// must materialize, versus running entirely in the build VM. Mirrors `docker buildx build --output`:
55 +// local/tar/oci always stream a result back; docker streams only when a 'dest=' is given (an omitted
56 +// dest loads the image into the VM store); image/registry/cacheonly never stream.
57 +bool OutputStreamsToClient(const services::BuildOutput& output);
58 +
59 +// True when the exporter writes a directory tree rather than a single file/stream. The local exporter
60 +// is always a directory; oci/docker export an OCI layout directory when 'tar=false' is set.
61 +bool OutputIsDirectory(const services::BuildOutput& output);
62 +
63 // Parses a --ulimit spec ("<name>=<soft>[:<hard>]") into (name, soft, hard). -1 means unlimited.
64 std::tuple<std::string, int64_t, int64_t> ParseUlimit(const std::wstring& input, const std::wstring& argName = {});
65
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::Output, false, std::nullopt, Localization::WSLCCLI_BuildOutputArgDescription()),
38 Argument::Create(ArgType::Secret, false, Limit::Unlimited),
39 Argument::Create(ArgType::Tag, false, Limit::Unlimited),
40 Argument::Create(ArgType::Verbose),
src/windows/wslc/services/ImageService.cpp
+71
@@ -14,6 +14,7 @@ Abstract:
14 #include "ImageService.h"
15 #include "RegistryService.h"
16 #include "SessionService.h"
17 +#include "SpecParsing.h"
18 #include "WarningCallback.h"
19 #include <wslutil.h>
20 #include <HandleConsoleProgressBar.h>
@@ -121,6 +122,7 @@ void ImageService::Build(
122 const std::vector<BuildSecret>& secrets,
123 const std::wstring& dockerfilePath,
124 const std::wstring& target,
125 + const std::optional<BuildOutput>& output,
126 WSLCBuildImageFlags flags,
127 IProgressCallback* callback,
128 HANDLE cancelEvent)
@@ -191,6 +193,71 @@ void ImageService::Build(
193
194 auto targetStr = wsl::windows::common::string::WideToMultiByte(target);
195
196 + // Route the docker-style --output exporter. A single-file exporter with a real destination (tar/oci/
197 + // docker with dest=) has the destination's parent directory mounted read-write into the VM, so buildx
198 + // writes the output file straight to the destination in place. dest=- (stdout) streams the exporter
199 + // tarball back out of the VM to OutputHandle. Exporters with no client destination (docker load,
200 + // image, registry, cacheonly) run entirely in the VM and the spec is forwarded as-is. Directory
201 + // exporters (type=local, or oci/docker with tar=false) are rejected up front by ParseOutputSpec: a
202 + // Linux tree cannot be written faithfully to a Windows-backed destination.
203 + std::string outputStr;
204 + HANDLE outputHandle = nullptr;
205 +
206 + // For a single-file exporter with a real destination the server writes the exporter output into a
207 + // read-write virtiofs mount of the destination's parent directory rather than streaming it back:
208 + // outputMountPath is that parent directory and outputMountFile the destination file's leaf name.
209 + std::wstring outputMountPath;
210 + std::wstring outputMountFile;
211 +
212 + if (output.has_value())
213 + {
214 + const auto& spec = output.value();
215 + // Route the exporter the same way `docker buildx build --output` does: some exporters produce a
216 + // result the client must materialize (a file or a stdout stream), while others run entirely in
217 + // the build VM. Directory exporters are already rejected by ParseOutputSpec. See
218 + // OutputStreamsToClient.
219 + const bool streamsBack = validation::OutputStreamsToClient(spec);
220 +
221 + if (streamsBack)
222 + {
223 + if (spec.Dest == L"-")
224 + {
225 + // dest=- streams the exporter tarball to the client's stdout, matching docker.
226 + outputHandle = GetStdHandle(STD_OUTPUT_HANDLE);
227 +
228 + // Refuse to dump the binary exporter stream onto an interactive console (matching docker).
229 + // Otherwise ToCOMInputHandle would fail the marshal with a cryptic ERROR_NOT_SUPPORTED for
230 + // the console handle. A redirected character device such as NUL is not a console, so
231 + // IsConsoleHandle (which also checks GetConsoleMode) still lets those through.
232 + THROW_HR_WITH_USER_ERROR_IF(
233 + E_INVALIDARG,
234 + Localization::MessageWslcOutputConsoleNotSupported(validation::FormatOutputSpec(spec)),
235 + IsConsoleHandle(outputHandle));
236 + }
237 + else
238 + {
239 + // Single-file exporter with a real destination: mount the destination's parent directory
240 + // read-write into the VM so buildx writes the exporter output file straight to the
241 + // destination in place.
242 + auto destPath = std::filesystem::absolute(spec.Dest);
243 + auto destDir = destPath.parent_path();
244 + std::filesystem::create_directories(destDir);
245 +
246 + outputMountPath = destDir.wstring();
247 + outputMountFile = destPath.filename().wstring();
248 + }
249 +
250 + // The server picks the VM-side dest, so forward the spec without the client's dest.
251 + BuildOutput vmSpec = spec;
252 + vmSpec.Dest.clear();
253 + outputStr = wsl::windows::common::string::WideToMultiByte(validation::FormatOutputSpec(vmSpec));
254 + }
255 + else
256 + {
257 + outputStr = wsl::windows::common::string::WideToMultiByte(validation::FormatOutputSpec(spec));
258 + }
259 + }
260 +
261 auto contextPathStr = absolutePath.wstring();
262 WSLCBuildImageOptions options{
263 .ContextPath = contextPathStr.c_str(),
@@ -201,6 +268,10 @@ void ImageService::Build(
268 .Flags = flags,
269 .Labels = {labelPointers.data(), static_cast<ULONG>(labelPointers.size())},
270 .Secrets = {secretEntries.data(), static_cast<ULONG>(secretEntries.size())},
271 + .Output = outputStr.empty() ? nullptr : outputStr.c_str(),
272 + .OutputHandle = outputHandle != nullptr ? ToCOMInputHandle(outputHandle) : WSLCHandle{.Type = WSLCHandleTypeUnknown},
273 + .OutputMountPath = outputMountPath.empty() ? nullptr : outputMountPath.c_str(),
274 + .OutputMountFile = outputMountFile.empty() ? nullptr : outputMountFile.c_str(),
275 };
276
277 THROW_IF_FAILED(session.Get()->BuildImage(&options, callback, cancelEvent));
src/windows/wslc/services/ImageService.h
+13
@@ -16,6 +16,9 @@ Abstract:
16 #include "SessionModel.h"
17 #include "ImageModel.h"
18 #include "Reporter.h"
19 +#include <map>
20 +#include <optional>
21 +#include <vector>
22 #include <wslc_schema.h>
23
24 namespace wsl::windows::wslc::services {
@@ -32,6 +35,15 @@ struct BuildSecret
35 std::vector<BYTE> Value;
36 };
37
38 +// Parsed docker-style --output spec (buildx exporter). Type/Dest are the resolved exporter type and
39 +// destination; any remaining key=value attributes (name, push, compression, ...) are carried verbatim.
40 +struct BuildOutput
41 +{
42 + std::wstring Type; // resolved exporter type (e.g. L"local", L"tar", ...)
43 + std::wstring Dest; // destination path; L"-" means stdout; empty when not applicable
44 + std::map<std::wstring, std::wstring> Attributes; // remaining key=value attributes
45 +};
46 +
47 class ImageService
48 {
49 public:
@@ -44,6 +56,7 @@ public:
56 const std::vector<BuildSecret>& secrets,
57 const std::wstring& dockerfilePath,
58 const std::wstring& target,
59 + const std::optional<BuildOutput>& output,
60 WSLCBuildImageFlags flags,
61 IProgressCallback* callback,
62 HANDLE cancelEvent = nullptr);
src/windows/wslc/tasks/ImageTasks.cpp
+10 -1
@@ -126,6 +126,14 @@ void BuildImage(CLIExecutionContext& context)
126 target = context.Args.Get<ArgType::BuildTarget>();
127 }
128
129 + std::optional<services::BuildOutput> output;
130 + if (context.Args.Contains(ArgType::Output))
131 + {
132 + // Validate and normalize the spec client-side; ImageService::Build decides how to route the
133 + // exporter (stream a destination file/dir back over a handle, or run entirely in the VM).
134 + output = validation::ParseOutputSpec(context.Args.Get<ArgType::Output>());
135 + }
136 +
137 WSLCBuildImageFlags flags = WSLCBuildImageFlagsNone;
138 WI_SetFlagIf(flags, WSLCBuildImageFlagsVerbose, context.Args.GetFlag<ArgType::Verbose>());
139 WI_SetFlagIf(flags, WSLCBuildImageFlagsNoCache, context.Args.GetFlag<ArgType::NoCache>());
@@ -133,7 +141,8 @@ void BuildImage(CLIExecutionContext& context)
141
142 auto cancelEvent = context.CreateCancelEvent();
143 BuildImageCallback callback(context.Reporter, cancelEvent, context.Args.GetFlag<ArgType::Verbose>());
136 - services::ImageService::Build(session, contextPath, tags, buildArgs, labels, secrets, dockerfilePath, target, flags, &callback, cancelEvent);
144 + services::ImageService::Build(
145 + session, contextPath, tags, buildArgs, labels, secrets, dockerfilePath, target, output, flags, &callback, cancelEvent);
146 }
147
148 void GetImages(CLIExecutionContext& context)
src/windows/wslcsession/WSLCSession.cpp
+69 -5
@@ -931,8 +931,9 @@ try
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);
934 + // context (1), the single-file exporter output destination (1), and one parent directory per file
935 + // secret are mounted.
936 + mountedPaths.reserve(static_cast<size_t>(2) + Options->Secrets.Count);
937
938 auto mountPath = mountInVm(Options->ContextPath, TRUE);
939
@@ -953,6 +954,50 @@ try
954 buildArgs.push_back("--target");
955 buildArgs.push_back(Options->Target);
956 }
957 + // Docker-style --output routing. Three cases, distinguished by what the client set:
958 + // * OutputHandle set (dest=- stdout): the client stripped dest= and expects the exporter output
959 + // streamed back. The exporter writes to the build process's stdout, which is relayed to the
960 + // client handle as the build runs, so the output never touches the VM's disk.
961 + // * OutputMountPath set (single-file exporter with a real destination): the client stripped dest=
962 + // and passed the destination file's parent directory, mounted read-write into the VM so buildx
963 + // writes the file (at OutputMountFile within the mount) in place - nothing is streamed back.
964 + // * Neither set: the spec is forwarded verbatim and the build runs entirely in the VM.
965 + // Directory exporters (type=local, or oci/docker with tar=false) are rejected by the client while
966 + // parsing --output: a Linux tree cannot be written faithfully to a Windows destination.
967 + const bool streamOutput = Options->OutputHandle.Type != WSLCHandleTypeUnknown;
968 + const bool mountOutput = Options->OutputMountPath != nullptr && Options->OutputMountPath[0] != L'\0';
969 + // Streaming or mounting the exporter output requires a non-empty Output spec to route from, and the
970 + // two destinations are mutually exclusive. Reject the mismatched combinations at the boundary rather
971 + // than later failing to assign a dest path.
972 + RETURN_HR_IF(E_INVALIDARG, (streamOutput || mountOutput) && (Options->Output == nullptr || Options->Output[0] == '\0'));
973 + RETURN_HR_IF(E_INVALIDARG, streamOutput && mountOutput);
974 +
975 + if (Options->Output != nullptr && Options->Output[0] != '\0')
976 + {
977 + std::string outputSpec = Options->Output;
978 + if (streamOutput)
979 + {
980 + // buildx writes the exporter tarball to stdout for dest=-, which is relayed to the client
981 + // handle below. With no image to load, buildx prints no image ID, so stdout carries only
982 + // the tarball.
983 + outputSpec += ",dest=-";
984 + }
985 + else if (mountOutput)
986 + {
987 + // Mount the client's destination directory read-write and point the exporter at the temp file
988 + // to write within it, so buildx writes the single-file output straight to the Windows target.
989 + auto guestMountPath = mountInVm(Options->OutputMountPath, FALSE);
990 + std::string dest = guestMountPath;
991 + if (Options->OutputMountFile != nullptr && Options->OutputMountFile[0] != L'\0')
992 + {
993 + dest += '/';
994 + dest += wsl::shared::string::WideToMultiByte(Options->OutputMountFile);
995 + }
996 + outputSpec += std::format(",dest={}", dest);
997 + }
998 + buildArgs.push_back("--output");
999 + buildArgs.push_back(outputSpec);
1000 + }
1001 for (ULONG i = 0; i < Options->Tags.Count; i++)
1002 {
1003 RETURN_HR_IF_NULL(E_INVALIDARG, Options->Tags.Values[i]);
@@ -1076,6 +1121,13 @@ try
1121 ServiceProcessLauncher buildLauncher(buildArgs[0], buildArgs, buildEnv, WSLCProcessFlagsStdin);
1122 auto buildProcess = buildLauncher.Launch(*m_virtualMachine);
1123
1124 + // Opened before the IO context so it outlives the relay registered on it below.
1125 + std::optional<UserHandle> userHandle;
1126 + if (streamOutput)
1127 + {
1128 + userHandle.emplace(OpenUserHandle(Options->OutputHandle));
1129 + }
1130 +
1131 auto io = CreateIOContext();
1132
1133 io.AddHandle(
@@ -1263,9 +1315,21 @@ try
1315 };
1316
1317 // With --progress=rawjson, docker writes progress to stderr and the final image ID to stdout on success (empty on
1266 - // failure). Stdout is drained into allOutput (shown only on error) and its EOF signals build completion.
1267 - io.AddHandle(std::make_unique<io::ReadHandle>(
1268 - buildProcess.GetStdHandle(1), [&](const auto& content) { allOutput.append(content.begin(), content.end()); }));
1318 + // failure).
1319 + //
1320 + // For dest=- the exporter tarball is written to stdout, so it is relayed to the client handle as the
1321 + // build runs. RelayHandle is an overlapped handle, so a slow client only marks the relay pending and
1322 + // stderr keeps draining in the same IO loop.
1323 + if (streamOutput)
1324 + {
1325 + io.AddHandle(std::make_unique<io::RelayHandle<io::ReadHandle>>(
1326 + common::io::HandleWrapper{buildProcess.GetStdHandle(1)}, userHandle->Get()));
1327 + }
1328 + else
1329 + {
1330 + io.AddHandle(std::make_unique<io::ReadHandle>(
1331 + buildProcess.GetStdHandle(1), [&](const auto& content) { allOutput.append(content.begin(), content.end()); }));
1332 + }
1333
1334 io.AddHandle(std::make_unique<io::LineBasedReadHandle>(buildProcess.GetStdHandle(2), captureOutput, false));
1335
test/windows/WSLCTests.cpp
+1 -1
@@ -2342,7 +2342,7 @@ class WSLCTests
2342 WSLCBuildImageOptions options{
2343 .ContextPath = contextDir.c_str(),
2344 .DockerfileHandle = ToCOMInputHandle(dummyDockerfile.get()),
2345 - .Flags = static_cast<WSLCBuildImageFlags>(0x8)};
2345 + .Flags = static_cast<WSLCBuildImageFlags>(0x10)};
2346
2347 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->BuildImage(&options, nullptr, nullptr));
2348 }
test/windows/wslc/WSLCCLIOutputParserUnitTests.cpp new
+473
@@ -0,0 +1,473 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + WSLCCLIOutputParserUnitTests.cpp
8 +
9 +Abstract:
10 +
11 + This file contains unit tests for WSLC CLI --output spec validation and parsing (validation::ParseOutputSpec).
12 +
13 + These tests define the contract for the docker-style `wslc image build --output` flag, mirroring
14 + `docker buildx build --output`. The parser under test is expected to expose:
15 +
16 + namespace wsl::windows::wslc::services {
17 + struct BuildOutput
18 + {
19 + std::wstring Type; // resolved exporter type (e.g. L"local", L"tar", ...)
20 + std::wstring Dest; // destination path; L"-" means stdout; empty when not applicable
21 + std::map<std::wstring, std::wstring> Attributes; // remaining key=value attributes (name, push, compression, ...)
22 + };
23 + }
24 +
25 + namespace wsl::windows::wslc::validation {
26 + services::BuildOutput ParseOutputSpec(const std::wstring& spec);
27 + }
28 +
29 + Grammar / behavior (docker buildx parity):
30 + * The spec is parsed as a single CSV record (RFC 4180, as buildx does via go-csvvalue): fields
31 + are comma separated, a field may be double-quoted, "" inside a quoted field is a literal quote,
32 + and a comma inside a quoted field is part of the value.
33 + * A single field that equals the whole input and does not start with "type=" is shorthand for the
34 + destination:
35 + - L"-" -> {type=tar, dest=-} (stream a tarball to stdout, matching docker)
36 + - any other path -> {type=local, dest=<path>} (rejected: directory exporters are unsupported)
37 + * Otherwise each field is split on its FIRST '=' into key/value (two parts required). The key is
38 + trimmed and lowercased; the value is kept verbatim (may itself contain '='). 'type' and 'dest'
39 + populate the struct fields; every other key is stored in Attributes.
40 + * Validation / destination resolution:
41 + - 'type' is required and must be one of: local, tar, oci, docker, image, registry, cacheonly.
42 + - Directory exporters (local, or oci/docker with tar=false) are not supported and are rejected.
43 + - tar / oci with no 'dest=' default to streaming a tarball to stdout ('dest=-'), matching buildx.
44 + - docker with no 'dest=' loads the image into the store; 'dest=-' streams a tarball to stdout;
45 + a path writes a file.
46 + - image / registry / cacheonly run in the build VM and ignore 'dest'; 'name=' is optional
47 + (buildx only enforces it at export time, not at parse time).
48 + * On rejection the parser throws ArgumentException whose message is the standard
49 + "Invalid --output value '<spec>': <reason>" wrapper (Localization::MessageWslcOutputInvalidSpec).
50 +
51 +--*/
52 +
53 +#include "precomp.h"
54 +#include "windows/Common.h"
55 +#include "WSLCCLITestHelpers.h"
56 +#include "ArgumentValidation.h"
57 +#include "ImageService.h"
58 +#include "Exceptions.h"
59 +#include <map>
60 +#include <string>
61 +
62 +using namespace wsl::windows::wslc;
63 +using namespace WEX::Logging;
64 +using namespace WEX::Common;
65 +
66 +namespace WSLCCLIOutputParserUnitTests {
67 +
68 +using AttrMap = std::map<std::wstring, std::wstring>;
69 +
70 +class WSLCCLIOutputParserUnitTests
71 +{
72 + WSLC_TEST_CLASS(WSLCCLIOutputParserUnitTests)
73 +
74 + // Parses a spec expected to be valid and asserts the resolved type, destination and attributes.
75 + static void VerifyValid(const std::wstring& spec, const std::wstring& expectedType, const std::wstring& expectedDest, const AttrMap& expectedAttrs = {})
76 + {
77 + auto output = validation::ParseOutputSpec(spec);
78 + VERIFY_ARE_EQUAL(expectedType, output.Type);
79 + VERIFY_ARE_EQUAL(expectedDest, output.Dest);
80 + VERIFY_ARE_EQUAL(expectedAttrs.size(), output.Attributes.size());
81 + for (const auto& [key, value] : expectedAttrs)
82 + {
83 + const auto it = output.Attributes.find(key);
84 + VERIFY_IS_TRUE(it != output.Attributes.end());
85 + if (it != output.Attributes.end())
86 + {
87 + VERIFY_ARE_EQUAL(value, it->second);
88 + }
89 + }
90 + }
91 +
92 + // Parses a spec expected to be rejected and asserts it throws an ArgumentException whose message is
93 + // the standard "Invalid --output value '<spec>': <reason>" wrapper and contains the expected reason.
94 + static void VerifyInvalid(const std::wstring& spec, const std::wstring& expectedReasonSubstr)
95 + {
96 + Log::Comment(String().Format(L"Rejecting: %ls", spec.c_str()));
97 + try
98 + {
99 + (void)validation::ParseOutputSpec(spec);
100 + VERIFY_FAIL(L"Expected ArgumentException for invalid output spec");
101 + }
102 + catch (const ArgumentException& ex)
103 + {
104 + const std::wstring& message = ex.Message();
105 + VERIFY_IS_TRUE(message.find(L"Invalid --output value") != std::wstring::npos);
106 + VERIFY_IS_TRUE(message.find(expectedReasonSubstr) != std::wstring::npos);
107 + }
108 + }
109 +
110 + // --- Valid: shorthand (single token, no key=value pairs) ---
111 +
112 + TEST_METHOD(Output_Shorthand_DashIsTarToStdout)
113 + {
114 + // '-' is docker's shorthand for streaming a tarball to stdout ('type=tar,dest=-').
115 + VerifyValid(L"-", L"tar", L"-");
116 + }
117 +
118 + // --- Invalid: directory exporters are not supported ---
119 +
120 + TEST_METHOD(Output_LocalExporter_Rejected)
121 + {
122 + // The local exporter - a bare-path shorthand or an explicit type=local - writes a Linux
123 + // directory tree, which is not supported, so every form is rejected regardless of destination.
124 + // 'dest=./out' is buildx's single-field shorthand quirk: a lone field containing '=' that does
125 + // not start with 'type=' still names a local path.
126 + for (const auto* spec :
127 + {L"./out", L"C:\\build\\artifacts", L"dest=./out", L"type=local", L"type=local,dest=./out", L"type=local,dest=-"})
128 + {
129 + VerifyInvalid(spec, L"directory exporters are not supported");
130 + }
131 + }
132 +
133 + TEST_METHOD(Output_OciDockerTarFalse_Rejected)
134 + {
135 + // oci/docker export an OCI layout directory when 'tar' is false; buildx parses 'tar' with Go's
136 + // ParseBool, so every false spelling (false/False/0/f) is a directory exporter and is rejected
137 + // the same way, with or without a destination.
138 + for (const auto* spec :
139 + {L"type=oci,dest=./layout,tar=false",
140 + L"type=oci,tar=false",
141 + L"type=oci,dest=./layout,tar=False",
142 + L"type=oci,dest=./layout,tar=0",
143 + L"type=oci,dest=./layout,tar=f",
144 + L"type=docker,dest=./layout,tar=false",
145 + L"type=docker,dest=./layout,tar=0"})
146 + {
147 + VerifyInvalid(spec, L"directory exporters are not supported");
148 + }
149 + }
150 +
151 + // --- Valid: explicit tar / oci / docker exporters ---
152 +
153 + TEST_METHOD(Output_Tar_ToFile)
154 + {
155 + VerifyValid(L"type=tar,dest=out.tar", L"tar", L"out.tar");
156 + }
157 +
158 + TEST_METHOD(Output_Tar_ToStdout)
159 + {
160 + // tar streams a single tarball, so it may target stdout ('dest=-'), matching docker.
161 + VerifyValid(L"type=tar,dest=-", L"tar", L"-");
162 + }
163 +
164 + TEST_METHOD(Output_Tar_NoDest_DefaultsToStdout)
165 + {
166 + // tar with no destination streams a tarball to stdout ('dest=-'), matching buildx.
167 + VerifyValid(L"type=tar", L"tar", L"-");
168 + }
169 +
170 + TEST_METHOD(Output_Oci_ToFile)
171 + {
172 + VerifyValid(L"type=oci,dest=image.tar", L"oci", L"image.tar");
173 + }
174 +
175 + TEST_METHOD(Output_Oci_NoDest_DefaultsToStdout)
176 + {
177 + // oci with no destination streams a tarball to stdout ('dest=-'), matching buildx.
178 + VerifyValid(L"type=oci", L"oci", L"-");
179 + }
180 +
181 + // --- Valid: 'tar' true spellings keep oci/docker a single tarball (buildx parity) ---
182 +
183 + TEST_METHOD(Output_Oci_TarTrue_IsSingleTarballToStdout)
184 + {
185 + // "True" is true for Go's ParseBool, so oci stays a single tarball and defaults to stdout.
186 + VerifyValid(L"type=oci,tar=True", L"oci", L"-", AttrMap{{L"tar", L"True"}});
187 + }
188 +
189 + TEST_METHOD(Output_Oci_TarOne_IsSingleTarballToFile)
190 + {
191 + // "1" is true, so this is a single-tarball export to a file (not a directory).
192 + VerifyValid(L"type=oci,dest=image.tar,tar=1", L"oci", L"image.tar", AttrMap{{L"tar", L"1"}});
193 + }
194 +
195 + TEST_METHOD(Output_Docker_TarTrue_LoadsIntoStore)
196 + {
197 + // docker with tar=true is a single tarball; with no dest it loads into the VM store (dest empty).
198 + VerifyValid(L"type=docker,tar=t", L"docker", L"", AttrMap{{L"tar", L"t"}});
199 + }
200 +
201 + TEST_METHOD(Output_Oci_TarInvalidBool_Rejected)
202 + {
203 + // A non-boolean 'tar' value is rejected up front, matching buildx (which errors in ParseBool).
204 + VerifyInvalid(L"type=oci,dest=./layout,tar=yes", L"invalid boolean value 'yes' for 'tar'");
205 + }
206 +
207 + TEST_METHOD(Output_Docker_TarInvalidBool_Rejected)
208 + {
209 + VerifyInvalid(L"type=docker,dest=./layout,tar=maybe", L"invalid boolean value 'maybe' for 'tar'");
210 + }
211 +
212 + TEST_METHOD(Output_Docker_ToFile)
213 + {
214 + VerifyValid(L"type=docker,dest=image.tar", L"docker", L"image.tar");
215 + }
216 +
217 + TEST_METHOD(Output_Docker_ToStdout)
218 + {
219 + // docker with dest=- streams the image tarball to stdout (matching docker), which the client
220 + // routes to the redirected stdout handle.
221 + VerifyValid(L"type=docker,dest=-", L"docker", L"-");
222 + }
223 +
224 + TEST_METHOD(Output_Docker_NoDestLoadsIntoStore)
225 + {
226 + // The docker exporter loads the image into the local store when no destination is given.
227 + VerifyValid(L"type=docker", L"docker", L"");
228 + }
229 +
230 + TEST_METHOD(Output_CacheOnly)
231 + {
232 + // cacheonly runs the build without exporting an artifact.
233 + VerifyValid(L"type=cacheonly", L"cacheonly", L"");
234 + }
235 +
236 + // --- Valid: image / registry exporters with attributes ---
237 +
238 + TEST_METHOD(Output_Image_NameAndPush)
239 + {
240 + VerifyValid(
241 + L"type=image,name=myrepo/app:1.0,push=true", L"image", L"", AttrMap{{L"name", L"myrepo/app:1.0"}, {L"push", L"true"}});
242 + }
243 +
244 + TEST_METHOD(Output_Registry_Name)
245 + {
246 + VerifyValid(L"type=registry,name=myrepo/app:latest", L"registry", L"", AttrMap{{L"name", L"myrepo/app:latest"}});
247 + }
248 +
249 + TEST_METHOD(Output_Registry_NoName_Valid)
250 + {
251 + // buildx only enforces 'name=' at export time, not at parse time, so parsing must accept it.
252 + VerifyValid(L"type=registry", L"registry", L"");
253 + }
254 +
255 + TEST_METHOD(Output_Registry_PushAttributes)
256 + {
257 + // Registry/push related attributes are passed through verbatim.
258 + VerifyValid(
259 + L"type=registry,name=myrepo/app:latest,push-by-digest=true,insecure=true,dangling-name-prefix=cache",
260 + L"registry",
261 + L"",
262 + AttrMap{
263 + {L"name", L"myrepo/app:latest"},
264 + {L"push-by-digest", L"true"},
265 + {L"insecure", L"true"},
266 + {L"dangling-name-prefix", L"cache"}});
267 + }
268 +
269 + TEST_METHOD(Output_Image_StoreAttributes)
270 + {
271 + // Image-store related attributes are passed through verbatim.
272 + VerifyValid(
273 + L"type=image,name=x,store=true,unpack=true,name-canonical=true",
274 + L"image",
275 + L"",
276 + AttrMap{{L"name", L"x"}, {L"store", L"true"}, {L"unpack", L"true"}, {L"name-canonical", L"true"}});
277 + }
278 +
279 + // --- Valid: attribute passthrough ---
280 +
281 + TEST_METHOD(Output_Attributes_CompressionOptions)
282 + {
283 + VerifyValid(
284 + L"type=image,name=x,compression=zstd,compression-level=19,oci-mediatypes=true",
285 + L"image",
286 + L"",
287 + AttrMap{{L"name", L"x"}, {L"compression", L"zstd"}, {L"compression-level", L"19"}, {L"oci-mediatypes", L"true"}});
288 + }
289 +
290 + TEST_METHOD(Output_Attributes_ForceCompression)
291 + {
292 + VerifyValid(
293 + L"type=oci,dest=o.tar,compression=gzip,compression-level=5,force-compression=true",
294 + L"oci",
295 + L"o.tar",
296 + AttrMap{{L"compression", L"gzip"}, {L"compression-level", L"5"}, {L"force-compression", L"true"}});
297 + }
298 +
299 + TEST_METHOD(Output_Attributes_ScopedAnnotation)
300 + {
301 + // Scoped annotations (annotation-manifest./annotation-index.) are preserved as-is.
302 + VerifyValid(
303 + L"type=oci,dest=o.tar,annotation-manifest.org.opencontainers.image.title=app",
304 + L"oci",
305 + L"o.tar",
306 + AttrMap{{L"annotation-manifest.org.opencontainers.image.title", L"app"}});
307 + }
308 +
309 + TEST_METHOD(Output_Tar_PlatformSplit)
310 + {
311 + // platform-split is forwarded verbatim as an exporter attribute for the tar exporter.
312 + VerifyValid(L"type=tar,dest=out.tar,platform-split=false", L"tar", L"out.tar", AttrMap{{L"platform-split", L"false"}});
313 + }
314 +
315 + TEST_METHOD(Output_Attributes_AnnotationValueMayContainEquals)
316 + {
317 + // Only the first '=' separates key from value, so annotation values may themselves contain '='.
318 + VerifyValid(
319 + L"type=oci,dest=o.tar,annotation.org.opencontainers.image.source=https://example.com/repo?ref=main",
320 + L"oci",
321 + L"o.tar",
322 + AttrMap{{L"annotation.org.opencontainers.image.source", L"https://example.com/repo?ref=main"}});
323 + }
324 +
325 + TEST_METHOD(Output_Attributes_EmptyValuePreserved)
326 + {
327 + // A key with an explicit but empty value is preserved (the separator was present).
328 + VerifyValid(L"type=image,name=x,push=", L"image", L"", AttrMap{{L"name", L"x"}, {L"push", L""}});
329 + }
330 +
331 + TEST_METHOD(Output_Keys_AreCaseInsensitive)
332 + {
333 + VerifyValid(L"TYPE=tar,DEST=out.tar", L"tar", L"out.tar");
334 + }
335 +
336 + // --- Invalid: spec structure ---
337 +
338 + TEST_METHOD(Output_Invalid_Empty)
339 + {
340 + VerifyInvalid(L"", L"may not be empty");
341 + }
342 +
343 + TEST_METHOD(Output_Invalid_FieldWithoutEquals)
344 + {
345 + VerifyInvalid(L"type=local,garbage", L"expected key=value pairs separated by ','");
346 + }
347 +
348 + TEST_METHOD(Output_Invalid_LeadingFieldWithoutEquals)
349 + {
350 + VerifyInvalid(L"garbage,type=local", L"expected key=value pairs separated by ','");
351 + }
352 +
353 + TEST_METHOD(Output_Invalid_EmptyField)
354 + {
355 + VerifyInvalid(L"type=local,,dest=x", L"expected key=value pairs separated by ','");
356 + }
357 +
358 + // --- Invalid: type constraints ---
359 +
360 + TEST_METHOD(Output_Invalid_EmptyTypeValue)
361 + {
362 + VerifyInvalid(L"type=,dest=x", L"type is required");
363 + }
364 +
365 + TEST_METHOD(Output_Invalid_MissingType)
366 + {
367 + // With two or more fields no shorthand applies, so a spec without 'type=' is rejected.
368 + VerifyInvalid(L"dest=./out,compression=gzip", L"type is required");
369 + }
370 +
371 + TEST_METHOD(Output_Invalid_UnsupportedType)
372 + {
373 + VerifyInvalid(L"type=bogus", L"unsupported output type 'bogus'");
374 + }
375 +
376 + // --- CSV grammar (buildx go-csvvalue parity) ---
377 +
378 + TEST_METHOD(Output_Csv_QuotedValueWithComma)
379 + {
380 + // A comma inside a double-quoted field is part of the value, not a field separator.
381 + VerifyValid(
382 + L"type=image,name=x,\"annotation.foo=a,b,c\"", L"image", L"", AttrMap{{L"name", L"x"}, {L"annotation.foo", L"a,b,c"}});
383 + }
384 +
385 + TEST_METHOD(Output_Csv_QuotedValueWithEscapedQuote)
386 + {
387 + // A doubled quote inside a quoted field is a single literal quote.
388 + VerifyValid(
389 + L"type=image,name=x,\"annotation.foo=a\"\"b\"", L"image", L"", AttrMap{{L"name", L"x"}, {L"annotation.foo", L"a\"b"}});
390 + }
391 +
392 + TEST_METHOD(Output_Csv_LeadingSpaceAfterCommaTrimmedFromKey)
393 + {
394 + // buildx TrimSpace's the key, so a space after a comma is accepted (the value is untrimmed).
395 + VerifyValid(L"type=tar, dest=out.tar", L"tar", L"out.tar");
396 + }
397 +
398 + TEST_METHOD(Output_Csv_UnterminatedQuoteRejected)
399 + {
400 + VerifyInvalid(L"type=image,\"name=x", L"malformed quoting");
401 + }
402 +
403 + // --- Round-trip: FormatOutputSpec re-serializes a BuildOutput into a canonical buildx spec ---
404 +
405 + // Parses spec, formats the result, and asserts the canonical serialized form.
406 + static void VerifyFormat(const std::wstring& spec, const std::wstring& expectedCanonical)
407 + {
408 + const auto canonical = validation::FormatOutputSpec(validation::ParseOutputSpec(spec));
409 + VERIFY_ARE_EQUAL(expectedCanonical, canonical);
410 +
411 + // The canonical form must itself parse back to an equivalent BuildOutput (idempotent round-trip).
412 + const auto reparsed = validation::ParseOutputSpec(canonical);
413 + const auto original = validation::ParseOutputSpec(spec);
414 + VERIFY_ARE_EQUAL(original.Type, reparsed.Type);
415 + VERIFY_ARE_EQUAL(original.Dest, reparsed.Dest);
416 + VERIFY_ARE_EQUAL(original.Attributes.size(), reparsed.Attributes.size());
417 + for (const auto& [key, value] : original.Attributes)
418 + {
419 + const auto it = reparsed.Attributes.find(key);
420 + VERIFY_IS_TRUE(it != reparsed.Attributes.end());
421 + if (it != reparsed.Attributes.end())
422 + {
423 + VERIFY_ARE_EQUAL(value, it->second);
424 + }
425 + }
426 + }
427 +
428 + TEST_METHOD(Format_TypeOnly_NoDestOrAttributes)
429 + {
430 + // docker/cacheonly need neither dest nor attributes, so the canonical form is just the type.
431 + VerifyFormat(L"type=docker", L"type=docker");
432 + VerifyFormat(L"type=cacheonly", L"type=cacheonly");
433 + }
434 +
435 + TEST_METHOD(Format_TypeAndDest)
436 + {
437 + VerifyFormat(L"type=tar,dest=out.tar", L"type=tar,dest=out.tar");
438 + }
439 +
440 + TEST_METHOD(Format_CaseInsensitiveKeysNormalizedToLower)
441 + {
442 + // 'type'/'dest' keys are lowercased; the type value is lowercased too.
443 + VerifyFormat(L"TYPE=TAR,DEST=out.tar", L"type=tar,dest=out.tar");
444 + }
445 +
446 + TEST_METHOD(Format_AttributesAppendedAfterDest)
447 + {
448 + // Attributes follow type/dest; std::map orders them, so 'name' precedes 'push'.
449 + VerifyFormat(L"type=image,push=true,name=x", L"type=image,name=x,push=true");
450 + }
451 +
452 + TEST_METHOD(Format_RegistryWithAttributes)
453 + {
454 + VerifyFormat(
455 + L"type=registry,name=myrepo/app:latest,push-by-digest=true",
456 + L"type=registry,name=myrepo/app:latest,push-by-digest=true");
457 + }
458 +
459 + TEST_METHOD(Format_QuotesValueContainingComma)
460 + {
461 + // An attribute value containing a comma is CSV-quoted so it round-trips through the parser.
462 + // std::map orders attributes, so 'annotation.foo' precedes 'name'.
463 + VerifyFormat(L"type=image,name=x,\"annotation.foo=a,b,c\"", L"type=image,\"annotation.foo=a,b,c\",name=x");
464 + }
465 +
466 + TEST_METHOD(Format_TarNoDestDefaultsToStdout)
467 + {
468 + // tar with no dest resolves to dest=- and serializes back to that canonical form.
469 + VerifyFormat(L"type=tar", L"type=tar,dest=-");
470 + }
471 +};
472 +
473 +} // namespace WSLCCLIOutputParserUnitTests
test/windows/wslc/WSLCCLISecretParserUnitTests.cpp
+2
@@ -36,9 +36,11 @@ public:
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 + THROW_HR_IF_MSG(E_FAIL, !file.is_open(), "Failed to create temp file: %ls", m_path.c_str());
40 if (!bytes.empty())
41 {
42 file.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
43 + THROW_HR_IF_MSG(E_FAIL, !file.good(), "Failed to write temp file: %ls", m_path.c_str());
44 }
45 }
46
test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp
+311
@@ -70,6 +70,22 @@ class WSLCE2EImageBuildTests
70 return dir;
71 }
72
73 + // All --output tests build from this single shared (empty) context directory, for the same reason
74 + // as SharedSecretBuildContext above: the session never releases virtiofs shares (see
75 + // WSLCVirtualMachine::UnmountWindowsFolder), so giving each --output test its own context directory
76 + // would permanently consume one share slot per test and eventually exhaust the session's budget.
77 + // Reusing one path keeps all --output builds to a single shared slot. Each test's Dockerfile is
78 + // streamed via -f and its output artifacts (tarballs, extracted trees) live under its own testRoot,
79 + // so none of that is mounted.
80 + static std::filesystem::path SharedOutputBuildContext()
81 + {
82 + auto dir = std::filesystem::current_path() / L"wslc-e2e-build-output-context";
83 + std::error_code ec;
84 + std::filesystem::create_directories(dir, ec);
85 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::is_directory(dir));
86 + return dir;
87 + }
88 +
89 WSLC_TEST_METHOD(WSLCE2E_Image_Build_EmptyContextDirectory_Success)
90 {
91 auto imageCleanup = DeleteImageOnExit(BuiltImage);
@@ -858,6 +874,284 @@ class WSLCE2EImageBuildTests
874 VERIFY_IS_TRUE(buildResult.Stderr->find(L"Invalid --secret value 'id=x,type=bogus': unsupported secret type 'bogus'") != std::wstring::npos);
875 }
876
877 + // An invalid --output spec is rejected client-side before any build runs. This exercises the
878 + // full parser through the real binary and asserts the localized "Invalid --output value" wrapper.
879 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_UnsupportedType_Fails)
880 + {
881 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-type-bad";
882 + auto cleanup = SetupTestDirectory(testRoot);
883 +
884 + auto contextDir = SharedOutputBuildContext();
885 +
886 + auto dockerfilePath = testRoot / L"Dockerfile";
887 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
888 +
889 + auto buildResult =
890 + RunWslc(std::format(L"build \"{}\" -f \"{}\" --output type=bogus", contextDir.wstring(), dockerfilePath.wstring()));
891 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
892 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
893 + VERIFY_IS_TRUE(buildResult.Stderr->find(L"Invalid --output value 'type=bogus': unsupported output type 'bogus'") != std::wstring::npos);
894 + }
895 +
896 + // The docker exporter loads the built image into the engine's image store, so the result is
897 + // host-observable via inspect. The -t flag supplies the tag; the default docker builder does not
898 + // honor the exporter 'name=' attribute for tagging (that requires the docker-container driver), so
899 + // these tests deliberately tag with -t rather than name=.
900 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeDockerWithTagFlag_LoadsIntoStore_Success)
901 + {
902 + auto imageCleanup = DeleteImageOnExit(BuiltImageOutputDockerTag);
903 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-docker-tag";
904 + auto cleanup = SetupTestDirectory(testRoot);
905 +
906 + auto contextDir = SharedOutputBuildContext();
907 +
908 + auto dockerfilePath = testRoot / L"Dockerfile";
909 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"output-docker-tag-ok\"]\n");
910 +
911 + auto buildResult = RunWslc(std::format(
912 + L"build \"{}\" -f \"{}\" -t {} --output type=docker",
913 + contextDir.wstring(),
914 + dockerfilePath.wstring(),
915 + BuiltImageOutputDockerTag.NameAndTag()));
916 + buildResult.Verify({.Stdout = L"", .ExitCode = 0});
917 +
918 + auto inspectData = InspectImage(BuiltImageOutputDockerTag.NameAndTag());
919 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
920 + VERIFY_ARE_EQUAL(1u, inspectData.RepoTags.value().size());
921 + VERIFY_ARE_EQUAL(BuiltImageOutputDockerTag.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0]));
922 + }
923 +
924 + // The docker exporter produces a complete, correct image (not just a tag). Build with a
925 + // distinctive CMD and verify it round-trips through inspect, proving --output built a real image.
926 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeDocker_ProducesImageWithConfig_Success)
927 + {
928 + auto imageCleanup = DeleteImageOnExit(BuiltImageOutputDockerConfig);
929 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-docker-config";
930 + auto cleanup = SetupTestDirectory(testRoot);
931 +
932 + auto contextDir = SharedOutputBuildContext();
933 +
934 + auto dockerfilePath = testRoot / L"Dockerfile";
935 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"output-docker-config-ok\"]\n");
936 +
937 + auto buildResult = RunWslc(std::format(
938 + L"build \"{}\" -f \"{}\" -t {} --output type=docker",
939 + contextDir.wstring(),
940 + dockerfilePath.wstring(),
941 + BuiltImageOutputDockerConfig.NameAndTag()));
942 + buildResult.Verify({.Stdout = L"", .ExitCode = 0});
943 +
944 + auto inspectData = InspectImage(BuiltImageOutputDockerConfig.NameAndTag());
945 + VERIFY_IS_TRUE(inspectData.Config.has_value());
946 + VERIFY_IS_TRUE(inspectData.Config.value().Cmd.has_value());
947 + const std::vector<std::string> expectedCmd{"echo", "output-docker-config-ok"};
948 + VERIFY_ARE_EQUAL(expectedCmd, inspectData.Config.value().Cmd.value());
949 + }
950 +
951 + // The tar exporter streams a filesystem tarball out of the VM to a client-side file. Verify
952 + // the file is a valid, non-empty tar that contains the marker written by the build. Asserting the
953 + // file is non-empty guards the regression where the streamed tarball once came back with 0 bytes.
954 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeTarToFile_ProducesValidTarball_Success)
955 + {
956 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-tar-file";
957 + auto cleanup = SetupTestDirectory(testRoot);
958 +
959 + auto contextDir = SharedOutputBuildContext();
960 +
961 + auto dockerfilePath = testRoot / L"Dockerfile";
962 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-tar-marker > /wslc-build-marker.txt\n");
963 +
964 + auto tarPath = testRoot / L"out.tar";
965 + auto buildResult = RunWslc(std::format(
966 + L"build \"{}\" -f \"{}\" --output type=tar,dest=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), tarPath.wstring()));
967 + buildResult.Verify({.ExitCode = 0});
968 +
969 + VERIFY_IS_TRUE(std::filesystem::exists(tarPath));
970 + VERIFY_IS_TRUE(std::filesystem::file_size(tarPath) > 0, L"the streamed tarball must not be empty");
971 + VERIFY_IS_TRUE(ListTarEntries(tarPath).find(L"wslc-build-marker.txt") != std::wstring::npos);
972 + }
973 +
974 + // dest=- streams the tarball to the client's stdout (matching docker). This is the exact path
975 + // that once regressed to an empty tarball, so it redirects stdout to a file and asserts the result is
976 + // a non-empty tar containing the build marker.
977 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeTarToStdout_ProducesValidTarball_Success)
978 + {
979 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-tar-stdout";
980 + auto cleanup = SetupTestDirectory(testRoot);
981 +
982 + auto contextDir = SharedOutputBuildContext();
983 +
984 + auto dockerfilePath = testRoot / L"Dockerfile";
985 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-tar-marker > /wslc-build-marker.txt\n");
986 +
987 + auto tarPath = testRoot / L"stdout.tar";
988 + auto buildResult = RunWslcAndRedirectToFile(
989 + std::format(L"build \"{}\" -f \"{}\" --output type=tar,dest=-", contextDir.wstring(), dockerfilePath.wstring()), tarPath);
990 + buildResult.Verify({.ExitCode = 0});
991 +
992 + VERIFY_IS_TRUE(std::filesystem::exists(tarPath));
993 + VERIFY_IS_TRUE(std::filesystem::file_size(tarPath) > 0, L"the streamed tarball must not be empty");
994 + VERIFY_IS_TRUE(ListTarEntries(tarPath).find(L"wslc-build-marker.txt") != std::wstring::npos);
995 + }
996 +
997 + // The local exporter writes a Linux directory tree, which cannot be materialized faithfully on a
998 + // Windows destination, so it is rejected client-side before any build runs.
999 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeLocalToDirectory_Rejected_Fails)
1000 + {
1001 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-local-dir";
1002 + auto cleanup = SetupTestDirectory(testRoot);
1003 +
1004 + auto contextDir = SharedOutputBuildContext();
1005 +
1006 + auto dockerfilePath = testRoot / L"Dockerfile";
1007 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-local-marker > /wslc-build-marker.txt\n");
1008 +
1009 + auto destDir = testRoot / L"export";
1010 + auto buildResult = RunWslc(std::format(
1011 + L"build \"{}\" -f \"{}\" --output type=local,dest=\"{}\"", contextDir.wstring(), dockerfilePath.wstring(), destDir.wstring()));
1012 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1013 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1014 + VERIFY_IS_TRUE(buildResult.Stderr->find(L"directory exporters are not supported") != std::wstring::npos);
1015 + }
1016 +
1017 + // The local exporter is a directory exporter, which is not supported, so it is rejected client-side
1018 + // before any build runs (dest=- included).
1019 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeLocalToStdout_Rejected_Fails)
1020 + {
1021 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-local-stdout";
1022 + auto cleanup = SetupTestDirectory(testRoot);
1023 +
1024 + auto contextDir = SharedOutputBuildContext();
1025 +
1026 + auto dockerfilePath = testRoot / L"Dockerfile";
1027 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
1028 +
1029 + auto buildResult =
1030 + RunWslc(std::format(L"build \"{}\" -f \"{}\" --output type=local,dest=-", contextDir.wstring(), dockerfilePath.wstring()));
1031 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1032 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1033 + VERIFY_IS_TRUE(
1034 + buildResult.Stderr->find(L"Invalid --output value 'type=local,dest=-': directory exporters are not supported") != std::wstring::npos);
1035 + }
1036 +
1037 + // The image exporter loads the built image into the engine's image store (like the docker
1038 + // exporter with no dest), so the result is host-observable via inspect. Tag with -t.
1039 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeImage_LoadsIntoStore_Success)
1040 + {
1041 + auto imageCleanup = DeleteImageOnExit(BuiltImageOutputImage);
1042 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-image";
1043 + auto cleanup = SetupTestDirectory(testRoot);
1044 +
1045 + auto contextDir = SharedOutputBuildContext();
1046 +
1047 + auto dockerfilePath = testRoot / L"Dockerfile";
1048 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"output-image-ok\"]\n");
1049 +
1050 + auto buildResult = RunWslc(std::format(
1051 + L"build \"{}\" -f \"{}\" -t {} --output type=image",
1052 + contextDir.wstring(),
1053 + dockerfilePath.wstring(),
1054 + BuiltImageOutputImage.NameAndTag()));
1055 + buildResult.Verify({.Stdout = L"", .ExitCode = 0});
1056 +
1057 + auto inspectData = InspectImage(BuiltImageOutputImage.NameAndTag());
1058 + VERIFY_IS_TRUE(inspectData.RepoTags.has_value());
1059 + VERIFY_ARE_EQUAL(1u, inspectData.RepoTags.value().size());
1060 + VERIFY_ARE_EQUAL(BuiltImageOutputImage.NameAndTag(), wsl::shared::string::MultiByteToWide(inspectData.RepoTags.value()[0]));
1061 + }
1062 +
1063 + // The cacheonly exporter runs the build only to populate the build cache, producing no image
1064 + // artifact. Verify the build succeeds and, because nothing is exported, the tag is not in the store.
1065 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeCacheOnly_ProducesNoImage_Success)
1066 + {
1067 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-cacheonly";
1068 + auto cleanup = SetupTestDirectory(testRoot);
1069 +
1070 + auto contextDir = SharedOutputBuildContext();
1071 +
1072 + auto dockerfilePath = testRoot / L"Dockerfile";
1073 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"cacheonly-ok\"]\n");
1074 +
1075 + // Guard against a leaked image if cacheonly ever regresses to loading into the store.
1076 + auto imageCleanup = DeleteImageOnExit(BuiltImageOutputCacheOnly);
1077 +
1078 + auto buildResult = RunWslc(std::format(
1079 + L"build \"{}\" -f \"{}\" -t {} --output type=cacheonly",
1080 + contextDir.wstring(),
1081 + dockerfilePath.wstring(),
1082 + BuiltImageOutputCacheOnly.NameAndTag()));
1083 + buildResult.Verify({.ExitCode = 0});
1084 +
1085 + // cacheonly exports nothing, so the tag must not resolve in the image store.
1086 + auto inspectResult = RunWslc(std::format(L"image inspect {}", BuiltImageOutputCacheOnly.NameAndTag()));
1087 + VERIFY_ARE_NOT_EQUAL(0u, inspectResult.ExitCode.value_or(0u), L"cacheonly must not load an image into the store");
1088 + }
1089 +
1090 + // tar with no 'dest=' defaults to streaming a tarball to stdout ('dest=-'), matching buildx.
1091 + // Redirect stdout to a file and assert the result is a non-empty tar containing the build marker.
1092 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeTarNoDest_StreamsToStdout_Success)
1093 + {
1094 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-tar-nodest";
1095 + auto cleanup = SetupTestDirectory(testRoot);
1096 +
1097 + auto contextDir = SharedOutputBuildContext();
1098 +
1099 + auto dockerfilePath = testRoot / L"Dockerfile";
1100 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-tar-nodest-marker > /wslc-build-marker.txt\n");
1101 +
1102 + auto tarPath = testRoot / L"stdout.tar";
1103 + auto buildResult = RunWslcAndRedirectToFile(
1104 + std::format(L"build \"{}\" -f \"{}\" --output type=tar", contextDir.wstring(), dockerfilePath.wstring()), tarPath);
1105 + buildResult.Verify({.ExitCode = 0});
1106 +
1107 + VERIFY_IS_TRUE(std::filesystem::exists(tarPath));
1108 + VERIFY_IS_TRUE(std::filesystem::file_size(tarPath) > 0, L"the streamed tarball must not be empty");
1109 + VERIFY_IS_TRUE(ListTarEntries(tarPath).find(L"wslc-build-marker.txt") != std::wstring::npos);
1110 + }
1111 +
1112 + // A failing build step must surface as a non-zero exit with the image exporter, and the tag
1113 + // must not be left in the store. This is the failing counterpart to TypeImage_LoadsIntoStore.
1114 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeImage_BuildFailure_Fails)
1115 + {
1116 + auto imageCleanup = DeleteImageOnExit(BuiltImageOutputImageFail);
1117 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-image-fail";
1118 + auto cleanup = SetupTestDirectory(testRoot);
1119 +
1120 + auto contextDir = SharedOutputBuildContext();
1121 +
1122 + auto dockerfilePath = testRoot / L"Dockerfile";
1123 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN exit 7\n");
1124 +
1125 + auto buildResult = RunWslc(std::format(
1126 + L"build \"{}\" -f \"{}\" -t {} --output type=image",
1127 + contextDir.wstring(),
1128 + dockerfilePath.wstring(),
1129 + BuiltImageOutputImageFail.NameAndTag()));
1130 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1131 + VERIFY_IS_TRUE(buildResult.StderrContainsSubstring(L"failed to solve"));
1132 +
1133 + auto inspectResult = RunWslc(std::format(L"image inspect {}", BuiltImageOutputImageFail.NameAndTag()));
1134 + VERIFY_ARE_NOT_EQUAL(0u, inspectResult.ExitCode.value_or(0u), L"a failed build must not leave an image in the store");
1135 + }
1136 +
1137 + // A failing build step must surface as a non-zero exit with the cacheonly exporter. This is
1138 + // the failing counterpart to TypeCacheOnly_ProducesNoImage.
1139 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_Output_TypeCacheOnly_BuildFailure_Fails)
1140 + {
1141 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-output-cacheonly-fail";
1142 + auto cleanup = SetupTestDirectory(testRoot);
1143 +
1144 + auto contextDir = SharedOutputBuildContext();
1145 +
1146 + auto dockerfilePath = testRoot / L"Dockerfile";
1147 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN exit 7\n");
1148 +
1149 + auto buildResult =
1150 + RunWslc(std::format(L"build \"{}\" -f \"{}\" --output type=cacheonly", contextDir.wstring(), dockerfilePath.wstring()));
1151 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1152 + VERIFY_IS_TRUE(buildResult.StderrContainsSubstring(L"failed to solve"));
1153 + }
1154 +
1155 WSLC_TEST_METHOD(WSLCE2E_Image_Build_DockerfileInContextDir_Success)
1156 {
1157 auto imageCleanup = DeleteImageOnExit(BuiltImageDockerfile);
@@ -988,6 +1282,23 @@ private:
1282 // Maximum secret size allowed by BuildKit (500kb)
1283 static constexpr size_t c_maxSecretSize = 500 * 1024;
1284
1285 + const TestImage BuiltImageOutputDockerTag{L"wslc-e2e-build-output-docker-tag", L"latest", L""};
1286 + const TestImage BuiltImageOutputDockerConfig{L"wslc-e2e-build-output-docker-config", L"latest", L""};
1287 + const TestImage BuiltImageOutputImage{L"wslc-e2e-build-output-image", L"latest", L""};
1288 + const TestImage BuiltImageOutputImageFail{L"wslc-e2e-build-output-image-fail", L"latest", L""};
1289 + const TestImage BuiltImageOutputCacheOnly{L"wslc-e2e-build-output-cacheonly", L"latest", L""};
1290 +
1291 + // Runs `tar.exe -tf <path>` and returns the member listing so tests can assert an exporter produced a
1292 + // valid, non-empty archive that contains an expected entry.
1293 + static std::wstring ListTarEntries(const std::filesystem::path& tarPath)
1294 + {
1295 + auto cmd = std::format(L"tar.exe -tf \"{}\"", tarPath.wstring());
1296 + wsl::windows::common::SubProcess process(nullptr, cmd.c_str());
1297 + auto output = process.RunAndCaptureOutput();
1298 + VERIFY_ARE_EQUAL(0u, output.ExitCode, L"tar.exe failed to list the produced archive");
1299 + return output.Stdout;
1300 + }
1301 +
1302 void BuildFromContextFile(const std::wstring& fileName, const TestImage& image)
1303 {
1304 auto testRoot = std::filesystem::current_path() / image.Name;