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