master
cpp 376 lines 14.8 KB
Raw
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 WSLCCLISecretParserUnitTests.cpp
8
9 Abstract:
10
11 This file contains unit tests for WSLC CLI --secret spec validation and parsing (validation::ParseSecretSpec).
12
13 --*/
14
15 #include "precomp.h"
16 #include "windows/Common.h"
17 #include "WSLCCLITestHelpers.h"
18 #include "ArgumentValidation.h"
19 #include "ImageService.h"
20 #include "Exceptions.h"
21 #include <filesystem>
22 #include <fstream>
23 #include <string>
24 #include <vector>
25
26 using namespace wsl::windows::wslc;
27
28 namespace WSLCCLISecretParserUnitTests {
29
30 // RAII helper: writes the given bytes to a uniquely named temp file and deletes it on destruction.
31 class ScopedTempFile
32 {
33 public:
34 explicit ScopedTempFile(const std::vector<BYTE>& bytes)
35 {
36 m_path = std::filesystem::temp_directory_path() /
37 (L"wslc_ut_secret_" + std::to_wstring(GetCurrentProcessId()) + L"_" + std::to_wstring(++s_counter) + L".bin");
38 std::ofstream file(m_path, std::ios::binary | std::ios::trunc);
39 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
47 ~ScopedTempFile()
48 {
49 std::error_code ec;
50 std::filesystem::remove(m_path, ec);
51 }
52
53 ScopedTempFile(const ScopedTempFile&) = delete;
54 ScopedTempFile& operator=(const ScopedTempFile&) = delete;
55
56 std::wstring wpath() const
57 {
58 return m_path.wstring();
59 }
60
61 private:
62 std::filesystem::path m_path;
63 static inline int s_counter = 0;
64 };
65
66 class WSLCCLISecretParserUnitTests
67 {
68 WSLC_TEST_CLASS(WSLCCLISecretParserUnitTests)
69
70 static std::vector<BYTE> ToBytes(std::string_view text)
71 {
72 return std::vector<BYTE>(text.begin(), text.end());
73 }
74
75 // Parses a spec expected to be valid and asserts the resolved id and bytes.
76 static void VerifyValid(const std::wstring& spec, const std::wstring& expectedId, const std::vector<BYTE>& expectedValue)
77 {
78 auto secret = validation::ParseSecretSpec(spec);
79 VERIFY_ARE_EQUAL(expectedId, secret.Id);
80 VERIFY_ARE_EQUAL(expectedValue.size(), secret.Value.size());
81 VERIFY_IS_TRUE(expectedValue == secret.Value);
82 }
83
84 // Parses a spec expected to be rejected and asserts it throws an ArgumentException whose message is
85 // the standard "Invalid --secret value '<spec>': <reason>" wrapper and contains the expected reason.
86 static void VerifyInvalid(const std::wstring& spec, const std::wstring& expectedReasonSubstr)
87 {
88 try
89 {
90 (void)validation::ParseSecretSpec(spec);
91 VERIFY_FAIL(L"Expected ArgumentException for invalid secret spec");
92 }
93 catch (const ArgumentException& ex)
94 {
95 const std::wstring& message = ex.Message();
96 VERIFY_IS_TRUE(message.find(L"Invalid --secret value") != std::wstring::npos);
97 VERIFY_IS_TRUE(message.find(expectedReasonSubstr) != std::wstring::npos);
98 }
99 }
100
101 // Parses a spec expected to resolve to a file-backed secret and asserts the resolved id, that no
102 // bytes were read (Value is empty), and that the canonicalized source path is forwarded in place -
103 // file secrets are delivered by mounting the file's directory into the VM, never by copying bytes.
104 static void VerifyValidFileSecret(const std::wstring& spec, const std::wstring& expectedId, const std::wstring& expectedPath)
105 {
106 auto secret = validation::ParseSecretSpec(spec);
107 VERIFY_ARE_EQUAL(expectedId, secret.Id);
108 VERIFY_IS_TRUE(secret.Value.empty());
109 std::error_code ec;
110 const auto expectedCanonical = std::filesystem::weakly_canonical(std::filesystem::absolute(expectedPath, ec), ec);
111 VERIFY_ARE_EQUAL(expectedCanonical.wstring(), secret.SourcePath);
112 }
113
114 // --- Valid: environment-variable backed secrets ---
115
116 TEST_METHOD(Secret_Env_BareIdReadsIdNamedVariable)
117 {
118 ScopedEnvVariable env(L"WSLC_UT_SECRET_BARE", L"bare-value");
119 VerifyValid(L"id=WSLC_UT_SECRET_BARE", L"WSLC_UT_SECRET_BARE", ToBytes("bare-value"));
120 }
121
122 TEST_METHOD(Secret_Env_ExplicitEnvName)
123 {
124 ScopedEnvVariable env(L"WSLC_UT_SECRET_ENV", L"explicit-env");
125 VerifyValid(L"id=my.secret,env=WSLC_UT_SECRET_ENV", L"my.secret", ToBytes("explicit-env"));
126 }
127
128 TEST_METHOD(Secret_Env_TypeEnvBareSrcIsVariableName)
129 {
130 ScopedEnvVariable env(L"WSLC_UT_SECRET_TYPEENV", L"type-env-src");
131 VerifyValid(L"id=s,type=env,src=WSLC_UT_SECRET_TYPEENV", L"s", ToBytes("type-env-src"));
132 }
133
134 TEST_METHOD(Secret_Env_WinsOverSrcWhenBothPresent)
135 {
136 ScopedEnvVariable env(L"WSLC_UT_SECRET_ENVWINS", L"env-wins");
137 // A non-existent src path is provided but must be ignored because env= takes precedence.
138 VerifyValid(L"id=s,env=WSLC_UT_SECRET_ENVWINS,src=C:\\wslc-ut\\does-not-exist.txt", L"s", ToBytes("env-wins"));
139 }
140
141 TEST_METHOD(Secret_Env_ExplicitEnvUnsetYieldsEmptyValue)
142 {
143 // Ensure the variable is not set.
144 ScopedEnvVariable env(L"WSLC_UT_SECRET_EXPLICIT_UNSET");
145 VerifyValid(L"id=s,env=WSLC_UT_SECRET_EXPLICIT_UNSET", L"s", {});
146 }
147
148 TEST_METHOD(Secret_Env_EmptyVariableYieldsEmptyValue)
149 {
150 ScopedEnvVariable env(L"WSLC_UT_SECRET_EMPTY", L"");
151 VerifyValid(L"id=WSLC_UT_SECRET_EMPTY", L"WSLC_UT_SECRET_EMPTY", {});
152 }
153
154 TEST_METHOD(Secret_Env_ValueEncodedAsUtf8)
155 {
156 // 'é' (U+00E9) encodes to the two UTF-8 bytes 0xC3 0xA9.
157 ScopedEnvVariable env(L"WSLC_UT_SECRET_UTF8", L"h\u00e9llo");
158 VerifyValid(L"id=WSLC_UT_SECRET_UTF8", L"WSLC_UT_SECRET_UTF8", {0x68, 0xC3, 0xA9, 0x6C, 0x6C, 0x6F});
159 }
160
161 TEST_METHOD(Secret_Env_IdAllowedCharacters)
162 {
163 ScopedEnvVariable env(L"WSLC_UT_SECRET_IDCHARS", L"ok");
164 VerifyValid(L"id=Ab.9_-x,env=WSLC_UT_SECRET_IDCHARS", L"Ab.9_-x", ToBytes("ok"));
165 }
166
167 // --- Valid: file backed secrets ---
168
169 TEST_METHOD(Secret_File_BareSrcForwardsPath)
170 {
171 ScopedTempFile file(ToBytes("file-content"));
172 VerifyValidFileSecret(L"id=s,src=" + file.wpath(), L"s", file.wpath());
173 }
174
175 TEST_METHOD(Secret_File_TypeFileForwardsPath)
176 {
177 ScopedTempFile file(ToBytes("typed-file-content"));
178 VerifyValidFileSecret(L"id=s,type=file,src=" + file.wpath(), L"s", file.wpath());
179 }
180
181 TEST_METHOD(Secret_File_SourceKeyAlias)
182 {
183 ScopedTempFile file(ToBytes("aliased"));
184 VerifyValidFileSecret(L"id=s,source=" + file.wpath(), L"s", file.wpath());
185 }
186
187 TEST_METHOD(Secret_File_QuotedFieldWithCommaInSrcPath)
188 {
189 // Docker parity: buildx parses --secret as a single CSV record, so a whole 'src=' field can be
190 // double-quoted to carry a path containing commas; the comma must stay part of the value rather
191 // than splitting into bogus extra key=value parts. This exercises SplitCsvFields end-to-end
192 // through secret parsing. Note the entire "src=<path>" field is quoted (Go's CSV grammar), not
193 // just the value - a bare quote after 'src=' would be an unquoted-field bare quote (malformed).
194 const auto path =
195 std::filesystem::temp_directory_path() / (L"wslc_ut_secret_" + std::to_wstring(GetCurrentProcessId()) + L"_a,b,c.bin");
196 {
197 std::ofstream file(path, std::ios::binary | std::ios::trunc);
198 VERIFY_IS_TRUE(file.is_open());
199 file << 'x';
200 }
201 auto cleanup = wil::scope_exit([&]() {
202 std::error_code ec;
203 std::filesystem::remove(path, ec);
204 });
205
206 const std::wstring spec = L"id=s,\"src=" + path.wstring() + L"\"";
207 auto secret = validation::ParseSecretSpec(spec);
208 VERIFY_ARE_EQUAL(std::wstring(L"s"), secret.Id);
209 VERIFY_IS_TRUE(secret.Value.empty());
210
211 std::error_code ec;
212 const auto expectedCanonical = std::filesystem::weakly_canonical(std::filesystem::absolute(path, ec), ec);
213 VERIFY_ARE_EQUAL(expectedCanonical.wstring(), secret.SourcePath);
214 }
215
216 TEST_METHOD(Secret_File_EmptyFileForwardsPath)
217 {
218 // An empty file is still a valid file secret: its path is forwarded and mounted (docker delivers
219 // an empty /run/secrets/<id>); no bytes are carried in Value.
220 ScopedTempFile file({});
221 VerifyValidFileSecret(L"id=s,src=" + file.wpath(), L"s", file.wpath());
222 }
223
224 TEST_METHOD(Secret_File_BinaryFileForwardsPath)
225 {
226 // Binary content does not affect parsing: the file is referenced by path, not read, so arbitrary
227 // bytes (including embedded NULs) are irrelevant to the client and delivered verbatim via mount.
228 const std::vector<BYTE> bytes = {0x00, 0x01, 0x02, 0xFF, 0x00, 0x41, 0x00, 0x7F, 0x80};
229 ScopedTempFile file(bytes);
230 VerifyValidFileSecret(L"id=s,src=" + file.wpath(), L"s", file.wpath());
231 }
232
233 TEST_METHOD(Secret_File_LargeFileSucceeds)
234 {
235 // A large file must parse into a valid file secret.
236 const std::vector<BYTE> bytes(512000, 0x41);
237 ScopedTempFile file(bytes);
238 VerifyValidFileSecret(L"id=s,src=" + file.wpath(), L"s", file.wpath());
239 }
240
241 TEST_METHOD(Secret_File_RelativeSrcResolvedToAbsolutePath)
242 {
243 // A relative src= must be resolved to an absolute SourcePath. The server rejects non-absolute
244 // secret paths (the client and server may have different current directories), so the parser is
245 // responsible for producing an absolute path before the spec is forwarded.
246 ScopedTempFile file(ToBytes("relative-src"));
247 const std::filesystem::path absPath = file.wpath();
248
249 auto originalDir = std::filesystem::current_path();
250 auto restoreDir = wil::scope_exit([&]() {
251 std::error_code ec;
252 std::filesystem::current_path(originalDir, ec);
253 });
254 std::filesystem::current_path(absPath.parent_path());
255
256 const auto relativeSrc = absPath.filename().wstring();
257 VERIFY_IS_FALSE(std::filesystem::path(relativeSrc).is_absolute());
258
259 auto secret = validation::ParseSecretSpec(L"id=s,src=" + relativeSrc);
260 VERIFY_ARE_EQUAL(std::wstring(L"s"), secret.Id);
261 VERIFY_IS_TRUE(std::filesystem::path(secret.SourcePath).is_absolute());
262
263 std::error_code ec;
264 const auto expectedCanonical = std::filesystem::weakly_canonical(std::filesystem::absolute(absPath, ec), ec);
265 VERIFY_ARE_EQUAL(expectedCanonical.wstring(), secret.SourcePath);
266 }
267
268 // A relative src= naming a file that does not exist must still resolve to an absolute SourcePath.
269 // Parsing deliberately does not require the file to exist, so this case is reachable and the server
270 // still rejects a non-absolute path. std::filesystem::weakly_canonical cannot handle it on its own:
271 // it only produces an absolute path by canonicalizing the longest leading sequence of elements that
272 // exist, so a bare missing filename has nothing to canonicalize and is returned unchanged. The
273 // relative-src test above cannot catch this because its file exists.
274 TEST_METHOD(Secret_File_RelativeSrcMissingFileResolvedToAbsolutePath)
275 {
276 const auto directory = std::filesystem::temp_directory_path();
277 const auto relativeSrc = L"wslc_ut_secret_missing_" + std::to_wstring(GetCurrentProcessId()) + L"_" +
278 std::to_wstring(GetTickCount64()) + L".bin";
279 VERIFY_IS_FALSE(std::filesystem::exists(directory / relativeSrc));
280
281 auto originalDir = std::filesystem::current_path();
282 auto restoreDir = wil::scope_exit([&]() {
283 std::error_code ec;
284 std::filesystem::current_path(originalDir, ec);
285 });
286 std::filesystem::current_path(directory);
287
288 VERIFY_IS_FALSE(std::filesystem::path(relativeSrc).is_absolute());
289
290 auto secret = validation::ParseSecretSpec(L"id=s,src=" + relativeSrc);
291 VERIFY_ARE_EQUAL(std::wstring(L"s"), secret.Id);
292 VERIFY_IS_TRUE(std::filesystem::path(secret.SourcePath).is_absolute());
293
294 // The leading directory exists, so it canonicalizes; only the missing filename is appended.
295 const auto expected = std::filesystem::canonical(directory) / relativeSrc;
296 VERIFY_ARE_EQUAL(expected.wstring(), secret.SourcePath);
297 }
298
299 // --- Invalid: spec structure ---
300
301 TEST_METHOD(Secret_Invalid_EmptyId)
302 {
303 VerifyInvalid(L"id=", L"'id=' is required");
304 }
305
306 TEST_METHOD(Secret_Invalid_MissingIdKey)
307 {
308 VerifyInvalid(L"env=WSLC_UT_SECRET_ANY", L"'id=' is required");
309 }
310
311 TEST_METHOD(Secret_Invalid_PartWithoutEquals)
312 {
313 VerifyInvalid(L"id=s,garbage", L"expected key=value pairs separated by ','");
314 }
315
316 TEST_METHOD(Secret_Invalid_PartWithLeadingEquals)
317 {
318 VerifyInvalid(L"=value", L"expected key=value pairs separated by ','");
319 }
320
321 TEST_METHOD(Secret_Invalid_UnsupportedKey)
322 {
323 VerifyInvalid(L"id=s,bogus=1", L"unsupported key 'bogus'");
324 }
325
326 // --- Invalid: id constraints ---
327
328 TEST_METHOD(Secret_Invalid_IdStartsWithDash)
329 {
330 VerifyInvalid(L"id=-secret", L"'id' may not start with '-'");
331 }
332
333 TEST_METHOD(Secret_Invalid_IdContainsDisallowedCharacter)
334 {
335 VerifyInvalid(L"id=bad$id", L"'id' may only contain letters, digits");
336 }
337
338 TEST_METHOD(Secret_Invalid_IdContainsSlash)
339 {
340 VerifyInvalid(L"id=a/b", L"'id' may only contain letters, digits");
341 }
342
343 // --- Invalid: type constraints ---
344
345 TEST_METHOD(Secret_Invalid_UnsupportedType)
346 {
347 VerifyInvalid(L"id=s,type=bogus", L"unsupported secret type 'bogus'");
348 }
349
350 TEST_METHOD(Secret_Invalid_TypeFileRequiresSrc)
351 {
352 VerifyInvalid(L"id=s,type=file", L"'type=file' requires 'src='");
353 }
354
355 // --- Invalid: value resolution ---
356
357 TEST_METHOD(Secret_File_MissingSourceForwardsPath)
358 {
359 // A missing source file is not rejected client-side. The path is forwarded as
360 // an absolute SourcePath and the service/BuildKit reports if it can't be mounted or read.
361 const std::wstring missing = L"C:\\wslc-ut\\definitely-missing-secret-file.txt";
362 auto secret = validation::ParseSecretSpec(L"id=s,src=" + missing);
363 VERIFY_ARE_EQUAL(std::wstring(L"s"), secret.Id);
364 VERIFY_IS_TRUE(secret.Value.empty());
365 VERIFY_IS_TRUE(std::filesystem::path(secret.SourcePath).is_absolute());
366 }
367
368 TEST_METHOD(Secret_Invalid_BareIdVariableNotSet)
369 {
370 // A bare id whose matching environment variable is undefined must be rejected (Docker parity).
371 ScopedEnvVariable env(L"WSLC_UT_SECRET_BARE_UNSET");
372 VerifyInvalid(L"id=WSLC_UT_SECRET_BARE_UNSET", L"environment variable 'WSLC_UT_SECRET_BARE_UNSET' is not set");
373 }
374 };
375
376 } // namespace WSLCCLISecretParserUnitTests