Add support for saving multiple images in a single tar (#40694)

* Add support for saving multiple images in a single tar * Restore the alpine:latest image at the end of DeleteImage * Don't delete the image in ListImages * Prune after SaveImages test * Merge

Blue committed Jun 5, 2026 at 12:38 UTC c5351e0a296313a34ed1a96d399420204c30a0c8
12 files changed +279 -21
localization/strings/en-US/Resources.resw
+3
@@ -2365,6 +2365,9 @@ For privacy information about this product please visit https://aka.ms/privacy.<
2365 <value>Invalid image tag format: '{}'. Expected format is 'name:tag'</value>
2366 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
2367 </data>
2368 + <data name="MessageWslcSaveInProgress" xml:space="preserve">
2369 + <value>Save in progress.</value>
2370 + </data>
2371 <data name="WSLCCLI_RootCommandDesc" xml:space="preserve">
2372 <value>WSLC is the Windows Subsystem for Linux Container CLI tool.</value>
2373 <comment>{Locked="WSLC"}Product names should not be translated</comment>
src/windows/service/inc/wslc.idl
+3
@@ -27,6 +27,7 @@ cpp_quote("#endif")
27 #define WSLC_MAX_NETWORK_NAME_LENGTH 255
28 #define WSLC_CONTAINER_ID_LENGTH 64
29 #define WSLC_MAX_BINDING_ADDRESS_LENGTH 45
30 +#define WSLC_MAX_SAVE_IMAGES_COUNT 256
31
32 cpp_quote("#define WSLC_MAX_CONTAINER_NAME_LENGTH 255")
33 cpp_quote("#define WSLC_MAX_IMAGE_NAME_LENGTH 255")
@@ -35,6 +36,7 @@ cpp_quote("#define WSLC_MAX_VOLUME_DRIVER_LENGTH 255")
36 cpp_quote("#define WSLC_MAX_NETWORK_NAME_LENGTH 255")
37 cpp_quote("#define WSLC_CONTAINER_ID_LENGTH 64")
38 cpp_quote("#define WSLC_MAX_BINDING_ADDRESS_LENGTH 45")
39 +cpp_quote("#define WSLC_MAX_SAVE_IMAGES_COUNT 256")
40 cpp_quote("#define WSLC_EPHEMERAL_PORT 0")
41
42 typedef
@@ -739,6 +741,7 @@ interface IWSLCSession : IUnknown
741 HRESULT LoadImage([in] WSLCHandle ImageHandle, [in, unique] IProgressCallback* ProgressCallback, [in] ULONGLONG ContentLength, [in, unique] IWarningCallback* WarningCallback);
742 HRESULT ImportImage([in] WSLCHandle ImageHandle, [in] LPCSTR ImageName, [in, unique] IProgressCallback* ProgressCallback, [in] ULONGLONG ContentLength, [in, unique] IWarningCallback* WarningCallback);
743 HRESULT SaveImage([in] WSLCHandle OutputHandle, [in] LPCSTR ImageNameOrID, [in, unique] IProgressCallback * ProgressCallback, [in, unique, system_handle(sh_event)] HANDLE CancelEvent);
744 + HRESULT SaveImages([in] WSLCHandle OutputHandle, [in] const WSLCStringArray* ImageNames, [in, unique] IProgressCallback * ProgressCallback, [in, unique, system_handle(sh_event)] HANDLE CancelEvent);
745 HRESULT ListImages([in, unique] const WSLCListImagesOptions* Options, [out, size_is(, *Count)] WSLCImageInformation** Images, [out] ULONG* Count);
746 HRESULT DeleteImage([in] const WSLCDeleteImageOptions* Options, [out, size_is(, *Count)] WSLCDeletedImageInformation** DeletedImages, [out] ULONG* Count);
747 HRESULT TagImage([in] const WSLCTagImageOptions* Options);
src/windows/wslc/commands/ImageSaveCommand.cpp
+1 -1
@@ -27,7 +27,7 @@ namespace wsl::windows::wslc {
27 std::vector<Argument> ImageSaveCommand::GetArguments() const
28 {
29 return {
30 - Argument::Create(ArgType::ImageId, true),
30 + Argument::Create(ArgType::ImageId, true, NO_LIMIT),
31 Argument::Create(ArgType::Output),
32 Argument::Create(ArgType::Session),
33 };
src/windows/wslc/services/ImageService.cpp
+27 -5
@@ -303,20 +303,42 @@ void ImageService::Push(wsl::windows::wslc::models::Session& session, const std:
303 THROW_IF_FAILED(session.Get()->PushImage(image.c_str(), auth.c_str(), callback, warningCallback.Get()));
304 }
305
306 -void ImageService::Save(wsl::windows::wslc::models::Session& session, const std::string& image, const std::wstring& output, HANDLE cancelEvent)
306 +void ImageService::Save(wsl::windows::wslc::models::Session& session, const std::vector<std::string>& images, const std::wstring& output, HANDLE cancelEvent)
307 {
308 wil::unique_hfile outputFile{
309 CreateFileW(output.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
310 THROW_LAST_ERROR_IF(!outputFile);
311
312 - Save(session, image, outputFile.get(), cancelEvent);
312 + Save(session, images, outputFile.get(), cancelEvent);
313 }
314
315 -void ImageService::Save(wsl::windows::wslc::models::Session& session, const std::string& image, HANDLE outputHandle, HANDLE cancelEvent)
315 +void ImageService::Save(wsl::windows::wslc::models::Session& session, const std::vector<std::string>& images, HANDLE outputHandle, HANDLE cancelEvent)
316 {
317 + WI_ASSERT(!images.empty());
318 +
319 wsl::windows::common::HandleConsoleProgressBar progressBar(
318 - outputHandle, L"Save in progress.", wsl::windows::common::HandleConsoleProgressBar::Format::FileSize);
319 - THROW_IF_FAILED(session.Get()->SaveImage(ToCOMInputHandle(outputHandle), image.c_str(), nullptr, cancelEvent));
320 + outputHandle, Localization::MessageWslcSaveInProgress(), wsl::windows::common::HandleConsoleProgressBar::Format::FileSize);
321 +
322 + if (images.size() == 1)
323 + {
324 + THROW_IF_FAILED(session.Get()->SaveImage(ToCOMInputHandle(outputHandle), images[0].c_str(), nullptr, cancelEvent));
325 + }
326 + else
327 + {
328 + std::vector<LPCSTR> imagePointers;
329 + imagePointers.reserve(images.size());
330 + for (const auto& image : images)
331 + {
332 + imagePointers.push_back(image.c_str());
333 + }
334 +
335 + WSLCStringArray imageArray{
336 + .Values = imagePointers.data(),
337 + .Count = static_cast<ULONG>(imagePointers.size()),
338 + };
339 +
340 + THROW_IF_FAILED(session.Get()->SaveImages(ToCOMInputHandle(outputHandle), &imageArray, nullptr, cancelEvent));
341 + }
342 }
343
344 wsl::windows::wslc::models::PruneImagesResult ImageService::Prune(
src/windows/wslc/services/ImageService.h
+2 -2
@@ -40,8 +40,8 @@ public:
40 static wsl::windows::common::wslc_schema::InspectImage Inspect(wsl::windows::wslc::models::Session& session, const std::string& image);
41 static void Pull(wsl::windows::wslc::models::Session& session, const std::string& image, IProgressCallback* callback);
42 static void Push(wsl::windows::wslc::models::Session& session, const std::string& image, IProgressCallback* callback);
43 - static void Save(wsl::windows::wslc::models::Session& session, const std::string& image, const std::wstring& output, HANDLE cancelEvent = nullptr);
44 - static void Save(wsl::windows::wslc::models::Session& session, const std::string& image, HANDLE outputHandle, HANDLE cancelEvent = nullptr);
43 + static void Save(wsl::windows::wslc::models::Session& session, const std::vector<std::string>& images, const std::wstring& output, HANDLE cancelEvent = nullptr);
44 + static void Save(wsl::windows::wslc::models::Session& session, const std::vector<std::string>& images, HANDLE outputHandle, HANDLE cancelEvent = nullptr);
45 static void Tag(wsl::windows::wslc::models::Session& session, const std::string& sourceImage, const std::string& targetImage);
46 static wsl::windows::wslc::models::PruneImagesResult Prune(
47 wsl::windows::wslc::models::Session& session, bool all, const std::vector<std::pair<std::string, std::string>>& filters = {});
src/windows/wslc/tasks/ImageTasks.cpp
+10 -3
@@ -272,12 +272,19 @@ void SaveImage(CLIExecutionContext& context)
272 WI_ASSERT(context.Data.Contains(Data::Session));
273 WI_ASSERT(context.Args.Contains(ArgType::ImageId));
274 auto& session = context.Data.Get<Data::Session>();
275 - auto& imageId = context.Args.Get<ArgType::ImageId>();
275 + auto imageIds = context.Args.GetAll<ArgType::ImageId>();
276 +
277 + std::vector<std::string> images;
278 + images.reserve(imageIds.size());
279 + for (const auto& id : imageIds)
280 + {
281 + images.push_back(WideToMultiByte(id));
282 + }
283
284 if (context.Args.Contains(ArgType::Output))
285 {
286 auto& output = context.Args.Get<ArgType::Output>();
280 - services::ImageService::Save(session, WideToMultiByte(imageId), output, context.CreateCancelEvent());
287 + services::ImageService::Save(session, images, output, context.CreateCancelEvent());
288 }
289 else
290 {
@@ -287,7 +294,7 @@ void SaveImage(CLIExecutionContext& context)
294 THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::WSLCCLI_ImageSaveStdoutIsTerminalError());
295 }
296
290 - services::ImageService::Save(session, WideToMultiByte(imageId), stdoutHandle, context.CreateCancelEvent());
297 + services::ImageService::Save(session, images, stdoutHandle, context.CreateCancelEvent());
298 }
299 }
300
src/windows/wslcsession/DockerHTTPClient.cpp
+15
@@ -227,6 +227,21 @@ std::pair<uint32_t, wil::unique_socket> DockerHTTPClient::SaveImage(const std::s
227 return {response.result_int(), std::move(socket)};
228 }
229
230 +std::pair<uint32_t, wil::unique_socket> DockerHTTPClient::SaveImages(const std::vector<std::string>& NamesOrIds)
231 +{
232 + auto url = URL::Create("/images/get");
233 + for (const auto& name : NamesOrIds)
234 + {
235 + // 'names' is a repeated query parameter.
236 + // See: https://docs.docker.com/reference/api/engine/version/v1.52/#tag/Image/operation/ImageGet
237 + url.SetParameter("names", name);
238 + }
239 +
240 + auto [response, socket] = SendRequest(verb::get, url, {}, {});
241 +
242 + return {response.result_int(), std::move(socket)};
243 +}
244 +
245 docker_schema::PruneImageResult DockerHTTPClient::PruneImages(const std::map<std::string, std::vector<std::string>>& filters)
246 {
247 auto url = URL::Create("/images/prune");
src/windows/wslcsession/DockerHTTPClient.h
+2 -1
@@ -164,6 +164,7 @@ public:
164 common::docker_schema::InspectImage InspectImage(const std::string& NameOrId);
165 std::vector<common::docker_schema::DeletedImage> DeleteImage(const char* Image, bool Force, bool NoPrune); // Image can be ID or Repo:Tag.
166 std::pair<uint32_t, wil::unique_socket> SaveImage(const std::string& NameOrId);
167 + std::pair<uint32_t, wil::unique_socket> SaveImages(const std::vector<std::string>& NamesOrIds);
168 common::docker_schema::PruneImageResult PruneImages(const std::map<std::string, std::vector<std::string>>& filters = {});
169
170 // Exec.
@@ -224,7 +225,7 @@ private:
225 static std::string Escape(const std::string& Value);
226
227 std::string m_path;
227 - std::map<std::string, std::string> m_parameters;
228 + std::multimap<std::string, std::string> m_parameters;
229 };
230
231 wil::unique_socket ConnectSocket();
src/windows/wslcsession/WSLCSession.cpp
+33
@@ -1200,6 +1200,39 @@ try
1200 }
1201 CATCH_RETURN();
1202
1203 +HRESULT WSLCSession::SaveImages(WSLCHandle OutHandle, const WSLCStringArray* ImageNames, IProgressCallback* ProgressCallback, HANDLE CancelEvent)
1204 +try
1205 +{
1206 + UNREFERENCED_PARAMETER(ProgressCallback);
1207 +
1208 + COMServiceExecutionContext context;
1209 +
1210 + RETURN_HR_IF_NULL(E_POINTER, ImageNames);
1211 + RETURN_HR_IF(E_INVALIDARG, ImageNames->Count == 0);
1212 + RETURN_HR_IF(E_INVALIDARG, ImageNames->Count > WSLC_MAX_SAVE_IMAGES_COUNT);
1213 + RETURN_HR_IF_NULL(E_INVALIDARG, ImageNames->Values);
1214 +
1215 + std::vector<std::string> names;
1216 + names.reserve(ImageNames->Count);
1217 + for (ULONG i = 0; i < ImageNames->Count; i += 1)
1218 + {
1219 + RETURN_HR_IF_NULL(E_INVALIDARG, ImageNames->Values[i]);
1220 + const size_t length = strlen(ImageNames->Values[i]);
1221 + RETURN_HR_IF(E_INVALIDARG, length == 0);
1222 + RETURN_HR_IF(E_INVALIDARG, length > WSLC_MAX_IMAGE_NAME_LENGTH);
1223 + names.emplace_back(ImageNames->Values[i]);
1224 + }
1225 +
1226 + auto lock = m_lock.lock_shared();
1227 +
1228 + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_dockerClient.has_value());
1229 +
1230 + auto retVal = m_dockerClient->SaveImages(names);
1231 + SaveImageImpl(retVal, OutHandle, CancelEvent);
1232 + return S_OK;
1233 +}
1234 +CATCH_RETURN();
1235 +
1236 void WSLCSession::SaveImageImpl(std::pair<uint32_t, wil::unique_socket>& SocketCodePair, WSLCHandle OutputHandle, HANDLE CancelEvent)
1237 {
1238 auto userHandle = OpenUserHandle(OutputHandle);
src/windows/wslcsession/WSLCSession.h
+1
@@ -109,6 +109,7 @@ public:
109 _In_ ULONGLONG ContentLength,
110 _In_opt_ IWarningCallback* WarningCallback) override;
111 IFACEMETHOD(SaveImage)(_In_ WSLCHandle OutputHandle, _In_ LPCSTR ImageNameOrID, _In_ IProgressCallback* ProgressCallback, _In_opt_ HANDLE CancelEvent) override;
112 + IFACEMETHOD(SaveImages)(_In_ WSLCHandle OutputHandle, _In_ const WSLCStringArray* ImageNames, _In_ IProgressCallback* ProgressCallback, _In_opt_ HANDLE CancelEvent) override;
113 IFACEMETHOD(ListImages)(_In_opt_ const WSLCListImagesOptions* Options, _Out_ WSLCImageInformation** Images, _Out_ ULONG* Count) override;
114 IFACEMETHOD(DeleteImage)(_In_ const WSLCDeleteImageOptions* Options, _Out_ WSLCDeletedImageInformation** DeletedImages, _Out_ ULONG* Count) override;
115 IFACEMETHOD(TagImage)(_In_ const WSLCTagImageOptions* Options) override;
test/windows/WSLCTests.cpp
+139 -8
@@ -1022,17 +1022,13 @@ class WSLCTests
1022 LogInfo("Test: Dangling filter");
1023 {
1024 // Setup a dangling image
1025 - LoadTestImage(*m_defaultSession, "alpine:latest");
1025 WSLCTagImageOptions tagOptions{};
1026 tagOptions.Image = "debian:latest";
1027 tagOptions.Repo = "alpine";
1028 tagOptions.Tag = "latest";
1029 VERIFY_SUCCEEDED(m_defaultSession->TagImage(&tagOptions));
1030
1032 - auto alpineCleanup = wil::scope_exit([&]() {
1033 - RunCommand(m_defaultSession.get(), {"/usr/bin/docker", "image", "prune", "-f"});
1034 - LOG_IF_FAILED(DeleteImageNoThrow("alpine:latest", WSLCDeleteImageFlagsNone).first);
1035 - });
1031 + auto restore = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LoadTestImage(*m_defaultSession, "alpine:latest"); });
1032
1033 // List only dangling images
1034 WSLCFilter danglingTrueFilter{.Key = "dangling", .Value = "true"};
@@ -1305,12 +1301,11 @@ class WSLCTests
1301
1302 WSLC_TEST_METHOD(DeleteImage)
1303 {
1308 - // Prepare alpine image to delete.
1309 - LoadTestImage(*m_defaultSession, "alpine:latest");
1310 -
1304 // Verify that the image is in the list of images.
1305 ExpectImagePresent(*m_defaultSession, "alpine:latest");
1306
1307 + auto restore = wil::scope_exit([&]() { LoadTestImage(*m_defaultSession, "alpine:latest"); });
1308 +
1309 // Launch a container to ensure that image deletion fails when in use.
1310 WSLCContainerLauncher launcher("alpine:latest", "test-delete-container-in-use", {"sleep", "99999"}, {}, "host");
1311
@@ -2492,6 +2487,142 @@ class WSLCTests
2487 }
2488 }
2489
2490 + WSLC_TEST_METHOD(SaveImages)
2491 + {
2492 + auto BuildStringArray = [](const std::vector<LPCSTR>& values) -> WSLCStringArray {
2493 + return WSLCStringArray{.Values = values.empty() ? nullptr : values.data(), .Count = static_cast<ULONG>(values.size())};
2494 + };
2495 +
2496 + // Save multiple images to a single tar, delete one, then load back and verify.
2497 + {
2498 + std::filesystem::path imageTar = L"MultiImageExport.tar";
2499 + auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
2500 + DeleteFileW(imageTar.c_str());
2501 +
2502 + wil::unique_cotaskmem_array_ptr<WSLCDeletedImageInformation> deletedImages;
2503 + ULONGLONG spaceReclaimed = 0;
2504 +
2505 + LOG_IF_FAILED(m_defaultSession->PruneImages(
2506 + nullptr, 0, deletedImages.addressof(), deletedImages.size_address<ULONG>(), &spaceReclaimed));
2507 + });
2508 +
2509 + {
2510 + wil::unique_handle imageTarFileHandle{CreateFileW(
2511 + imageTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
2512 + VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
2513 +
2514 + std::vector<LPCSTR> names = {"hello-world:latest", "alpine:latest"};
2515 + WSLCStringArray array = BuildStringArray(names);
2516 + VERIFY_SUCCEEDED(m_defaultSession->SaveImages(ToCOMInputHandle(imageTarFileHandle.get()), &array, nullptr, nullptr));
2517 +
2518 + LARGE_INTEGER fileSize{};
2519 + VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
2520 + VERIFY_IS_TRUE(fileSize.QuadPart > 0);
2521 + }
2522 +
2523 + // Delete hello-world:latest and verify it's gone.
2524 + wil::unique_cotaskmem_array_ptr<WSLCDeletedImageInformation> deleted;
2525 + WSLCDeleteImageOptions delOpts{};
2526 + delOpts.Image = "hello-world:latest";
2527 + delOpts.Flags = WSLCDeleteImageFlagsForce;
2528 + VERIFY_SUCCEEDED(m_defaultSession->DeleteImage(&delOpts, &deleted, deleted.size_address<ULONG>()));
2529 + ExpectImagePresent(*m_defaultSession, "hello-world:latest", false);
2530 +
2531 + // Load it back from the multi-image tar — hello-world should reappear and alpine should still be present.
2532 + {
2533 + wil::unique_handle imageTarFileHandle{CreateFileW(
2534 + imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
2535 + VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
2536 + LARGE_INTEGER fileSize{};
2537 + VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
2538 + VERIFY_SUCCEEDED(m_defaultSession->LoadImage(ToCOMInputHandle(imageTarFileHandle.get()), nullptr, fileSize.QuadPart, nullptr));
2539 + }
2540 +
2541 + ExpectImagePresent(*m_defaultSession, "hello-world:latest");
2542 + ExpectImagePresent(*m_defaultSession, "alpine:latest");
2543 +
2544 + // Sanity check that the loaded hello-world image is functional.
2545 + WSLCContainerLauncher launcher("hello-world:latest", "wslc-multi-save-container");
2546 + auto container = launcher.Launch(*m_defaultSession);
2547 +
2548 + auto output = container.GetInitProcess().WaitAndCaptureOutput();
2549 + VERIFY_ARE_EQUAL(0, output.Code);
2550 + VERIFY_IS_TRUE(output.Output[1].find("Hello from Docker!") != std::string::npos);
2551 + }
2552 +
2553 + // Single image via SaveImages — must produce a valid tar archive.
2554 + {
2555 + std::filesystem::path imageTar = L"MultiImageSingle.tar";
2556 + auto cleanup =
2557 + wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTar.c_str())); });
2558 +
2559 + wil::unique_handle imageTarFileHandle{
2560 + CreateFileW(imageTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
2561 + VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
2562 +
2563 + std::vector<LPCSTR> names = {"hello-world:latest"};
2564 + WSLCStringArray array = BuildStringArray(names);
2565 + VERIFY_SUCCEEDED(m_defaultSession->SaveImages(ToCOMInputHandle(imageTarFileHandle.get()), &array, nullptr, nullptr));
2566 +
2567 + LARGE_INTEGER fileSize{};
2568 + VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
2569 + VERIFY_IS_TRUE(fileSize.QuadPart > 0);
2570 + }
2571 +
2572 + // Validate that invalid input parameters are rejected.
2573 + {
2574 + // Use a real temp file so ToCOMInputHandle doesn't throw before SaveImages runs.
2575 + std::filesystem::path placeholderTar = L"MultiImageValidation.tar";
2576 + auto placeholderCleanup =
2577 + wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(placeholderTar.c_str())); });
2578 +
2579 + wil::unique_handle placeholder{CreateFileW(
2580 + placeholderTar.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
2581 + VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == placeholder.get());
2582 + HANDLE phHandle = placeholder.get();
2583 +
2584 + // Empty array (Count=0).
2585 + WSLCStringArray emptyArray{.Values = nullptr, .Count = 0};
2586 + VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->SaveImages(ToCOMInputHandle(phHandle), &emptyArray, nullptr, nullptr));
2587 +
2588 + // Empty string entry.
2589 + LPCSTR emptyEntry[] = {""};
2590 + WSLCStringArray emptyEntryArray{.Values = emptyEntry, .Count = 1};
2591 + VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->SaveImages(ToCOMInputHandle(phHandle), &emptyEntryArray, nullptr, nullptr));
2592 +
2593 + // Name longer than WSLC_MAX_IMAGE_NAME_LENGTH.
2594 + std::string longName(WSLC_MAX_IMAGE_NAME_LENGTH + 1, 'a');
2595 + LPCSTR longEntry[] = {longName.c_str()};
2596 + WSLCStringArray longEntryArray{.Values = longEntry, .Count = 1};
2597 + VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->SaveImages(ToCOMInputHandle(phHandle), &longEntryArray, nullptr, nullptr));
2598 +
2599 + // Too many images.
2600 + std::vector<LPCSTR> names(WSLC_MAX_SAVE_IMAGES_COUNT + 1, "foo");
2601 + WSLCStringArray tooManyArray = BuildStringArray(names);
2602 + VERIFY_ARE_EQUAL(E_INVALIDARG, m_defaultSession->SaveImages(ToCOMInputHandle(phHandle), &tooManyArray, nullptr, nullptr));
2603 + }
2604 +
2605 + // Try to save with one of the images not found — must fail
2606 + {
2607 + std::filesystem::path imageTar = L"MultiImageError.tar";
2608 + auto cleanup =
2609 + wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LOG_IF_WIN32_BOOL_FALSE(DeleteFileW(imageTar.c_str())); });
2610 +
2611 + wil::unique_handle imageTarFileHandle{CreateFileW(
2612 + imageTar.c_str(), GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
2613 + VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == imageTarFileHandle.get());
2614 +
2615 + std::vector<LPCSTR> names = {"alpine:latest", "not-found"};
2616 + WSLCStringArray array = BuildStringArray(names);
2617 + VERIFY_FAILED(m_defaultSession->SaveImages(ToCOMInputHandle(imageTarFileHandle.get()), &array, nullptr, nullptr));
2618 +
2619 + ValidateCOMErrorMessage(L"No such image: not-found");
2620 + LARGE_INTEGER fileSize{};
2621 + VERIFY_IS_TRUE(GetFileSizeEx(imageTarFileHandle.get(), &fileSize));
2622 + VERIFY_ARE_EQUAL(0ull, static_cast<ULONGLONG>(fileSize.QuadPart));
2623 + }
2624 + }
2625 +
2626 WSLC_TEST_METHOD(SynchronousIoCancellation)
2627 {
2628 // Create a blocked operation that will cause the service to get stuck on a ReadFile() call.
test/windows/wslc/e2e/WSLCE2EImageSaveTests.cpp
+43 -1
@@ -26,6 +26,7 @@ class WSLCE2EImageSaveTests
26 TEST_CLASS_CLEANUP(ClassCleanup)
27 {
28 EnsureImageIsDeleted(DebianImage);
29 + EnsureImageIsDeleted(AlpineImage);
30 return true;
31 }
32
@@ -128,9 +129,50 @@ class WSLCE2EImageSaveTests
129 auto runResult = RunWslc(std::format(L"container run --rm {} echo Hello from saved image!", DebianImage.NameAndTag()));
130 runResult.Verify({.Stdout = L"Hello from saved image!\n", .Stderr = L"", .ExitCode = 0});
131 }
132 + WSLC_TEST_METHOD(WSLCE2E_Image_Save_MultipleImages_Load)
133 + {
134 + EnsureImageIsLoaded(AlpineImage);
135 +
136 + // Force a pristine re-load of DebianImage at the end so subsequent tests (in fast mode)
137 + // see the same on-disk tar as DebianImage.Path. Without this, reloading from a
138 + // multi-image archive can produce a slightly different on-disk representation that
139 + // breaks byte-exact size checks in WSLCE2E_Image_Save_Success.
140 + auto restoreDebian = wil::scope_exit([&]() { EnsureImageIsDeleted(DebianImage); });
141 +
142 + // Save both images into a single archive.
143 + const auto saveResult = RunWslc(std::format(
144 + L"image save --output \"{}\" {} {}", SavedArchivePath.wstring(), DebianImage.NameAndTag(), AlpineImage.NameAndTag()));
145 + saveResult.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0});
146 +
147 + // Delete both source images.
148 + EnsureImageIsDeleted(DebianImage);
149 + EnsureImageIsDeleted(AlpineImage);
150 +
151 + // Load both images back from the single archive.
152 + const auto loadResult = RunWslc(std::format(L"image load --input \"{}\"", SavedArchivePath.wstring()));
153 + loadResult.Verify({.Stderr = L"", .ExitCode = 0});
154 +
155 + // Run a container from each loaded image to confirm both are restored and runnable.
156 + const auto runDebian = RunWslc(std::format(L"container run --rm {} echo ok!", DebianImage.NameAndTag()));
157 + runDebian.Verify({.Stdout = std::format(L"ok!\n"), .Stderr = L"", .ExitCode = 0});
158 +
159 + const auto runAlpine = RunWslc(std::format(L"container run --rm {} echo ok!", AlpineImage.NameAndTag()));
160 + runAlpine.Verify({.Stdout = std::format(L"ok!\n"), .Stderr = L"", .ExitCode = 0});
161 + }
162 +
163 + WSLC_TEST_METHOD(WSLCE2E_Image_Save_MultipleImages_InvalidImage)
164 + {
165 + const auto result = RunWslc(std::format(
166 + L"image save --output \"{}\" {} {}", SavedArchivePath.wstring(), DebianImage.NameAndTag(), InvalidImage.NameAndTag()));
167 + VERIFY_IS_TRUE(result.ExitCode.has_value());
168 + VERIFY_ARE_EQUAL(1u, result.ExitCode.value());
169 + VERIFY_IS_TRUE(result.Stderr.has_value());
170 + VERIFY_ARE_NOT_EQUAL(0u, result.Stderr.value().size());
171 + }
172
173 private:
174 const TestImage DebianImage = DebianTestImage();
175 + const TestImage& AlpineImage = AlpineTestImage();
176 const TestImage& InvalidImage = InvalidTestImage();
177
178 std::filesystem::path SavedArchivePath{};
@@ -176,4 +218,4 @@ private:
218 return options.str();
219 }
220 };
179 -} // namespace WSLCE2ETests
\ No newline at end of file
221 +} // namespace WSLCE2ETests