master
cpp 14,265 lines 635 KB
Raw
Large file — syntax highlighting disabled.
1 /*++
2
3 Copyright (c) Microsoft. All rights reserved.
4
5 Module Name:
6
7 WSLCTests.cpp
8
9 Abstract:
10
11 This file contains test cases for the WSLC API.
12
13 --*/
14
15 #include "precomp.h"
16 #include "Common.h"
17 #include "wslc.h"
18 #include "wslccompat.h"
19 #include "WSLCProcessLauncher.h"
20 #include "WSLCContainerLauncher.h"
21 #include "WSLCContainerEntry.h"
22 #include "WslCoreFilesystem.h"
23 #include "hcs.hpp"
24 #include "ContainerNameGenerator.h"
25 #include "wslc/e2e/WSLCE2EHelpers.h"
26 #include "HttpHeaderEndDetector.h"
27 #include "WSLCSessionDefaults.h"
28 #include <nlohmann/json.hpp>
29
30 using namespace std::chrono;
31 using namespace std::literals::chrono_literals;
32 using namespace wsl::windows::common::registry;
33 using wsl::windows::common::ClientRunningWSLCProcess;
34 using wsl::windows::common::RunningWSLCContainer;
35 using wsl::windows::common::RunningWSLCProcess;
36 using wsl::windows::common::WSLCContainerLauncher;
37 using wsl::windows::common::WSLCProcessLauncher;
38 using wsl::windows::common::io::OverlappedIOHandle;
39 using wsl::windows::common::io::WriteHandle;
40 using namespace wsl::windows::common::wslutil;
41 using WSLCE2ETests::StartLocalRegistry;
42
43 extern std::wstring g_testDataPath;
44 extern bool g_fastTestRun;
45
46 class WSLCTests
47 {
48 WSLC_TEST_CLASS(WSLCTests)
49
50 WSADATA m_wsadata;
51 std::filesystem::path m_storagePath;
52 WSLCSessionSettings m_defaultSessionSettings{};
53 wil::com_ptr<IWSLCSession> m_defaultSession;
54 static inline auto c_testSessionName = L"wslc-test";
55
56 TEST_CLASS_SETUP(TestClassSetup)
57 {
58 THROW_IF_WIN32_ERROR(WSAStartup(MAKEWORD(2, 2), &m_wsadata));
59
60 // The WSLC SDK tests use this same storage to reduce pull overhead.
61 m_storagePath = std::filesystem::current_path() / "test-storage";
62 m_defaultSessionSettings = GetDefaultSessionSettings(c_testSessionName, true, WSLCNetworkingModeConsomme);
63 m_defaultSession = CreateSession(m_defaultSessionSettings);
64
65 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
66 VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, &images, images.size_address<ULONG>()));
67
68 auto hasImage = [&](const std::string& imageName) {
69 return std::ranges::any_of(
70 images.get(), images.get() + images.size(), [&](const auto& e) { return e.Image == imageName; });
71 };
72
73 if (!hasImage("debian:latest"))
74 {
75 LoadTestImage(*m_defaultSession, "debian:latest");
76 }
77
78 if (!hasImage("python:3.12-alpine"))
79 {
80 LoadTestImage(*m_defaultSession, "python:3.12-alpine");
81 }
82
83 if (!hasImage("hello-world:latest"))
84 {
85 LoadTestImage(*m_defaultSession, "hello-world:latest");
86 }
87
88 if (!hasImage("alpine:latest"))
89 {
90 LoadTestImage(*m_defaultSession, "alpine:latest");
91 }
92
93 if (!hasImage("wslc-registry:latest"))
94 {
95 LoadTestImage(*m_defaultSession, "wslc-registry:latest");
96 }
97
98 PruneResult result;
99 VERIFY_SUCCEEDED(m_defaultSession->PruneContainers(nullptr, 0, &result.result));
100 if (result.result.ContainersCount > 0)
101 {
102 LogInfo("Pruned %lu containers", result.result.ContainersCount);
103 }
104
105 return true;
106 }
107
108 TEST_CLASS_CLEANUP(TestClassCleanup)
109 {
110 m_defaultSession.reset();
111
112 // Keep the VHD when running in -f mode, to speed up subsequent test runs.
113 if (!g_fastTestRun && !m_storagePath.empty())
114 {
115 std::error_code error;
116 std::filesystem::remove_all(m_storagePath, error);
117 if (error)
118 {
119 LogError("Failed to cleanup storage path %ws: %hs", m_storagePath.c_str(), error.message().c_str());
120 }
121 }
122
123 return true;
124 }
125
126 WSLCSessionSettings GetDefaultSessionSettings(LPCWSTR Name, bool enableStorage = false, WSLCNetworkingMode networkingMode = WSLCNetworkingModeNone)
127 {
128 WSLCSessionSettings settings{};
129 settings.DisplayName = Name;
130 settings.CpuCount = 4;
131 settings.MemoryMb = 2048;
132 settings.BootTimeoutMs = 30 * 1000;
133 settings.StoragePath = enableStorage ? m_storagePath.c_str() : nullptr;
134 settings.MaximumStorageSizeMb = 1024 * 20; // 20GB.
135 settings.NetworkingMode = networkingMode;
136
137 return settings;
138 }
139
140 auto ResetTestSession()
141 {
142 m_defaultSession.reset();
143
144 return wil::scope_exit([this]() { m_defaultSession = CreateSession(m_defaultSessionSettings); });
145 }
146
147 static wil::com_ptr<IWSLCSessionManager> OpenSessionManager()
148 {
149 wil::com_ptr<IWSLCSessionManager> sessionManager;
150 VERIFY_SUCCEEDED(CoCreateInstance(__uuidof(WSLCSessionManager), nullptr, CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(&sessionManager)));
151 wsl::windows::common::security::ConfigureForCOMImpersonation(sessionManager.get());
152
153 return sessionManager;
154 }
155
156 // Returns true for the names the wslc CLI reserves for its default sessions.
157 static bool IsCliSessionName(std::wstring_view Name)
158 {
159 constexpr std::wstring_view prefix{wsl::windows::wslc::DefaultSessionName};
160
161 return Name.size() >= prefix.size() && wsl::shared::string::IsEqual(Name.substr(0, prefix.size()), prefix, true) &&
162 (Name.size() == prefix.size() || Name[prefix.size()] == L'-');
163 }
164
165 // ListSessions() reports every session on the machine, including the persistent sessions the
166 // wslc CLI creates for itself. Those are outside this class's control, so they are filtered
167 // out to keep assertions independent of what else has run on the machine.
168 static std::set<std::wstring> ListTestSessionNames(IWSLCSessionManager* SessionManager)
169 {
170 wil::unique_cotaskmem_array_ptr<WSLCSessionListEntry> sessions;
171 VERIFY_SUCCEEDED(SessionManager->ListSessions(&sessions, sessions.size_address<ULONG>()));
172
173 std::set<std::wstring> names;
174 for (const auto& e : sessions)
175 {
176 if (IsCliSessionName(e.DisplayName))
177 {
178 continue;
179 }
180
181 auto [it, inserted] = names.emplace(e.DisplayName);
182 VERIFY_IS_TRUE(inserted);
183 }
184
185 return names;
186 }
187
188 wil::com_ptr<IWSLCSession> CreateSession(const WSLCSessionSettings& sessionSettings, WSLCSessionFlags Flags = WSLCSessionFlagsNone)
189 {
190 const auto sessionManager = OpenSessionManager();
191
192 wil::com_ptr<IWSLCSession> session;
193
194 VERIFY_SUCCEEDED(sessionManager->CreateSession(&sessionSettings, Flags, nullptr, &session));
195 wsl::windows::common::security::ConfigureForCOMImpersonation(session.get());
196
197 WSLCSessionState state{};
198 VERIFY_SUCCEEDED(session->GetState(&state));
199 VERIFY_ARE_EQUAL(state, WSLCSessionStateRunning);
200
201 return session;
202 }
203
204 RunningWSLCContainer OpenContainer(IWSLCSession* session, const std::string& name)
205 {
206 wil::com_ptr<IWSLCContainer> rawContainer;
207 VERIFY_SUCCEEDED(session->OpenContainer(name.c_str(), &rawContainer));
208
209 return RunningWSLCContainer(std::move(rawContainer), {});
210 }
211
212 RunningWSLCContainer LaunchContainerWithBlockingStopHandler(const std::string& name)
213 {
214 WSLCContainerLauncher launcher(
215 "debian:latest",
216 name,
217 {"/bin/sh", "-c", "trap 'echo stopping; read value; exit 0' TERM; echo ready; while true; do sleep 1; done"},
218 {},
219 "host",
220 WSLCProcessFlagsStdin);
221
222 return launcher.Launch(*m_defaultSession);
223 }
224
225 struct ListContainersResult
226 {
227 wsl::windows::common::wslc::unique_container_entry_array Containers;
228 wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> Ports;
229 };
230
231 // Issues IWSLCSession::ListContainers with WSLCListContainersFlagsAll (all containers, no filter).
232 // If a future caller needs a different flag set, add a parameter.
233 ListContainersResult ListContainers(IWSLCSession* session)
234 {
235 WSLCListContainersOptions options{};
236 options.Flags = WSLCListContainersFlagsAll;
237
238 ListContainersResult result;
239 VERIFY_SUCCEEDED(session->ListContainers(
240 &options,
241 result.Containers.addressof(),
242 result.Containers.size_address<ULONG>(),
243 result.Ports.addressof(),
244 result.Ports.size_address<ULONG>()));
245
246 return result;
247 }
248
249 std::string PushImageToRegistry(const std::string& imageName, const std::string& registryAddress, const std::string& registryAuth)
250 {
251 auto reference = ImageReference::Parse(imageName);
252 const auto& repo = reference.Repository.Name;
253 auto tag = reference.TagOrDigest();
254 auto registryImage = std::format("{}/{}:{}", registryAddress, repo, tag.value_or("latest"));
255 auto registryRepo = std::format("{}/{}", registryAddress, repo);
256 auto registryTag = tag.value_or("latest");
257
258 WSLCTagImageOptions tagOptions{};
259 tagOptions.Image = imageName.c_str();
260 tagOptions.Repo = registryRepo.c_str();
261 tagOptions.Tag = registryTag.c_str();
262
263 // Tag the image with the registry address so it can be pushed.
264 VERIFY_SUCCEEDED(m_defaultSession->TagImage(&tagOptions));
265
266 // Ensures the tag is removed to allow tests to try to push or pull the same image again.
267 auto cleanup = wil::scope_exit_log(
268 WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_FAILED(DeleteImageNoThrow(registryImage, WSLCDeleteImageFlagsNone).first); });
269
270 VERIFY_SUCCEEDED(m_defaultSession->PushImage(registryImage.c_str(), registryAuth.c_str(), nullptr, nullptr));
271
272 return registryImage;
273 }
274
275 WSLC_TEST_METHOD(GetVersion)
276 {
277 wil::com_ptr<IWSLCSessionManager> sessionManager;
278 VERIFY_SUCCEEDED(CoCreateInstance(__uuidof(WSLCSessionManager), nullptr, CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(&sessionManager)));
279
280 WSLCVersion version{};
281
282 VERIFY_SUCCEEDED(sessionManager->GetVersion(&version));
283
284 VERIFY_ARE_EQUAL(version.Major, WSL_PACKAGE_VERSION_MAJOR);
285 VERIFY_ARE_EQUAL(version.Minor, WSL_PACKAGE_VERSION_MINOR);
286 VERIFY_ARE_EQUAL(version.Revision, WSL_PACKAGE_VERSION_REVISION);
287 }
288
289 WSLC_TEST_METHOD(IsClientVersionSupported)
290 {
291 wil::com_ptr<IWSLCCompatSessionManager> sessionManager;
292 VERIFY_SUCCEEDED(CoCreateInstance(__uuidof(WSLCSessionManager), nullptr, CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(&sessionManager)));
293
294 BOOL isSupported = FALSE;
295
296 // The current version should always be supported.
297 const WSLCCompatVersion currentVersion{WSL_PACKAGE_VERSION_MAJOR, WSL_PACKAGE_VERSION_MINOR, WSL_PACKAGE_VERSION_REVISION};
298 VERIFY_SUCCEEDED(sessionManager->IsClientVersionSupported(&currentVersion, &isSupported));
299 VERIFY_IS_TRUE(isSupported);
300
301 // A very old version should not be supported.
302 const WSLCCompatVersion oldVersion{1, 0, 0};
303 VERIFY_SUCCEEDED(sessionManager->IsClientVersionSupported(&oldVersion, &isSupported));
304 VERIFY_IS_FALSE(isSupported);
305
306 // A very high version should be supported.
307 const WSLCCompatVersion futureVersion{99, 0, 0};
308 VERIFY_SUCCEEDED(sessionManager->IsClientVersionSupported(&futureVersion, &isSupported));
309 VERIFY_IS_TRUE(isSupported);
310 }
311
312 static RunningWSLCProcess::ProcessResult RunCommand(IWSLCSession* session, const std::vector<std::string>& command, int timeout = 600000)
313 {
314 WSLCProcessLauncher process(command[0], command);
315
316 return process.Launch(*session).WaitAndCaptureOutput(timeout);
317 }
318
319 static RunningWSLCProcess::ProcessResult ExpectCommandResult(
320 IWSLCSession* session, const std::vector<std::string>& command, int expectResult, int timeout = 600000)
321 {
322 auto result = RunCommand(session, command, timeout);
323
324 if (result.Code != expectResult)
325 {
326 auto cmd = wsl::shared::string::Join(command, ' ');
327 LogError(
328 "Command: %hs didn't return expected code (%i). ExitCode: %i, Stdout: '%hs', Stderr: '%hs'",
329 cmd.c_str(),
330 expectResult,
331 result.Code,
332 result.Output[1].c_str(),
333 result.Output[2].c_str());
334 }
335
336 return result;
337 }
338
339 void ValidateProcessOutput(RunningWSLCProcess& process, const std::map<int, std::string>& expectedOutput, int expectedResult = 0, DWORD Timeout = INFINITE)
340 {
341 auto result = process.WaitAndCaptureOutput(Timeout);
342
343 if (result.Code != expectedResult)
344 {
345 LogError(
346 "Command didn't return expected code (%i). ExitCode: %i, Stdout: '%hs', Stderr: '%hs'",
347 expectedResult,
348 result.Code,
349 EscapeString(result.Output[1]).c_str(),
350 EscapeString(result.Output[2]).c_str());
351
352 return;
353 }
354
355 for (const auto& [fd, expected] : expectedOutput)
356 {
357 auto it = result.Output.find(fd);
358 if (it == result.Output.end())
359 {
360 LogError("Expected output on fd %i, but none found.", fd);
361 return;
362 }
363
364 if (it->second != expected)
365 {
366 LogError(
367 "Unexpected output on fd %i. Expected: '%hs', Actual: '%hs'",
368 fd,
369 EscapeString(expected).c_str(),
370 EscapeString(it->second).c_str());
371
372 return;
373 }
374 }
375 }
376
377 void ValidateContainerOutput(RunningWSLCContainer& container, const std::map<int, std::string>& expectedOutput, int expectedResult = 0, DWORD timeout = INFINITE)
378 {
379 auto initProcess = container.GetInitProcess();
380 ValidateProcessOutput(initProcess, expectedOutput, expectedResult, timeout);
381 }
382
383 void ValidateContainerOutput(WSLCContainerLauncher& launcher, const std::map<int, std::string>& expectedOutput, int expectedResult = 0, DWORD timeout = INFINITE)
384 {
385 auto container = launcher.Launch(*m_defaultSession);
386 ValidateContainerOutput(container, expectedOutput, expectedResult, timeout);
387 }
388
389 void ExpectMount(IWSLCSession* session, const std::string& target, const std::optional<std::string>& options)
390 {
391 auto cmd = std::format("set -o pipefail ; findmnt '{}' | tail -n 1", target);
392 auto result = ExpectCommandResult(session, {"/bin/sh", "-c", cmd}, options.has_value() ? 0 : 1);
393
394 const auto& output = result.Output[1];
395 const auto& error = result.Output[2];
396
397 if (result.Code != (options.has_value() ? 0 : 1))
398 {
399 LogError("%hs failed. code=%i, output: %hs, error: %hs", cmd.c_str(), result.Code, output.c_str(), error.c_str());
400 VERIFY_FAIL();
401 }
402
403 if (options.has_value() && !PathMatchSpecA(output.c_str(), options->c_str()))
404 {
405 std::wstring message = std::format(L"Output: '{}' didn't match pattern: '{}'", output, options.value());
406 VERIFY_FAIL(message.c_str());
407 }
408 }
409
410 WSLC_TEST_METHOD(ListSessionsReturnsSessionWithDisplayName)
411 {
412 auto sessionManager = OpenSessionManager();
413
414 // Act: list sessions
415 {
416 const auto names = ListTestSessionNames(sessionManager.get());
417
418 // Assert
419 VERIFY_ARE_EQUAL(names.size(), 1u);
420
421 // SessionId is implementation detail (starts at 1), so we only assert DisplayName here.
422 VERIFY_IS_TRUE(names.contains(c_testSessionName));
423 }
424
425 // List multiple sessions.
426 {
427 auto session2 = CreateSession(GetDefaultSessionSettings(L"wslc-test-list-2"));
428
429 const auto names = ListTestSessionNames(sessionManager.get());
430
431 VERIFY_ARE_EQUAL(names.size(), 2u);
432 VERIFY_IS_TRUE(names.contains(c_testSessionName));
433 VERIFY_IS_TRUE(names.contains(L"wslc-test-list-2"));
434 }
435 }
436
437 WSLC_TEST_METHOD(OpenSessionByNameFindsExistingSession)
438 {
439 auto sessionManager = OpenSessionManager();
440
441 // Act: open by the same display name
442 wil::com_ptr<IWSLCSession> opened;
443 VERIFY_SUCCEEDED(sessionManager->OpenSessionByName(c_testSessionName, &opened));
444 VERIFY_IS_NOT_NULL(opened.get());
445
446 // And verify we get WSLC_E_SESSION_NOT_FOUND for a nonexistent name
447 wil::com_ptr<IWSLCSession> notFound;
448 auto hr = sessionManager->OpenSessionByName(L"this-name-does-not-exist", &notFound);
449 VERIFY_ARE_EQUAL(hr, WSLC_E_SESSION_NOT_FOUND);
450 }
451
452 WSLC_TEST_METHOD(GetDisplayNameReturnsSessionName)
453 {
454 wil::unique_cotaskmem_string displayName;
455 VERIFY_SUCCEEDED(m_defaultSession->GetDisplayName(&displayName));
456 VERIFY_IS_NOT_NULL(displayName.get());
457 VERIFY_ARE_EQUAL(std::wstring(displayName.get()), c_testSessionName);
458 }
459
460 WSLC_TEST_METHOD(CreateSessionValidation)
461 {
462 auto sessionManager = OpenSessionManager();
463
464 // Reject NULL DisplayName.
465 {
466 auto settings = GetDefaultSessionSettings(nullptr);
467 wil::com_ptr<IWSLCSession> session;
468 VERIFY_ARE_EQUAL(sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, &session), WSLC_E_INVALID_SESSION_NAME);
469 }
470
471 // Reject DisplayName at exact boundary (no room for null terminator).
472 {
473 std::wstring boundaryName(std::size(WSLCSessionListEntry{}.DisplayName), L'x');
474 auto settings = GetDefaultSessionSettings(boundaryName.c_str());
475 wil::com_ptr<IWSLCSession> session;
476 VERIFY_ARE_EQUAL(sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, &session), WSLC_E_INVALID_SESSION_NAME);
477 }
478
479 // Reject too long DisplayName.
480 {
481 std::wstring longName(std::size(WSLCSessionListEntry{}.DisplayName) + 1, L'x');
482 auto settings = GetDefaultSessionSettings(longName.c_str());
483 wil::com_ptr<IWSLCSession> session;
484 VERIFY_ARE_EQUAL(sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, &session), WSLC_E_INVALID_SESSION_NAME);
485 }
486
487 // Validate that creating a session on a non-existing storage fails if WSLCSessionStorageFlagsNoCreate is set.
488 {
489 auto settings = GetDefaultSessionSettings(L"storage-not-found");
490 settings.StoragePath = L"C:\\does-not-exist";
491 settings.StorageFlags = WSLCSessionStorageFlagsNoCreate;
492 wil::com_ptr<IWSLCSession> session;
493 VERIFY_ARE_EQUAL(sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, &session), HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND));
494 }
495
496 // Reject invalid storage flags.
497 {
498 auto settings = GetDefaultSessionSettings(L"invalid-storage-flags");
499 settings.StorageFlags = static_cast<WSLCSessionStorageFlags>(0x4);
500 wil::com_ptr<IWSLCSession> session;
501 VERIFY_ARE_EQUAL(sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, &session), E_INVALIDARG);
502 }
503
504 // Reject non-empty storage directory that doesn't contain a session VHD.
505 {
506 const auto storagePath = std::filesystem::temp_directory_path() /
507 std::format(L"wslc-test-storage-{}-{}", GetCurrentProcessId(), GetTickCount64());
508 std::filesystem::create_directories(storagePath);
509 auto cleanup = wil::scope_exit([&]() {
510 std::error_code ignored;
511 std::filesystem::remove_all(storagePath, ignored);
512 });
513
514 std::ofstream{storagePath / L"userfile.txt"} << "data";
515
516 auto settings = GetDefaultSessionSettings(L"storage-not-empty");
517 const auto storagePathString = storagePath.wstring();
518 settings.StoragePath = storagePathString.c_str();
519 wil::com_ptr<IWSLCSession> session;
520 VERIFY_ARE_EQUAL(sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, &session), E_INVALIDARG);
521 ValidateCOMErrorMessage(std::format(L"Cannot use '{}' as session storage because the directory is not empty", storagePathString));
522 }
523
524 // Reject storage path that exists but is not a directory.
525 {
526 const auto storagePath = std::filesystem::temp_directory_path() /
527 std::format(L"wslc-test-storage-file-{}-{}", GetCurrentProcessId(), GetTickCount64());
528 std::ofstream{storagePath} << "data";
529 auto cleanup = wil::scope_exit([&]() {
530 std::error_code ignored;
531 std::filesystem::remove(storagePath, ignored);
532 });
533
534 auto settings = GetDefaultSessionSettings(L"storage-not-directory");
535 const auto storagePathString = storagePath.wstring();
536 settings.StoragePath = storagePathString.c_str();
537 wil::com_ptr<IWSLCSession> session;
538 VERIFY_ARE_EQUAL(sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, &session), E_INVALIDARG);
539 ValidateCOMErrorMessage(std::format(L"Cannot use '{}' as session storage because it is not a directory", storagePathString));
540 }
541
542 // Reject invalid session flags.
543 {
544 auto settings = GetDefaultSessionSettings(L"invalid-session-flags");
545 wil::com_ptr<IWSLCSession> session;
546 VERIFY_ARE_EQUAL(E_INVALIDARG, sessionManager->CreateSession(&settings, static_cast<WSLCSessionFlags>(0x4), nullptr, &session));
547 }
548
549 // Reject invalid feature flags.
550 {
551 auto settings = GetDefaultSessionSettings(L"invalid-feature-flags");
552 settings.FeatureFlags = static_cast<WSLCFeatureFlags>(0x40);
553 wil::com_ptr<IWSLCSession> session;
554 VERIFY_ARE_EQUAL(E_INVALIDARG, sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, &session));
555 }
556
557 // Reject NULL output pointers across the session manager API.
558 {
559 auto settings = GetDefaultSessionSettings(L"null-out-session");
560 VERIFY_ARE_EQUAL(
561 HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), sessionManager->CreateSession(&settings, WSLCSessionFlagsNone, nullptr, nullptr));
562 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), sessionManager->OpenSession(0, nullptr));
563 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), sessionManager->OpenSessionByName(c_testSessionName, nullptr));
564
565 WSLCSessionListEntry* entries = nullptr;
566 ULONG count = 0;
567 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), sessionManager->ListSessions(nullptr, &count));
568 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), sessionManager->ListSessions(&entries, nullptr));
569 }
570
571 // The session object must reject NULL output pointers.
572 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), m_defaultSession->GetId(nullptr));
573 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), m_defaultSession->GetDisplayName(nullptr));
574 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), m_defaultSession->GetState(nullptr));
575 }
576
577 struct VmInfo
578 {
579 std::wstring Id;
580 std::wstring Owner;
581 };
582
583 // Returns VM info (Id + Owner) for all compute systems via the HCS API.
584 static std::vector<VmInfo> ListVms()
585 {
586 const wsl::windows::common::ExecutionContext context(wsl::windows::common::Context::HCS);
587
588 auto operation = wsl::windows::common::hcs::CreateOperation();
589 THROW_IF_FAILED(::HcsEnumerateComputeSystems(L"{}", operation.get()));
590
591 wil::unique_cotaskmem_string resultDocument;
592 const auto result = ::HcsWaitForOperationResult(operation.get(), 10000, &resultDocument);
593 THROW_IF_FAILED_MSG(result, "HcsEnumerateComputeSystems failed (error: %ls)", resultDocument.get());
594
595 LogInfo("HcsEnumerateComputeSystems result='%ws'", resultDocument.get());
596
597 std::vector<VmInfo> vms;
598 const auto json = nlohmann::json::parse(wsl::shared::string::WideToMultiByte(resultDocument.get()));
599 if (!json.is_array())
600 {
601 return vms;
602 }
603
604 for (const auto& entry : json)
605 {
606 if (entry.contains("Owner") && entry["Owner"].is_string() && entry.contains("Id") && entry["Id"].is_string())
607 {
608 vms.push_back(
609 {wsl::shared::string::MultiByteToWide(entry["Id"].get<std::string>()),
610 wsl::shared::string::MultiByteToWide(entry["Owner"].get<std::string>())});
611 }
612 }
613
614 return vms;
615 }
616
617 WSLC_TEST_METHOD(VmOwnerMatchesSessionDisplayName)
618 {
619 // The default session (c_testSessionName) is already running from class setup.
620 // Verify its display name appears as a VM owner in hcsdiag output.
621 auto vms = ListVms();
622
623 auto found = std::ranges::find_if(vms, [](const auto& vm) { return vm.Owner == c_testSessionName; });
624 if (found == vms.end())
625 {
626 LogError("Expected VM owner '%ws' not found. Owners:", c_testSessionName);
627 for (const auto& vm : vms)
628 {
629 LogError(" '%ws'", vm.Owner.c_str());
630 }
631
632 VERIFY_FAIL();
633 }
634 }
635
636 void ExpectImagePresent(IWSLCSession& Session, const char* Image, bool Present = true)
637 {
638 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
639 THROW_IF_FAILED(Session.ListImages(nullptr, images.addressof(), images.size_address<ULONG>()));
640
641 std::vector<std::string> tags;
642 for (const auto& e : images)
643 {
644 tags.push_back(e.Image);
645 }
646
647 auto found = std::ranges::find(tags, Image) != tags.end();
648 if (Present != found)
649 {
650 LogError("Image presence check failed for image: %hs, images: %hs", Image, wsl::shared::string::Join(tags, ',').c_str());
651 VERIFY_FAIL();
652 }
653 }
654
655 std::pair<HRESULT, wil::unique_cotaskmem_array_ptr<WSLCDeletedImageInformation>> DeleteImageNoThrow(const std::string& Image, DWORD Flags)
656 {
657 WSLCDeleteImageOptions options{};
658 options.Image = Image.c_str();
659 options.Flags = Flags;
660 wil::unique_cotaskmem_array_ptr<WSLCDeletedImageInformation> deletedImages;
661 auto hr = m_defaultSession->DeleteImage(&options, deletedImages.addressof(), deletedImages.size_address<ULONG>());
662 return {hr, std::move(deletedImages)};
663 }
664
665 wil::unique_cotaskmem_array_ptr<WSLCDeletedImageInformation> DeleteImage(const std::string& Image, DWORD Flags)
666 {
667 auto [hr, deletedImages] = DeleteImageNoThrow(Image, Flags);
668 VERIFY_SUCCEEDED(hr);
669
670 return std::move(deletedImages);
671 }
672
673 std::vector<wsl::windows::common::wslc_schema::VolumeListEntry> ListVolumeEntries(const std::vector<WSLCFilter>& Filters = {})
674 {
675 wil::unique_cotaskmem_ansistring output;
676 VERIFY_SUCCEEDED(m_defaultSession->ListVolumes(Filters.empty() ? nullptr : Filters.data(), static_cast<ULONG>(Filters.size()), &output));
677
678 return wsl::shared::FromJson<std::vector<wsl::windows::common::wslc_schema::VolumeListEntry>>(output.get());
679 }
680
681 std::set<std::string> ListVolumes(const std::vector<WSLCFilter>& Filters = {})
682 {
683 std::set<std::string> names;
684 for (const auto& v : ListVolumeEntries(Filters))
685 {
686 names.insert(v.Name);
687 }
688 return names;
689 }
690
691 void CreateNamedVolume(
692 const std::string& Name,
693 const std::string& Driver,
694 const std::vector<WSLCLabel>& Labels = {},
695 const std::vector<WSLCDriverOption>& DriverOpts = {})
696 {
697 WSLCVolumeOptions options{};
698 options.Name = Name.c_str();
699 options.Driver = Driver.c_str();
700 options.DriverOpts = DriverOpts.empty() ? nullptr : DriverOpts.data();
701 options.DriverOptsCount = static_cast<ULONG>(DriverOpts.size());
702 options.Labels = Labels.empty() ? nullptr : Labels.data();
703 options.LabelsCount = static_cast<ULONG>(Labels.size());
704
705 WSLCVolumeInformation info{};
706 VERIFY_SUCCEEDED(m_defaultSession->CreateVolume(&options, &info));
707 }
708
709 WSLC_TEST_METHOD(PullImage)
710 {
711 {
712 // Start a local registry without auth and push hello-world:latest to it.
713 auto [registryContainer, registryAddress] = StartLocalRegistry(*m_defaultSession);
714
715 auto image = PushImageToRegistry("hello-world:latest", registryAddress, BuildRegistryAuthHeader("", ""));
716 ExpectImagePresent(*m_defaultSession, image.c_str(), false);
717
718 VERIFY_SUCCEEDED(m_defaultSession->PullImage(image.c_str(), nullptr, nullptr, nullptr));
719 auto cleanup = wil::scope_exit([&]() { LOG_IF_FAILED(DeleteImageNoThrow(image, WSLCDeleteImageFlagsForce).first); });
720
721 // Verify that the image is in the list of images.
722 ExpectImagePresent(*m_defaultSession, image.c_str());
723 WSLCContainerLauncher launcher(image, "wslc-pull-image-container");
724
725 auto container = launcher.Launch(*m_defaultSession);
726 auto result = container.GetInitProcess().WaitAndCaptureOutput();
727
728 VERIFY_ARE_EQUAL(0, result.Code);
729 VERIFY_IS_TRUE(result.Output[1].find("Hello from Docker!") != std::string::npos);
730 }
731
732 {
733 std::wstring expectedError =
734 L"pull access denied for does-not, repository does not exist or may require 'docker login': denied: requested "
735 L"access to the resource is denied";
736
737 VERIFY_ARE_EQUAL(m_defaultSession->PullImage("does-not:exist", nullptr, nullptr, nullptr), WSLC_E_IMAGE_NOT_FOUND);
738 ValidateCOMErrorMessage(expectedError.c_str());
739 }
740
741 // Validate that PullImage() returns the appropriate error if the session is terminated.
742 {
743 VERIFY_SUCCEEDED(m_defaultSession->Terminate());
744
745 auto cleanup = wil::scope_exit([&]() {
746 ResetTestSession(); // Reopen the test session since the session was terminated.
747 });
748
749 VERIFY_ARE_EQUAL(m_defaultSession->PullImage("hello-world:linux", nullptr, nullptr, nullptr), HRESULT_FROM_WIN32(ERROR_INVALID_STATE));
750 }
751 }
752
753 WSLC_TEST_METHOD(PullImageAdvanced)
754 {
755 // Start a local registry without auth to avoid Docker Hub rate limits.
756 auto [registryContainer, registryAddress] = StartLocalRegistry(*m_defaultSession);
757 auto auth = BuildRegistryAuthHeader("", "");
758
759 auto validatePull = [&](const std::string& sourceImage) {
760 // Push the source image to the local registry.
761 auto registryImage = PushImageToRegistry(sourceImage, registryAddress, auth);
762 ExpectImagePresent(*m_defaultSession, registryImage.c_str(), false);
763
764 VERIFY_SUCCEEDED(m_defaultSession->PullImage(registryImage.c_str(), nullptr, nullptr, nullptr));
765
766 auto cleanup =
767 wil::scope_exit([&]() { LOG_IF_FAILED(DeleteImageNoThrow(registryImage, WSLCDeleteImageFlagsForce).first); });
768
769 ExpectImagePresent(*m_defaultSession, registryImage.c_str());
770 };
771
772 validatePull("debian:latest");
773 validatePull("alpine:latest");
774 validatePull("hello-world:latest");
775 }
776
777 WSLC_TEST_METHOD(PullImageFromDockerHub)
778 {
779 SKIP_TEST_UNSTABLE();
780
781 auto validatePull = [&](const std::string& Image, const std::optional<std::string>& ExpectedTag = {}) {
782 VERIFY_SUCCEEDED(m_defaultSession->PullImage(Image.c_str(), nullptr, nullptr, nullptr));
783
784 auto cleanup = wil::scope_exit(
785 [&]() { LOG_IF_FAILED(DeleteImageNoThrow(ExpectedTag.value_or(Image), WSLCDeleteImageFlagsForce).first); });
786
787 if (!ExpectedTag.has_value())
788 {
789 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
790 VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, images.addressof(), images.size_address<ULONG>()));
791
792 for (const auto& e : images)
793 {
794 wil::unique_cotaskmem_ansistring json;
795 VERIFY_SUCCEEDED(m_defaultSession->InspectImage(e.Hash, &json));
796
797 auto parsed = wsl::shared::FromJson<wsl::windows::common::wslc_schema::InspectImage>(json.get());
798
799 for (const auto& repoTag : parsed.RepoDigests.value_or({}))
800 {
801 if (Image == repoTag)
802 {
803 return;
804 }
805 }
806 }
807
808 LogError("Expected digest '%hs' not found ", Image.c_str());
809
810 VERIFY_FAIL();
811 }
812 else
813 {
814 ExpectImagePresent(*m_defaultSession, ExpectedTag->c_str());
815 }
816 };
817
818 validatePull("ubuntu@sha256:2e863c44b718727c860746568e1d54afd13b2fa71b160f5cd9058fc436217b30", {});
819 validatePull("ubuntu", "ubuntu:latest");
820 validatePull("debian:bookworm", "debian:bookworm");
821 validatePull("pytorch/pytorch", "pytorch/pytorch:latest");
822 validatePull("registry.k8s.io/pause:3.2", "registry.k8s.io/pause:3.2");
823
824 // Validate that PullImage() fails appropriately when the session runs out of space.
825 {
826 auto settings = GetDefaultSessionSettings(L"wslc-pull-image-out-of-space", false);
827 settings.NetworkingMode = WSLCNetworkingModeConsomme;
828 settings.MemoryMb = 1024;
829 auto session = CreateSession(settings);
830
831 VERIFY_ARE_EQUAL(session->PullImage("pytorch/pytorch", nullptr, nullptr, nullptr), E_FAIL);
832
833 ValidateCOMErrorMessageContains(L"no space left on device");
834 }
835 }
836
837 WSLC_TEST_METHOD(PushImage)
838 {
839 auto emptyAuth = BuildRegistryAuthHeader("", "");
840
841 // Validate that pushing a non-existent image fails.
842 {
843 VERIFY_ARE_EQUAL(m_defaultSession->PushImage("does-not-exist:latest", emptyAuth.c_str(), nullptr, nullptr), E_FAIL);
844 ValidateCOMErrorMessage(L"An image does not exist locally with the tag: does-not-exist");
845 }
846
847 // Validate passing empty auth string returns an appropriate error.
848 {
849 VERIFY_ARE_EQUAL(m_defaultSession->PushImage("does-not-exist:latest", "", nullptr, nullptr), E_INVALIDARG);
850 }
851
852 // Validate that PushImage() returns the appropriate error if the session is terminated.
853 {
854 VERIFY_SUCCEEDED(m_defaultSession->Terminate());
855 auto cleanup = wil::scope_exit([&]() { ResetTestSession(); });
856
857 VERIFY_ARE_EQUAL(m_defaultSession->PushImage("hello-world:latest", emptyAuth.c_str(), nullptr, nullptr), HRESULT_FROM_WIN32(ERROR_INVALID_STATE));
858 }
859 }
860
861 WSLC_TEST_METHOD(Authenticate)
862 {
863 constexpr auto c_username = "wslctest";
864 constexpr auto c_password = "password";
865
866 auto [registryContainer, registryAddress] = StartLocalRegistry(*m_defaultSession, c_username, c_password);
867
868 wil::unique_cotaskmem_ansistring token;
869 VERIFY_ARE_EQUAL(m_defaultSession->Authenticate(registryAddress.c_str(), c_username, "wrong-password", &token), E_FAIL);
870 ValidateCOMErrorMessageContains(L"failed with status: 401 Unauthorized");
871
872 VERIFY_SUCCEEDED(m_defaultSession->Authenticate(registryAddress.c_str(), c_username, c_password, &token));
873 VERIFY_IS_NOT_NULL(token.get());
874
875 auto xRegistryAuth = BuildRegistryAuthHeader(c_username, c_password);
876 auto image = PushImageToRegistry("hello-world:latest", registryAddress, xRegistryAuth);
877
878 // Pulling without credentials should fail.
879 VERIFY_ARE_EQUAL(m_defaultSession->PullImage(image.c_str(), nullptr, nullptr, nullptr), E_FAIL);
880 ValidateCOMErrorMessageContains(L"no basic auth credentials");
881
882 // Pulling with credentials should succeed.
883 VERIFY_SUCCEEDED(m_defaultSession->PullImage(image.c_str(), xRegistryAuth.c_str(), nullptr, nullptr));
884 ExpectImagePresent(*m_defaultSession, image.c_str());
885 }
886
887 WSLC_TEST_METHOD(ListImages)
888 {
889 // Setup: Ensure debian:latest is available
890 ExpectImagePresent(*m_defaultSession, "debian:latest");
891
892 // Create additional tags for testing
893 WSLCTagImageOptions tagOptions{};
894 tagOptions.Image = "debian:latest";
895 tagOptions.Repo = "debian";
896 tagOptions.Tag = "test-tag1";
897 VERIFY_SUCCEEDED(m_defaultSession->TagImage(&tagOptions));
898 tagOptions.Tag = "test-tag2";
899 VERIFY_SUCCEEDED(m_defaultSession->TagImage(&tagOptions));
900
901 auto cleanup = wil::scope_exit([&]() {
902 LOG_IF_FAILED(DeleteImageNoThrow("debian:test-tag1", WSLCDeleteImageFlagsNone).first);
903 LOG_IF_FAILED(DeleteImageNoThrow("debian:test-tag2", WSLCDeleteImageFlagsNone).first);
904 });
905
906 LogInfo("Test: Basic listing with nullptr options");
907 {
908 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
909 VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, images.addressof(), images.size_address<ULONG>()));
910
911 VERIFY_IS_TRUE(images.size() > 0);
912
913 // Find debian images and verify they exist
914 bool foundLatest = false, foundTag1 = false, foundTag2 = false;
915 for (const auto& image : images)
916 {
917 std::string imageName = image.Image;
918 if (imageName == "debian:latest")
919 {
920 foundLatest = true;
921 }
922 if (imageName == "debian:test-tag1")
923 {
924 foundTag1 = true;
925 }
926 if (imageName == "debian:test-tag2")
927 {
928 foundTag2 = true;
929 }
930 }
931
932 VERIFY_IS_TRUE(foundLatest);
933 VERIFY_IS_TRUE(foundTag1);
934 VERIFY_IS_TRUE(foundTag2);
935 }
936
937 LogInfo("Test: Verify all fields are populated");
938 {
939 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
940 VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, images.addressof(), images.size_address<ULONG>()));
941
942 std::string commonHash;
943 int debianTagCount = 0;
944
945 for (const auto& image : images)
946 {
947 std::string imageName = image.Image;
948 if (imageName.starts_with("debian:"))
949 {
950 debianTagCount++;
951
952 // Verify Hash field
953 VERIFY_IS_TRUE(strlen(image.Hash) > 0);
954 VERIFY_IS_TRUE(std::string(image.Hash).starts_with("sha256:"));
955
956 // All debian tags should have the same hash (same underlying image)
957 if (commonHash.empty())
958 {
959 commonHash = image.Hash;
960 }
961 else
962 {
963 VERIFY_ARE_EQUAL(commonHash, std::string(image.Hash));
964 }
965
966 // Verify Size field
967 VERIFY_IS_TRUE(image.Size > 0);
968
969 // Verify Created timestamp
970 VERIFY_IS_TRUE(image.Created > 0);
971 }
972 }
973
974 VERIFY_IS_TRUE(debianTagCount >= 3); // At least debian:latest, test-tag1, test-tag2
975 }
976
977 LogInfo("Test: Multiple tags for same image return separate entries");
978 {
979 WSLCFilter filter{.Key = "reference", .Value = "debian"};
980 WSLCListImagesOptions options{.Flags = WSLCListImagesFlagsNone, .Filters = &filter, .FiltersCount = 1};
981
982 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
983 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
984
985 // Should find at least our 3 debian tags
986 VERIFY_IS_TRUE(images.size() >= 3);
987
988 // Verify each tag is a separate entry
989 std::set<std::string> imageTags;
990 for (const auto& image : images)
991 {
992 imageTags.insert(image.Image);
993 }
994
995 VERIFY_IS_TRUE(imageTags.contains("debian:latest"));
996 VERIFY_IS_TRUE(imageTags.contains("debian:test-tag1"));
997 VERIFY_IS_TRUE(imageTags.contains("debian:test-tag2"));
998 }
999
1000 LogInfo("Test: Filter by specific reference");
1001 {
1002 WSLCFilter filter{.Key = "reference", .Value = "debian:test-tag1"};
1003 WSLCListImagesOptions options{.Flags = WSLCListImagesFlagsNone, .Filters = &filter, .FiltersCount = 1};
1004
1005 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
1006 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
1007
1008 // When filtering by exact tag, Docker returns all tags for that image
1009 // So we should get debian:latest, debian:test-tag1, debian:test-tag2
1010 bool foundTag1 = false;
1011 for (const auto& image : images)
1012 {
1013 std::string imageName = image.Image;
1014 if (imageName == "debian:test-tag1")
1015 {
1016 foundTag1 = true;
1017 }
1018 }
1019 VERIFY_IS_TRUE(foundTag1);
1020 }
1021
1022 LogInfo("Test: Digests flag");
1023 {
1024 WSLCFilter filter{.Key = "reference", .Value = "debian:latest"};
1025 WSLCListImagesOptions options{.Flags = WSLCListImagesFlagsDigests, .Filters = &filter, .FiltersCount = 1};
1026
1027 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
1028 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
1029
1030 // Check if digests are available (they may not be for all images)
1031 bool hasDigest = false;
1032 for (const auto& image : images)
1033 {
1034 if (strlen(image.Digest) > 0)
1035 {
1036 hasDigest = true;
1037 // Digest should be in format repo@sha256:...
1038 VERIFY_IS_TRUE(std::string(image.Digest).find("@sha256:") != std::string::npos);
1039 }
1040 }
1041 // Note: Pulled images from registry should have digests, locally built may not
1042 }
1043
1044 LogInfo("Test: Invalid flags are rejected");
1045 {
1046 constexpr auto c_invalidFlags = static_cast<WSLCListImagesFlags>(0x4 | 0x8);
1047
1048 WSLCListImagesOptions options{.Flags = c_invalidFlags, .Filters = nullptr, .FiltersCount = 0};
1049 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
1050
1051 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
1052 }
1053
1054 LogInfo("Test: Before/Since filters");
1055 {
1056 // Get all images to find their IDs and creation times
1057 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> allImages;
1058 VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, allImages.addressof(), allImages.size_address<ULONG>()));
1059
1060 std::string debianId, pythonId;
1061 LONGLONG debianCreated = 0, pythonCreated = 0;
1062 for (const auto& image : allImages)
1063 {
1064 std::string imageName = image.Image;
1065 if (imageName == "debian:latest")
1066 {
1067 debianId = image.Hash;
1068 debianCreated = image.Created;
1069 }
1070 else if (imageName == "python:3.12-alpine")
1071 {
1072 pythonId = image.Hash;
1073 pythonCreated = image.Created;
1074 }
1075 }
1076
1077 VERIFY_IS_FALSE(debianId.empty());
1078 VERIFY_IS_FALSE(pythonId.empty());
1079
1080 // Both Created timestamps must be populated and distinct so that the since/before
1081 // boundaries are unambiguous. Equal timestamps would make Docker's filter behavior
1082 // ambiguous and could reintroduce flakiness.
1083 VERIFY_IS_GREATER_THAN(debianCreated, 0LL);
1084 VERIFY_IS_GREATER_THAN(pythonCreated, 0LL);
1085 VERIFY_ARE_NOT_EQUAL(debianCreated, pythonCreated);
1086
1087 // Determine which image is older/newer based on actual creation timestamps.
1088 // Image creation times come from the registry and can change independently.
1089 const bool debianIsOlder = debianCreated < pythonCreated;
1090 const auto& olderId = debianIsOlder ? debianId : pythonId;
1091 const auto& newerId = debianIsOlder ? pythonId : debianId;
1092 const auto* olderName = debianIsOlder ? "debian:latest" : "python:3.12-alpine";
1093 const auto* newerName = debianIsOlder ? "python:3.12-alpine" : "debian:latest";
1094
1095 LogInfo(
1096 "Older image: %hs (Created: %lld), Newer image: %hs (Created: %lld)",
1097 olderName,
1098 debianIsOlder ? debianCreated : pythonCreated,
1099 newerName,
1100 debianIsOlder ? pythonCreated : debianCreated);
1101
1102 // Test 'since' filter - images created after the older image
1103 {
1104 WSLCFilter filter{.Key = "since", .Value = olderId.c_str()};
1105 WSLCListImagesOptions options{.Flags = WSLCListImagesFlagsNone, .Filters = &filter, .FiltersCount = 1};
1106
1107 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
1108 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
1109 VERIFY_IS_TRUE(images.size() > 0);
1110
1111 bool foundNewer = false;
1112 for (const auto& image : images)
1113 {
1114 LogInfo("Image: %hs, Hash: %hs, Created: %lld", image.Image, image.Hash, image.Created);
1115 if (std::string{image.Image} == newerName)
1116 {
1117 foundNewer = true;
1118 }
1119 }
1120
1121 VERIFY_IS_TRUE(foundNewer);
1122 }
1123
1124 // Test 'before' filter - images created before the newer image
1125 {
1126 WSLCFilter filter{.Key = "before", .Value = newerId.c_str()};
1127 WSLCListImagesOptions options{.Flags = WSLCListImagesFlagsNone, .Filters = &filter, .FiltersCount = 1};
1128 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
1129 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
1130 VERIFY_IS_TRUE(images.size() > 0);
1131
1132 bool foundOlder = false;
1133 for (const auto& image : images)
1134 {
1135 if (std::string{image.Image} == olderName)
1136 {
1137 foundOlder = true;
1138 }
1139 }
1140
1141 VERIFY_IS_TRUE(foundOlder);
1142 }
1143 }
1144
1145 LogInfo("Test: Dangling filter");
1146 {
1147 // Setup a dangling image
1148 WSLCTagImageOptions tagOptions{};
1149 tagOptions.Image = "debian:latest";
1150 tagOptions.Repo = "alpine";
1151 tagOptions.Tag = "latest";
1152 VERIFY_SUCCEEDED(m_defaultSession->TagImage(&tagOptions));
1153
1154 auto restore = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LoadTestImage(*m_defaultSession, "alpine:latest"); });
1155
1156 // List only dangling images
1157 WSLCFilter danglingTrueFilter{.Key = "dangling", .Value = "true"};
1158 WSLCListImagesOptions options{.Flags = WSLCListImagesFlagsNone, .Filters = &danglingTrueFilter, .FiltersCount = 1};
1159
1160 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> danglingImages;
1161 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, danglingImages.addressof(), danglingImages.size_address<ULONG>()));
1162
1163 VERIFY_ARE_EQUAL(1, danglingImages.size());
1164
1165 // All dangling images should have <none>:<none> as the tag
1166 for (const auto& image : danglingImages)
1167 {
1168 std::string imageName = image.Image;
1169 VERIFY_ARE_EQUAL(imageName, std::string("<none>:<none>"));
1170 }
1171
1172 // List non-dangling images
1173 WSLCFilter danglingFalseFilter{.Key = "dangling", .Value = "false"};
1174 options.Filters = &danglingFalseFilter;
1175 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> nonDanglingImages;
1176 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, nonDanglingImages.addressof(), nonDanglingImages.size_address<ULONG>()));
1177 VERIFY_IS_TRUE(nonDanglingImages.size() > 0);
1178
1179 // None of these should be <none>:<none>
1180 for (const auto& image : nonDanglingImages)
1181 {
1182 std::string imageName = image.Image;
1183 VERIFY_ARE_NOT_EQUAL(imageName, std::string("<none>:<none>"));
1184 }
1185 }
1186
1187 LogInfo("Test: Label filter");
1188 {
1189 // Test with no filters (nullptr)
1190 WSLCListImagesOptions options{.Flags = WSLCListImagesFlagsNone, .Filters = nullptr, .FiltersCount = 0};
1191
1192 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
1193 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
1194
1195 // Test with single label filter
1196 {
1197 WSLCFilter filters[] = {{"label", "test.label"}};
1198 options.Filters = filters;
1199 options.FiltersCount = 1;
1200
1201 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
1202 }
1203
1204 // Test with multiple label filters (labels are AND'ed together)
1205 {
1206 WSLCFilter filters[] = {{"label", "test.label1"}, {"label", "test.label2=value"}};
1207 options.Filters = filters;
1208 options.FiltersCount = 2;
1209
1210 VERIFY_SUCCEEDED(m_defaultSession->ListImages(&options, images.addressof(), images.size_address<ULONG>()));
1211 }
1212
1213 // Note: To fully test label filtering with actual matches, would need to:
1214 // 1. Build an image with specific labels using docker build --label
1215 // 2. Filter with matching labels
1216 // 3. Verify the filtered image appears
1217 // This only tests the API usage not fail without requiring image builds
1218 }
1219
1220 cleanup.reset();
1221 ExpectImagePresent(*m_defaultSession, "debian:test-tag1", false);
1222 ExpectImagePresent(*m_defaultSession, "debian:test-tag2", false);
1223 ExpectImagePresent(*m_defaultSession, "debian:latest", true);
1224 }
1225
1226 WSLC_TEST_METHOD(LoadImage)
1227 {
1228 SKIP_TEST_SERVER();
1229
1230 std::filesystem::path imageTar = GetTestImagePath("hello-world:latest");
1231 wil::unique_handle imageTarFileHandle{
1232 CreateFileW(imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
1233 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
1234
1235 LARGE_INTEGER fileSize{};
1236 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
1237
1238 VERIFY_SUCCEEDED(m_defaultSession->LoadImage(ToCOMInputHandle(imageTarFileHandle.get()), fileSize.QuadPart, nullptr, nullptr));
1239
1240 // Verify that the image is in the list of images.
1241 ExpectImagePresent(*m_defaultSession, "hello-world:latest");
1242
1243 // Validate container launch from the loaded image
1244 {
1245 WSLCContainerLauncher launcher("hello-world:latest", "wslc-load-image-container");
1246
1247 auto container = launcher.Launch(*m_defaultSession);
1248 auto result = container.GetInitProcess().WaitAndCaptureOutput();
1249
1250 VERIFY_ARE_EQUAL(0, result.Code);
1251 VERIFY_IS_TRUE(result.Output[1].find("Hello from Docker!") != std::string::npos);
1252 }
1253
1254 // Validate that invalid tars fail with proper error message and code.
1255 {
1256 auto currentExecutableHandle = wil::open_file(wil::GetModuleFileNameW<std::wstring>().c_str());
1257 VERIFY_IS_TRUE(GetFileSizeEx(currentExecutableHandle.get(), &fileSize));
1258
1259 VERIFY_ARE_EQUAL(
1260 m_defaultSession->LoadImage(ToCOMInputHandle(currentExecutableHandle.get()), fileSize.QuadPart, nullptr, nullptr), E_FAIL);
1261
1262 ValidateCOMErrorMessage(L"archive/tar: invalid tar header");
1263 }
1264
1265 // Validate that LoadImage fails when the input pipe is closed during reading.
1266 {
1267 wil::unique_handle pipeRead;
1268 wil::unique_handle pipeWrite;
1269 VERIFY_WIN32_BOOL_SUCCEEDED(CreatePipe(&pipeRead, &pipeWrite, nullptr, 2));
1270
1271 std::promise<HRESULT> loadResult;
1272 std::thread operationThread([&]() {
1273 loadResult.set_value(m_defaultSession->LoadImage(ToCOMInputHandle(pipeRead.get()), 1024 * 1024, nullptr, nullptr));
1274 });
1275
1276 auto threadCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { operationThread.join(); });
1277
1278 // Write some data to ensure the service has started reading from the pipe (pipe buffer is 2 bytes).
1279 DWORD bytesWritten{};
1280 VERIFY_WIN32_BOOL_SUCCEEDED(WriteFile(pipeWrite.get(), "data", 4, &bytesWritten, nullptr));
1281
1282 // Close the write end.
1283 pipeWrite.reset();
1284
1285 VERIFY_ARE_EQUAL(E_FAIL, loadResult.get_future().get());
1286 }
1287
1288 // Validate that LoadImage is aborted when the session terminates.
1289 {
1290 wil::unique_handle pipeRead;
1291 wil::unique_handle pipeWrite;
1292 VERIFY_WIN32_BOOL_SUCCEEDED(CreatePipe(&pipeRead, &pipeWrite, nullptr, 2));
1293
1294 std::promise<HRESULT> terminateResult;
1295 wil::unique_event testCompleted{wil::EventOptions::ManualReset};
1296 std::thread operationThread([&]() {
1297 terminateResult.set_value(m_defaultSession->LoadImage(ToCOMInputHandle(pipeRead.get()), 1024 * 1024, nullptr, nullptr));
1298 WI_ASSERT(testCompleted.is_signaled());
1299 });
1300
1301 auto threadCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { operationThread.join(); });
1302
1303 // Write some data to validate that the service has started reading from the pipe (pipe buffer is 2 bytes).
1304 DWORD bytesWritten{};
1305 VERIFY_WIN32_BOOL_SUCCEEDED(WriteFile(pipeWrite.get(), "data", 4, &bytesWritten, nullptr));
1306
1307 testCompleted.SetEvent();
1308
1309 VERIFY_SUCCEEDED(m_defaultSession->Terminate());
1310
1311 auto restore = ResetTestSession();
1312
1313 auto hr = terminateResult.get_future().get();
1314 VERIFY_IS_TRUE(hr == E_ABORT || hr == HRESULT_FROM_WIN32(ERROR_OPERATION_ABORTED));
1315 }
1316 }
1317
1318 class CapturingImageLoadCallback
1319 : public Microsoft::WRL::RuntimeClass<Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, IImageLoadCallback, IFastRundown>
1320 {
1321 public:
1322 HRESULT OnImageLoaded(LPCSTR ImageName, EnumReferenceFormat Format) override
1323 {
1324 m_images.emplace_back(ImageName, Format);
1325 return S_OK;
1326 }
1327
1328 const std::vector<std::pair<std::string, EnumReferenceFormat>>& GetImages() const
1329 {
1330 return m_images;
1331 }
1332
1333 private:
1334 std::vector<std::pair<std::string, EnumReferenceFormat>> m_images;
1335 };
1336
1337 WSLC_TEST_METHOD(LoadImageCallback)
1338 {
1339 SKIP_TEST_SERVER();
1340
1341 const std::filesystem::path imageTar = L"LoadImageCallbackExport.tar";
1342 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTar.c_str())); });
1343
1344 // Save both images into a single archive.
1345 {
1346 wil::unique_handle tarFile{
1347 CreateFileW(imageTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
1348 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == tarFile.get());
1349
1350 std::vector<LPCSTR> names = {"debian:latest", "hello-world:latest"};
1351 WSLCStringArray array{.Values = names.data(), .Count = static_cast<ULONG>(names.size())};
1352 VERIFY_SUCCEEDED(m_defaultSession->SaveImages(ToCOMInputHandle(tarFile.get()), &array, nullptr, nullptr));
1353 }
1354
1355 // Delete both images so that loading actually recreates them.
1356 DeleteImage("hello-world:latest", WSLCDeleteImageFlagsForce);
1357 DeleteImage("debian:latest", WSLCDeleteImageFlagsForce);
1358 ExpectImagePresent(*m_defaultSession, "hello-world:latest", false);
1359 ExpectImagePresent(*m_defaultSession, "debian:latest", false);
1360
1361 auto callback = Microsoft::WRL::Make<CapturingImageLoadCallback>();
1362 {
1363 wil::unique_handle tarFile{
1364 CreateFileW(imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
1365 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == tarFile.get());
1366
1367 LARGE_INTEGER fileSize{};
1368 VERIFY_IS_TRUE(GetFileSizeEx(tarFile.get(), &fileSize));
1369 VERIFY_SUCCEEDED(m_defaultSession->LoadImage(ToCOMInputHandle(tarFile.get()), fileSize.QuadPart, nullptr, callback.Get()));
1370 }
1371
1372 ExpectImagePresent(*m_defaultSession, "debian:latest");
1373 ExpectImagePresent(*m_defaultSession, "hello-world:latest");
1374
1375 // Validate that both images have been reported.
1376 const auto loaded = callback->GetImages();
1377 VERIFY_ARE_EQUAL(static_cast<size_t>(2), loaded.size());
1378 VERIFY_IS_TRUE(std::ranges::find(loaded, std::make_pair(std::string("debian:latest"), EnumReferenceFormatTag)) != loaded.end());
1379 VERIFY_IS_TRUE(
1380 std::ranges::find(loaded, std::make_pair(std::string("hello-world:latest"), EnumReferenceFormatTag)) != loaded.end());
1381 }
1382
1383 WSLC_TEST_METHOD(LoadImageCallbackById)
1384 {
1385 SKIP_TEST_SERVER();
1386
1387 const std::filesystem::path imageTar = L"LoadImageCallbackByIdExport.tar";
1388 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTar.c_str())); });
1389
1390 auto restore = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LoadTestImage(*m_defaultSession, "hello-world:latest"); });
1391
1392 std::string imageId;
1393 {
1394 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
1395 VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, images.addressof(), images.size_address<ULONG>()));
1396 for (const auto& image : images)
1397 {
1398 if (std::strcmp(image.Image, "hello-world:latest") == 0)
1399 {
1400 imageId = image.Hash;
1401 break;
1402 }
1403 }
1404 }
1405
1406 VERIFY_IS_FALSE(imageId.empty());
1407 VERIFY_IS_TRUE(imageId.starts_with("sha256:"));
1408
1409 // Save the image by ID.
1410 {
1411 wil::unique_handle tarFile{
1412 CreateFileW(imageTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
1413 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == tarFile.get());
1414
1415 std::vector<LPCSTR> names = {imageId.c_str()};
1416 WSLCStringArray array{.Values = names.data(), .Count = static_cast<ULONG>(names.size())};
1417 VERIFY_SUCCEEDED(m_defaultSession->SaveImages(ToCOMInputHandle(tarFile.get()), &array, nullptr, nullptr));
1418 }
1419
1420 auto callback = Microsoft::WRL::Make<CapturingImageLoadCallback>();
1421 {
1422 wil::unique_handle tarFile{
1423 CreateFileW(imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
1424 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == tarFile.get());
1425
1426 LARGE_INTEGER fileSize{};
1427 VERIFY_IS_TRUE(GetFileSizeEx(tarFile.get(), &fileSize));
1428 VERIFY_SUCCEEDED(m_defaultSession->LoadImage(ToCOMInputHandle(tarFile.get()), fileSize.QuadPart, nullptr, callback.Get()));
1429 }
1430
1431 // Validate that the expected image ID was reported.
1432 const auto& loaded = callback->GetImages();
1433 VERIFY_ARE_EQUAL(static_cast<size_t>(1), loaded.size());
1434 VERIFY_ARE_EQUAL(imageId, loaded[0].first);
1435 VERIFY_ARE_EQUAL(EnumReferenceFormatDigest, loaded[0].second);
1436 }
1437
1438 // Loading the same image tar repeatedly must not permanently grow the session storage VHD.
1439 //
1440 // Docker's /images/load handler extracts the incoming tar into a temporary directory under its data
1441 // root (/var/lib/docker, which is the storage VHD) before inspecting any digest, so every call
1442 // writes roughly one tar's worth of data to the VHD even when the layers already exist and are
1443 // deduplicated. That temporary directory is deleted afterwards, but the VHD is a non-sparse
1444 // dynamically expanding VHDX mounted without 'discard', so the freed blocks are never returned to
1445 // the host, and ext4 tends to satisfy the next extraction from a different region rather than
1446 // reusing the just-freed one. The result is a VHD that grows by about the tar size on every load.
1447 //
1448 // The size of the tar matters: a small tar is re-extracted into blocks the VHDX has already
1449 // allocated, so the growth plateaus immediately and the bug does not reproduce. This test therefore
1450 // builds a large image with incompressible layers (which is what a real from-source application
1451 // image looks like once 'save' has written its uncompressed layers out) rather than reusing one of
1452 // the small prebuilt test tars.
1453 WSLC_TEST_METHOD(LoadImageRepeatedDoesNotGrowStorageVhd)
1454 {
1455 SKIP_TEST_SERVER();
1456
1457 constexpr auto c_sessionName = L"wslc-load-image-vhd-growth";
1458 constexpr auto c_imageName = "wslc-test-load-growth:latest";
1459 constexpr auto c_layerCount = 4;
1460 constexpr auto c_layerSizeMb = 256;
1461 constexpr auto c_extraLoads = 3;
1462
1463 // Build the image in the shared session (it already has debian:latest), then export it. Each
1464 // layer is /dev/urandom so it cannot be compressed away, making the exported tar's size
1465 // representative of the data the engine has to move on every load.
1466 //
1467 // Pass /p:LoadGrowthTar=<path> to run the loop against an existing tar (for example one produced
1468 // by 'wslc build' + 'wslc save' for a real application image) instead of building one here.
1469 WEX::Common::String existingTar;
1470 WEX::TestExecution::RuntimeParameters::TryGetValue(L"LoadGrowthTar", existingTar);
1471 const bool useExistingTar = !existingTar.IsEmpty();
1472
1473 auto contextDir = std::filesystem::current_path() / "build-context-load-growth";
1474 const auto imageTar = useExistingTar ? std::filesystem::path{static_cast<LPCWSTR>(existingTar)}
1475 : std::filesystem::current_path() / "wslc-load-growth.tar";
1476
1477 auto buildCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
1478 if (useExistingTar)
1479 {
1480 return;
1481 }
1482
1483 LOG_IF_FAILED(DeleteImageNoThrow(c_imageName, WSLCDeleteImageFlagsForce).first);
1484
1485 std::error_code ec;
1486 std::filesystem::remove_all(contextDir, ec);
1487 std::filesystem::remove(imageTar, ec);
1488 });
1489
1490 if (!useExistingTar)
1491 {
1492 std::filesystem::create_directories(contextDir);
1493
1494 {
1495 std::ofstream dockerfile(contextDir / "Dockerfile");
1496 dockerfile << "FROM debian:latest\n";
1497 for (auto i = 0; i < c_layerCount; i++)
1498 {
1499 dockerfile << std::format("RUN dd if=/dev/urandom bs=1M count={} of=/blob{}.bin status=none\n", c_layerSizeMb, i);
1500 }
1501 }
1502
1503 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, c_imageName));
1504
1505 wil::unique_handle tarFile{CreateFileW(
1506 imageTar.c_str(), GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
1507 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == tarFile.get());
1508 VERIFY_SUCCEEDED(m_defaultSession->SaveImage(ToCOMInputHandle(tarFile.get()), c_imageName, nullptr, nullptr));
1509 }
1510
1511 const auto tarSize = static_cast<uint64_t>(std::filesystem::file_size(imageTar));
1512 VERIFY_IS_TRUE(tarSize > 0);
1513
1514 // A dedicated storage directory is required: the shared class storage is preloaded with the test
1515 // images and is written to by every other test in this class, so its size says nothing here.
1516 const auto storageDir = std::filesystem::current_path() / "test-storage-load-image-growth";
1517 std::error_code storageError;
1518 std::filesystem::remove_all(storageDir, storageError);
1519 std::filesystem::create_directories(storageDir);
1520 auto storageCleanup = wil::scope_exit([&]() {
1521 std::error_code ec;
1522 std::filesystem::remove_all(storageDir, ec);
1523 });
1524
1525 auto settings = GetDefaultSessionSettings(c_sessionName);
1526 settings.StoragePath = storageDir.c_str();
1527 auto session = CreateSession(settings);
1528
1529 const auto vhdPath = storageDir / wsl::windows::wslc::DefaultStorageVhdName;
1530
1531 // Size on disk, which is what grows as the dynamically expanding VHDX allocates blocks.
1532 auto vhdSizeOnDisk = [&]() {
1533 DWORD highPart{};
1534 SetLastError(NO_ERROR);
1535 const auto lowPart = GetCompressedFileSizeW(vhdPath.c_str(), &highPart);
1536 THROW_LAST_ERROR_IF(lowPart == INVALID_FILE_SIZE && GetLastError() != NO_ERROR);
1537
1538 ULARGE_INTEGER size{};
1539 size.LowPart = lowPart;
1540 size.HighPart = highPart;
1541 return static_cast<uint64_t>(size.QuadPart);
1542 };
1543
1544 // Bytes used by the guest filesystem backing the docker data root.
1545 auto guestUsedBytes = [&]() {
1546 const auto result =
1547 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "df -k /var/lib/docker | awk 'NR == 2 {print $3}'"}, 0);
1548
1549 return std::stoull(result.Output.at(1)) * 1024;
1550 };
1551
1552 // Entries left behind in the directory docker extracts the incoming tar into.
1553 auto guestTempEntryCount = [&]() {
1554 const auto result =
1555 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "ls -A /var/lib/docker/tmp 2>/dev/null | wc -l"}, 0);
1556
1557 return std::stoull(result.Output.at(1));
1558 };
1559
1560 auto loadImage = [&]() {
1561 wil::unique_handle tarFile{
1562 CreateFileW(imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
1563 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == tarFile.get());
1564
1565 LARGE_INTEGER fileSize{};
1566 VERIFY_IS_TRUE(GetFileSizeEx(tarFile.get(), &fileSize));
1567 VERIFY_SUCCEEDED(session->LoadImage(ToCOMInputHandle(tarFile.get()), fileSize.QuadPart, nullptr, nullptr));
1568
1569 // Flush the guest page cache so the writes have reached the VHD before it is measured.
1570 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "sync"}, 0);
1571 };
1572
1573 // The first load legitimately grows the VHD: this is where the layers are actually registered.
1574 // Everything measured after it is overhead from re-loading content docker already has.
1575 loadImage();
1576 if (!useExistingTar)
1577 {
1578 ExpectImagePresent(*session, c_imageName);
1579 }
1580
1581 const auto baselineVhdSize = vhdSizeOnDisk();
1582 const auto baselineGuestUsed = guestUsedBytes();
1583
1584 LogInfo(
1585 "Tar size=%llu, baseline vhd size on disk=%llu, baseline guest used=%llu",
1586 static_cast<unsigned long long>(tarSize),
1587 static_cast<unsigned long long>(baselineVhdSize),
1588 static_cast<unsigned long long>(baselineGuestUsed));
1589
1590 for (auto i = 0; i < c_extraLoads; i++)
1591 {
1592 loadImage();
1593
1594 const auto vhdSize = vhdSizeOnDisk();
1595 const auto guestUsed = guestUsedBytes();
1596
1597 LogInfo(
1598 "Load %d: vhd size on disk=%llu (+%lld), guest used=%llu (+%lld), temp entries=%llu",
1599 i + 1,
1600 static_cast<unsigned long long>(vhdSize),
1601 static_cast<long long>(vhdSize - baselineVhdSize),
1602 static_cast<unsigned long long>(guestUsed),
1603 static_cast<long long>(guestUsed - baselineGuestUsed),
1604 static_cast<unsigned long long>(guestTempEntryCount()));
1605 }
1606
1607 const auto finalVhdSize = vhdSizeOnDisk();
1608 const auto finalGuestUsed = guestUsedBytes();
1609
1610 // The host-side VHD must not grow by roughly one tar per load. The budget allows a single tar of
1611 // slack in total (with a floor so that small tars don't make this flaky), which is well under the
1612 // c_extraLoads * tarSize that linear growth would produce.
1613 constexpr uint64_t c_minimumGrowthBudget = 64ull * 1024 * 1024;
1614 const auto growthBudget = std::max<uint64_t>(tarSize, c_minimumGrowthBudget);
1615
1616 LogInfo(
1617 "Storage VHD grew by %llu bytes over %d reloads of a %llu byte tar (budget=%llu). Guest usage grew by %lld bytes.",
1618 static_cast<unsigned long long>(finalVhdSize - baselineVhdSize),
1619 c_extraLoads,
1620 static_cast<unsigned long long>(tarSize),
1621 static_cast<unsigned long long>(growthBudget),
1622 static_cast<long long>(finalGuestUsed - baselineGuestUsed));
1623
1624 // Reloading the same tar must not leave docker's extraction directory behind.
1625 VERIFY_ARE_EQUAL(0ull, guestTempEntryCount());
1626
1627 // Docker deduplicates the identical layers, so the guest filesystem must not retain a tar's
1628 // worth of data per load. A failure here means the temporary extraction is being leaked inside
1629 // the guest rather than merely being unreclaimable on the host.
1630 VERIFY_IS_TRUE(finalGuestUsed < baselineGuestUsed + tarSize);
1631
1632 if (finalVhdSize >= baselineVhdSize + growthBudget)
1633 {
1634 LogError("The storage VHD is growing with each load: the space is being written and then not reclaimed.");
1635
1636 VERIFY_FAIL();
1637 }
1638
1639 VERIFY_SUCCEEDED(session->Terminate());
1640 }
1641
1642 WSLC_TEST_METHOD(ImportImage)
1643 {
1644 SKIP_TEST_SERVER();
1645
1646 auto cleanup =
1647 wil::scope_exit([&]() { LOG_IF_FAILED(DeleteImageNoThrow("my-hello-world:test", WSLCDeleteImageFlagsNone).first); });
1648
1649 std::filesystem::path imageTar = std::filesystem::path{g_testDataPath} / L"HelloWorldExported.tar";
1650 wil::unique_handle imageTarFileHandle{
1651 CreateFileW(imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
1652 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
1653
1654 LARGE_INTEGER fileSize{};
1655 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
1656
1657 wil::unique_cotaskmem_ansistring imageId;
1658 VERIFY_SUCCEEDED(m_defaultSession->ImportImage(
1659 ToCOMInputHandle(imageTarFileHandle.get()), "my-hello-world:test", fileSize.QuadPart, nullptr, &imageId));
1660
1661 ExpectImagePresent(*m_defaultSession, "my-hello-world:test");
1662
1663 // Validate that containers can be started from the imported image.
1664 {
1665 WSLCContainerLauncher launcher("my-hello-world:test", "wslc-import-image-container", {"/hello"});
1666
1667 auto container = launcher.Launch(*m_defaultSession);
1668 auto result = container.GetInitProcess().WaitAndCaptureOutput();
1669
1670 VERIFY_ARE_EQUAL(0, result.Code);
1671 VERIFY_IS_TRUE(result.Output[1].find("Hello from Docker!") != std::string::npos);
1672 }
1673
1674 // Validate that ImportImage fails if no tag is passed
1675 {
1676 VERIFY_ARE_EQUAL(
1677 m_defaultSession->ImportImage(ToCOMInputHandle(imageTarFileHandle.get()), "my-hello-world", fileSize.QuadPart, nullptr, &imageId),
1678 E_INVALIDARG);
1679 }
1680
1681 // Validate that invalid tars fail with proper error message and code.
1682 {
1683 auto currentExecutableHandle = wil::open_file(wil::GetModuleFileNameW<std::wstring>().c_str());
1684
1685 VERIFY_IS_TRUE(GetFileSizeEx(currentExecutableHandle.get(), &fileSize));
1686
1687 VERIFY_ARE_EQUAL(
1688 m_defaultSession->ImportImage(
1689 ToCOMInputHandle(currentExecutableHandle.get()), "invalid-image:test", fileSize.QuadPart, nullptr, &imageId),
1690 E_FAIL);
1691
1692 ValidateCOMErrorMessage(L"archive/tar: invalid tar header");
1693 }
1694
1695 // Validate that a large (300MB) invalid tar fails with proper error message and code.
1696 {
1697 auto largeFile =
1698 wil::create_new_file(L"largefile", GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, nullptr, FILE_FLAG_DELETE_ON_CLOSE);
1699
1700 // Create an invalid header (docker ignores the entire file if its header is only null bytes).
1701 DWORD bytesWritten{};
1702 THROW_IF_WIN32_BOOL_FALSE(WriteFile(largeFile.get(), "foo", 3, &bytesWritten, nullptr));
1703 THROW_LAST_ERROR_IF(SetFilePointer(largeFile.get(), static_cast<LONG>(300 * _1MB), nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER);
1704
1705 THROW_IF_WIN32_BOOL_FALSE(SetEndOfFile(largeFile.get()));
1706 THROW_LAST_ERROR_IF(SetFilePointer(largeFile.get(), 0, nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER);
1707
1708 VERIFY_IS_TRUE(GetFileSizeEx(largeFile.get(), &fileSize));
1709 VERIFY_ARE_EQUAL(fileSize.QuadPart, 300 * _1MB);
1710
1711 VERIFY_ARE_EQUAL(
1712 m_defaultSession->ImportImage(ToCOMInputHandle(largeFile.get()), "invalid-large-image:test", fileSize.QuadPart, nullptr, &imageId),
1713 E_FAIL);
1714
1715 ValidateCOMErrorMessage(L"archive/tar: invalid tar header");
1716 }
1717
1718 // Validate that ImportImage fails when the input pipe is closed during reading.
1719 {
1720 wil::unique_handle pipeRead;
1721 wil::unique_handle pipeWrite;
1722 VERIFY_WIN32_BOOL_SUCCEEDED(CreatePipe(&pipeRead, &pipeWrite, nullptr, 2));
1723
1724 std::promise<HRESULT> importResult;
1725 std::thread operationThread([&]() {
1726 wil::unique_cotaskmem_ansistring id;
1727 importResult.set_value(
1728 m_defaultSession->ImportImage(ToCOMInputHandle(pipeRead.get()), "broken-read:eof", 1024 * 1024, nullptr, &id));
1729 });
1730
1731 auto threadCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { operationThread.join(); });
1732
1733 // Write some data to ensure the service has started reading from the pipe (pipe buffer is 2 bytes).
1734 DWORD bytesWritten{};
1735 VERIFY_WIN32_BOOL_SUCCEEDED(WriteFile(pipeWrite.get(), "data", 4, &bytesWritten, nullptr));
1736
1737 // Close the write end.
1738 pipeWrite.reset();
1739
1740 VERIFY_ARE_EQUAL(E_FAIL, importResult.get_future().get());
1741 }
1742
1743 // Validate that ImportImage is aborted when the session terminates.
1744 {
1745 wil::unique_handle pipeRead;
1746 wil::unique_handle pipeWrite;
1747 VERIFY_WIN32_BOOL_SUCCEEDED(CreatePipe(&pipeRead, &pipeWrite, nullptr, 2));
1748
1749 std::promise<HRESULT> terminateResult;
1750 wil::unique_event testCompleted{wil::EventOptions::ManualReset};
1751 std::thread operationThread([&]() {
1752 wil::unique_cotaskmem_ansistring id;
1753 terminateResult.set_value(m_defaultSession->ImportImage(
1754 ToCOMInputHandle(pipeRead.get()), "session-terminate:test", 1024 * 1024, nullptr, &id));
1755 WI_ASSERT(testCompleted.is_signaled());
1756 });
1757
1758 auto threadCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { operationThread.join(); });
1759
1760 // Write some data to validate that the service has started reading from the pipe (pipe buffer is 2 bytes).
1761 DWORD bytesWritten{};
1762 VERIFY_WIN32_BOOL_SUCCEEDED(WriteFile(pipeWrite.get(), "data", 4, &bytesWritten, nullptr));
1763
1764 testCompleted.SetEvent();
1765
1766 VERIFY_SUCCEEDED(m_defaultSession->Terminate());
1767
1768 auto restore = ResetTestSession();
1769
1770 auto hr = terminateResult.get_future().get();
1771 VERIFY_IS_TRUE(hr == E_ABORT || hr == HRESULT_FROM_WIN32(ERROR_OPERATION_ABORTED));
1772 }
1773 }
1774
1775 WSLC_TEST_METHOD(DeleteImage)
1776 {
1777 // Verify that the image is in the list of images.
1778 ExpectImagePresent(*m_defaultSession, "alpine:latest");
1779
1780 auto restore = wil::scope_exit([&]() { LoadTestImage(*m_defaultSession, "alpine:latest"); });
1781
1782 // Launch a container to ensure that image deletion fails when in use.
1783 WSLCContainerLauncher launcher("alpine:latest", "test-delete-container-in-use", {"sleep", "99999"}, {}, "host");
1784
1785 auto container = launcher.Launch(*m_defaultSession);
1786
1787 // Verify that the container is in running state.
1788 VERIFY_ARE_EQUAL(container.State(), WslcContainerStateRunning);
1789
1790 // Test delete failed if image in use.
1791 VERIFY_ARE_EQUAL(
1792 HRESULT_FROM_WIN32(ERROR_SHARING_VIOLATION), DeleteImageNoThrow("alpine:latest", WSLCDeleteImageFlagsNone).first);
1793
1794 // Force should succeed.
1795 auto deletedImages = DeleteImage("alpine:latest", WSLCDeleteImageFlagsForce);
1796 VERIFY_IS_TRUE(deletedImages.size() > 0);
1797 VERIFY_IS_TRUE(std::strlen(deletedImages[0].Image) > 0);
1798
1799 // Verify that the image is no longer in the list of images.
1800 ExpectImagePresent(*m_defaultSession, "alpine:latest", false);
1801
1802 // Test delete failed if image does not exist.
1803 VERIFY_ARE_EQUAL(WSLC_E_IMAGE_NOT_FOUND, DeleteImageNoThrow("alpine:latest", WSLCDeleteImageFlagsForce).first);
1804
1805 // Validate that invalid flags are rejected.
1806 {
1807 WSLCDeleteImageOptions invalidOptions{.Image = "alpine:latest", .Flags = 0x4};
1808 VERIFY_ARE_EQUAL(
1809 m_defaultSession->DeleteImage(&invalidOptions, deletedImages.addressof(), deletedImages.size_address<ULONG>()), E_INVALIDARG);
1810 }
1811 }
1812
1813 class CapturingProgressCallback
1814 : public Microsoft::WRL::RuntimeClass<Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, IProgressCallback>
1815 {
1816 public:
1817 CapturingProgressCallback(std::string& output) : m_output(output)
1818 {
1819 }
1820
1821 HRESULT OnProgress(LPCSTR status, LPCSTR, ULONGLONG, ULONGLONG) override
1822 {
1823 m_output.append(status);
1824 return S_OK;
1825 }
1826
1827 private:
1828 std::string& m_output;
1829 };
1830
1831 HRESULT BuildImageFromContext(const std::filesystem::path& contextDir, const WSLCBuildImageOptions* options, IProgressCallback* callback = nullptr)
1832 {
1833 auto dockerfileHandle = wil::open_file((contextDir / "Dockerfile").c_str());
1834
1835 auto contextPathStr = contextDir.wstring();
1836 WSLCBuildImageOptions optionsCopy = *options;
1837 optionsCopy.ContextPath = contextPathStr.c_str();
1838 optionsCopy.DockerfileHandle = ToCOMInputHandle(dockerfileHandle.get());
1839
1840 auto buildResult = m_defaultSession->BuildImage(&optionsCopy, callback, nullptr);
1841
1842 if (FAILED(buildResult))
1843 {
1844 LogInfo("BuildImage failed: 0x%08x", buildResult);
1845 }
1846
1847 return buildResult;
1848 }
1849
1850 HRESULT BuildImageFromContext(const std::filesystem::path& contextDir, const char* imageTag)
1851 {
1852 LPCSTR tag = imageTag;
1853 WSLCBuildImageOptions options{
1854 .Tags = {&tag, 1},
1855 };
1856 return BuildImageFromContext(contextDir, &options);
1857 }
1858
1859 WSLC_TEST_METHOD(BuildImage)
1860 {
1861 auto contextDir = std::filesystem::current_path() / "build-context";
1862 std::filesystem::create_directories(contextDir);
1863 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
1864 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build:latest", WSLCDeleteImageFlagsForce).first);
1865
1866 std::error_code ec;
1867 std::filesystem::remove_all(contextDir, ec);
1868 });
1869
1870 {
1871 std::ofstream dockerfile(contextDir / "Dockerfile");
1872 dockerfile << "FROM debian:latest\n";
1873 dockerfile << "CMD [\"echo\", \"Hello from a WSL container!\"]\n";
1874 }
1875
1876 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-build:latest"));
1877 ExpectImagePresent(*m_defaultSession, "wslc-test-build:latest");
1878
1879 WSLCContainerLauncher launcher("wslc-test-build:latest", "wslc-build-test-container");
1880 auto container = launcher.Launch(*m_defaultSession);
1881 auto result = container.GetInitProcess().WaitAndCaptureOutput();
1882
1883 VERIFY_ARE_EQUAL(0, result.Code);
1884 VERIFY_IS_TRUE(result.Output[1].find("Hello from a WSL container!") != std::string::npos);
1885 }
1886
1887 // This test validates both that we can build an image with an empty CMD, and that we can run such an image.
1888 WSLC_TEST_METHOD(BuildImageEntrypoint)
1889 {
1890 auto contextDir = std::filesystem::current_path() / "build-context-entrypoint";
1891 std::filesystem::create_directories(contextDir);
1892 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
1893 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-entrypoint:latest", WSLCDeleteImageFlagsForce).first);
1894
1895 std::error_code ec;
1896 std::filesystem::remove_all(contextDir, ec);
1897 });
1898
1899 {
1900 std::ofstream dockerfile(contextDir / "Dockerfile");
1901 dockerfile << "FROM debian:latest\n";
1902 dockerfile << "CMD []\n";
1903 dockerfile << "ENTRYPOINT [\"/bin/echo\", \"Entrypoint\"]\n";
1904 }
1905
1906 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-entrypoint:latest"));
1907 ExpectImagePresent(*m_defaultSession, "wslc-test-entrypoint:latest");
1908
1909 // Validate that the entrypoint is started by default.
1910 {
1911 WSLCContainerLauncher launcher("wslc-test-entrypoint:latest", "wslc-entrypoint-test-1");
1912 auto container = launcher.Launch(*m_defaultSession);
1913 auto initProcess = container.GetInitProcess();
1914 ValidateProcessOutput(initProcess, {{1, "Entrypoint\n"}});
1915 }
1916
1917 // Validate that arguments are passed to the entrypoint, and don't override it.
1918 {
1919 WSLCContainerLauncher launcher("wslc-test-entrypoint:latest", "wslc-entrypoint-test-2", {"extra-arg"});
1920 auto container = launcher.Launch(*m_defaultSession);
1921 auto initProcess = container.GetInitProcess();
1922 ValidateProcessOutput(initProcess, {{1, "Entrypoint extra-arg\n"}});
1923 }
1924
1925 // Validate that the entrypoint can be overridden.
1926 {
1927 WSLCContainerLauncher launcher("wslc-test-entrypoint:latest", "wslc-entrypoint-test-3");
1928 launcher.SetEntrypoint({"/bin/echo", "OverriddenEntrypoint"});
1929 auto container = launcher.Launch(*m_defaultSession);
1930 auto initProcess = container.GetInitProcess();
1931 ValidateProcessOutput(initProcess, {{1, "OverriddenEntrypoint\n"}});
1932 }
1933
1934 // Validate that the entrypoint can be overridden and that CMD args are passed to the entrypoint.
1935 {
1936 WSLCContainerLauncher launcher("wslc-test-entrypoint:latest", "wslc-entrypoint-test-4", {"extra-arg"});
1937 launcher.SetEntrypoint({"/bin/echo", "OverriddenEntrypoint"});
1938 auto container = launcher.Launch(*m_defaultSession);
1939 auto initProcess = container.GetInitProcess();
1940 ValidateProcessOutput(initProcess, {{1, "OverriddenEntrypoint extra-arg\n"}});
1941 }
1942 }
1943
1944 WSLC_TEST_METHOD(BuildImageHealthCheck)
1945 {
1946 auto contextDir = std::filesystem::current_path() / "build-context-healthcheck";
1947 std::filesystem::create_directories(contextDir);
1948 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
1949 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-healthcheck:latest", WSLCDeleteImageFlagsForce).first);
1950 std::error_code ec;
1951 std::filesystem::remove_all(contextDir, ec);
1952 });
1953
1954 // Create an image with a healthcheck that only passes once a specific file exists.
1955 constexpr auto c_healthReadyFile = "/tmp/wslc-health-ready";
1956
1957 {
1958 std::ofstream dockerfile(contextDir / "Dockerfile");
1959 dockerfile << "FROM debian:latest\n";
1960 dockerfile << "HEALTHCHECK --interval=1s --timeout=100ms --start-period=300s --retries=1000 CMD test -f "
1961 << c_healthReadyFile << "\n";
1962 dockerfile << "CMD [\"sleep\", \"99999\"]\n";
1963 }
1964
1965 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-healthcheck:latest"));
1966 ExpectImagePresent(*m_defaultSession, "wslc-test-healthcheck:latest");
1967
1968 auto waitForHealthStatus = [](auto& container, const std::string& expectedStatus, std::chrono::seconds timeout) {
1969 wsl::shared::retry::RetryWithTimeout<void>(
1970 [&]() {
1971 const auto inspect = container.Inspect();
1972 THROW_HR_IF_MSG(E_FAIL, !inspect.State.Health.has_value(), "container does not report a health status yet");
1973 THROW_HR_IF_MSG(
1974 E_FAIL,
1975 inspect.State.Health->Status != expectedStatus,
1976 "health status is '%hs', expected '%hs'",
1977 inspect.State.Health->Status.c_str(),
1978 expectedStatus.c_str());
1979 },
1980 std::chrono::milliseconds{100},
1981 timeout);
1982 };
1983
1984 // Validate that the image's default health check is inherited by a started container, and that its runtime
1985 // status stays "starting" until the health command passes, then deterministically becomes "healthy".
1986 {
1987 WSLCContainerLauncher launcher("wslc-test-healthcheck:latest", "wslc-healthcheck-test-default");
1988 auto container = launcher.Launch(*m_defaultSession);
1989
1990 auto inspect = container.Inspect();
1991 VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value());
1992
1993 const auto& health = inspect.Config.Healthcheck.value();
1994 VERIFY_IS_TRUE(health.Test.has_value());
1995 const std::vector<std::string> expectedTest{"CMD-SHELL", std::string("test -f ") + c_healthReadyFile};
1996 VERIFY_ARE_EQUAL(expectedTest, health.Test.value());
1997 VERIFY_ARE_EQUAL(1'000'000'000LL, health.Interval.value_or(0));
1998 VERIFY_ARE_EQUAL(100'000'000LL, health.Timeout.value_or(0));
1999 VERIFY_ARE_EQUAL(300'000'000'000LL, health.StartPeriod.value_or(0));
2000
2001 // The health command fails while the file is absent, so the container stays "starting".
2002 waitForHealthStatus(container, "starting", 60s);
2003
2004 auto touchProcess = WSLCProcessLauncher({}, {"/usr/bin/touch", c_healthReadyFile}).Launch(container.Get());
2005 ValidateProcessOutput(touchProcess, {}, 0);
2006
2007 waitForHealthStatus(container, "healthy", 60s);
2008 }
2009
2010 // Validate that the image's default health check can be overridden, and that a failing (exit 1) check drives
2011 // the runtime status to "unhealthy".
2012 {
2013 WSLCContainerLauncher launcher("wslc-test-healthcheck:latest", "wslc-healthcheck-test-override");
2014 launcher.SetHealthCmd("exit 1");
2015 launcher.SetHealthInterval(1'000'000'000LL); // 1s
2016 launcher.SetHealthStartPeriod(1'000'000'000LL); // 1s
2017 launcher.SetHealthRetries(1);
2018 auto container = launcher.Launch(*m_defaultSession);
2019
2020 auto inspect = container.Inspect();
2021 VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value());
2022
2023 const auto& health = inspect.Config.Healthcheck.value();
2024 VERIFY_IS_TRUE(health.Test.has_value());
2025 const std::vector<std::string> expectedTest{"CMD-SHELL", "exit 1"};
2026 VERIFY_ARE_EQUAL(expectedTest, health.Test.value());
2027 VERIFY_ARE_EQUAL(1'000'000'000LL, health.Interval.value_or(0));
2028 // The override must set an explicit start period: otherwise the engine merges the image's healthcheck
2029 // fields for any zero-valued field (see moby daemon merge()), inheriting the image's 300s start period,
2030 // during which failing checks keep the container "starting" instead of transitioning to "unhealthy".
2031 VERIFY_ARE_EQUAL(1'000'000'000LL, health.StartPeriod.value_or(0));
2032 VERIFY_ARE_EQUAL(1, health.Retries.value_or(0));
2033
2034 // Validate that the container transitions to "unhealthy" after the health command fails.
2035 waitForHealthStatus(container, "unhealthy", 60s);
2036 }
2037
2038 // Validate that WSLCContainerFlagsNoHealthCheck disables the image's default health check.
2039 {
2040 WSLCContainerLauncher launcher("wslc-test-healthcheck:latest", "wslc-healthcheck-test-disabled");
2041 launcher.SetNoHealthcheck();
2042 auto container = launcher.Launch(*m_defaultSession);
2043
2044 auto inspect = container.Inspect();
2045 VERIFY_IS_TRUE(inspect.Config.Healthcheck.has_value());
2046
2047 const auto& health = inspect.Config.Healthcheck.value();
2048 VERIFY_IS_TRUE(health.Test.has_value());
2049 const std::vector<std::string> expectedTest{"NONE"};
2050 VERIFY_ARE_EQUAL(expectedTest, health.Test.value());
2051
2052 // A disabled health check is not monitored, so the container never reports a runtime health status.
2053 VERIFY_IS_FALSE(inspect.State.Health.has_value());
2054 }
2055
2056 // Validate that combining WSLCContainerFlagsNoHealthCheck with an explicit health check command is rejected.
2057 {
2058 WSLCContainerLauncher launcher("wslc-test-healthcheck:latest", "wslc-healthcheck-test-conflict");
2059 launcher.SetNoHealthcheck();
2060 launcher.SetHealthCmd("exit 0");
2061
2062 auto [result, container] = launcher.CreateNoThrow(*m_defaultSession);
2063 VERIFY_ARE_EQUAL(result, E_INVALIDARG);
2064 VERIFY_IS_FALSE(container.has_value());
2065 }
2066 }
2067
2068 WSLC_TEST_METHOD(BuildImageWithContext)
2069 {
2070 auto contextDir = std::filesystem::current_path() / "build-context-file";
2071 std::filesystem::create_directories(contextDir);
2072 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2073 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build-context:latest", WSLCDeleteImageFlagsForce).first);
2074
2075 std::error_code ec;
2076 std::filesystem::remove_all(contextDir, ec);
2077 });
2078
2079 {
2080 std::ofstream dockerfile(contextDir / "Dockerfile");
2081 dockerfile << "FROM debian:latest\n";
2082 dockerfile << "COPY message.txt /message.txt\n";
2083 dockerfile << "CMD [\"cat\", \"/message.txt\"]\n";
2084 }
2085
2086 {
2087 std::ofstream message(contextDir / "message.txt");
2088 message << "Hello from a WSL container context file!\n";
2089 }
2090
2091 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-build-context:latest"));
2092 ExpectImagePresent(*m_defaultSession, "wslc-test-build-context:latest");
2093
2094 WSLCContainerLauncher launcher("wslc-test-build-context:latest", "wslc-build-context-container");
2095 auto container = launcher.Launch(*m_defaultSession);
2096 auto result = container.GetInitProcess().WaitAndCaptureOutput();
2097
2098 VERIFY_ARE_EQUAL(0, result.Code);
2099 VERIFY_IS_TRUE(result.Output[1].find("Hello from a WSL container context file!") != std::string::npos);
2100 }
2101
2102 WSLC_TEST_METHOD(BuildImageManyFiles)
2103 {
2104 static constexpr int fileCount = 1024;
2105
2106 auto contextDir = std::filesystem::current_path() / "build-context-many";
2107 std::filesystem::create_directories(contextDir / "files");
2108 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2109 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build-many:latest", WSLCDeleteImageFlagsForce).first);
2110
2111 std::error_code ec;
2112 std::filesystem::remove_all(contextDir, ec);
2113 });
2114
2115 // Generate the context files.
2116 for (int i = 0; i < fileCount; i++)
2117 {
2118 auto name = std::format("file{:04d}.txt", i);
2119 auto content = std::format("content-{:04d}\n", i);
2120 std::ofstream file(contextDir / "files" / name);
2121 file << content;
2122 }
2123
2124 {
2125 std::ofstream dockerfile(contextDir / "Dockerfile");
2126 dockerfile << "FROM debian:latest\n";
2127 dockerfile << "COPY files/ /files/\n";
2128 // Verify every file is present and contains the expected content.
2129 // Only mismatches are printed; on success just the sentinel.
2130 dockerfile << "CMD [\"sh\", \"-c\", "
2131 << "\"cd /files && failed=0 && "
2132 << "for i in $(seq 0 " << (fileCount - 1) << "); do "
2133 << "f=$(printf 'file%04d.txt' $i); "
2134 << "e=$(printf 'content-%04d' $i); "
2135 << "if [ ! -f $f ]; then echo MISSING:$f; failed=1; "
2136 << "elif ! grep -q $e $f; then echo BAD:$f; failed=1; fi; "
2137 << "done && "
2138 << "[ $failed -eq 0 ] && echo all_ok_" << fileCount << "\"]\n";
2139 }
2140
2141 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-build-many:latest"));
2142 ExpectImagePresent(*m_defaultSession, "wslc-test-build-many:latest");
2143
2144 WSLCContainerLauncher launcher("wslc-test-build-many:latest", "wslc-build-many-container");
2145 auto container = launcher.Launch(*m_defaultSession);
2146 auto result = container.GetInitProcess().WaitAndCaptureOutput();
2147
2148 VERIFY_ARE_EQUAL(0, result.Code);
2149 auto sentinel = std::format("all_ok_{}", fileCount);
2150 VERIFY_IS_TRUE(result.Output[1].find(sentinel) != std::string::npos);
2151 }
2152
2153 WSLC_TEST_METHOD(BuildImageLargeFile)
2154 {
2155 RunCommand(m_defaultSession.get(), {"/usr/bin/docker", "rmi", "-f", "wslc-test-build-large:latest"});
2156 ExpectCommandResult(m_defaultSession.get(), {"/usr/bin/docker", "builder", "prune", "-f"}, 0);
2157
2158 auto contextDir = std::filesystem::current_path() / "build-context-large";
2159 std::filesystem::create_directories(contextDir);
2160 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2161 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build-large:latest", WSLCDeleteImageFlagsForce).first);
2162
2163 std::error_code ec;
2164 std::filesystem::remove_all(contextDir, ec);
2165 });
2166
2167 static constexpr int fileSizeMb = 1024;
2168
2169 {
2170 std::ofstream dockerfile(contextDir / "Dockerfile");
2171 dockerfile << "FROM debian:latest\n";
2172 dockerfile << "COPY large.bin /large.bin\n";
2173 dockerfile << std::format(
2174 "CMD [\"sh\", \"-c\", \"test $(stat -c %s /large.bin) -eq {} && echo size_ok\"]\n",
2175 static_cast<long long>(fileSizeMb) * 1024 * 1024);
2176 }
2177
2178 {
2179 auto largePath = contextDir / "large.bin";
2180 wil::unique_hfile largeFile{CreateFileW(largePath.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
2181 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == largeFile.get());
2182
2183 std::vector<char> buffer(1024 * 1024, '\0');
2184 for (int i = 0; i < fileSizeMb; i++)
2185 {
2186 DWORD written = 0;
2187 if (!WriteFile(largeFile.get(), buffer.data(), static_cast<DWORD>(buffer.size()), &written, nullptr) ||
2188 written != static_cast<DWORD>(buffer.size()))
2189 {
2190 LogError("WriteFile failed at chunk %d/%d: 0x%08x", i, fileSizeMb, GetLastError());
2191 VERIFY_FAIL();
2192 }
2193 }
2194 }
2195
2196 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-build-large:latest"));
2197 ExpectImagePresent(*m_defaultSession, "wslc-test-build-large:latest");
2198
2199 WSLCContainerLauncher launcher("wslc-test-build-large:latest", "wslc-build-large-container");
2200 auto container = launcher.Launch(*m_defaultSession);
2201 auto result = container.GetInitProcess().WaitAndCaptureOutput();
2202
2203 VERIFY_ARE_EQUAL(0, result.Code);
2204 VERIFY_IS_TRUE(result.Output[1].find("size_ok") != std::string::npos);
2205 }
2206
2207 WSLC_TEST_METHOD(BuildImageMultiStage)
2208 {
2209 auto contextDir = std::filesystem::current_path() / "build-context-multistage";
2210 std::filesystem::create_directories(contextDir);
2211 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2212 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build-multistage:latest", WSLCDeleteImageFlagsForce).first);
2213
2214 std::error_code ec;
2215 std::filesystem::remove_all(contextDir, ec);
2216 });
2217
2218 {
2219 std::ofstream dockerfile(contextDir / "Dockerfile");
2220 // Two independent stages that can build in parallel, each producing
2221 // part of the final output. The last stage combines them.
2222 dockerfile << "FROM debian:latest AS greeting\n";
2223 dockerfile << "RUN echo -n 'WSL containers' | tee /part.txt\n";
2224 dockerfile << "\n";
2225 dockerfile << "FROM debian:latest AS description\n";
2226 dockerfile << "RUN echo -n 'support multi-stage builds' | tee /part.txt\n";
2227 dockerfile << "\n";
2228 dockerfile << "FROM debian:latest\n";
2229 dockerfile << "COPY --from=greeting /part.txt /greeting.txt\n";
2230 dockerfile << "COPY --from=description /part.txt /description.txt\n";
2231 dockerfile << "CMD [\"sh\", \"-c\", "
2232 << "\"echo \\\"$(cat /greeting.txt) $(cat /description.txt)\\\"\"]\n";
2233 }
2234
2235 std::string output;
2236 auto callback = Microsoft::WRL::Make<CapturingProgressCallback>(output);
2237 LPCSTR tag = "wslc-test-build-multistage:latest";
2238 WSLCBuildImageOptions options{.Tags = {&tag, 1}, .Flags = WSLCBuildImageFlagsNoCache};
2239 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, &options, callback.Get()));
2240 VERIFY_IS_TRUE(output.find("[greeting] WSL containers") != std::string::npos);
2241 VERIFY_IS_TRUE(output.find("[description] support multi-stage builds") != std::string::npos);
2242 ExpectImagePresent(*m_defaultSession, "wslc-test-build-multistage:latest");
2243
2244 WSLCContainerLauncher launcher("wslc-test-build-multistage:latest", "wslc-build-multistage-container");
2245 auto container = launcher.Launch(*m_defaultSession);
2246 auto result = container.GetInitProcess().WaitAndCaptureOutput();
2247
2248 VERIFY_ARE_EQUAL(0, result.Code);
2249 VERIFY_IS_TRUE(result.Output[1].find("WSL containers support multi-stage builds") != std::string::npos);
2250 }
2251
2252 WSLC_TEST_METHOD(BuildImageDockerIgnore)
2253 {
2254 auto contextDir = std::filesystem::current_path() / "build-context-dockerignore";
2255 std::filesystem::create_directories(contextDir / "temp");
2256 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2257 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build-dockerignore:latest", WSLCDeleteImageFlagsForce).first);
2258
2259 std::error_code ec;
2260 std::filesystem::remove_all(contextDir, ec);
2261 });
2262
2263 {
2264 std::ofstream ignore(contextDir / ".dockerignore");
2265 ignore << "# Ignore log files and temp directory\n";
2266 ignore << "*.log\n";
2267 ignore << "temp/\n";
2268 }
2269
2270 {
2271 std::ofstream(contextDir / "keep.txt") << "kept\n";
2272 std::ofstream(contextDir / "debug.log") << "excluded\n";
2273 std::ofstream(contextDir / "temp" / "cache.dat") << "excluded\n";
2274 }
2275
2276 {
2277 std::ofstream dockerfile(contextDir / "Dockerfile");
2278 dockerfile << "FROM debian:latest\n";
2279 dockerfile << "COPY . /ctx/\n";
2280 dockerfile << "CMD [\"sh\", \"-c\", "
2281 << "\"test -f /ctx/keep.txt "
2282 << "&& ! test -f /ctx/debug.log "
2283 << "&& ! test -d /ctx/temp "
2284 << "&& echo dockerignore_ok\"]\n";
2285 }
2286
2287 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-build-dockerignore:latest"));
2288 ExpectImagePresent(*m_defaultSession, "wslc-test-build-dockerignore:latest");
2289
2290 WSLCContainerLauncher launcher("wslc-test-build-dockerignore:latest", "wslc-build-dockerignore-container");
2291 auto container = launcher.Launch(*m_defaultSession);
2292 auto result = container.GetInitProcess().WaitAndCaptureOutput();
2293
2294 VERIFY_ARE_EQUAL(0, result.Code);
2295 VERIFY_IS_TRUE(result.Output[1].find("dockerignore_ok") != std::string::npos);
2296 }
2297
2298 WSLC_TEST_METHOD(BuildImageFailure)
2299 {
2300 auto contextDir = std::filesystem::current_path() / "build-context-failure";
2301 std::filesystem::create_directories(contextDir);
2302 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2303 std::error_code ec;
2304 std::filesystem::remove_all(contextDir, ec);
2305 });
2306
2307 {
2308 std::ofstream dockerfile(contextDir / "Dockerfile");
2309 dockerfile << "FROM does-not-exist:invalid\n";
2310 }
2311
2312 VERIFY_FAILED(BuildImageFromContext(contextDir, "wslc-test-build-failure:latest"));
2313 auto comError = wsl::windows::common::wslutil::GetCOMErrorInfo();
2314 VERIFY_IS_TRUE(comError.has_value());
2315 LogInfo("Expected build error: %ls", comError->Message.get());
2316
2317 ExpectImagePresent(*m_defaultSession, "wslc-test-build-failure:latest", false);
2318 }
2319
2320 WSLC_TEST_METHOD(BuildImageFailureShowsBuildOutput)
2321 {
2322 auto contextDir = std::filesystem::current_path() / "build-context-failure-output";
2323 std::filesystem::create_directories(contextDir);
2324 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2325 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build-args:latest", WSLCDeleteImageFlagsForce).first);
2326
2327 std::error_code ec;
2328 std::filesystem::remove_all(contextDir, ec);
2329 });
2330
2331 {
2332 std::ofstream dockerfile(contextDir / "Dockerfile");
2333 dockerfile << "FROM debian:latest\n";
2334 dockerfile << "RUN echo 'build-log-marker' && /bin/false\n";
2335 }
2336
2337 class ProgressAccumulator
2338 : public Microsoft::WRL::RuntimeClass<Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, IProgressCallback>
2339 {
2340 public:
2341 ProgressAccumulator(std::string& output) : m_output(output)
2342 {
2343 }
2344 HRESULT OnProgress(LPCSTR message, LPCSTR, ULONGLONG, ULONGLONG) override
2345 {
2346 if (message)
2347 {
2348 m_output.append(message);
2349 }
2350 return S_OK;
2351 }
2352
2353 private:
2354 std::string& m_output;
2355 };
2356
2357 std::string progressOutput;
2358 auto callback = Microsoft::WRL::Make<ProgressAccumulator>(progressOutput);
2359
2360 auto dockerfileHandle = wil::open_file((contextDir / "Dockerfile").c_str());
2361 auto contextPathStr = contextDir.wstring();
2362 LPCSTR tag = "wslc-test-build-failure-output:latest";
2363 WSLCBuildImageOptions options{
2364 .ContextPath = contextPathStr.c_str(),
2365 .DockerfileHandle = ToCOMInputHandle(dockerfileHandle.get()),
2366 .Tags = {&tag, 1},
2367 };
2368
2369 VERIFY_FAILED(m_defaultSession->BuildImage(&options, callback.Get(), nullptr));
2370 VERIFY_IS_TRUE(progressOutput.find("build-log-marker") != std::string::npos);
2371 }
2372
2373 WSLC_TEST_METHOD(BuildImageStdinDockerfile)
2374 {
2375 auto contextDir = std::filesystem::current_path() / "build-context-stdin";
2376 std::filesystem::create_directories(contextDir);
2377 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2378 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build-stdin:latest", WSLCDeleteImageFlagsForce).first);
2379
2380 std::error_code ec;
2381 std::filesystem::remove_all(contextDir, ec);
2382 });
2383
2384 auto dockerfileContent = "FROM debian:latest\nCMD [\"echo\", \"stdin-dockerfile-ok\"]\n";
2385
2386 wil::unique_hfile readHandle;
2387 wil::unique_hfile writeHandle;
2388 THROW_IF_WIN32_BOOL_FALSE(CreatePipe(readHandle.addressof(), writeHandle.addressof(), nullptr, 0));
2389
2390 DWORD bytesWritten;
2391 THROW_IF_WIN32_BOOL_FALSE(
2392 WriteFile(writeHandle.get(), dockerfileContent, static_cast<DWORD>(strlen(dockerfileContent)), &bytesWritten, nullptr));
2393 writeHandle.reset();
2394
2395 auto contextPathStr = contextDir.wstring();
2396 LPCSTR tag = "wslc-test-build-stdin:latest";
2397 WSLCBuildImageOptions options{
2398 .ContextPath = contextPathStr.c_str(),
2399 .DockerfileHandle = ToCOMInputHandle(readHandle.get()),
2400 .Tags = {&tag, 1},
2401 };
2402 VERIFY_SUCCEEDED(m_defaultSession->BuildImage(&options, nullptr, nullptr));
2403 ExpectImagePresent(*m_defaultSession, "wslc-test-build-stdin:latest");
2404
2405 WSLCContainerLauncher launcher("wslc-test-build-stdin:latest", "wslc-build-stdin-container");
2406 auto container = launcher.Launch(*m_defaultSession);
2407 auto result = container.GetInitProcess().WaitAndCaptureOutput();
2408
2409 VERIFY_ARE_EQUAL(0, result.Code);
2410 VERIFY_IS_TRUE(result.Output[1].find("stdin-dockerfile-ok") != std::string::npos);
2411 }
2412
2413 WSLC_TEST_METHOD(BuildImageBuildArgs)
2414 {
2415 auto contextDir = std::filesystem::current_path() / "build-context-buildargs";
2416 std::filesystem::create_directories(contextDir);
2417 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2418 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build-args:latest", WSLCDeleteImageFlagsForce).first);
2419
2420 std::error_code ec;
2421 std::filesystem::remove_all(contextDir, ec);
2422 });
2423
2424 {
2425 std::ofstream dockerfile(contextDir / "Dockerfile");
2426 dockerfile << "FROM debian:latest\n";
2427 dockerfile << "ARG TEST_VALUE\n";
2428 dockerfile << "ENV TEST_VALUE=${TEST_VALUE}\n";
2429 dockerfile << "CMD echo \"build-arg-value=${TEST_VALUE}\"\n";
2430 }
2431
2432 LPCSTR tag = "wslc-test-build-args:latest";
2433 LPCSTR buildArg = "TEST_VALUE=hello-from-build-arg";
2434 WSLCBuildImageOptions options{.Tags = {&tag, 1}, .BuildArgs = {&buildArg, 1}};
2435 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, &options));
2436 ExpectImagePresent(*m_defaultSession, "wslc-test-build-args:latest");
2437
2438 WSLCContainerLauncher launcher("wslc-test-build-args:latest", "wslc-build-args-container");
2439 auto container = launcher.Launch(*m_defaultSession);
2440 auto initProcess = container.GetInitProcess();
2441 ValidateProcessOutput(initProcess, {{1, "build-arg-value=hello-from-build-arg\n"}});
2442 }
2443
2444 WSLC_TEST_METHOD(BuildImageMultipleTags)
2445 {
2446 auto contextDir = std::filesystem::current_path() / "build-context-multitag";
2447 std::filesystem::create_directories(contextDir);
2448 LPCSTR tags[] = {"wslc-test-multitag:v1", "wslc-test-multitag:v2"};
2449 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2450 for (auto* tag : tags)
2451 {
2452 LOG_IF_FAILED(DeleteImageNoThrow(tag, WSLCDeleteImageFlagsForce).first);
2453 }
2454
2455 std::error_code ec;
2456 std::filesystem::remove_all(contextDir, ec);
2457 });
2458
2459 {
2460 std::ofstream dockerfile(contextDir / "Dockerfile");
2461 dockerfile << "FROM debian:latest\n";
2462 dockerfile << "CMD [\"echo\", \"multi-tag-ok\"]\n";
2463 }
2464 WSLCBuildImageOptions options{.Tags = {tags, 2}};
2465 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, &options));
2466 ExpectImagePresent(*m_defaultSession, "wslc-test-multitag:v1");
2467 ExpectImagePresent(*m_defaultSession, "wslc-test-multitag:v2");
2468 }
2469
2470 WSLC_TEST_METHOD(BuildImageNullHandle)
2471 {
2472 WSLCBuildImageOptions options{.ContextPath = L"C:\\", .DockerfileHandle = {}, .Tags = {nullptr, 0}};
2473
2474 VERIFY_ARE_EQUAL(m_defaultSession->BuildImage(&options, nullptr, nullptr), HRESULT_FROM_WIN32(ERROR_INVALID_HANDLE));
2475 }
2476
2477 WSLC_TEST_METHOD(BuildImageCancel)
2478 {
2479 class TestProgressCallback
2480 : public Microsoft::WRL::RuntimeClass<Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, IProgressCallback>
2481 {
2482 public:
2483 TestProgressCallback(wil::unique_event& event) : m_event(event)
2484 {
2485 }
2486
2487 HRESULT OnProgress(LPCSTR, LPCSTR, ULONGLONG, ULONGLONG) override
2488 {
2489 m_event.SetEvent();
2490 return S_OK;
2491 }
2492
2493 private:
2494 wil::unique_event& m_event;
2495 };
2496
2497 auto contextDir = std::filesystem::current_path() / "build-context-cancel";
2498 std::filesystem::create_directories(contextDir);
2499 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2500 std::error_code ec;
2501 std::filesystem::remove_all(contextDir, ec);
2502 });
2503
2504 // Use a Dockerfile that takes a long time to build so we can cancel it mid-build.
2505 {
2506 std::ofstream dockerfile(contextDir / "Dockerfile");
2507 dockerfile << "FROM debian:latest\n";
2508 dockerfile << "RUN sleep 120\n";
2509 }
2510
2511 wil::unique_event cancelEvent{wil::EventOptions::ManualReset};
2512 wil::unique_event progressEvent{wil::EventOptions::ManualReset};
2513
2514 // Use a progress callback to detect when the build is actively running
2515 // before signaling cancellation, avoiding a racy Sleep().
2516 auto callback = Microsoft::WRL::Make<TestProgressCallback>(progressEvent);
2517
2518 auto contextPathStr = contextDir.wstring();
2519 auto dockerfileHandle = wil::open_file((contextDir / "Dockerfile").c_str());
2520
2521 LPCSTR tag = "wslc-test-build-cancel:latest";
2522 WSLCBuildImageOptions options{
2523 .ContextPath = contextPathStr.c_str(), .DockerfileHandle = ToCOMInputHandle(dockerfileHandle.get()), .Tags = {&tag, 1}};
2524
2525 std::promise<HRESULT> result;
2526 std::thread buildThread(
2527 [&]() { result.set_value(m_defaultSession->BuildImage(&options, callback.Get(), cancelEvent.get())); });
2528
2529 auto joinThread = wil::scope_exit([&]() { buildThread.join(); });
2530
2531 VERIFY_IS_TRUE(progressEvent.wait(60 * 1000));
2532 cancelEvent.SetEvent();
2533
2534 VERIFY_ARE_EQUAL(E_ABORT, result.get_future().get());
2535 }
2536
2537 WSLC_TEST_METHOD(BuildImageNoCache)
2538 {
2539 auto contextDir = std::filesystem::current_path() / "build-context-nocache";
2540 std::filesystem::create_directories(contextDir);
2541 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2542 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-nocache:latest", WSLCDeleteImageFlagsForce).first);
2543
2544 std::error_code ec;
2545 std::filesystem::remove_all(contextDir, ec);
2546 });
2547
2548 {
2549 std::ofstream dockerfile(contextDir / "Dockerfile");
2550 dockerfile << "FROM debian:latest\n";
2551 dockerfile << "RUN echo -n Image && echo -n is && echo -n rebuilt\n";
2552 }
2553
2554 // First build to populate cache.
2555 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-nocache:latest"));
2556
2557 // Validate that the image isn't rebuilt when NoCache isn't set.
2558 {
2559 std::string output;
2560 auto callback = Microsoft::WRL::Make<CapturingProgressCallback>(output);
2561 LPCSTR tag = "wslc-test-nocache:latest";
2562 WSLCBuildImageOptions options{.Tags = {&tag, 1}};
2563 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, &options, callback.Get()));
2564 VERIFY_IS_TRUE(output.find("Imageisrebuilt") == std::string::npos);
2565 }
2566
2567 // Validate that the image is rebuilt when WSLCBuildImageFlagsNoCache is set, and that the output from the RUN step appears in the progress callback.
2568 {
2569 std::string output;
2570 auto callback = Microsoft::WRL::Make<CapturingProgressCallback>(output);
2571 LPCSTR tag = "wslc-test-nocache:latest";
2572 WSLCBuildImageOptions options{.Tags = {&tag, 1}, .Flags = WSLCBuildImageFlagsNoCache};
2573 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, &options, callback.Get()));
2574 VERIFY_IS_TRUE(output.find("Imageisrebuilt") != std::string::npos);
2575 }
2576 }
2577
2578 WSLC_TEST_METHOD(BuildImageInvalidFlags)
2579 {
2580 auto dummyDockerfile = wil::create_new_file(
2581 (std::filesystem::current_path() / "Dockerfile").c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, FILE_FLAG_DELETE_ON_CLOSE);
2582
2583 auto contextDir = std::filesystem::current_path();
2584
2585 WSLCBuildImageOptions options{
2586 .ContextPath = contextDir.c_str(),
2587 .DockerfileHandle = ToCOMInputHandle(dummyDockerfile.get()),
2588 .Flags = static_cast<WSLCBuildImageFlags>(0x10)};
2589
2590 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->BuildImage(&options, nullptr, nullptr));
2591 }
2592
2593 WSLC_TEST_METHOD(AnonymousVolumes)
2594 {
2595 auto contextDir = std::filesystem::current_path() / "build-context";
2596 std::filesystem::create_directories(contextDir);
2597 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2598 std::error_code ec;
2599 std::filesystem::remove_all(contextDir, ec);
2600
2601 LOG_IF_FAILED(DeleteImageNoThrow("wslc-test-build:latest", WSLCDeleteImageFlagsForce).first);
2602 });
2603
2604 {
2605 std::ofstream dockerfile(contextDir / "Dockerfile");
2606 dockerfile << "FROM debian:latest\n";
2607 dockerfile << "VOLUME /volume\n"; // Use VOLUME to force the creation of an anonymous volume.
2608 }
2609
2610 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, "wslc-test-build:latest"));
2611 ExpectImagePresent(*m_defaultSession, "wslc-test-build:latest");
2612
2613 const std::vector<WSLCFilter> anonymousVolumeFilters = {{"driver", "guest"}, {"label", "com.docker.volume.anonymous="}};
2614 auto verifyAnonymousVolumeMount = [](const auto& inspect) {
2615 VERIFY_ARE_EQUAL(inspect.Mounts.size(), 1u);
2616 VERIFY_ARE_EQUAL(inspect.Mounts[0].Type, "volume");
2617 VERIFY_IS_FALSE(inspect.Mounts[0].Name.empty());
2618 VERIFY_IS_TRUE(inspect.Mounts[0].Source.empty());
2619 VERIFY_ARE_EQUAL(inspect.Mounts[0].Destination, "/volume");
2620 VERIFY_IS_TRUE(inspect.Mounts[0].ReadWrite);
2621 };
2622
2623 // Session-restart scenario: an anonymous volume-backed container survives a session reset.
2624 {
2625 WSLCContainerLauncher launcher("wslc-test-build:latest", "wslc-test-anonymous-volume", {"test", "-d", "/volume"});
2626 auto container = launcher.Launch(*m_defaultSession);
2627 container.SetDeleteOnClose(false);
2628 verifyAnonymousVolumeMount(container.Inspect());
2629
2630 auto containerId = container.Id();
2631
2632 auto result = container.GetInitProcess();
2633 ValidateProcessOutput(result, {});
2634
2635 ResetTestSession();
2636
2637 // Manually cleanup the container and delete anonymous volumes since the session has been reset.
2638 auto containerCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2639 wil::com_ptr<IWSLCContainer> container;
2640 VERIFY_SUCCEEDED(m_defaultSession->OpenContainer(containerId.c_str(), &container));
2641
2642 VERIFY_SUCCEEDED(container->Delete(WSLCDeleteFlagsForce | WSLCDeleteFlagsDeleteVolumes));
2643 });
2644
2645 // Validate that the session is correctly restarted.
2646 auto [containers, ports] = ListContainers(m_defaultSession.get());
2647
2648 VERIFY_ARE_EQUAL(containers.size(), 1);
2649 VERIFY_ARE_EQUAL(containers[0].Id, containerId);
2650
2651 auto recoveredContainer = OpenContainer(m_defaultSession.get(), containerId);
2652 recoveredContainer.SetDeleteOnClose(false);
2653 verifyAnonymousVolumeMount(recoveredContainer.Inspect());
2654 }
2655
2656 // Delete container without WSLCDeleteFlagsDeleteVolumes -> anonymous volume is leaked.
2657 {
2658 WSLCContainerLauncher launcher("wslc-test-build:latest", "wslc-test-delete-vol-leak", {"test", "-d", "/volume"});
2659 auto container = launcher.Launch(*m_defaultSession);
2660 container.GetInitProcess().Wait();
2661 container.SetDeleteOnClose(false);
2662
2663 // Clean up any leaked anonymous volumes when this block exits.
2664 auto volumeCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2665 wil::unique_cotaskmem_array_ptr<WSLCVolumeName> deleted;
2666 ULONGLONG spaceReclaimed = 0;
2667 LOG_IF_FAILED(m_defaultSession->PruneVolumes(nullptr, 0, nullptr, deleted.addressof(), deleted.size_address<ULONG>(), &spaceReclaimed));
2668 });
2669
2670 VERIFY_ARE_EQUAL(ListVolumes(anonymousVolumeFilters).size(), 1u);
2671
2672 VERIFY_SUCCEEDED(container.Get().Delete(WSLCDeleteFlagsNone));
2673
2674 // Anonymous volume was NOT deleted by Docker.
2675 VERIFY_ARE_EQUAL(ListVolumes(anonymousVolumeFilters).size(), 1u);
2676 }
2677
2678 // Delete container with WSLCDeleteFlagsDeleteVolumes -> anonymous volume is cleaned up.
2679 {
2680 WSLCContainerLauncher launcher("wslc-test-build:latest", "wslc-test-delete-vol-rm", {"sleep", "99999"});
2681 auto container = launcher.Launch(*m_defaultSession);
2682 container.SetDeleteOnClose(false);
2683
2684 VERIFY_SUCCEEDED(container.Get().Stop(WSLCSignalSIGKILL, 0));
2685
2686 VERIFY_ARE_EQUAL(ListVolumes(anonymousVolumeFilters).size(), 1u);
2687
2688 VERIFY_SUCCEEDED(container.Get().Delete(WSLCDeleteFlagsDeleteVolumes));
2689 VERIFY_ARE_EQUAL(ListVolumes(anonymousVolumeFilters).size(), 0u);
2690 }
2691
2692 // Container with WSLCContainerFlagsRm -> anonymous volume cleaned up when the container auto-removes on exit.
2693 {
2694 WSLCContainerLauncher launcher("wslc-test-build:latest", "wslc-test-delete-vol-rm", {"sleep", "99999"});
2695 launcher.SetContainerFlags(WSLCContainerFlagsRm);
2696
2697 auto container = launcher.Launch(*m_defaultSession);
2698 VERIFY_ARE_EQUAL(ListVolumes(anonymousVolumeFilters).size(), 1u);
2699 VERIFY_SUCCEEDED(container.Get().Stop(WSLCSignalSIGKILL, 0));
2700
2701 VERIFY_ARE_EQUAL(ListVolumes(anonymousVolumeFilters).size(), 0u);
2702 }
2703 }
2704
2705 WSLC_TEST_METHOD(ContainerInspectDockerfileVolumes)
2706 {
2707 const auto contextDir = std::filesystem::current_path() / "container-inspect-volume-build-context";
2708 constexpr auto imageName = "wslc-test-container-inspect-volume:latest";
2709 std::filesystem::create_directories(contextDir);
2710
2711 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2712 std::error_code ec;
2713 std::filesystem::remove_all(contextDir, ec);
2714 LOG_IF_FAILED(DeleteImageNoThrow(imageName, WSLCDeleteImageFlagsForce).first);
2715 });
2716
2717 {
2718 std::ofstream dockerfile(contextDir / "Dockerfile");
2719 dockerfile << "FROM debian:latest\n";
2720 dockerfile << "VOLUME [\"/volume-a\", \"/volume-b\"]\n";
2721 }
2722
2723 VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, imageName));
2724
2725 WSLCContainerLauncher launcher(imageName, "wslc-test-container-inspect-volume");
2726 auto container = launcher.Create(*m_defaultSession);
2727 const auto inspect = container.Inspect();
2728
2729 VERIFY_ARE_EQUAL(inspect.Mounts.size(), 2u);
2730 for (const auto* destination : {"/volume-a", "/volume-b"})
2731 {
2732 const auto mount =
2733 std::ranges::find_if(inspect.Mounts, [&](const auto& entry) { return entry.Destination == destination; });
2734 VERIFY_IS_TRUE(mount != inspect.Mounts.end());
2735 VERIFY_ARE_EQUAL(mount->Type, "volume");
2736 VERIFY_IS_FALSE(mount->Name.empty());
2737 VERIFY_IS_TRUE(mount->Source.empty());
2738 VERIFY_IS_TRUE(mount->ReadWrite);
2739 }
2740 }
2741
2742 WSLC_TEST_METHOD(TagImage)
2743 {
2744 auto runTagImage = [&](LPCSTR Image, LPCSTR Repo, LPCSTR Tag) {
2745 WSLCTagImageOptions options{};
2746 options.Image = Image;
2747 options.Repo = Repo;
2748 options.Tag = Tag;
2749
2750 return m_defaultSession->TagImage(&options);
2751 };
2752
2753 // Positive test: Tag an existing image with a new tag in the same repository.
2754 {
2755 ExpectImagePresent(*m_defaultSession, "debian:latest");
2756
2757 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2758 DeleteImage("debian:test-tag", WSLCDeleteImageFlagsNoPrune);
2759
2760 ExpectImagePresent(*m_defaultSession, "debian:test-tag", false);
2761 ExpectImagePresent(*m_defaultSession, "debian:latest");
2762 });
2763
2764 VERIFY_SUCCEEDED(runTagImage("debian:latest", "debian", "test-tag"));
2765
2766 // Verify both tags exist and point to the same image.
2767 ExpectImagePresent(*m_defaultSession, "debian:latest");
2768 ExpectImagePresent(*m_defaultSession, "debian:test-tag");
2769
2770 // Verify they have the same image hash.
2771 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
2772 VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, images.addressof(), images.size_address<ULONG>()));
2773
2774 std::string latestHash;
2775 std::string testTagHash;
2776 for (const auto& image : images)
2777 {
2778 if (std::strcmp(image.Image, "debian:latest") == 0)
2779 {
2780 latestHash = image.Hash;
2781 }
2782 else if (std::strcmp(image.Image, "debian:test-tag") == 0)
2783 {
2784 testTagHash = image.Hash;
2785 }
2786 }
2787
2788 VERIFY_IS_FALSE(latestHash.empty());
2789 VERIFY_IS_FALSE(testTagHash.empty());
2790 VERIFY_ARE_EQUAL(latestHash, testTagHash);
2791 }
2792
2793 // Positive test: Tag with a different repository name.
2794 {
2795 ExpectImagePresent(*m_defaultSession, "debian:latest");
2796
2797 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2798 DeleteImage("myrepo/myimage:v1.0.0", WSLCDeleteImageFlagsNoPrune);
2799
2800 ExpectImagePresent(*m_defaultSession, "myrepo/myimage:v1.0.0", false);
2801 });
2802
2803 VERIFY_SUCCEEDED(runTagImage("debian:latest", "myrepo/myimage", "v1.0.0"));
2804
2805 ExpectImagePresent(*m_defaultSession, "myrepo/myimage:v1.0.0");
2806 }
2807
2808 // Positive test: Tag using image ID.
2809 {
2810 ExpectImagePresent(*m_defaultSession, "debian:latest");
2811
2812 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2813 DeleteImage("debian:test-by-id", WSLCDeleteImageFlagsNoPrune);
2814
2815 ExpectImagePresent(*m_defaultSession, "debian:test-by-id", false);
2816 });
2817
2818 wil::unique_cotaskmem_array_ptr<WSLCImageInformation> images;
2819 VERIFY_SUCCEEDED(m_defaultSession->ListImages(nullptr, images.addressof(), images.size_address<ULONG>()));
2820
2821 std::string imageId;
2822 for (const auto& image : images)
2823 {
2824 if (std::strcmp(image.Image, "debian:latest") == 0)
2825 {
2826 imageId = image.Hash;
2827 break;
2828 }
2829 }
2830 VERIFY_IS_FALSE(imageId.empty());
2831
2832 VERIFY_SUCCEEDED(runTagImage(imageId.c_str(), "debian", "test-by-id"));
2833
2834 ExpectImagePresent(*m_defaultSession, "debian:test-by-id");
2835 }
2836
2837 // Positive test: Overwrite existing tag.
2838 {
2839 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2840 DeleteImage("test:duplicate-tag", WSLCDeleteImageFlagsNoPrune);
2841
2842 ExpectImagePresent(*m_defaultSession, "test:duplicate-tag", false);
2843 });
2844
2845 VERIFY_SUCCEEDED(runTagImage("debian:latest", "test", "duplicate-tag"));
2846 VERIFY_SUCCEEDED(runTagImage("debian:latest", "test", "duplicate-tag"));
2847 }
2848
2849 // Negative test: Null options pointer.
2850 {
2851 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), m_defaultSession->TagImage(nullptr));
2852 }
2853
2854 // Negative test: Null Image field.
2855 {
2856 VERIFY_ARE_EQUAL(E_POINTER, runTagImage(nullptr, "test", "tag"));
2857 }
2858
2859 // Negative test: Null Repo field.
2860 {
2861 VERIFY_ARE_EQUAL(E_POINTER, runTagImage("debian:latest", nullptr, "tag"));
2862 }
2863
2864 // Negative test: Null Tag field.
2865 {
2866 VERIFY_ARE_EQUAL(E_POINTER, runTagImage("debian:latest", "test", nullptr));
2867 }
2868
2869 // Negative test: Tag a non-existent image.
2870 {
2871 VERIFY_ARE_EQUAL(WSLC_E_IMAGE_NOT_FOUND, runTagImage("nonexistent:notfound", "test", "fail"));
2872 ValidateCOMErrorMessage(L"No such image: nonexistent:notfound");
2873 }
2874
2875 // Negative test: Invalid tag format with spaces.
2876 {
2877 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(ERROR_BAD_ARGUMENTS), runTagImage("debian:latest", "test", "invalid tag"));
2878 ValidateCOMErrorMessage(L"invalid tag format");
2879 }
2880 }
2881
2882 WSLC_TEST_METHOD(InspectImage)
2883 {
2884 // Test inspect debian:latest
2885 {
2886 wil::unique_cotaskmem_ansistring output;
2887 VERIFY_SUCCEEDED(m_defaultSession->InspectImage("debian:latest", &output));
2888
2889 // Verify output is valid JSON
2890 VERIFY_IS_NOT_NULL(output.get());
2891 VERIFY_IS_TRUE(std::strlen(output.get()) > 0);
2892 LogInfo("Inspect output: %hs", output.get());
2893
2894 // Parse and validate JSON structure
2895 auto inspectResult = wsl::shared::FromJson<wsl::windows::common::wslc_schema::InspectImage>(output.get());
2896
2897 // Verify all fields exposed in wslc_schema::InspectImage
2898 VERIFY_IS_TRUE(inspectResult.Id.find("sha256:") == 0);
2899
2900 VERIFY_IS_TRUE(inspectResult.RepoTags.has_value());
2901 VERIFY_IS_FALSE(inspectResult.RepoTags->empty());
2902 bool foundTag = false;
2903 for (const auto& tag : inspectResult.RepoTags.value())
2904 {
2905 if (tag.find("debian:latest") != std::string::npos)
2906 {
2907 foundTag = true;
2908 break;
2909 }
2910 }
2911 VERIFY_IS_TRUE(foundTag);
2912
2913 // skip testing RepoDigests for loaded test image.
2914 VERIFY_IS_FALSE(inspectResult.Created.empty());
2915 VERIFY_IS_TRUE(inspectResult.Architecture == "amd64" || inspectResult.Architecture == "arm64");
2916 VERIFY_ARE_EQUAL("linux", inspectResult.Os);
2917 VERIFY_IS_TRUE(inspectResult.Size > 0);
2918 VERIFY_IS_TRUE(inspectResult.Metadata.has_value());
2919 VERIFY_IS_TRUE(inspectResult.Metadata->size() > 0);
2920
2921 VERIFY_IS_TRUE(inspectResult.Config.has_value());
2922 const auto& config = inspectResult.Config.value();
2923 VERIFY_IS_TRUE(config.Cmd.has_value());
2924 VERIFY_IS_TRUE(config.Cmd->size() > 0);
2925 VERIFY_IS_TRUE(config.Entrypoint.has_value());
2926 VERIFY_ARE_EQUAL(0, config.Entrypoint->size());
2927 VERIFY_IS_TRUE(config.Env.has_value());
2928 VERIFY_IS_TRUE(config.Env->size() > 0);
2929 VERIFY_IS_FALSE(config.Labels.has_value());
2930 }
2931
2932 // Negative test: Image not found
2933 {
2934 wil::unique_cotaskmem_ansistring output;
2935 VERIFY_ARE_EQUAL(WSLC_E_IMAGE_NOT_FOUND, m_defaultSession->InspectImage("nonexistent:image", &output));
2936 ValidateCOMErrorMessage(L"No such image: nonexistent:image");
2937 }
2938
2939 // Negative test: Bad image name input
2940 {
2941 wil::unique_cotaskmem_ansistring output;
2942
2943 std::string longImageName(WSLC_MAX_IMAGE_NAME_LENGTH + 1, 'a');
2944 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->InspectImage(longImageName.c_str(), &output));
2945
2946 // Invalid name.
2947 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(ERROR_BAD_ARGUMENTS), m_defaultSession->InspectImage("debian latest", &output));
2948 ValidateCOMErrorMessage(L"invalid reference format");
2949
2950 // Attempt to fake to call search endpoint. Our implementation escaped the image name correctly.
2951 VERIFY_ARE_EQUAL(WSLC_E_IMAGE_NOT_FOUND, m_defaultSession->InspectImage("search/debian:latest", &output));
2952 ValidateCOMErrorMessage(L"No such image: search/debian:latest");
2953 }
2954 }
2955
2956 struct BlockingOperation
2957 {
2958 NON_COPYABLE(BlockingOperation);
2959 NON_MOVABLE(BlockingOperation);
2960
2961 BlockingOperation(std::function<HRESULT(HANDLE)>&& Operation, HRESULT ExpectedResult = S_OK, bool AllowEarlyCompletion = false, bool UseOverlappedWritePipe = false) :
2962 m_operation(std::move(Operation)), m_expectedResult(ExpectedResult), m_allowEarlyCompletion(AllowEarlyCompletion)
2963 {
2964 auto [pipeRead, pipeWrite] = wsl::windows::common::wslutil::OpenAnonymousPipe(100000, false, UseOverlappedWritePipe);
2965
2966 m_operationThread = std::thread(&BlockingOperation::RunOperation, this, std::move(pipeWrite));
2967 m_ioThread = std::thread(&BlockingOperation::RunIO, this, std::move(pipeRead));
2968
2969 // Wait for the operation to be running before continuing.
2970 VERIFY_IS_TRUE(m_startedEvent.wait(60 * 1000));
2971 }
2972
2973 ~BlockingOperation()
2974 {
2975 if (m_operationThread.joinable())
2976 {
2977 m_operationThread.join();
2978 }
2979
2980 if (m_ioThread.joinable())
2981 {
2982 m_ioThread.join();
2983 }
2984 }
2985
2986 void RunOperation(wil::unique_hfile Handle)
2987 {
2988 m_result.set_value(m_operation(Handle.get()));
2989
2990 // Fail if the operation completed before the test signaled completion
2991 // (unless early completion is expected, e.g. session termination).
2992 // Don't use VERIFY macros since this is running in a separate thread.
2993 WI_ASSERT(m_allowEarlyCompletion || m_testCompleteEvent.is_signaled());
2994 }
2995
2996 void RunIO(wil::unique_hfile Handle)
2997 {
2998 std::vector<char> buffer(1024 * 1024);
2999 while (true)
3000 {
3001 DWORD bytesRead{};
3002 if (!ReadFile(Handle.get(), buffer.data(), static_cast<DWORD>(buffer.size()), &bytesRead, nullptr))
3003 {
3004 if (GetLastError() != ERROR_BROKEN_PIPE)
3005 {
3006 LogError("Unexpected ReadFile() error: %u", GetLastError());
3007 }
3008
3009 break;
3010 }
3011
3012 if (bytesRead == 0)
3013 {
3014 break;
3015 }
3016
3017 if (!m_startedEvent.is_signaled())
3018 {
3019 m_startedEvent.SetEvent();
3020 }
3021
3022 // Block until the test completes.
3023 if (!m_testCompleteEvent.wait(60 * 1000))
3024 {
3025 LogError("Timed out waiting for test completion");
3026 break;
3027 }
3028 }
3029 }
3030
3031 void Complete()
3032 {
3033 m_testCompleteEvent.SetEvent();
3034
3035 VERIFY_ARE_EQUAL(m_expectedResult, m_result.get_future().get());
3036 }
3037
3038 std::function<HRESULT(HANDLE)> m_operation;
3039 wil::unique_event m_startedEvent{wil::EventOptions::ManualReset};
3040 wil::unique_event m_testCompleteEvent{wil::EventOptions::ManualReset};
3041 std::thread m_operationThread;
3042 std::thread m_ioThread;
3043 std::promise<HRESULT> m_result;
3044 HRESULT m_expectedResult{};
3045 bool m_allowEarlyCompletion{};
3046 };
3047
3048 WSLC_TEST_METHOD(SaveImage)
3049 {
3050 {
3051 std::filesystem::path imageTar = GetTestImagePath("hello-world:latest");
3052 wil::unique_handle imageTarFileHandle{
3053 CreateFileW(imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
3054 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3055 LARGE_INTEGER fileSize{};
3056 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3057 // Load the image from a saved tar
3058 VERIFY_SUCCEEDED(m_defaultSession->LoadImage(ToCOMInputHandle(imageTarFileHandle.get()), fileSize.QuadPart, nullptr, nullptr));
3059 // Verify that the image is in the list of images.
3060 ExpectImagePresent(*m_defaultSession, "hello-world:latest");
3061 WSLCContainerLauncher launcher("hello-world:latest", "wslc-hello-world-container");
3062 auto container = launcher.Launch(*m_defaultSession);
3063 auto result = container.GetInitProcess().WaitAndCaptureOutput();
3064 VERIFY_ARE_EQUAL(0, result.Code);
3065 VERIFY_IS_TRUE(result.Output[1].find("Hello from Docker!") != std::string::npos);
3066 }
3067
3068 {
3069 std::filesystem::path imageTar = L"HelloWorldExported.tar";
3070 auto cleanup =
3071 wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTar.c_str())); });
3072 // Save the image to a tar file.
3073 {
3074 wil::unique_handle imageTarFileHandle{CreateFileW(
3075 imageTar.c_str(), GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
3076 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3077 LARGE_INTEGER fileSize{};
3078 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3079 VERIFY_ARE_EQUAL(fileSize.QuadPart > 0, false);
3080 VERIFY_SUCCEEDED(m_defaultSession->SaveImage(ToCOMInputHandle(imageTarFileHandle.get()), "hello-world:latest", nullptr, nullptr));
3081 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3082 VERIFY_ARE_EQUAL(fileSize.QuadPart > 0, true);
3083 }
3084
3085 // Load the saved image to verify it's valid.
3086 {
3087 wil::unique_handle imageTarFileHandle{CreateFileW(
3088 imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
3089 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3090 LARGE_INTEGER fileSize{};
3091 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3092 // Load the image from a saved tar
3093 VERIFY_SUCCEEDED(m_defaultSession->LoadImage(ToCOMInputHandle(imageTarFileHandle.get()), fileSize.QuadPart, nullptr, nullptr));
3094 // Verify that the image is in the list of images.
3095 ExpectImagePresent(*m_defaultSession, "hello-world:latest");
3096 WSLCContainerLauncher launcher("hello-world:latest", "wslc-hello-world-container");
3097 auto container = launcher.Launch(*m_defaultSession);
3098 auto result = container.GetInitProcess().WaitAndCaptureOutput();
3099 VERIFY_ARE_EQUAL(0, result.Code);
3100 VERIFY_IS_TRUE(result.Output[1].find("Hello from Docker!") != std::string::npos);
3101 }
3102 }
3103
3104 // Try to save an invalid image.
3105 {
3106 std::filesystem::path imageTar = L"HelloWorldError.tar";
3107 auto cleanfile =
3108 wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTar.c_str())); });
3109 wil::unique_handle imageTarFileHandle{CreateFileW(
3110 imageTar.c_str(), GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
3111 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3112 LARGE_INTEGER fileSize{};
3113 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3114 VERIFY_ARE_EQUAL(fileSize.QuadPart > 0, false);
3115 VERIFY_FAILED(m_defaultSession->SaveImage(ToCOMInputHandle(imageTarFileHandle.get()), "hello-wld:latest", nullptr, nullptr));
3116 ValidateCOMErrorMessage(L"reference does not exist");
3117
3118 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3119 VERIFY_ARE_EQUAL(fileSize.QuadPart > 0, false);
3120 }
3121
3122 // Validate that cancellation works.
3123 {
3124 wil::unique_event cancelEvent{wil::EventOptions::ManualReset};
3125
3126 BlockingOperation operation(
3127 [&](HANDLE handle) {
3128 return m_defaultSession->SaveImage(ToCOMInputHandle(handle), "debian:latest", nullptr, cancelEvent.get());
3129 },
3130 E_ABORT);
3131
3132 cancelEvent.SetEvent();
3133 operation.Complete();
3134 }
3135 }
3136
3137 WSLC_TEST_METHOD(SaveImages)
3138 {
3139 auto BuildStringArray = [](const std::vector<LPCSTR>& values) -> WSLCStringArray {
3140 return WSLCStringArray{.Values = values.empty() ? nullptr : values.data(), .Count = static_cast<ULONG>(values.size())};
3141 };
3142
3143 // Save multiple images to a single tar, delete one, then load back and verify.
3144 {
3145 std::filesystem::path imageTar = L"MultiImageExport.tar";
3146 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
3147 DeleteFileW(imageTar.c_str());
3148
3149 wil::unique_cotaskmem_array_ptr<WSLCDeletedImageInformation> deletedImages;
3150 ULONGLONG spaceReclaimed = 0;
3151
3152 LOG_IF_FAILED(m_defaultSession->PruneImages(
3153 nullptr, 0, deletedImages.addressof(), deletedImages.size_address<ULONG>(), &spaceReclaimed));
3154 });
3155
3156 {
3157 wil::unique_handle imageTarFileHandle{CreateFileW(
3158 imageTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
3159 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3160
3161 std::vector<LPCSTR> names = {"hello-world:latest", "alpine:latest"};
3162 WSLCStringArray array = BuildStringArray(names);
3163 VERIFY_SUCCEEDED(m_defaultSession->SaveImages(ToCOMInputHandle(imageTarFileHandle.get()), &array, nullptr, nullptr));
3164
3165 LARGE_INTEGER fileSize{};
3166 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3167 VERIFY_IS_TRUE(fileSize.QuadPart > 0);
3168 }
3169
3170 // Delete hello-world:latest and verify it's gone.
3171 wil::unique_cotaskmem_array_ptr<WSLCDeletedImageInformation> deleted;
3172 WSLCDeleteImageOptions delOpts{};
3173 delOpts.Image = "hello-world:latest";
3174 delOpts.Flags = WSLCDeleteImageFlagsForce;
3175 VERIFY_SUCCEEDED(m_defaultSession->DeleteImage(&delOpts, &deleted, deleted.size_address<ULONG>()));
3176 ExpectImagePresent(*m_defaultSession, "hello-world:latest", false);
3177
3178 // Load it back from the multi-image tar — hello-world should reappear and alpine should still be present.
3179 {
3180 wil::unique_handle imageTarFileHandle{CreateFileW(
3181 imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
3182 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3183 LARGE_INTEGER fileSize{};
3184 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3185 VERIFY_SUCCEEDED(m_defaultSession->LoadImage(ToCOMInputHandle(imageTarFileHandle.get()), fileSize.QuadPart, nullptr, nullptr));
3186 }
3187
3188 ExpectImagePresent(*m_defaultSession, "hello-world:latest");
3189 ExpectImagePresent(*m_defaultSession, "alpine:latest");
3190
3191 // Sanity check that the loaded hello-world image is functional.
3192 WSLCContainerLauncher launcher("hello-world:latest", "wslc-multi-save-container");
3193 auto container = launcher.Launch(*m_defaultSession);
3194
3195 auto output = container.GetInitProcess().WaitAndCaptureOutput();
3196 VERIFY_ARE_EQUAL(0, output.Code);
3197 VERIFY_IS_TRUE(output.Output[1].find("Hello from Docker!") != std::string::npos);
3198 }
3199
3200 // Single image via SaveImages — must produce a valid tar archive.
3201 {
3202 std::filesystem::path imageTar = L"MultiImageSingle.tar";
3203 auto cleanup =
3204 wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTar.c_str())); });
3205
3206 wil::unique_handle imageTarFileHandle{
3207 CreateFileW(imageTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
3208 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3209
3210 std::vector<LPCSTR> names = {"hello-world:latest"};
3211 WSLCStringArray array = BuildStringArray(names);
3212 VERIFY_SUCCEEDED(m_defaultSession->SaveImages(ToCOMInputHandle(imageTarFileHandle.get()), &array, nullptr, nullptr));
3213
3214 LARGE_INTEGER fileSize{};
3215 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3216 VERIFY_IS_TRUE(fileSize.QuadPart > 0);
3217 }
3218
3219 // Validate that invalid input parameters are rejected.
3220 {
3221 // Use a real temp file so ToCOMInputHandle doesn't throw before SaveImages runs.
3222 std::filesystem::path placeholderTar = L"MultiImageValidation.tar";
3223 auto placeholderCleanup =
3224 wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(placeholderTar.c_str())); });
3225
3226 wil::unique_handle placeholder{CreateFileW(
3227 placeholderTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
3228 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == placeholder.get());
3229 HANDLE phHandle = placeholder.get();
3230
3231 // Empty array (Count=0).
3232 WSLCStringArray emptyArray{.Values = nullptr, .Count = 0};
3233 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->SaveImages(ToCOMInputHandle(phHandle), &emptyArray, nullptr, nullptr));
3234
3235 // Empty string entry.
3236 LPCSTR emptyEntry[] = {""};
3237 WSLCStringArray emptyEntryArray{.Values = emptyEntry, .Count = 1};
3238 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->SaveImages(ToCOMInputHandle(phHandle), &emptyEntryArray, nullptr, nullptr));
3239
3240 // Name longer than WSLC_MAX_IMAGE_NAME_LENGTH.
3241 std::string longName(WSLC_MAX_IMAGE_NAME_LENGTH + 1, 'a');
3242 LPCSTR longEntry[] = {longName.c_str()};
3243 WSLCStringArray longEntryArray{.Values = longEntry, .Count = 1};
3244 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->SaveImages(ToCOMInputHandle(phHandle), &longEntryArray, nullptr, nullptr));
3245
3246 // Too many images.
3247 std::vector<LPCSTR> names(WSLC_MAX_SAVE_IMAGES_COUNT + 1, "foo");
3248 WSLCStringArray tooManyArray = BuildStringArray(names);
3249 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->SaveImages(ToCOMInputHandle(phHandle), &tooManyArray, nullptr, nullptr));
3250 }
3251
3252 // Try to save with one of the images not found — must fail
3253 {
3254 std::filesystem::path imageTar = L"MultiImageError.tar";
3255 auto cleanup =
3256 wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTar.c_str())); });
3257
3258 wil::unique_handle imageTarFileHandle{CreateFileW(
3259 imageTar.c_str(), GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
3260 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3261
3262 std::vector<LPCSTR> names = {"alpine:latest", "not-found"};
3263 WSLCStringArray array = BuildStringArray(names);
3264 VERIFY_FAILED(m_defaultSession->SaveImages(ToCOMInputHandle(imageTarFileHandle.get()), &array, nullptr, nullptr));
3265
3266 ValidateCOMErrorMessage(L"No such image: not-found");
3267 LARGE_INTEGER fileSize{};
3268 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3269 VERIFY_ARE_EQUAL(0ull, static_cast<ULONGLONG>(fileSize.QuadPart));
3270 }
3271 }
3272
3273 WSLC_TEST_METHOD(SynchronousIoCancellation)
3274 {
3275 // Create a blocked operation that will cause the service to get stuck on a ReadFile() call.
3276 // Because the pipe handle that we're passing in doesn't support overlapped IO, the service will get stuck in a
3277 // synchronous ReadFile() call. Validate that terminating the session correctly cancels the IO.
3278
3279 wil::unique_handle pipeRead;
3280 wil::unique_handle pipeWrite;
3281 VERIFY_WIN32_BOOL_SUCCEEDED(CreatePipe(&pipeRead, &pipeWrite, nullptr, 2));
3282
3283 std::promise<HRESULT> result;
3284
3285 wil::unique_event testCompleted{wil::EventOptions::ManualReset};
3286 std::thread operationThread([&]() {
3287 wil::unique_cotaskmem_ansistring id;
3288 result.set_value(m_defaultSession->ImportImage(ToCOMInputHandle(pipeRead.get()), "dummy:latest", 1024 * 1024, nullptr, &id));
3289
3290 WI_ASSERT(testCompleted.is_signaled()); // Sanity check.
3291 });
3292
3293 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { operationThread.join(); });
3294
3295 // Write 4 bytes to validate that the service has started reading from the pipe (since the pipe buffer is 2).
3296 DWORD bytesWritten{};
3297 VERIFY_WIN32_BOOL_SUCCEEDED(WriteFile(pipeWrite.get(), "data", 4, &bytesWritten, nullptr));
3298
3299 testCompleted.SetEvent();
3300
3301 // N.B. It's not possible to deterministically wait for the service to be stuck in the ReadFile() call.
3302 // It's possible that the service will check the session termination event before calling ReadFile() on the pipe
3303 // but that's OK since we can also accept that error code here (E_ABORT).
3304 VERIFY_SUCCEEDED(m_defaultSession->Terminate());
3305
3306 auto reset = ResetTestSession();
3307
3308 auto hr = result.get_future().get();
3309 if (hr != E_ABORT && hr != HRESULT_FROM_WIN32(ERROR_OPERATION_ABORTED))
3310 {
3311 LogError("Unexpected result: 0x%08X", hr);
3312 VERIFY_FAIL();
3313 }
3314 }
3315
3316 WSLC_TEST_METHOD(ExportContainer)
3317 {
3318 // Load an image and launch a container to verify image is valid.
3319 // Then export the container to a tar file.
3320 // Load the exported tar file to verify it's a valid image and can be launched.
3321 // Finally, stop and delete the container, then try to export again to verify it fails as expected.
3322 {
3323 std::filesystem::path containerTar = L"HelloWorldExported.tar";
3324 auto cleanup =
3325 wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(containerTar.c_str())); });
3326
3327 // Load the image from a saved tar and launch a container
3328 {
3329 std::filesystem::path imageTar = GetTestImagePath("hello-world:latest");
3330 wil::unique_handle imageTarFileHandle{CreateFileW(
3331 imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
3332 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
3333 LARGE_INTEGER fileSize{};
3334 VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
3335 VERIFY_SUCCEEDED(m_defaultSession->LoadImage(ToCOMInputHandle(imageTarFileHandle.get()), fileSize.QuadPart, nullptr, nullptr));
3336 // Verify that the image is in the list of images.
3337 ExpectImagePresent(*m_defaultSession, "hello-world:latest");
3338 WSLCContainerLauncher launcher("hello-world:latest", "wslc-hello-world-container");
3339 auto container = launcher.Launch(*m_defaultSession);
3340 auto result = container.GetInitProcess().WaitAndCaptureOutput();
3341 VERIFY_ARE_EQUAL(0, result.Code);
3342 VERIFY_IS_TRUE(result.Output[1].find("Hello from Docker!") != std::string::npos);
3343
3344 // Export the container to a tar file.
3345 wil::unique_handle containerTarFileHandle{CreateFileW(
3346 containerTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
3347 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == containerTarFileHandle.get());
3348 VERIFY_IS_TRUE(GetFileSizeEx(containerTarFileHandle.get(), &fileSize));
3349 VERIFY_ARE_EQUAL(fileSize.QuadPart, 0);
3350 VERIFY_SUCCEEDED(container.Get().Export(ToCOMInputHandle(containerTarFileHandle.get())));
3351 VERIFY_IS_TRUE(GetFileSizeEx(containerTarFileHandle.get(), &fileSize));
3352 VERIFY_ARE_NOT_EQUAL(fileSize.QuadPart, 0);
3353 }
3354
3355 // Load the exported container to verify it's valid.
3356 {
3357 wil::unique_handle containerTarFileHandle{CreateFileW(
3358 containerTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
3359 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == containerTarFileHandle.get());
3360 LARGE_INTEGER fileSize{};
3361 VERIFY_IS_TRUE(GetFileSizeEx(containerTarFileHandle.get(), &fileSize));
3362
3363 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
3364 LOG_IF_FAILED(DeleteImageNoThrow("test-imported-container:latest", WSLCDeleteImageFlagsNone).first);
3365 });
3366
3367 wil::unique_cotaskmem_ansistring importedImageId;
3368 VERIFY_SUCCEEDED(m_defaultSession->ImportImage(
3369 ToCOMInputHandle(containerTarFileHandle.get()), "test-imported-container:latest", fileSize.QuadPart, nullptr, &importedImageId));
3370
3371 // Verify that the image is in the list of images.
3372 ExpectImagePresent(*m_defaultSession, "test-imported-container:latest");
3373 WSLCContainerLauncher launcher("test-imported-container:latest", "wslc-hello-world-container", {"/hello"});
3374 auto container = launcher.Launch(*m_defaultSession);
3375 auto result = container.GetInitProcess().WaitAndCaptureOutput();
3376 VERIFY_ARE_EQUAL(0, result.Code);
3377 VERIFY_IS_TRUE(result.Output[1].find("Hello from Docker!") != std::string::npos);
3378
3379 // Stop and delete the above container and try to export.
3380
3381 std::filesystem::path imageTarFile = L"HelloWorldExportError.tar";
3382 auto cleanfile =
3383 wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTarFile.c_str())); });
3384 wil::unique_handle contTarFileHandle{CreateFileW(
3385 imageTarFile.c_str(), GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
3386 VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == contTarFileHandle.get());
3387 VERIFY_IS_TRUE(GetFileSizeEx(contTarFileHandle.get(), &fileSize));
3388 VERIFY_ARE_EQUAL(fileSize.QuadPart, 0);
3389
3390 auto outFile = ToCOMInputHandle(contTarFileHandle.get());
3391
3392 container.Get().Stop(WSLCSignalSIGILL, 10);
3393 container.Get().Delete(WSLCDeleteFlagsNone);
3394 VERIFY_ARE_EQUAL(container.Get().Export(outFile), RPC_E_DISCONNECTED);
3395
3396 VERIFY_IS_TRUE(GetFileSizeEx(contTarFileHandle.get(), &fileSize));
3397 VERIFY_ARE_EQUAL(fileSize.QuadPart, 0);
3398 }
3399 }
3400 }
3401
3402 WSLC_TEST_METHOD(CustomDmesgOutput)
3403 {
3404 SKIP_TEST_ARM64();
3405
3406 auto createVmWithDmesg = [this](bool earlyBootLogging) {
3407 auto [read, write] = CreateSubprocessPipe(false, false);
3408
3409 auto settings = GetDefaultSessionSettings(L"dmesg-output-test");
3410 settings.DmesgOutput = ToCOMInputHandle(write.get());
3411 WI_UpdateFlag(settings.FeatureFlags, WslcFeatureFlagsEarlyBootDmesg, earlyBootLogging);
3412
3413 std::vector<char> dmesgContent;
3414 auto readDmesg = [read = read.get(), &dmesgContent]() mutable {
3415 DWORD Offset = 0;
3416
3417 constexpr auto bufferSize = 1024;
3418 while (true)
3419 {
3420 dmesgContent.resize(Offset + bufferSize);
3421
3422 DWORD Read{};
3423 if (!ReadFile(read, &dmesgContent[Offset], bufferSize, &Read, nullptr))
3424 {
3425 LogInfo("ReadFile() failed: %lu", GetLastError());
3426 }
3427
3428 if (Read == 0)
3429 {
3430 break;
3431 }
3432
3433 Offset += Read;
3434 }
3435 };
3436
3437 std::thread thread(readDmesg); // Needs to be created before the VM starts, to avoid a pipe deadlock.
3438
3439 // Ensure the thread is joined even if CreateSession throws, to avoid std::terminate.
3440 auto threadGuard = wil::scope_exit([&]() {
3441 write.reset();
3442 if (thread.joinable())
3443 {
3444 thread.join();
3445 }
3446 });
3447
3448 auto session = CreateSession(settings);
3449 threadGuard.release(); // CreateSession succeeded, detach scope_exit below takes over.
3450
3451 auto detach = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
3452 session.reset();
3453 if (thread.joinable())
3454 {
3455 thread.join();
3456 }
3457 });
3458
3459 write.reset();
3460
3461 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "echo DmesgTest > /dev/kmsg"}, 0);
3462
3463 session.reset();
3464 detach.reset();
3465
3466 auto contentString = std::string(dmesgContent.begin(), dmesgContent.end());
3467
3468 VERIFY_ARE_NOT_EQUAL(contentString.find("Run /init as init process"), std::string::npos);
3469 VERIFY_ARE_NOT_EQUAL(contentString.find("DmesgTest"), std::string::npos);
3470
3471 return contentString;
3472 };
3473
3474 auto validateFirstDmesgLine = [](const std::string& dmesg, const char* expected) {
3475 auto firstLf = dmesg.find("\n");
3476 VERIFY_ARE_NOT_EQUAL(firstLf, std::string::npos);
3477 VERIFY_IS_TRUE(dmesg.find(expected) < firstLf);
3478 };
3479
3480 // Dmesg without early boot logging
3481 {
3482 auto dmesg = createVmWithDmesg(false);
3483
3484 // Verify that the first line is "brd: module loaded";
3485 validateFirstDmesgLine(dmesg, "brd: module loaded");
3486 }
3487
3488 // Dmesg with early boot logging
3489 {
3490 auto dmesg = createVmWithDmesg(true);
3491 validateFirstDmesgLine(dmesg, "Linux version");
3492 }
3493 }
3494
3495 WSLC_TEST_METHOD(TerminationEvent)
3496 {
3497 auto session = CreateSession(GetDefaultSessionSettings(L"termination-event-test"));
3498
3499 wil::unique_handle terminationEvent;
3500 VERIFY_SUCCEEDED(session->GetTerminationEvent(&terminationEvent));
3501 VERIFY_IS_NOT_NULL(terminationEvent.get());
3502
3503 // The reason is unavailable until the session has terminated.
3504 WSLCVirtualMachineTerminationReason reason{};
3505 wil::unique_cotaskmem_string details;
3506 VERIFY_ARE_EQUAL(session->GetTerminationReason(&reason, &details), HRESULT_FROM_WIN32(ERROR_INVALID_STATE));
3507
3508 // Terminating the session should signal the event and record a graceful shutdown reason.
3509 VERIFY_SUCCEEDED(session->Terminate());
3510
3511 VERIFY_ARE_EQUAL(WaitForSingleObject(terminationEvent.get(), 30 * 1000), static_cast<DWORD>(WAIT_OBJECT_0));
3512
3513 VERIFY_SUCCEEDED(session->GetTerminationReason(&reason, &details));
3514 VERIFY_ARE_EQUAL(reason, WSLCVirtualMachineTerminationReasonShutdown);
3515 }
3516
3517 WSLC_TEST_METHOD(CrashDumpCallback)
3518 {
3519 struct Invocation
3520 {
3521 std::wstring DumpPath;
3522 std::string ProcessName;
3523 ULONG Pid;
3524 ULONG Signal;
3525 ULONGLONG Timestamp;
3526 };
3527
3528 class DECLSPEC_UUID("8C5A7B14-9D26-4FAE-AB31-7E5BC23F4802") CallbackInstance
3529 : public Microsoft::WRL::RuntimeClass<Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, ICrashDumpCallback, IFastRundown, Microsoft::WRL::FtmBase>
3530 {
3531 public:
3532 CallbackInstance(std::promise<Invocation>& promise, wil::unique_event& release) :
3533 m_promise(promise), m_release(release)
3534 {
3535 }
3536
3537 HRESULT OnCrashDump(LPCWSTR DumpPath, LPCSTR ProcessName, ULONG Pid, ULONG Signal, ULONGLONG Timestamp) override
3538 {
3539 m_promise.set_value(Invocation{
3540 DumpPath ? std::wstring{DumpPath} : std::wstring{}, ProcessName ? std::string{ProcessName} : std::string{}, Pid, Signal, Timestamp});
3541
3542 // Block until the test has finished probing, so anything the test verifies is observed mid-callback.
3543 m_release.wait();
3544 return S_OK;
3545 }
3546
3547 private:
3548 std::promise<Invocation>& m_promise;
3549 wil::unique_event& m_release;
3550 };
3551
3552 std::promise<Invocation> promise;
3553 wil::unique_event release{wil::EventOptions::ManualReset};
3554 auto callback = Microsoft::WRL::Make<CallbackInstance>(promise, release);
3555 auto releaseCallback = wil::scope_exit([&]() { release.SetEvent(); });
3556
3557 WSLCSessionSettings sessionSettings = GetDefaultSessionSettings(L"crash-dump-callback-test");
3558 auto session = CreateSession(sessionSettings);
3559
3560 // Register the callback through IWSLCSession::RegisterCrashDumpCallback. Holding the
3561 // returned subscription keeps the registration alive; releasing it auto-unregisters.
3562 wil::com_ptr<IUnknown> subscription;
3563 VERIFY_SUCCEEDED(session->RegisterCrashDumpCallback(callback.Get(), &subscription));
3564
3565 // Trigger a Linux process crash. The shell exits with 128 + SIGSEGV.
3566 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "kill -SEGV $$"}, 128 + WSLCSignalSIGSEGV);
3567
3568 auto future = promise.get_future();
3569 VERIFY_ARE_EQUAL(future.wait_for(std::chrono::seconds(60)), std::future_status::ready);
3570
3571 auto invocation = future.get();
3572 VERIFY_IS_FALSE(invocation.DumpPath.empty());
3573 VERIFY_IS_TRUE(invocation.ProcessName.find("sh") != std::string::npos);
3574 VERIFY_ARE_EQUAL(invocation.Signal, static_cast<ULONG>(WSLCSignalSIGSEGV));
3575 VERIFY_IS_GREATER_THAN(invocation.Pid, 0u);
3576 VERIFY_IS_GREATER_THAN(invocation.Timestamp, 0ull);
3577
3578 // The dump file should be readable and non-empty.
3579 wil::unique_hfile dumpFile{CreateFileW(
3580 invocation.DumpPath.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
3581 VERIFY_IS_TRUE(dumpFile.is_valid());
3582 VERIFY_IS_GREATER_THAN(std::filesystem::file_size(invocation.DumpPath), 0ull);
3583 }
3584
3585 WSLC_TEST_METHOD(BuildImageStuckCallbackCancellation)
3586 {
3587 SKIP_TEST_SERVER();
3588
3589 class StuckBuildProgressCallback
3590 : public Microsoft::WRL::RuntimeClass<Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, IProgressCallback>
3591 {
3592 public:
3593 StuckBuildProgressCallback(std::promise<void>& reachedPromise, wil::unique_event& exitEvent) :
3594 m_reachedPromise(reachedPromise), m_exitEvent(exitEvent)
3595 {
3596 }
3597
3598 HRESULT OnProgress(LPCSTR, LPCSTR, ULONGLONG, ULONGLONG) override
3599 {
3600 if (!m_signaled)
3601 {
3602 m_signaled = true;
3603 m_reachedPromise.set_value();
3604 m_exitEvent.wait(); // Block until this test case is complete.
3605 }
3606
3607 return S_OK;
3608 }
3609
3610 private:
3611 std::promise<void>& m_reachedPromise;
3612 wil::unique_event& m_exitEvent;
3613 bool m_signaled{};
3614 };
3615
3616 auto contextDir = std::filesystem::current_path() / "build-context-stuck-callback";
3617 std::filesystem::create_directories(contextDir);
3618 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
3619 std::error_code ec;
3620 std::filesystem::remove_all(contextDir, ec);
3621 });
3622
3623 {
3624 std::ofstream dockerfile(contextDir / "Dockerfile");
3625 dockerfile << "FROM debian:latest\n";
3626 dockerfile << "RUN echo hello\n";
3627 }
3628
3629 auto contextPathStr = contextDir.wstring();
3630 auto dockerfileHandle = wil::open_file((contextDir / "Dockerfile").c_str());
3631
3632 WSLCBuildImageOptions options{
3633 .ContextPath = contextPathStr.c_str(),
3634 .DockerfileHandle = ToCOMInputHandle(dockerfileHandle.get()),
3635 .Flags = WSLCBuildImageFlagsVerbose,
3636 };
3637
3638 std::promise<void> callbackReached;
3639 wil::unique_event exitEvent{wil::EventOptions::ManualReset};
3640 auto callback = Microsoft::WRL::Make<StuckBuildProgressCallback>(callbackReached, exitEvent);
3641
3642 std::promise<HRESULT> buildResult;
3643 std::thread buildThread(
3644 [&]() { buildResult.set_value(m_defaultSession->BuildImage(&options, callback.Get(), exitEvent.get())); });
3645
3646 auto joinThread = wil::scope_exit([&]() {
3647 exitEvent.SetEvent();
3648 buildThread.join();
3649 });
3650
3651 // Wait for the progress callback to be called, proving the COM call is in flight.
3652 auto reachedFuture = callbackReached.get_future();
3653 auto reachedStatus = reachedFuture.wait_for(std::chrono::seconds(60));
3654 VERIFY_ARE_EQUAL(reachedStatus, std::future_status::ready);
3655
3656 // Terminate the session while the callback is stuck.
3657 // This should cancel the pending COM call and unblock BuildImage.
3658 VERIFY_SUCCEEDED(m_defaultSession->Terminate());
3659 ResetTestSession();
3660
3661 auto buildFuture = buildResult.get_future();
3662 auto buildStatus = buildFuture.wait_for(std::chrono::seconds(60));
3663 VERIFY_ARE_EQUAL(buildStatus, std::future_status::ready);
3664
3665 // BuildImage should have failed due to COM call cancellation.
3666 VERIFY_FAILED(buildFuture.get());
3667 }
3668
3669 WSLC_TEST_METHOD(InteractiveShell)
3670 {
3671 WSLCProcessLauncher launcher("/bin/sh", {"/bin/sh"}, {"TERM=xterm-256color"}, WSLCProcessFlagsTty | WSLCProcessFlagsStdin);
3672 auto process = launcher.Launch(*m_defaultSession);
3673
3674 auto tty = process.GetStdHandle(WSLCFDTty);
3675
3676 auto validateTtyOutput = [&](const std::string& expected) {
3677 std::string buffer(expected.size(), '\0');
3678
3679 DWORD offset = 0;
3680
3681 while (offset < buffer.size())
3682 {
3683 DWORD bytesRead{};
3684 VERIFY_IS_TRUE(ReadFile(tty.Get(), buffer.data() + offset, static_cast<DWORD>(buffer.size() - offset), &bytesRead, nullptr));
3685
3686 offset += bytesRead;
3687 }
3688
3689 buffer.resize(offset);
3690 VERIFY_ARE_EQUAL(buffer, expected);
3691 };
3692
3693 auto writeTty = [&](const std::string& content) {
3694 VERIFY_IS_TRUE(WriteFile(tty.Get(), content.data(), static_cast<DWORD>(content.size()), nullptr, nullptr));
3695 };
3696
3697 // Expect the shell prompt to be displayed
3698 validateTtyOutput("\033[?2004hsh-5.2# ");
3699 writeTty("echo OK\n");
3700 validateTtyOutput("echo OK\r\n\033[?2004l\rOK");
3701
3702 // Exit the shell
3703 writeTty("exit\n");
3704
3705 VERIFY_IS_TRUE(process.GetExitEvent().wait(30 * 1000));
3706 }
3707
3708 void ValidateNetworking(WSLCNetworkingMode mode, bool enableDnsTunneling = false)
3709 {
3710 // Reuse the default session if settings match (same networking mode and DNS tunneling setting).
3711 auto createNewSession = mode != m_defaultSessionSettings.NetworkingMode ||
3712 enableDnsTunneling != WI_IsFlagSet(m_defaultSessionSettings.FeatureFlags, WslcFeatureFlagsDnsTunneling);
3713
3714 auto settings = GetDefaultSessionSettings(L"networking-test", false, mode);
3715 WI_UpdateFlag(settings.FeatureFlags, WslcFeatureFlagsDnsTunneling, enableDnsTunneling);
3716 auto session = createNewSession ? CreateSession(settings) : m_defaultSession;
3717
3718 // Validate that eth0 has an ip address
3719 ExpectCommandResult(
3720 session.get(),
3721 {"/bin/sh",
3722 "-c",
3723 "ip a show dev eth0 | grep -iF 'inet ' | grep -E '[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}'"},
3724 0);
3725
3726 ExpectCommandResult(session.get(), {"/bin/grep", "-iF", "nameserver", "/etc/resolv.conf"}, 0);
3727
3728 // Verify that /etc/resolv.conf is correctly configured.
3729 if (enableDnsTunneling)
3730 {
3731 auto result = ExpectCommandResult(session.get(), {"/bin/grep", "-iF", "nameserver ", "/etc/resolv.conf"}, 0);
3732
3733 if (mode == WSLCNetworkingModeConsomme)
3734 {
3735 // Consomme points resolv.conf at the eth0 gateway.
3736 ExpectCommandResult(
3737 session.get(),
3738 {"/bin/sh",
3739 "-c",
3740 "ns=$(awk '/^nameserver/ {print $2; exit}' /etc/resolv.conf); "
3741 "gw=$(ip route show default | awk '{print $3; exit}'); "
3742 "[ -n \"$ns\" ] && [ -n \"$gw\" ] && [ \"$ns\" = \"$gw\" ]"},
3743 0);
3744 }
3745 else
3746 {
3747 VERIFY_ARE_EQUAL(result.Output[1], std::format("nameserver {}\n", LX_INIT_DNS_TUNNELING_IP_ADDRESS));
3748 }
3749 }
3750
3751 // Verify DNS resolution.
3752 // Note: without DNS tunneling, NAT mode uses the ICS SharedAccess DNS proxy which only supports UDP.
3753 // TCP DNS queries (dig +tcp) will time out without tunneling.
3754 VerifyDigDnsResolution(session.get(), "getent ahosts bing.com");
3755 VerifyDnsQueries(session.get(), mode, enableDnsTunneling);
3756 }
3757
3758 TEST_METHOD(NATNetworking)
3759 {
3760 ValidateNetworking(WSLCNetworkingModeNAT);
3761 }
3762
3763 TEST_METHOD(NATNetworkingWithDnsTunneling)
3764 {
3765 WINDOWS_11_TEST_ONLY();
3766 ValidateNetworking(WSLCNetworkingModeNAT, true);
3767 }
3768
3769 TEST_METHOD(ConsommeNetworking)
3770 {
3771 ValidateNetworking(WSLCNetworkingModeConsomme);
3772 }
3773
3774 TEST_METHOD(ConsommeNetworkingWithDnsTunneling)
3775 {
3776 WINDOWS_11_TEST_ONLY();
3777 ValidateNetworking(WSLCNetworkingModeConsomme, true);
3778 }
3779
3780 // DNS test helpers
3781
3782 void VerifyDigDnsResolution(IWSLCSession* session, const std::string& digCommandLine)
3783 {
3784 auto result = ExpectCommandResult(session, {"/bin/sh", "-c", digCommandLine}, 0);
3785 VERIFY_IS_FALSE(result.Output[1].empty());
3786 }
3787
3788 void VerifyDnsQueries(IWSLCSession* session, WSLCNetworkingMode mode, bool enableDnsTunneling)
3789 {
3790 // TCP DNS works except for NAT without tunneling (ICS SharedAccess DNS proxy is UDP-only).
3791 const bool includeTcp = (mode != WSLCNetworkingModeNAT) || enableDnsTunneling;
3792
3793 // UDP queries for all record types
3794 VerifyDigDnsResolution(session, "dig +short +time=5 A bing.com");
3795 VerifyDigDnsResolution(session, "dig +short +time=5 AAAA bing.com");
3796 VerifyDigDnsResolution(session, "dig +short +time=5 MX bing.com");
3797 VerifyDigDnsResolution(session, "dig +short +time=5 NS bing.com");
3798 VerifyDigDnsResolution(session, "dig +short +time=5 -x 8.8.8.8");
3799 VerifyDigDnsResolution(session, "dig +short +time=5 SOA bing.com");
3800 VerifyDigDnsResolution(session, "dig +short +time=5 TXT bing.com");
3801 VerifyDigDnsResolution(session, "dig +time=5 CNAME bing.com");
3802 VerifyDigDnsResolution(session, "dig +time=5 SRV bing.com");
3803
3804 if (includeTcp)
3805 {
3806 // ANY - dig expects a large response so it queries directly over TCP
3807 VerifyDigDnsResolution(session, "dig +short +time=5 ANY bing.com");
3808
3809 VerifyDigDnsResolution(session, "dig +tcp +short +time=5 A bing.com");
3810 VerifyDigDnsResolution(session, "dig +tcp +short +time=5 AAAA bing.com");
3811 VerifyDigDnsResolution(session, "dig +tcp +short +time=5 MX bing.com");
3812 VerifyDigDnsResolution(session, "dig +tcp +short +time=5 NS bing.com");
3813 VerifyDigDnsResolution(session, "dig +tcp +short +time=5 -x 8.8.8.8");
3814 VerifyDigDnsResolution(session, "dig +tcp +short +time=5 SOA bing.com");
3815 VerifyDigDnsResolution(session, "dig +tcp +short +time=5 TXT bing.com");
3816 VerifyDigDnsResolution(session, "dig +tcp +time=5 CNAME bing.com");
3817 VerifyDigDnsResolution(session, "dig +tcp +time=5 SRV bing.com");
3818 }
3819 }
3820
3821 void ValidatePortMapping(WSLCNetworkingMode networkingMode)
3822 {
3823 auto settings = GetDefaultSessionSettings(L"port-mapping-test");
3824 settings.NetworkingMode = networkingMode;
3825
3826 // Reuse the default session if the networking mode matches.
3827 auto createNewSession = networkingMode != m_defaultSessionSettings.NetworkingMode;
3828 auto session = createNewSession ? CreateSession(settings) : m_defaultSession;
3829
3830 // Install socat in the VM.
3831 {
3832 constexpr auto c_mountPoint = "/testdata";
3833 auto mountSource = std::filesystem::absolute(g_testDataPath);
3834
3835 VERIFY_SUCCEEDED(session->MountWindowsFolder(mountSource.c_str(), c_mountPoint, true, TRUE));
3836 auto unmount = wil::scope_exit_log(
3837 WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_FAILED(session->UnmountWindowsFolder(c_mountPoint, TRUE)); });
3838
3839 const auto installCommand = std::format("tdnf install -y --disablerepo='*' --nogpgcheck {}/packages/*.rpm", c_mountPoint);
3840 auto installSocat = WSLCProcessLauncher("/bin/sh", {"/bin/sh", "-c", installCommand}).Launch(*session);
3841 ValidateProcessOutput(installSocat, {}, 0, 120 * 1000);
3842 }
3843
3844 auto listen = [&](short port, const char* content, bool ipv6) {
3845 auto cmd = std::format("echo -n '{}' | /usr/bin/socat -dd TCP{}-LISTEN:{},reuseaddr -", content, ipv6 ? "6" : "", port);
3846 auto process = WSLCProcessLauncher("/bin/sh", {"/bin/sh", "-c", cmd}).Launch(*session);
3847 WaitForOutput(process.GetStdHandle(2), "listening on");
3848
3849 return process;
3850 };
3851
3852 auto connectAndRead = [&](short port, int family) -> std::string {
3853 SOCKADDR_INET addr{};
3854 addr.si_family = family;
3855 INETADDR_SETLOOPBACK((PSOCKADDR)&addr);
3856 SS_PORT(&addr) = htons(port);
3857
3858 wil::unique_socket hostSocket{socket(family, SOCK_STREAM, IPPROTO_TCP)};
3859 THROW_LAST_ERROR_IF(!hostSocket);
3860 THROW_LAST_ERROR_IF(connect(hostSocket.get(), reinterpret_cast<SOCKADDR*>(&addr), sizeof(addr)) == SOCKET_ERROR);
3861
3862 return ReadToString(hostSocket.get());
3863 };
3864
3865 auto expectContent = [&](short port, int family, const char* expected) {
3866 auto content = connectAndRead(port, family);
3867 VERIFY_ARE_EQUAL(content, expected);
3868 };
3869
3870 auto expectNotBound = [&](short port, int family) {
3871 auto result = wil::ResultFromException([&]() { connectAndRead(port, family); });
3872
3873 VERIFY_ARE_EQUAL(result, HRESULT_FROM_WIN32(WSAECONNREFUSED));
3874 };
3875
3876 // Map port
3877 VERIFY_SUCCEEDED(session->MapVmPort(AF_INET, 1234, 80));
3878
3879 // Validate that the same port can't be bound twice
3880 VERIFY_ARE_EQUAL(session->MapVmPort(AF_INET, 1234, 80), HRESULT_FROM_WIN32(WSAEADDRINUSE));
3881
3882 // Check simple case
3883 listen(80, "port80", false);
3884 expectContent(1234, AF_INET, "port80");
3885
3886 // Validate that same port mapping can be reused
3887 listen(80, "port80", false);
3888 expectContent(1234, AF_INET, "port80");
3889
3890 // Validate that the connection is immediately reset if the port is not bound on the linux side
3891 expectContent(1234, AF_INET, "");
3892
3893 // Add a ipv6 binding
3894 VERIFY_SUCCEEDED(session->MapVmPort(AF_INET6, 1234, 80));
3895
3896 // Validate that ipv6 bindings work as well.
3897 listen(80, "port80ipv6", true);
3898 expectContent(1234, AF_INET6, "port80ipv6");
3899
3900 // Unmap the ipv4 port
3901 VERIFY_SUCCEEDED(session->UnmapVmPort(AF_INET, 1234, 80));
3902
3903 // Verify that a proper error is returned if the mapping doesn't exist
3904 // TODO: update once virtionet error code is fixed.
3905 VERIFY_ARE_EQUAL(
3906 session->UnmapVmPort(AF_INET, 1234, 80), networkingMode == WSLCNetworkingModeNAT ? HRESULT_FROM_WIN32(ERROR_NOT_FOUND) : E_INVALIDARG);
3907
3908 // Unmap the v6 port
3909 VERIFY_SUCCEEDED(session->UnmapVmPort(AF_INET6, 1234, 80));
3910
3911 // Map another port as v6 only
3912 VERIFY_SUCCEEDED(session->MapVmPort(AF_INET6, 1235, 81));
3913
3914 listen(81, "port81ipv6", true);
3915 expectContent(1235, AF_INET6, "port81ipv6");
3916 expectNotBound(1235, AF_INET);
3917
3918 VERIFY_SUCCEEDED(session->UnmapVmPort(AF_INET6, 1235, 81));
3919 VERIFY_ARE_EQUAL(session->UnmapVmPort(AF_INET6, 1235, 81), HRESULT_FROM_WIN32(ERROR_NOT_FOUND));
3920 expectNotBound(1235, AF_INET6);
3921
3922 // Create a forking relay and stress test
3923 VERIFY_SUCCEEDED(session->MapVmPort(AF_INET, 1234, 80));
3924
3925 auto process =
3926 WSLCProcessLauncher{"/usr/bin/socat", {"/usr/bin/socat", "-dd", "TCP-LISTEN:80,fork,reuseaddr", "system:'echo -n OK'"}}
3927 .Launch(*session);
3928
3929 WaitForOutput(process.GetStdHandle(2), "listening on");
3930
3931 for (auto i = 0; i < 100; i++)
3932 {
3933 expectContent(1234, AF_INET, "OK");
3934 }
3935
3936 VERIFY_SUCCEEDED(session->UnmapVmPort(AF_INET, 1234, 80));
3937
3938 // Validate the 63-port limit.
3939 // TODO: Remove the 63-port limit by switching the relay's AcceptThread from
3940 // WaitForMultipleObjects to IO completion ports or similar.
3941 constexpr int c_maxPorts = 63;
3942 for (int i = 0; i < c_maxPorts; i++)
3943 {
3944 VERIFY_SUCCEEDED(session->MapVmPort(AF_INET, static_cast<uint16_t>(20000 + i), static_cast<uint16_t>(80 + i)));
3945 }
3946
3947 if (networkingMode == WSLCNetworkingModeNAT)
3948 {
3949 // In NAT mode, the 64th port mapping should fail with ERROR_TOO_MANY_OPEN_FILES since the relay process uses a file handle for each mapping.
3950 VERIFY_ARE_EQUAL(
3951 session->MapVmPort(AF_INET, static_cast<uint16_t>(20000 + c_maxPorts), static_cast<uint16_t>(80 + c_maxPorts)),
3952 HRESULT_FROM_WIN32(ERROR_TOO_MANY_OPEN_FILES));
3953 }
3954 else
3955 {
3956 VERIFY_SUCCEEDED(session->MapVmPort(AF_INET, static_cast<uint16_t>(20000 + c_maxPorts), static_cast<uint16_t>(80 + c_maxPorts)));
3957 VERIFY_SUCCEEDED(session->UnmapVmPort(AF_INET, static_cast<uint16_t>(20000 + c_maxPorts), static_cast<uint16_t>(80 + c_maxPorts)));
3958 }
3959
3960 for (int i = 0; i < c_maxPorts; i++)
3961 {
3962 VERIFY_SUCCEEDED(session->UnmapVmPort(AF_INET, static_cast<uint16_t>(20000 + i), static_cast<uint16_t>(80 + i)));
3963 }
3964 }
3965
3966 TEST_METHOD(PortMappingNat)
3967 {
3968 ValidatePortMapping(WSLCNetworkingModeNAT);
3969 }
3970
3971 TEST_METHOD(PortMappingConsomme)
3972 {
3973 ValidatePortMapping(WSLCNetworkingModeConsomme);
3974 }
3975
3976 WSLC_TEST_METHOD(StuckVmTermination)
3977 {
3978 // Create a 'stuck' process
3979 auto process = WSLCProcessLauncher{"/bin/cat", {"/bin/cat"}, {}, WSLCProcessFlagsStdin}.Launch(*m_defaultSession);
3980
3981 // Stop the service
3982 StopWslService();
3983
3984 ResetTestSession(); // Reopen the session since the service was stopped.
3985 }
3986
3987 void ValidateWindowsMounts(bool enableVirtioFs)
3988 {
3989 auto settings = GetDefaultSessionSettings(L"windows-mount-tests");
3990 WI_UpdateFlag(settings.FeatureFlags, WslcFeatureFlagsVirtioFs, enableVirtioFs);
3991
3992 // Reuse the default session if possible.
3993 auto createNewSession = enableVirtioFs != WI_IsFlagSet(m_defaultSessionSettings.FeatureFlags, WslcFeatureFlagsVirtioFs);
3994 auto session = createNewSession ? CreateSession(settings) : m_defaultSession;
3995
3996 auto expectedMountOptions = [&](bool readOnly) -> std::string {
3997 if (enableVirtioFs)
3998 {
3999 return std::format("/win-path*virtiofs*{},relatime*", readOnly ? "ro" : "rw");
4000 }
4001 else
4002 {
4003 return std::format(
4004 "/win-path*9p*{},relatime,aname=*,cache=0x5,access=client,msize=65536,trans=fd,rfd=*,wfd=*", readOnly ? "ro" : "rw");
4005 }
4006 };
4007
4008 auto testFolder = std::filesystem::current_path() / "test-folder";
4009 std::filesystem::create_directories(testFolder);
4010 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { std::filesystem::remove_all(testFolder); });
4011
4012 // Validate writeable mount.
4013 {
4014 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path", false, TRUE));
4015 ExpectMount(session.get(), "/win-path", expectedMountOptions(false));
4016
4017 // Validate that mount can't be stacked on each other
4018 VERIFY_ARE_EQUAL(session->MountWindowsFolder(testFolder.c_str(), "/win-path", false, TRUE), HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS));
4019
4020 // Validate that folder is writeable from linux
4021 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "echo -n content > /win-path/file.txt && sync"}, 0);
4022 VERIFY_ARE_EQUAL(ReadFileContent(testFolder / "file.txt"), L"content");
4023
4024 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path", TRUE));
4025 ExpectMount(session.get(), "/win-path", {});
4026 }
4027
4028 // Validate read-only mount.
4029 {
4030 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path", true, TRUE));
4031 ExpectMount(session.get(), "/win-path", expectedMountOptions(true));
4032
4033 // Validate that folder is not writeable from linux
4034 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "echo -n content > /win-path/file.txt"}, 1);
4035
4036 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path", TRUE));
4037 ExpectMount(session.get(), "/win-path", {});
4038 }
4039
4040 // Validate that a read-only share cannot be made writeable via mount -o remount,rw.
4041 {
4042 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path", true, TRUE));
4043 ExpectMount(session.get(), "/win-path", expectedMountOptions(true));
4044
4045 // Attempt an in-place remount to read-write from the guest.
4046 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "mount -o remount,rw /win-path"}, 0);
4047
4048 // Verify the folder is still not writeable.
4049 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "echo -n content > /win-path/file.txt"}, 1);
4050
4051 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path", TRUE));
4052 ExpectMount(session.get(), "/win-path", {});
4053 }
4054
4055 // Validate that the device host enforces read-only even if the guest tries to bypass mount options.
4056 if (enableVirtioFs)
4057 {
4058 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path", true, TRUE));
4059 ExpectMount(session.get(), "/win-path", expectedMountOptions(true));
4060
4061 // Remount a bind of the share as read-write to ensure the device host still enforces read-only access.
4062 ExpectCommandResult(
4063 session.get(),
4064 {"/bin/sh",
4065 "-c",
4066 "mkdir -p /win-path-rw && "
4067 "mount --bind /win-path /win-path-rw && "
4068 "mount -o remount,bind,rw /win-path-rw && "
4069 "findmnt -n -o VFS-OPTIONS /win-path-rw | grep -qE '(^|,)rw(,|$)'"},
4070 0);
4071
4072 // Verify the folder is still not writeable through the read-write bind.
4073 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "echo -n content > /win-path-rw/file.txt"}, 1);
4074 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "umount /win-path-rw && rmdir /win-path-rw"}, 0);
4075
4076 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path", TRUE));
4077 ExpectMount(session.get(), "/win-path", {});
4078 }
4079
4080 // Validate various error paths
4081 {
4082 VERIFY_ARE_EQUAL(session->MountWindowsFolder(L"relative-path", "/win-path", true, TRUE), E_INVALIDARG);
4083 VERIFY_ARE_EQUAL(session->MountWindowsFolder(L"C:\\does-not-exist", "/win-path", true, TRUE), HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND));
4084 VERIFY_ARE_EQUAL(session->MountWindowsFolder(testFolder.c_str(), "relative-mountpoint", true, TRUE), E_INVALIDARG);
4085 VERIFY_ARE_EQUAL(session->MountWindowsFolder(testFolder.c_str(), "", true, TRUE), E_INVALIDARG);
4086 VERIFY_ARE_EQUAL(session->UnmountWindowsFolder("/not-mounted", TRUE), HRESULT_FROM_WIN32(ERROR_NOT_FOUND));
4087 VERIFY_ARE_EQUAL(session->UnmountWindowsFolder("/proc", TRUE), HRESULT_FROM_WIN32(ERROR_NOT_FOUND));
4088
4089 // Validate that folders that are manually unmounted from the guest are handled properly
4090 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path", true, TRUE));
4091 ExpectMount(session.get(), "/win-path", expectedMountOptions(true));
4092
4093 ExpectCommandResult(session.get(), {"/usr/bin/umount", "/win-path"}, 0);
4094 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path", TRUE));
4095 }
4096 }
4097
4098 WSLC_TEST_METHOD(WindowsMounts)
4099 {
4100 ValidateWindowsMounts(false);
4101 }
4102
4103 WSLC_TEST_METHOD(WindowsMountsVirtioFs)
4104 {
4105 ValidateWindowsMounts(true);
4106 }
4107
4108 // Validates that each mount owns an independent child on the shared aggregate device.
4109 WSLC_TEST_METHOD(WindowsMountsVirtioFsIndependentShares)
4110 {
4111 auto settings = GetDefaultSessionSettings(L"virtiofs-independent-shares-test");
4112 WI_SetFlag(settings.FeatureFlags, WslcFeatureFlagsVirtioFs);
4113
4114 auto createNewSession = !WI_IsFlagSet(m_defaultSessionSettings.FeatureFlags, WslcFeatureFlagsVirtioFs);
4115 auto session = createNewSession ? CreateSession(settings) : m_defaultSession;
4116
4117 auto testFolder = std::filesystem::current_path() / "test-folder-independent-shares";
4118 std::filesystem::create_directories(testFolder);
4119 std::ofstream(testFolder / "marker.txt") << "content";
4120 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { std::filesystem::remove_all(testFolder); });
4121
4122 auto getMountField = [&](const char* mountPoint, const char* field) -> std::string {
4123 auto cmd = std::format("findmnt -n -o {} {}", field, mountPoint);
4124 auto result = ExpectCommandResult(session.get(), {"/bin/sh", "-c", cmd}, 0);
4125 return result.Output[1];
4126 };
4127
4128 // Concurrent mounts of the same host path use distinct children on the same aggregate device.
4129 {
4130 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path-1", false, TRUE));
4131 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path-2", false, TRUE));
4132
4133 auto firstDevice = getMountField("/win-path-1", "MAJ:MIN");
4134 auto secondDevice = getMountField("/win-path-2", "MAJ:MIN");
4135 auto firstRoot = getMountField("/win-path-1", "FSROOT");
4136 auto secondRoot = getMountField("/win-path-2", "FSROOT");
4137 VERIFY_ARE_EQUAL(firstDevice, secondDevice);
4138 VERIFY_ARE_NOT_EQUAL(firstRoot, secondRoot);
4139 VERIFY_IS_TRUE(firstRoot.starts_with('/'));
4140 VERIFY_IS_TRUE(firstRoot.ends_with('\n'));
4141 VERIFY_IS_TRUE(secondRoot.starts_with('/'));
4142 VERIFY_IS_TRUE(secondRoot.ends_with('\n'));
4143 firstRoot.pop_back();
4144 secondRoot.pop_back();
4145
4146 const auto firstChild = std::format("/run/wsl/virtiofs-mounts/{}{}", LX_INIT_DRVFS_VIRTIO_TAG, firstRoot);
4147 const auto secondChild = std::format("/run/wsl/virtiofs-mounts/{}{}", LX_INIT_DRVFS_VIRTIO_TAG, secondRoot);
4148
4149 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path-1", TRUE));
4150 ExpectCommandResult(session.get(), {"/bin/cat", "/win-path-2/marker.txt"}, 0);
4151 ExpectCommandResult(session.get(), {"/usr/bin/test", "!", "-e", firstChild}, 0);
4152 ExpectCommandResult(session.get(), {"/usr/bin/test", "-e", secondChild}, 0);
4153
4154 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path-2", TRUE));
4155 ExpectCommandResult(session.get(), {"/usr/bin/test", "!", "-e", secondChild}, 0);
4156 }
4157
4158 // Verify that read-write and read-only shares use different children on the same aggregate device.
4159 {
4160 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path-rw", false, TRUE));
4161 VERIFY_SUCCEEDED(session->MountWindowsFolder(testFolder.c_str(), "/win-path-ro", true, TRUE));
4162
4163 auto rwDevice = getMountField("/win-path-rw", "MAJ:MIN");
4164 auto roDevice = getMountField("/win-path-ro", "MAJ:MIN");
4165 auto rwRoot = getMountField("/win-path-rw", "FSROOT");
4166 auto roRoot = getMountField("/win-path-ro", "FSROOT");
4167
4168 VERIFY_ARE_EQUAL(rwDevice, roDevice);
4169 VERIFY_ARE_NOT_EQUAL(rwRoot, roRoot);
4170
4171 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path-rw", TRUE));
4172 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/win-path-ro", TRUE));
4173 }
4174 }
4175
4176 WSLC_TEST_METHOD(WindowsMountsVirtioFsRemoveChild)
4177 {
4178 auto settings = GetDefaultSessionSettings(L"virtiofs-remove-child-test");
4179 WI_SetFlag(settings.FeatureFlags, WslcFeatureFlagsVirtioFs);
4180 auto session = CreateSession(settings);
4181
4182 const auto testRoot = std::filesystem::current_path() / "test-folder-remove-child";
4183 const auto firstFolder = testRoot / "first";
4184 const auto secondFolder = testRoot / "second";
4185 std::filesystem::create_directories(firstFolder);
4186 std::filesystem::create_directories(secondFolder);
4187 std::ofstream(firstFolder / "marker.txt") << "first";
4188 std::ofstream(secondFolder / "marker.txt") << "second";
4189 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { std::filesystem::remove_all(testRoot); });
4190
4191 auto getMountRoot = [&](const char* mountPoint) {
4192 const auto command = std::format("findmnt -n -o FSROOT {}", mountPoint);
4193 auto root = ExpectCommandResult(session.get(), {"/bin/sh", "-c", command}, 0).Output.at(1);
4194 VERIFY_IS_TRUE(root.starts_with('/'));
4195 VERIFY_IS_TRUE(root.ends_with('\n'));
4196 root.pop_back();
4197 return root;
4198 };
4199
4200 VERIFY_SUCCEEDED(session->MountWindowsFolder(firstFolder.c_str(), "/remove-child-first", false, TRUE));
4201 VERIFY_SUCCEEDED(session->MountWindowsFolder(secondFolder.c_str(), "/remove-child-second", false, TRUE));
4202
4203 const auto firstRoot = getMountRoot("/remove-child-first");
4204 const auto secondRoot = getMountRoot("/remove-child-second");
4205 VERIFY_ARE_NOT_EQUAL(firstRoot, secondRoot);
4206
4207 const auto aggregateRoot = std::format("/run/wsl/virtiofs-mounts/{}", LX_INIT_DRVFS_VIRTIO_TAG);
4208 const auto firstChild = aggregateRoot + firstRoot;
4209 const auto secondChild = aggregateRoot + secondRoot;
4210 ExpectCommandResult(session.get(), {"/usr/bin/test", "-e", firstChild}, 0);
4211 ExpectCommandResult(session.get(), {"/usr/bin/test", "-e", secondChild}, 0);
4212
4213 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/remove-child-first", TRUE));
4214 ExpectCommandResult(session.get(), {"/usr/bin/test", "!", "-e", firstChild}, 0);
4215 ExpectCommandResult(session.get(), {"/bin/cat", "/remove-child-second/marker.txt"}, 0);
4216 ExpectCommandResult(session.get(), {"/usr/bin/test", "-e", secondChild}, 0);
4217
4218 VERIFY_SUCCEEDED(session->UnmountWindowsFolder("/remove-child-second", TRUE));
4219 ExpectCommandResult(session.get(), {"/usr/bin/test", "!", "-e", secondChild}, 0);
4220 }
4221
4222 // Validate that enough VirtioFs shares can be mounted to exceed the old per-device aperture limit.
4223 WSLC_TEST_METHOD(VirtiofsMountManyVolumes)
4224 {
4225 constexpr size_t c_shareCount = 32;
4226
4227 auto settings = GetDefaultSessionSettings(L"virtiofs-many-shares-test");
4228 WI_SetFlag(settings.FeatureFlags, WslcFeatureFlagsVirtioFs);
4229
4230 auto session = CreateSession(settings);
4231
4232 auto testRoot = std::filesystem::current_path() / "test-folder-many-shares";
4233 std::filesystem::create_directories(testRoot);
4234 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { std::filesystem::remove_all(testRoot); });
4235 std::vector<std::string> mountPoints;
4236
4237 for (size_t index = 0; index < c_shareCount; ++index)
4238 {
4239 const auto folder = testRoot / std::to_string(index);
4240 std::filesystem::create_directories(folder);
4241 std::ofstream(folder / "marker.txt") << index;
4242
4243 const auto mountPoint = std::format("/vfs-many-{}", index);
4244 VERIFY_SUCCEEDED(session->MountWindowsFolder(folder.c_str(), mountPoint.c_str(), false, TRUE));
4245 mountPoints.emplace_back(mountPoint);
4246
4247 const auto command = std::format("cat {}/marker.txt", mountPoint);
4248 const auto result = ExpectCommandResult(session.get(), {"/bin/sh", "-c", command}, 0);
4249 VERIFY_ARE_EQUAL(std::to_string(index), result.Output.at(1));
4250 }
4251
4252 for (const auto& mountPoint : mountPoints)
4253 {
4254 VERIFY_SUCCEEDED(session->UnmountWindowsFolder(mountPoint.c_str(), TRUE));
4255 }
4256 }
4257
4258 // This test case validates that no file descriptors are leaked to user processes.
4259 WSLC_TEST_METHOD(Fd)
4260 {
4261 auto result = ExpectCommandResult(
4262 m_defaultSession.get(), {"/bin/sh", "-c", "echo /proc/self/fd/* && (readlink -v /proc/self/fd/* || true)"}, 0);
4263
4264 // Note: fd/0 is opened by readlink to read the actual content of /proc/self/fd.
4265 if (!PathMatchSpecA(result.Output[1].c_str(), "/proc/self/fd/0 /proc/self/fd/1 /proc/self/fd/2\nsocket:*\nsocket:*"))
4266 {
4267 LogInfo("Found additional fds: %hs", result.Output[1].c_str());
4268 VERIFY_FAIL();
4269 }
4270 }
4271
4272 WSLC_TEST_METHOD(GPU)
4273 {
4274 // Validate that trying to mount the shares without GPU support enabled fails.
4275 {
4276 auto settings = GetDefaultSessionSettings(L"gpu-test-disabled");
4277 WI_ClearFlag(settings.FeatureFlags, WslcFeatureFlagsGPU);
4278
4279 auto createNewSession = WI_IsFlagSet(m_defaultSessionSettings.FeatureFlags, WslcFeatureFlagsGPU);
4280 auto session = createNewSession ? CreateSession(settings) : m_defaultSession;
4281
4282 // Validate that the GPU device is not available.
4283 ExpectMount(session.get(), "/usr/lib/wsl/drivers", {});
4284 ExpectMount(session.get(), "/usr/lib/wsl/lib", {});
4285 }
4286
4287 // Validate that the GPU device is available when enabled.
4288 {
4289 auto settings = GetDefaultSessionSettings(L"gpu-test");
4290 WI_SetFlag(settings.FeatureFlags, WslcFeatureFlagsGPU);
4291
4292 auto createNewSession = !WI_IsFlagSet(m_defaultSessionSettings.FeatureFlags, WslcFeatureFlagsGPU);
4293 auto session = createNewSession ? CreateSession(settings) : m_defaultSession;
4294
4295 // Validate that the GPU device is available.
4296 ExpectCommandResult(session.get(), {"/bin/sh", "-c", "test -c /dev/dxg"}, 0);
4297
4298 ExpectMount(
4299 session.get(),
4300 "/usr/lib/wsl/drivers",
4301 "/usr/lib/wsl/drivers*9p*relatime,aname=*,cache=0x5,access=client,msize=65536,trans=fd,rfd=*,wfd=*");
4302
4303 ExpectMount(
4304 session.get(),
4305 "/usr/lib/wsl/lib",
4306 "/usr/lib/wsl/lib none*overlay ro,relatime,lowerdir=/usr/lib/wsl/lib/packaged*");
4307
4308 // Validate that the mount points are not writeable.
4309 VERIFY_ARE_EQUAL(RunCommand(session.get(), {"/usr/bin/touch", "/usr/lib/wsl/drivers/test"}).Code, 1L);
4310 VERIFY_ARE_EQUAL(RunCommand(session.get(), {"/usr/bin/touch", "/usr/lib/wsl/lib/test"}).Code, 1L);
4311 }
4312 }
4313
4314 WSLC_TEST_METHOD(ContainerGpu)
4315 {
4316
4317 // Validate that setting the GPU flag on a non-GPU session fails.
4318 {
4319 WSLCContainerLauncher launcher("debian:latest", "test-container-gpu-fail");
4320 launcher.SetContainerFlags(WSLCContainerFlagsGpu);
4321
4322 auto [hr, _] = launcher.LaunchNoThrow(*m_defaultSession);
4323 VERIFY_ARE_EQUAL(hr, HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED));
4324 }
4325
4326 auto restore = ResetTestSession();
4327
4328 auto settings = GetDefaultSessionSettings(L"container-gpu-test", true);
4329 WI_SetFlag(settings.FeatureFlags, WslcFeatureFlagsGPU);
4330
4331 auto session = CreateSession(settings);
4332
4333 // Validate that GPU resources are available inside a container when WSLCContainerFlagsGpu is set.
4334 {
4335 WSLCContainerLauncher launcher("debian:latest", "test-container-gpu", {"sleep", "99999"});
4336 launcher.SetContainerFlags(WSLCContainerFlagsGpu);
4337
4338 auto container = launcher.Launch(*session);
4339
4340 auto expect = [&](const std::vector<std::string> command,
4341 int exitCode,
4342 const std::map<int, std::string>& expectedOutput = {},
4343 const std::vector<std::string>& env = {}) {
4344 auto process = WSLCProcessLauncher({}, command, env).Launch(container.Get());
4345 ValidateProcessOutput(process, expectedOutput, exitCode);
4346 };
4347
4348 // Validate that /dev/dxg is available as a character device with read/write permissions.
4349 expect({"/bin/sh", "-c", "test -c /dev/dxg && test -r /dev/dxg && test -w /dev/dxg"}, 0);
4350
4351 // Validate that the GPU library directory is mounted and contains libraries.
4352 expect({"/bin/sh", "-c", "test -d /usr/lib/wsl/lib && ls /usr/lib/wsl/lib | grep -q ."}, 0);
4353
4354 // Validate that the GPU drivers directory is mounted and accessible.
4355 expect({"/bin/sh", "-c", "test -d /usr/lib/wsl/drivers"}, 0);
4356
4357 // Validate that the GPU mount points are read-only.
4358 expect({"/usr/bin/touch", "/usr/lib/wsl/lib/test"}, 1);
4359 expect({"/usr/bin/touch", "/usr/lib/wsl/drivers/test"}, 1);
4360
4361 // Validate that the dynamic linker is configured to resolve the WSL GPU libraries.
4362 expect({"/bin/sh", "-c", "cat /etc/ld.so.conf.d/ld.wsl.conf"}, 0, {{1, "/usr/lib/wsl/lib\n"}});
4363 expect({"/bin/sh", "-c", "ldconfig -p | grep -q ' => /usr/lib/wsl/lib/'"}, 0);
4364
4365 std::vector<std::string> expectedBinaries;
4366 for (const auto& entry : std::filesystem::directory_iterator("C:\\Windows\\system32\\lxss\\lib"))
4367 {
4368 const auto fileName = entry.path().filename().wstring();
4369 if (entry.is_regular_file() && fileName.find(L".so") == std::wstring::npos)
4370 {
4371 expectedBinaries.push_back(wsl::shared::string::WideToMultiByte(fileName));
4372 }
4373 }
4374
4375 if (expectedBinaries.empty())
4376 {
4377 LogWarning("No executables found in C:\\Windows\\system32\\lxss\\lib. Skipping GPU executable bind mount test");
4378 }
4379 else
4380 {
4381 for (const auto& e : expectedBinaries)
4382 {
4383 expect({"test", "-x", std::format("/usr/bin/{}", e)}, 0);
4384 }
4385 }
4386 }
4387
4388 // Validate that containers without the GPU flag do not have GPU resources.
4389 {
4390 WSLCContainerLauncher launcher("debian:latest", "test-container-no-gpu", {"/bin/sh", "-c", "test -c /dev/dxg"});
4391 auto container = launcher.Launch(*session);
4392
4393 ValidateContainerOutput(container, {{1, ""}}, 1);
4394 }
4395
4396 // Validate that the directories are readable by non-root users.
4397 {
4398 WSLCContainerLauncher launcher(
4399 "debian:latest", "test-container-gpu-nobody", {"/bin/ls", "/usr/lib/wsl/lib", "/usr/lib/wsl/drivers"});
4400
4401 launcher.SetContainerFlags(WSLCContainerFlagsGpu);
4402 launcher.SetUser("nobody");
4403
4404 auto container = launcher.Launch(*session);
4405
4406 ValidateContainerOutput(container, {}, 0);
4407 }
4408 }
4409
4410 WSLC_TEST_METHOD(Modules)
4411 {
4412 // Sanity check.
4413 ExpectCommandResult(m_defaultSession.get(), {"/bin/sh", "-c", "lsmod | grep ^xsk_diag"}, 1);
4414
4415 // Validate that modules can be loaded.
4416 ExpectCommandResult(m_defaultSession.get(), {"/usr/sbin/modprobe", "xsk_diag"}, 0);
4417
4418 // Validate that xsk_diag is now loaded.
4419 ExpectCommandResult(m_defaultSession.get(), {"/bin/sh", "-c", "lsmod | grep ^xsk_diag"}, 0);
4420 }
4421
4422 WSLC_TEST_METHOD(CreateRootNamespaceProcess)
4423 {
4424 // Reject invalid process flags.
4425 {
4426 WSLCProcessOptions options{};
4427 options.Flags = static_cast<WSLCProcessFlags>(0x4);
4428 wil::com_ptr<IWSLCProcess> process;
4429 int err = 0;
4430 VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->CreateRootNamespaceProcess("/bin/true", &options, 0, 0, FALSE, &process, &err));
4431 }
4432
4433 // Simple case
4434 {
4435 auto result = ExpectCommandResult(m_defaultSession.get(), {"/bin/sh", "-c", "echo OK"}, 0);
4436 VERIFY_ARE_EQUAL(result.Output[1], "OK\n");
4437 VERIFY_ARE_EQUAL(result.Output[2], "");
4438 }
4439
4440 // Stdout + stderr
4441 {
4442
4443 auto result = ExpectCommandResult(m_defaultSession.get(), {"/bin/sh", "-c", "echo stdout && (echo stderr 1>& 2)"}, 0);
4444 VERIFY_ARE_EQUAL(result.Output[1], "stdout\n");
4445 VERIFY_ARE_EQUAL(result.Output[2], "stderr\n");
4446 }
4447
4448 // Write a large stdin buffer and expect it back on stdout.
4449 {
4450 std::vector<char> largeBuffer;
4451 std::string pattern = "ExpectedBufferContent";
4452
4453 for (size_t i = 0; i < 1024 * 1024; i++)
4454 {
4455 largeBuffer.insert(largeBuffer.end(), pattern.begin(), pattern.end());
4456 }
4457
4458 WSLCProcessLauncher launcher("/bin/sh", {"/bin/sh", "-c", "cat && (echo completed 1>& 2)"}, {}, WSLCProcessFlagsStdin);
4459
4460 auto process = launcher.Launch(*m_defaultSession);
4461
4462 std::unique_ptr<OverlappedIOHandle> writeStdin(new WriteHandle(process.GetStdHandle(0), largeBuffer));
4463 std::vector<std::unique_ptr<OverlappedIOHandle>> extraHandles;
4464 extraHandles.emplace_back(std::move(writeStdin));
4465
4466 auto result = process.WaitAndCaptureOutput(INFINITE, std::move(extraHandles));
4467
4468 VERIFY_IS_TRUE(std::equal(largeBuffer.begin(), largeBuffer.end(), result.Output[1].begin(), result.Output[1].end()));
4469 VERIFY_ARE_EQUAL(result.Output[2], "completed\n");
4470
4471 // Validate that a null out handle is rejected.
4472
4473 VERIFY_ARE_EQUAL(process.Get().GetStdHandle(WSLCFDStdout, nullptr), HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER));
4474
4475 // Validate that every IWSLCProcess output pointer is rejected when null.
4476 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), process.Get().GetExitEvent(nullptr));
4477 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), process.Get().GetPid(nullptr));
4478 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), process.Get().GetState(nullptr, nullptr));
4479 VERIFY_ARE_EQUAL(HRESULT_FROM_WIN32(RPC_X_NULL_REF_POINTER), process.Get().GetFlags(nullptr));
4480
4481 // GetFlags succeeds with a valid pointer and reports the launched flags.
4482 WSLCProcessFlags flags{};
4483 VERIFY_SUCCEEDED(process.Get().GetFlags(&flags));
4484 VERIFY_IS_TRUE(WI_IsFlagSet(flags, WSLCProcessFlagsStdin));
4485 }
4486
4487 // Create a stuck process and kill it.
4488 {
4489 WSLCProcessLauncher launcher("/bin/cat", {"/bin/cat"}, {}, WSLCProcessFlagsStdin);
4490
4491 auto process = launcher.Launch(*m_defaultSession);
4492
4493 // Try to send invalid signal to the process
4494 VERIFY_ARE_EQUAL(process.Get().Signal(9999), E_FAIL);
4495
4496 // Send SIGKILL(9) to the process.
4497 VERIFY_SUCCEEDED(process.Get().Signal(WSLCSignalSIGKILL));
4498
4499 auto result = process.WaitAndCaptureOutput();
4500 VERIFY_ARE_EQUAL(result.Code, WSLCSignalSIGKILL + 128);
4501 VERIFY_ARE_EQUAL(result.Output[1], "");
4502 VERIFY_ARE_EQUAL(result.Output[2], "");
4503
4504 // Validate that process can't be signalled after it exited.
4505 VERIFY_ARE_EQUAL(process.Get().Signal(WSLCSignalSIGKILL), HRESULT_FROM_WIN32(ERROR_INVALID_STATE));
4506 }
4507
4508 // Validate that errno is correctly propagated
4509 {
4510 WSLCProcessLauncher launcher("doesnotexist", {});
4511
4512 auto [hresult, process, error] = launcher.LaunchNoThrow(*m_defaultSession);
4513 VERIFY_ARE_EQUAL(hresult, E_FAIL);
4514 VERIFY_ARE_EQUAL(error, 2); // ENOENT
4515 VERIFY_IS_FALSE(process.has_value());
4516 }
4517
4518 {
4519 WSLCProcessLauncher launcher("/", {});
4520
4521 auto [hresult, process, error] = launcher.LaunchNoThrow(*m_defaultSession);
4522 VERIFY_ARE_EQUAL(hresult, E_FAIL);
4523 VERIFY_ARE_EQUAL(error, 13); // EACCESS
4524 VERIFY_IS_FALSE(process.has_value());
4525 }
4526
4527 {
4528 WSLCProcessLauncher launcher("/bin/cat", {"/bin/cat"}, {}, WSLCProcessFlagsStdin);
4529
4530 auto process = launcher.Launch(*m_defaultSession);
4531 auto stdoutHandle = process.GetStdHandle(1);
4532
4533 COMOutputHandle dummyHandle;
4534 // Verify that the same handle can only be acquired once.
4535 VERIFY_ARE_EQUAL(process.Get().GetStdHandle(WSLCFDStdout, &dummyHandle), HRESULT_FROM_WIN32(ERROR_INVALID_STATE));
4536
4537 // Verify that trying to acquire a std handle that doesn't exist fails as expected.
4538 VERIFY_ARE_EQUAL(process.Get().GetStdHandle(static_cast<WSLCFD>(3), &dummyHandle), E_INVALIDARG);
4539
4540 // Validate that the process object correctly handle requests after the VM has terminated.
4541 ResetTestSession();
4542 VERIFY_ARE_EQUAL(process.Get().Signal(WSLCSignalSIGKILL), HRESULT_FROM_WIN32(RPC_S_SERVER_UNAVAILABLE));
4543 }
4544
4545 // Validate that empty arguments are correctly handled.
4546 {
4547 WSLCProcessLauncher launcher({"/usr/bin/echo"}, {"/usr/bin/echo", "foo", "", "bar"});
4548
4549 auto process = launcher.Launch(*m_defaultSession);
4550 ValidateProcessOutput(process, {{1, "foo bar\n"}}); // expect two spaces for the empty argument.
4551 }
4552
4553 // Validate error paths
4554 {
4555 WSLCProcessLauncher launcher("/bin/bash", {"/bin/bash"});
4556 launcher.SetUser("nobody"); // Custom users are not supported for root namespace processes.
4557
4558 auto [hresult, error, process] = launcher.LaunchNoThrow(*m_defaultSession);
4559 VERIFY_ARE_EQUAL(hresult, HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED));
4560 }
4561 }
4562
4563 WSLC_TEST_METHOD(CrashDumpCollection)
4564 {
4565 int processId = 0;
4566
4567 // Cache the existing crash dumps so we can check that a new one is created.
4568 auto crashDumpsDir = std::filesystem::temp_directory_path() / "wslc-crashes";
4569 std::set<std::filesystem::path> existingDumps;
4570
4571 if (std::filesystem::exists(crashDumpsDir))
4572 {
4573 existingDumps = {std::filesystem::directory_iterator(crashDumpsDir), std::filesystem::directory_iterator{}};
4574 }
4575
4576 // Create a stuck process and crash it.
4577 {
4578 WSLCProcessLauncher launcher("/bin/cat", {"/bin/cat"}, {}, WSLCProcessFlagsStdin);
4579
4580 auto process = launcher.Launch(*m_defaultSession);
4581
4582 // Get the process id. This is need to identify the crash dump file.
4583 VERIFY_SUCCEEDED(process.Get().GetPid(&processId));
4584
4585 // Send SIGSEV(11) to crash the process.
4586 VERIFY_SUCCEEDED(process.Get().Signal(WSLCSignalSIGSEGV));
4587
4588 auto result = process.WaitAndCaptureOutput();
4589 VERIFY_ARE_EQUAL(result.Code, 128 + WSLCSignalSIGSEGV);
4590 VERIFY_ARE_EQUAL(result.Output[1], "");
4591 VERIFY_ARE_EQUAL(result.Output[2], "");
4592
4593 VERIFY_ARE_EQUAL(process.Get().Signal(WSLCSignalSIGKILL), HRESULT_FROM_WIN32(ERROR_INVALID_STATE));
4594 }
4595
4596 // Dumps files are named with the format: wsl-crash-<sessionId>-<pid>-<processname>-<code>.dmp
4597 // Check if a new file was added in crashDumpsDir matching the pattern and not in existingDumps.
4598 std::string expectedPattern = std::format("wsl-crash-*-{}-_usr_bin_cat-11.dmp", processId);
4599
4600 auto dumpFile = wsl::shared::retry::RetryWithTimeout<std::filesystem::path>(
4601 [crashDumpsDir, expectedPattern, existingDumps]() {
4602 for (const auto& entry : std::filesystem::directory_iterator(crashDumpsDir))
4603 {
4604 const auto& filePath = entry.path();
4605 if (existingDumps.find(filePath) == existingDumps.end() &&
4606 PathMatchSpecA(filePath.filename().string().c_str(), expectedPattern.c_str()))
4607 {
4608 return filePath;
4609 }
4610 }
4611
4612 throw wil::ResultException(HRESULT_FROM_WIN32(ERROR_NOT_FOUND));
4613 },
4614 std::chrono::milliseconds{100},
4615 std::chrono::seconds{10});
4616
4617 // Ensure that the dump file is cleaned up after test completion.
4618 auto cleanup = wil::scope_exit([&] {
4619 if (std::filesystem::exists(dumpFile))
4620 {
4621 std::filesystem::remove(dumpFile);
4622 }
4623 });
4624
4625 VERIFY_IS_TRUE(std::filesystem::exists(dumpFile));
4626 VERIFY_IS_TRUE(std::filesystem::file_size(dumpFile) > 0);
4627 }
4628
4629 WSLC_TEST_METHOD(VhdFormatting)
4630 {
4631 constexpr auto formatedVhd = L"test-format-vhd.vhdx";
4632
4633 // TODO: Replace this by a proper SDK method once it exists
4634 auto tokenInfo = wil::get_token_information<TOKEN_USER>();
4635 wsl::core::filesystem::CreateVhd(formatedVhd, 100 * 1024 * 1024, tokenInfo->User.Sid, false, false);
4636
4637 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(formatedVhd)); });
4638
4639 // Format the disk.
4640 auto absoluteVhdPath = std::filesystem::absolute(formatedVhd).wstring();
4641 VERIFY_SUCCEEDED(m_defaultSession->FormatVirtualDisk(absoluteVhdPath.c_str()));
4642
4643 // Validate error paths.
4644 VERIFY_ARE_EQUAL(m_defaultSession->FormatVirtualDisk(L"DoesNotExist.vhdx"), E_INVALIDARG);
4645 VERIFY_ARE_EQUAL(m_defaultSession->FormatVirtualDisk(L"C:\\DoesNotExist.vhdx"), HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND));
4646 }
4647
4648 // Exercises behavior that all volume drivers must implement identically:
4649 // create, duplicate-name rejection, multi-mount, cross-container read/write,
4650 // in-use deletion rejection, and clean deletion after the referencing container is removed.
4651 void ValidateNamedVolumeContract(std::string_view driver, const WSLCDriverOption* driverOpts, ULONG driverOptsCount)
4652 {
4653 const std::string driverStr(driver);
4654 const std::string volumeName = std::format("wslc-test-named-volume-{}", driver);
4655
4656 // Best-effort cleanup in case of leftovers from a previous failed run.
4657 LOG_IF_FAILED(m_defaultSession->DeleteVolume(volumeName.c_str()));
4658
4659 auto cleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteVolume(volumeName.c_str())); });
4660
4661 WSLCVolumeOptions volumeOptions{};
4662 volumeOptions.Name = volumeName.c_str();
4663 volumeOptions.Driver = driverStr.c_str();
4664 volumeOptions.DriverOpts = driverOpts;
4665 volumeOptions.DriverOptsCount = driverOptsCount;
4666
4667 // Create volume and validate duplicate volume name handling.
4668 WSLCVolumeInformation volInfo{};
4669 VERIFY_SUCCEEDED(m_defaultSession->CreateVolume(&volumeOptions, &volInfo));
4670 VERIFY_ARE_EQUAL(std::string(volInfo.Name), volumeName);
4671 VERIFY_ARE_EQUAL(std::string(volInfo.Driver), driverStr);
4672 VERIFY_ARE_EQUAL(m_defaultSession->CreateVolume(&volumeOptions, &volInfo), HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS));
4673
4674 // Verify the same named volume can be mounted more than once with different container paths.
4675 {
4676 WSLCContainerLauncher duplicateNamedVolumes(
4677 "debian:latest",
4678 std::format("named-volume-dup-{}", driver),
4679 {"/bin/sh", "-c", "echo duplicated >/data-a/dup.txt ; cat /data-b/dup.txt"});
4680 duplicateNamedVolumes.AddNamedVolume(volumeName, "/data-a", false);
4681 duplicateNamedVolumes.AddNamedVolume(volumeName, "/data-b", true);
4682
4683 auto duplicateNamedVolumesContainer = duplicateNamedVolumes.Launch(*m_defaultSession);
4684 auto duplicateNamedVolumesProcess = duplicateNamedVolumesContainer.GetInitProcess();
4685 ValidateProcessOutput(duplicateNamedVolumesProcess, {{1, "duplicated\n"}});
4686 }
4687
4688 // Verify CreateContainer with named volume mounts the volume into the container.
4689 {
4690 WSLCContainerLauncher writer(
4691 "debian:latest",
4692 std::format("named-volume-writer-{}", driver),
4693 {"/bin/sh", "-c", "echo wslc-named-volume >/data/marker.txt"});
4694 writer.AddNamedVolume(volumeName, "/data", false);
4695
4696 auto writerContainer = writer.Launch(*m_defaultSession);
4697 auto writerProcess = writerContainer.GetInitProcess();
4698 ValidateProcessOutput(writerProcess, {});
4699
4700 WSLCContainerLauncher reader(
4701 "debian:latest", std::format("named-volume-reader-{}", driver), {"/bin/sh", "-c", "cat /data/marker.txt"});
4702 reader.AddNamedVolume(volumeName, "/data", true);
4703
4704 auto readerContainer = reader.Launch(*m_defaultSession);
4705 auto readerProcess = readerContainer.GetInitProcess();
4706 ValidateProcessOutput(readerProcess, {{1, "wslc-named-volume\n"}});
4707 }
4708
4709 // Verify we cannot delete a named volume while a container references it.
4710 WSLCContainerLauncher holder("debian:latest", std::format("named-volume-holder-{}", driver), {"sleep", "99999"});
4711 holder.AddNamedVolume(volumeName, "/data", false);
4712
4713 auto [holderCreateResult, holderContainerResult] = holder.CreateNoThrow(*m_defaultSession);
4714 VERIFY_SUCCEEDED(holderCreateResult);
4715 VERIFY_IS_TRUE(holderContainerResult.has_value());
4716
4717 auto holderContainer = std::move(holderContainerResult.value());
4718 holderContainer.SetDeleteOnClose(false);
4719
4720 VERIFY_ARE_EQUAL(m_defaultSession->DeleteVolume(volumeName.c_str()), HRESULT_FROM_WIN32(ERROR_SHARING_VIOLATION));
4721
4722 // Verify that after deleting the container, the volume can be deleted.
4723 VERIFY_SUCCEEDED(holderContainer.Get().Delete(WSLCDeleteFlagsNone));
4724 VERIFY_SUCCEEDED(m_defaultSession->DeleteVolume(volumeName.c_str()));
4725
4726 cleanup.release();
4727 }
4728
4729 WSLC_TEST_METHOD(NamedVolumesVhd)
4730 {
4731 WSLCDriverOption driverOpts[] = {{"SizeBytes", "1073741824"}};
4732 ValidateNamedVolumeContract("vhd", driverOpts, ARRAYSIZE(driverOpts));
4733
4734 // VHD-driver-specific: validate the host-side .vhdx artifact and the
4735 // /mnt/wslc-volumes ext4 mount inside the VM appear and disappear with
4736 // the volume.
4737 const std::string volumeName = "wslc-test-named-volume-vhd-host";
4738 const std::filesystem::path volumeVhdPath = m_storagePath / "volumes" / (volumeName + ".vhdx");
4739
4740 WSLCVolumeOptions volumeOptions{};
4741 volumeOptions.Name = volumeName.c_str();
4742 volumeOptions.Driver = "vhd";
4743 volumeOptions.DriverOpts = driverOpts;
4744 volumeOptions.DriverOptsCount = ARRAYSIZE(driverOpts);
4745
4746 WSLCVolumeInformation volInfo{};
4747 VERIFY_SUCCEEDED(m_defaultSession->CreateVolume(&volumeOptions, &volInfo));
4748 auto cleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteVolume(volumeName.c_str())); });
4749
4750 VERIFY_IS_TRUE(std::filesystem::exists(volumeVhdPath));
4751 ExpectMount(m_defaultSession.get(), std::format("/mnt/wslc-volumes/{}", volumeName), std::optional<std::string>{"*ext4*"});
4752
4753 VERIFY_SUCCEEDED(m_defaultSession->DeleteVolume(volumeName.c_str()));
4754 cleanup.release();
4755
4756 ExpectMount(m_defaultSession.get(), std::format("/mnt/wslc-volumes/{}", volumeName), std::nullopt);
4757 VERIFY_IS_FALSE(std::filesystem::exists(volumeVhdPath));
4758 }
4759
4760 WSLC_TEST_METHOD(NamedVolumesVhdSeedsImageData)
4761 {
4762 // A freshly formatted VHD volume must be seeded with the image's content
4763 // on first use, just like a guest volume. mkfs.ext4 creates a lost+found
4764 // directory at the volume root; if it isn't removed, Docker treats the
4765 // volume as non-empty and skips the copy-up that seeds image data.
4766 // Mounting the empty volume over a directory the image is guaranteed to
4767 // populate (/etc) exercises that copy-up.
4768 WSLCDriverOption driverOpts[] = {{"SizeBytes", "1073741824"}};
4769 const std::string volumeName = "wslc-test-named-volume-vhd-seed";
4770
4771 LOG_IF_FAILED(m_defaultSession->DeleteVolume(volumeName.c_str()));
4772
4773 WSLCVolumeOptions volumeOptions{};
4774 volumeOptions.Name = volumeName.c_str();
4775 volumeOptions.Driver = "vhd";
4776 volumeOptions.DriverOpts = driverOpts;
4777 volumeOptions.DriverOptsCount = ARRAYSIZE(driverOpts);
4778
4779 WSLCVolumeInformation volInfo{};
4780 VERIFY_SUCCEEDED(m_defaultSession->CreateVolume(&volumeOptions, &volInfo));
4781 auto cleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteVolume(volumeName.c_str())); });
4782
4783 WSLCContainerLauncher launcher("debian:latest", "wslc-vhd-seed-container", {"/bin/sh", "-c", "ls -A /etc"});
4784 launcher.AddNamedVolume(volumeName, "/etc", false);
4785
4786 auto container = launcher.Launch(*m_defaultSession);
4787 auto result = container.GetInitProcess().WaitAndCaptureOutput();
4788
4789 VERIFY_ARE_EQUAL(0, result.Code);
4790
4791 // Image content was seeded into the volume...
4792 VERIFY_IS_TRUE(
4793 result.Output[1].find("passwd") != std::string::npos,
4794 L"Image's /etc content should be seeded into the fresh VHD volume");
4795
4796 // ...and the ext4 lost+found is gone, so it never blocked copy-up.
4797 VERIFY_IS_TRUE(
4798 result.Output[1].find("lost+found") == std::string::npos, L"lost+found should have been removed from the volume root");
4799 }
4800
4801 WSLC_TEST_METHOD(NamedVolumesGuest)
4802 {
4803 ValidateNamedVolumeContract("guest", nullptr, 0);
4804 }
4805
4806 WSLC_TEST_METHOD(NamedVolumesStress)
4807 {
4808 constexpr unsigned int c_threadCount = 8;
4809 constexpr unsigned int c_iterationsPerThread = 50;
4810 const std::string volumeName = "wslc-stress-vol";
4811
4812 // Best-effort cleanup of any leftover volume from prior runs / on test exit.
4813 auto cleanup = wil::scope_exit([&]() { LOG_IF_FAILED(m_defaultSession->DeleteVolume(volumeName.c_str())); });
4814
4815 std::atomic<unsigned int> failures = 0;
4816 std::vector<std::thread> threads;
4817 threads.reserve(c_threadCount);
4818
4819 for (unsigned int t = 0; t < c_threadCount; ++t)
4820 {
4821 threads.emplace_back([&]() {
4822 for (unsigned int i = 0; i < c_iterationsPerThread; ++i)
4823 {
4824 WSLCVolumeOptions volumeOptions{};
4825 volumeOptions.Name = volumeName.c_str();
4826 volumeOptions.Driver = "guest";
4827
4828 WSLCVolumeInformation volInfo{};
4829 HRESULT hrCreate = m_defaultSession->CreateVolume(&volumeOptions, &volInfo);
4830 if (FAILED(hrCreate) && hrCreate != HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS))
4831 {
4832 LogError("CreateVolume(%hs) unexpected HR: 0x%08x", volumeName.c_str(), hrCreate);
4833 ++failures;
4834 }
4835
4836 HRESULT hrDelete = m_defaultSession->DeleteVolume(volumeName.c_str());
4837 if (FAILED(hrDelete) && hrDelete != WSLC_E_VOLUME_NOT_FOUND)
4838 {
4839 LogError("DeleteVolume(%hs) unexpected HR: 0x%08x", volumeName.c_str(), hrDelete);
4840 ++failures;
4841 }
4842 }
4843 });
4844 }
4845
4846 for (auto& thread : threads)
4847 {
4848 thread.join();
4849 }
4850
4851 VERIFY_ARE_EQUAL(failures.load(), 0u);
4852
4853 // Every thread's iteration ends with a Delete, so the globally-last operation across
4854 // all threads is guaranteed to be a Delete. The volume must therefore not exist in
4855 // either our cache or docker -- if either disagrees, our state is desynced from docker.
4856
4857 // Our cache view: InspectVolume must report not-found.
4858 wil::unique_cotaskmem_ansistring inspectOutput;
4859 VERIFY_ARE_EQUAL(m_defaultSession->InspectVolume(volumeName.c_str(), &inspectOutput), WSLC_E_VOLUME_NOT_FOUND);
4860
4861 // Docker's view: `docker volume inspect` must also report not-found (non-zero exit).
4862 ExpectCommandResult(m_defaultSession.get(), {"/usr/bin/docker", "volume", "inspect", volumeName}, 1);
4863 }
4864
4865 // Verifies that a container using a named volume survives a session restart and the volume's data is preserved.
4866 void ValidateNamedVolumeRecoveryContract(std::string_view driver, const WSLCDriverOption* driverOpts, ULONG driverOptsCount)
4867 {
4868 const std::string driverStr(driver);
4869 const std::string volumeName = std::format("wslc-test-named-volume-{}", driver);
4870 const std::string containerName = std::format("wslc-test-container-{}", driver);
4871
4872 // Best-effort cleanup in case prior failed runs left artifacts behind.
4873 RunCommand(m_defaultSession.get(), {"/usr/bin/docker", "rm", "-f", containerName});
4874 LOG_IF_FAILED(m_defaultSession->DeleteVolume(volumeName.c_str()));
4875
4876 auto cleanup = wil::scope_exit([&]() {
4877 RunCommand(m_defaultSession.get(), {"/usr/bin/docker", "rm", "-f", containerName});
4878 LOG_IF_FAILED(m_defaultSession->DeleteVolume(volumeName.c_str()));
4879 });
4880
4881 WSLCVolumeOptions volumeOptions{};
4882 volumeOptions.Name = volumeName.c_str();
4883 volumeOptions.Driver = driverStr.c_str();
4884 volumeOptions.DriverOpts = driverOpts;
4885 volumeOptions.DriverOptsCount = driverOptsCount;
4886
4887 WSLCVolumeInformation volInfo{};
4888 VERIFY_SUCCEEDED(m_defaultSession->CreateVolume(&volumeOptions, &volInfo));
4889
4890 // Create a container that uses the named volume and writes a marker.
4891 {
4892 WSLCContainerLauncher writer(
4893 "debian:latest", containerName, {"/bin/sh", "-c", "echo named-volume-recovery >/data/marker.txt"});
4894 writer.AddNamedVolume(volumeName, "/data", false);
4895
4896 auto writerContainer = writer.Launch(*m_defaultSession);
4897 writerContainer.SetDeleteOnClose(false);
4898
4899 auto writerProcess = writerContainer.GetInitProcess();
4900 ValidateProcessOutput(writerProcess, {});
4901 }
4902
4903 // Restart the session and verify the container is recovered.
4904 ResetTestSession();
4905
4906 auto recoveredContainer = OpenContainer(m_defaultSession.get(), containerName);
4907 recoveredContainer.SetDeleteOnClose(false);
4908
4909 // Verify the named volume still contains the marker after restart.
4910 {
4911 WSLCContainerLauncher reader(
4912 "debian:latest", std::format("{}-reader", containerName), {"/bin/sh", "-c", "cat /data/marker.txt"});
4913 reader.AddNamedVolume(volumeName, "/data", true);
4914
4915 auto readerContainer = reader.Launch(*m_defaultSession);
4916 auto readerProcess = readerContainer.GetInitProcess();
4917 ValidateProcessOutput(readerProcess, {{1, "named-volume-recovery\n"}});
4918 }
4919 }
4920
4921 WSLC_TEST_METHOD(NamedVolumeRecovery)
4922 {
4923 ValidateNamedVolumeRecoveryContract("guest", nullptr, 0);
4924 }
4925
4926 WSLC_TEST_METHOD(NamedVolumesVhdSessionRecovery)
4927 {
4928
4929 WSLCDriverOption driverOpts[] = {{"SizeBytes", "1073741824"}};
4930 ValidateNamedVolumeRecoveryContract("vhd", driverOpts, ARRAYSIZE(driverOpts));
4931
4932 // Re-create the volume (the recovery helper cleans up on exit) so we
4933 // can test the "delete VHD while session is down" scenario.
4934 const std::string volumeName = "wslc-test-named-volume-vhd";
4935 const std::string containerName = "wslc-test-container-vhd";
4936
4937 // Prune containers on exit so this test doesn't leak "wslc-test-container-vhd" on exit.
4938 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
4939 PruneResult result;
4940 LOG_IF_FAILED(m_defaultSession->PruneContainers(nullptr, 0, &result.result));
4941 });
4942
4943 WSLCVolumeOptions volumeOptions{};
4944 volumeOptions.Name = volumeName.c_str();
4945 volumeOptions.Driver = "vhd";
4946 volumeOptions.DriverOpts = driverOpts;
4947 volumeOptions.DriverOptsCount = ARRAYSIZE(driverOpts);
4948
4949 WSLCVolumeInformation volInfo{};
4950 VERIFY_SUCCEEDED(m_defaultSession->CreateVolume(&volumeOptions, &volInfo));
4951
4952 // Create a container that depends on the volume so we can verify it
4953 // gets dropped when the backing .vhdx is removed.
4954 {
4955 WSLCContainerLauncher writer("debian:latest", containerName, {"/bin/sh", "-c", "echo vhd-recovery >/data/marker.txt"});
4956 writer.AddNamedVolume(volumeName, "/data", false);
4957
4958 auto writerContainer = writer.Launch(*m_defaultSession);
4959 writerContainer.SetDeleteOnClose(false);
4960
4961 auto writerProcess = writerContainer.GetInitProcess();
4962 ValidateProcessOutput(writerProcess, {});
4963 }
4964
4965 const std::filesystem::path volumeVhdPath = m_storagePath / "volumes" / (volumeName + ".vhdx");
4966
4967 {
4968 auto restartSession = ResetTestSession();
4969
4970 VERIFY_IS_TRUE(std::filesystem::exists(volumeVhdPath));
4971
4972 std::error_code error;
4973 VERIFY_IS_TRUE(std::filesystem::remove(volumeVhdPath, error));
4974 VERIFY_ARE_EQUAL(error, std::error_code{});
4975 }
4976
4977 // The container can still be opened even though its backing volume is gone, so the
4978 // user is able to inspect and delete it.
4979 wil::com_ptr<IWSLCContainer> recoveredContainer;
4980 VERIFY_SUCCEEDED(m_defaultSession->OpenContainer(containerName.c_str(), &recoveredContainer));
4981
4982 // Starting it must fail since the referenced volume cannot be brought online.
4983 VERIFY_ARE_EQUAL(recoveredContainer->Start(WSLCContainerStartFlagsNone, nullptr, nullptr), WSLC_E_VOLUME_NOT_AVAILABLE);
4984 ValidateCOMErrorMessageContains(wsl::shared::string::MultiByteToWide(volumeName));
4985
4986 // The container is not running, so the restart is only its start phase and is refused the same way.
4987 VERIFY_ARE_EQUAL(recoveredContainer->Restart(WSLCSignalSIGTERM, 0, nullptr), WSLC_E_VOLUME_NOT_AVAILABLE);
4988 ValidateCOMErrorMessageContains(wsl::shared::string::MultiByteToWide(volumeName));
4989
4990 // Inspecting the volume reports the failure via an "Error" entry in its status.
4991 {
4992 wil::unique_cotaskmem_ansistring inspectOutput;
4993 VERIFY_SUCCEEDED(m_defaultSession->InspectVolume(volumeName.c_str(), &inspectOutput));
4994 auto inspect = wsl::shared::FromJson<wsl::windows::common::wslc_schema::InspectVolume>(inspectOutput.get());
4995 VERIFY_IS_TRUE(inspect.Status.has_value());
4996 VERIFY_IS_TRUE(inspect.Status->contains("Error"));
4997
4998 // The backing .vhdx was deleted, so recovery fails to attach it with ERROR_FILE_NOT_FOUND.
4999 const auto expectedError = wsl::shared::string::WideToMultiByte(GetErrorString(HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND)));
5000 VERIFY_ARE_EQUAL(inspect.Status->at("Error"), expectedError);
Showing first 5,000 of 14,265 lines. View raw