| 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 |