master
cpp 473 lines 18.3 KB
Raw
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