@samitouri / QOSAMI-WSL / commits / e5385a5a

Make paths absolute before weakly_canonical and add regression tests (#41290)

std::filesystem::weakly_canonical only returns an absolute path when a leading element of its input exists, so a relative path naming a file that has not been created yet is returned unchanged, still relative. The service then rejects it with "Path is not absolute". This affected `wslc build --iidfile <relative>` and `--secret src=<relative>`, plus the distribution path and virtiofs share path helpers in the service. Add wsl::windows::common::filesystem::GetCanonicalPath, which applies std::filesystem::absolute first so the result is absolute whether or not the path exists, and replace the direct weakly_canonical calls with it. It checks the error from absolute before canonicalizing, because absolute returns an empty path on failure and weakly_canonical both succeeds on an empty path and clears the error_code on success, so nesting the two calls would discard the failure and report success Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

ggarzia-MSFT committed Aug 12, 2026 at 09:35 UTC e5385a5af2816977acbc996054b45d611482b057
15 files changed +292 -20
src/windows/common/filesystem.cpp
+29
@@ -774,6 +774,35 @@ bool wsl::windows::common::filesystem::FileExists(_In_ LPCWSTR Path)
774 return (Attributes != INVALID_FILE_ATTRIBUTES);
775 }
776
777 +std::filesystem::path wsl::windows::common::filesystem::GetCanonicalPath(const std::filesystem::path& Path)
778 +{
779 + std::error_code error;
780 + auto canonicalPath = GetCanonicalPath(Path, error);
781 + THROW_HR_IF_MSG(HRESULT_FROM_WIN32(error.value()), !!error, "GetCanonicalPath(%ls)", Path.c_str());
782 +
783 + return canonicalPath;
784 +}
785 +
786 +std::filesystem::path wsl::windows::common::filesystem::GetCanonicalPath(const std::filesystem::path& Path, std::error_code& Error)
787 +{
788 + // absolute() is applied first because weakly_canonical() does not resolve a relative path
789 + // against the current directory on its own. Its result is checked before canonicalizing because
790 + // weakly_canonical() clears Error on success, which would otherwise mask an absolute() failure.
791 + const auto absolutePath = std::filesystem::absolute(Path, Error);
792 + if (Error)
793 + {
794 + return {};
795 + }
796 +
797 + auto canonicalPath = std::filesystem::weakly_canonical(absolutePath, Error);
798 + if (Error)
799 + {
800 + return {};
801 + }
802 +
803 + return canonicalPath;
804 +}
805 +
806 std::filesystem::path wsl::windows::common::filesystem::GetFullPath(_In_ LPCWSTR Path)
807 {
808 DWORD Attributes = GetFileAttributesW(Path);
src/windows/common/filesystem.hpp
+15
@@ -137,6 +137,21 @@ void EnsureDirectoryWithAttributes(_In_ PCWSTR Path, _In_ ULONG Mode, _In_ ULONG
137
138 bool FileExists(_In_ LPCWSTR Path);
139
140 +/// <summary>
141 +/// Resolves Path to an absolute, canonical form. The path is made absolute against the current
142 +/// directory first because std::filesystem::weakly_canonical does not reliably resolve a relative
143 +/// path on its own. '..' components are collapsed and symlinks are resolved for the portion of the
144 +/// path that exists, so a path naming a file that does not exist yet still succeeds.
145 +/// Throws on failure.
146 +/// </summary>
147 +std::filesystem::path GetCanonicalPath(const std::filesystem::path& Path);
148 +
149 +/// <summary>
150 +/// Non-throwing overload of GetCanonicalPath. On failure Error is set and an empty path is
151 +/// returned; on success Error is cleared.
152 +/// </summary>
153 +std::filesystem::path GetCanonicalPath(const std::filesystem::path& Path, std::error_code& Error);
154 +
155 std::filesystem::path GetFullPath(_In_ LPCWSTR Path);
156
157 std::pair<std::string, std::string> GetHostAndDomainNames();
src/windows/service/exe/LxssUserSession.cpp
+2 -2
@@ -3852,7 +3852,7 @@ void LxssUserSessionImpl::_ValidateDistributionNameAndPathNotInUse(
3852
3853 if (Path != nullptr)
3854 {
3855 - canonicalPath = std::filesystem::weakly_canonical(Path, error);
3855 + canonicalPath = wsl::windows::common::filesystem::GetCanonicalPath(Path, error);
3856 if (error)
3857 {
3858 LOG_WIN32(error.value());
@@ -3896,7 +3896,7 @@ void LxssUserSessionImpl::_ValidateDistributionNameAndPathNotInUse(
3896
3897 if (Path != nullptr)
3898 {
3899 - auto canonicalDistroPath = std::filesystem::weakly_canonical(configuration.BasePath, error);
3899 + auto canonicalDistroPath = wsl::windows::common::filesystem::GetCanonicalPath(configuration.BasePath, error);
3900 if (error)
3901 {
3902 LOG_WIN32(error.value());
src/windows/service/exe/WslCoreVm.cpp
+1 -1
@@ -2182,7 +2182,7 @@ std::tuple<std::wstring, std::wstring, std::wstring> WslCoreVm::AddVirtioFsShare
2182 sharePath.push_back(L'\\');
2183 }
2184
2185 - sharePath = std::filesystem::weakly_canonical(sharePath).wstring();
2185 + sharePath = wsl::windows::common::filesystem::GetCanonicalPath(sharePath).wstring();
2186
2187 std::wstring effectiveOptions(Options);
2188
src/windows/wslc/arguments/SpecParsing.cpp
+2 -2
@@ -167,10 +167,10 @@ services::BuildSecret ParseSecretSpec(const std::wstring& spec)
167 // Normalize to an absolute path (the service requires one to mount the file's directory) but do
168 // not verify the file exists or is a regular file here: that would be a TOCTOU race with the
169 // build, and the file may only be reachable from the service's context. Let the service/BuildKit
170 - // reject an unmountable or unreadable file instead. weakly_canonical resolves a relative path
170 + // reject an unmountable or unreadable file instead. GetCanonicalPath resolves a relative path
171 // against the current directory, collapses '..', and resolves symlinks for the portion of the
172 // path that exists; it succeeds for a missing file but still reports genuine errors.
173 - auto absPath = std::filesystem::weakly_canonical(srcPath, ec);
173 + auto absPath = wsl::windows::common::filesystem::GetCanonicalPath(srcPath, ec);
174 if (ec.value() != 0)
175 {
176 throw ArgumentException(
src/windows/wslc/services/ImageService.cpp
+2 -1
@@ -16,6 +16,7 @@ Abstract:
16 #include "SessionService.h"
17 #include "SpecParsing.h"
18 #include "WarningCallback.h"
19 +#include <filesystem.hpp>
20 #include <wslutil.h>
21 #include <HandleConsoleProgressBar.h>
22 #include <relay.hpp>
@@ -266,7 +267,7 @@ void ImageService::Build(
267 std::wstring iidPathStr;
268 if (iidFilePath.has_value())
269 {
269 - iidPathStr = std::filesystem::weakly_canonical(std::filesystem::absolute(*iidFilePath)).wstring();
270 + iidPathStr = wsl::windows::common::filesystem::GetCanonicalPath(*iidFilePath).wstring();
271 }
272
273 WSLCBuildImageOptions options{
src/windows/wslc/tasks/ContainerTasks.cpp
+2 -1
@@ -23,6 +23,7 @@ Abstract:
23 #include "SessionService.h"
24 #include "TableOutput.h"
25 #include <wil/result_macros.h>
26 +#include <filesystem.hpp>
27 #include <wslc_schema.h>
28 #include <filesystem>
29
@@ -389,7 +390,7 @@ void ContainerCp(CLIExecutionContext& context)
390
391 // Resolve any symlinks in the target path since tar.exe refuses to extract through a symlink.
392 std::error_code canonicalError;
392 - auto absTarget = std::filesystem::weakly_canonical(std::filesystem::absolute(target), canonicalError);
393 + auto absTarget = wsl::windows::common::filesystem::GetCanonicalPath(target, canonicalError);
394 if (canonicalError)
395 {
396 absTarget = std::filesystem::absolute(target); // Fall back to absolute if canonicalization fails.
src/windows/wslinstaller/exe/WslInstaller.cpp
+2 -2
@@ -24,7 +24,7 @@ std::wstring GetMsiPackagePath()
24
25 static_assert(!wsl::shared::OfficialBuild);
26
27 - return std::filesystem::weakly_canonical(WSL_DEV_THIN_MSI_PACKAGE).wstring();
27 + return wsl::windows::common::filesystem::GetCanonicalPath(WSL_DEV_THIN_MSI_PACKAGE).wstring();
28
29 #endif
30
@@ -50,7 +50,7 @@ try
50 }
51
52 // A canonical path is required because msiexec doesn't like symlinks.
53 - return UpgradeLogInfo{std::filesystem::weakly_canonical(path), true};
53 + return UpgradeLogInfo{wsl::windows::common::filesystem::GetCanonicalPath(path), true};
54 }
55 catch (...)
56 {
test/windows/CMakeLists.txt
+1
@@ -5,6 +5,7 @@ set(SOURCES
5 NetworkTests.cpp
6 Plan9Tests.cpp
7 DrvFsTests.cpp
8 + FilesystemUnitTests.cpp
9 Common.cpp
10 PluginTests.cpp
11 PolicyTests.cpp
test/windows/FilesystemUnitTests.cpp new
+147
@@ -0,0 +1,147 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + FilesystemUnitTests.cpp
8 +
9 +Abstract:
10 +
11 + This file contains unit tests for the helpers in src/windows/common/filesystem.cpp.
12 + These tests only read from the local filesystem so they do not require an installed distribution.
13 +
14 +--*/
15 +
16 +#include "precomp.h"
17 +#include "Common.h"
18 +
19 +using wsl::windows::common::filesystem::GetCanonicalPath;
20 +
21 +namespace {
22 +
23 +// Returns a file name that does not exist in the given directory.
24 +std::wstring UniqueMissingName(const std::filesystem::path& Directory)
25 +{
26 + static int counter = 0;
27 + const auto name = std::format(L"wsl_ut_canonical_{}_{}.txt", GetCurrentProcessId(), ++counter);
28 + VERIFY_IS_FALSE(std::filesystem::exists(Directory / name));
29 +
30 + return name;
31 +}
32 +
33 +// The canonical form of the current directory, which is what a relative path is expected to resolve
34 +// against. std::filesystem::canonical is used rather than weakly_canonical so the expected value is
35 +// computed independently of the API under test.
36 +std::filesystem::path CanonicalCurrentDirectory()
37 +{
38 + return std::filesystem::canonical(std::filesystem::current_path());
39 +}
40 +
41 +// A path that std::filesystem::absolute is guaranteed to reject, because it exceeds the longest path
42 +// Win32 can express. An empty path is not used: whether absolute rejects one is implementation
43 +// defined, and some standard library versions accept it.
44 +std::filesystem::path UnresolvablePath()
45 +{
46 + return {L"C:\\" + std::wstring(40000, L'a')};
47 +}
48 +
49 +// A file that is known to exist, used to cover paths that resolve to a real filesystem entry. The
50 +// test module itself is used so that no file has to be created.
51 +std::filesystem::path ExistingFile()
52 +{
53 + return {wil::GetModuleFileNameW<std::wstring>(wil::GetModuleInstanceHandle())};
54 +}
55 +
56 +} // namespace
57 +
58 +namespace FilesystemUnitTests {
59 +class FilesystemUnitTests
60 +{
61 + WSL_TEST_CLASS(FilesystemUnitTests)
62 +
63 + // A relative path naming a file that does not exist must still resolve to an absolute path.
64 + // std::filesystem::weakly_canonical cannot do this on its own: it builds its result from the
65 + // longest leading sequence of elements that exist, so a bare missing file name has nothing to
66 + // canonicalize and is returned unchanged.
67 + TEST_METHOD(GetCanonicalPath_RelativeMissingPathIsMadeAbsolute)
68 + {
69 + const auto name = UniqueMissingName(std::filesystem::current_path());
70 + VERIFY_IS_FALSE(std::filesystem::weakly_canonical(name).is_absolute());
71 +
72 + const auto result = GetCanonicalPath(name);
73 +
74 + VERIFY_IS_TRUE(result.is_absolute());
75 + VERIFY_ARE_EQUAL((CanonicalCurrentDirectory() / name).wstring(), result.wstring());
76 + }
77 +
78 + // The same resolution must happen for a relative path whose target already exists.
79 + TEST_METHOD(GetCanonicalPath_RelativeExistingPathIsMadeAbsolute)
80 + {
81 + const auto existing = ExistingFile();
82 + const auto relativePath = std::filesystem::relative(existing, std::filesystem::current_path());
83 + VERIFY_IS_FALSE(relativePath.empty());
84 + VERIFY_IS_FALSE(relativePath.is_absolute());
85 +
86 + const auto result = GetCanonicalPath(relativePath);
87 +
88 + VERIFY_IS_TRUE(result.is_absolute());
89 + VERIFY_ARE_EQUAL(std::filesystem::canonical(existing).wstring(), result.wstring());
90 + }
91 +
92 + // '.' and '..' components must be collapsed even when the intermediate directory does not exist.
93 + TEST_METHOD(GetCanonicalPath_CollapsesDotSegments)
94 + {
95 + const auto name = UniqueMissingName(std::filesystem::current_path());
96 +
97 + const auto result = GetCanonicalPath(L".\\nonexistent\\..\\" + name);
98 +
99 + VERIFY_ARE_EQUAL((CanonicalCurrentDirectory() / name).wstring(), result.wstring());
100 + }
101 +
102 + // An already absolute path must be returned unchanged.
103 + TEST_METHOD(GetCanonicalPath_AbsolutePathIsUnchanged)
104 + {
105 + const auto expected = CanonicalCurrentDirectory() / UniqueMissingName(std::filesystem::current_path());
106 +
107 + VERIFY_ARE_EQUAL(expected.wstring(), GetCanonicalPath(expected).wstring());
108 + }
109 +
110 + // A failure from std::filesystem::absolute must be reported. absolute returns an empty path when
111 + // it fails, and weakly_canonical succeeds on an empty path and clears the error_code, so calling
112 + // the two in sequence without checking in between silently turns the failure into success.
113 + TEST_METHOD(GetCanonicalPath_ErrorOverloadReportsFailure)
114 + {
115 + std::error_code error;
116 + const auto result = GetCanonicalPath(UnresolvablePath(), error);
117 +
118 + VERIFY_ARE_NOT_EQUAL(std::error_code{}, error);
119 + VERIFY_IS_TRUE(result.empty());
120 + }
121 +
122 + // Error must be cleared when the call succeeds so callers can reuse the same variable.
123 + TEST_METHOD(GetCanonicalPath_ErrorOverloadClearsErrorOnSuccess)
124 + {
125 + const auto expected = CanonicalCurrentDirectory() / UniqueMissingName(std::filesystem::current_path());
126 +
127 + auto error = std::make_error_code(std::errc::permission_denied);
128 + const auto result = GetCanonicalPath(expected, error);
129 +
130 + VERIFY_ARE_EQUAL(std::error_code{}, error);
131 + VERIFY_ARE_EQUAL(expected.wstring(), result.wstring());
132 + }
133 +
134 + // The throwing overload must surface the same failure the non-throwing overload reports.
135 + TEST_METHOD(GetCanonicalPath_ThrowingOverloadSurfacesFailure)
136 + {
137 + std::error_code error;
138 + (void)GetCanonicalPath(UnresolvablePath(), error);
139 + VERIFY_ARE_NOT_EQUAL(std::error_code{}, error);
140 +
141 + const auto expectedResult = HRESULT_FROM_WIN32(error.value());
142 + VERIFY_THROWS_SPECIFIC(GetCanonicalPath(UnresolvablePath()), wil::ResultException, [&](const wil::ResultException& e) {
143 + return e.GetErrorCode() == expectedResult;
144 + });
145 + }
146 +};
147 +} // namespace FilesystemUnitTests
test/windows/InstallerTests.cpp
+4 -4
@@ -50,7 +50,7 @@ class InstallerTests
50
51 WEX::Common::String MsixPackagePath;
52 WEX::TestExecution::RuntimeParameters::TryGetValue(L"Package", MsixPackagePath);
53 - m_msixPackagePath = std::filesystem::weakly_canonical(static_cast<std::wstring>(MsixPackagePath)).wstring();
53 + m_msixPackagePath = wsl::windows::common::filesystem::GetCanonicalPath(static_cast<std::wstring>(MsixPackagePath)).wstring();
54 VERIFY_IS_FALSE(m_msixPackagePath.empty());
55
56 for (const auto& e : m_packageManager.FindPackages(wsl::windows::common::wslutil::c_msixPackageFamilyName))
@@ -61,7 +61,7 @@ class InstallerTests
61
62 #ifdef WSL_DEV_THIN_MSI_PACKAGE
63
64 - m_msiPath = std::filesystem::weakly_canonical(WSL_DEV_THIN_MSI_PACKAGE).wstring();
64 + m_msiPath = wsl::windows::common::filesystem::GetCanonicalPath(WSL_DEV_THIN_MSI_PACKAGE).wstring();
65
66 #else
67
@@ -383,12 +383,12 @@ class InstallerTests
383
384 if (auto found = L"wsl." + version + arch + L".msi"; PathFileExists(found.c_str()))
385 {
386 - installerFile = std::filesystem::weakly_canonical(found);
386 + installerFile = wsl::windows::common::filesystem::GetCanonicalPath(found);
387 cleanup.release();
388 }
389 else if (auto found = L"Microsoft.WSL_" + version + L".0_x64_ARM64.msixbundle"; PathFileExists(found.c_str()))
390 {
391 - installerFile = std::filesystem::weakly_canonical(found);
391 + installerFile = wsl::windows::common::filesystem::GetCanonicalPath(found);
392 cleanup.release();
393 }
394 else
test/windows/UnitTests.cpp
+2 -2
@@ -2622,7 +2622,7 @@ Error code: Wsl/InstallDistro/WSL_E_DISTRO_NOT_FOUND
2622 WSL2_TEST_METHOD(CorruptedVhd)
2623 {
2624 // Create a 100MB vhd without a filesystem.
2625 - auto distroPath = std::filesystem::weakly_canonical(wil::GetCurrentDirectoryW<std::wstring>());
2625 + auto distroPath = wsl::windows::common::filesystem::GetCanonicalPath(wil::GetCurrentDirectoryW<std::wstring>());
2626 auto vhdPath = distroPath / L"CorruptedTest.vhdx";
2627
2628 VIRTUAL_STORAGE_TYPE storageType{};
@@ -3083,7 +3083,7 @@ Error code: Wsl/InstallDistro/WSL_E_DISTRO_NOT_FOUND
3083 VERIFY_IS_TRUE(std::filesystem::exists(std::format(L"{}\\ext4.vhdx", testFolder)));
3084 }
3085
3086 - auto absolutePath = std::filesystem::weakly_canonical(".").wstring();
3086 + auto absolutePath = wsl::windows::common::filesystem::GetCanonicalPath(".").wstring();
3087
3088 // Move the distro to a different folder (absolute path)
3089 {
test/windows/WSLCTests.cpp
+3 -2
@@ -9369,8 +9369,9 @@ class WSLCTests
9369
9370 WSLC_TEST_METHOD(ContainerVolumesAdvanced)
9371 {
9372 - auto hostFolder = std::filesystem::weakly_canonical(std::filesystem::current_path() / "test-volume");
9373 - auto symlinkFolder = std::filesystem::weakly_canonical(std::filesystem::current_path() / "test-volume-symlink");
9372 + auto hostFolder = wsl::windows::common::filesystem::GetCanonicalPath(std::filesystem::current_path() / "test-volume");
9373 + auto symlinkFolder =
9374 + wsl::windows::common::filesystem::GetCanonicalPath(std::filesystem::current_path() / "test-volume-symlink");
9375 std::filesystem::create_directories(hostFolder);
9376
9377 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
test/windows/wslc/WSLCCLISecretParserUnitTests.cpp
+34 -3
@@ -107,7 +107,7 @@ class WSLCCLISecretParserUnitTests
107 VERIFY_ARE_EQUAL(expectedId, secret.Id);
108 VERIFY_IS_TRUE(secret.Value.empty());
109 std::error_code ec;
110 - const auto expectedCanonical = std::filesystem::weakly_canonical(std::filesystem::absolute(expectedPath), ec);
110 + const auto expectedCanonical = std::filesystem::weakly_canonical(std::filesystem::absolute(expectedPath, ec), ec);
111 VERIFY_ARE_EQUAL(expectedCanonical.wstring(), secret.SourcePath);
112 }
113
@@ -209,7 +209,7 @@ class WSLCCLISecretParserUnitTests
209 VERIFY_IS_TRUE(secret.Value.empty());
210
211 std::error_code ec;
212 - const auto expectedCanonical = std::filesystem::weakly_canonical(path, ec);
212 + const auto expectedCanonical = std::filesystem::weakly_canonical(std::filesystem::absolute(path, ec), ec);
213 VERIFY_ARE_EQUAL(expectedCanonical.wstring(), secret.SourcePath);
214 }
215
@@ -261,10 +261,41 @@ class WSLCCLISecretParserUnitTests
261 VERIFY_IS_TRUE(std::filesystem::path(secret.SourcePath).is_absolute());
262
263 std::error_code ec;
264 - const auto expectedCanonical = std::filesystem::weakly_canonical(absPath, ec);
264 + const auto expectedCanonical = std::filesystem::weakly_canonical(std::filesystem::absolute(absPath, ec), ec);
265 VERIFY_ARE_EQUAL(expectedCanonical.wstring(), secret.SourcePath);
266 }
267
268 + // A relative src= naming a file that does not exist must still resolve to an absolute SourcePath.
269 + // Parsing deliberately does not require the file to exist, so this case is reachable and the server
270 + // still rejects a non-absolute path. std::filesystem::weakly_canonical cannot handle it on its own:
271 + // it only produces an absolute path by canonicalizing the longest leading sequence of elements that
272 + // exist, so a bare missing filename has nothing to canonicalize and is returned unchanged. The
273 + // relative-src test above cannot catch this because its file exists.
274 + TEST_METHOD(Secret_File_RelativeSrcMissingFileResolvedToAbsolutePath)
275 + {
276 + const auto directory = std::filesystem::temp_directory_path();
277 + const auto relativeSrc = L"wslc_ut_secret_missing_" + std::to_wstring(GetCurrentProcessId()) + L"_" +
278 + std::to_wstring(GetTickCount64()) + L".bin";
279 + VERIFY_IS_FALSE(std::filesystem::exists(directory / relativeSrc));
280 +
281 + auto originalDir = std::filesystem::current_path();
282 + auto restoreDir = wil::scope_exit([&]() {
283 + std::error_code ec;
284 + std::filesystem::current_path(originalDir, ec);
285 + });
286 + std::filesystem::current_path(directory);
287 +
288 + VERIFY_IS_FALSE(std::filesystem::path(relativeSrc).is_absolute());
289 +
290 + auto secret = validation::ParseSecretSpec(L"id=s,src=" + relativeSrc);
291 + VERIFY_ARE_EQUAL(std::wstring(L"s"), secret.Id);
292 + VERIFY_IS_TRUE(std::filesystem::path(secret.SourcePath).is_absolute());
293 +
294 + // The leading directory exists, so it canonicalizes; only the missing filename is appended.
295 + const auto expected = std::filesystem::canonical(directory) / relativeSrc;
296 + VERIFY_ARE_EQUAL(expected.wstring(), secret.SourcePath);
297 + }
298 +
299 // --- Invalid: spec structure ---
300
301 TEST_METHOD(Secret_Invalid_EmptyId)
test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp
+46
@@ -1301,6 +1301,51 @@ class WSLCE2EImageBuildTests
1301 VERIFY_ARE_EQUAL(inspectedId, wsl::windows::common::string::WideToMultiByte(iid));
1302 }
1303
1304 + // --iidfile must accept a path relative to the caller's current directory. The client is responsible
1305 + // for making the path absolute before it reaches the service, which rejects non-absolute paths.
1306 + // std::filesystem::weakly_canonical alone is not sufficient here: --iidfile names a file that does
1307 + // not exist yet, so there is no leading element to canonicalize and the path is returned unchanged.
1308 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_RelativePath)
1309 + {
1310 + auto imageCleanup = DeleteImageOnExit(BuiltImageIidFileRelative);
1311 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-iidfile-relative";
1312 + auto cleanup = SetupTestDirectory(testRoot);
1313 +
1314 + auto contextDir = SharedOutputBuildContext();
1315 +
1316 + auto dockerfilePath = testRoot / L"Dockerfile";
1317 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nRUN echo wslc-iidfile-relative-marker > /marker.txt\n");
1318 +
1319 + // Run wslc from testRoot so the --iidfile argument below resolves against it. Declared after
1320 + // the directory cleanup so the working directory is restored before the directory is removed.
1321 + auto originalDirectory = std::filesystem::current_path();
1322 + auto restoreDirectory = wil::scope_exit([&]() {
1323 + std::error_code ec;
1324 + std::filesystem::current_path(originalDirectory, ec);
1325 + });
1326 + std::filesystem::current_path(testRoot);
1327 +
1328 + const std::wstring relativeIidFile = L"image.id";
1329 + VERIFY_IS_FALSE(std::filesystem::path(relativeIidFile).is_absolute());
1330 + VERIFY_IS_FALSE(std::filesystem::exists(testRoot / relativeIidFile));
1331 +
1332 + auto buildResult = RunWslc(std::format(
1333 + L"build \"{}\" -f \"{}\" -t {} --iidfile \"{}\"",
1334 + contextDir.wstring(),
1335 + dockerfilePath.wstring(),
1336 + BuiltImageIidFileRelative.NameAndTag(),
1337 + relativeIidFile));
1338 + buildResult.Verify({.ExitCode = 0});
1339 +
1340 + VERIFY_IS_TRUE(std::filesystem::exists(testRoot / relativeIidFile), L"--iidfile must accept a relative path");
1341 + const auto iid = ReadFileContent((testRoot / relativeIidFile).wstring());
1342 + VERIFY_IS_TRUE(iid.starts_with(L"sha256:"), L"iidfile must contain a sha256 digest");
1343 +
1344 + // The digest written to the iidfile must match the ID the image is stored under.
1345 + const auto inspectedId = InspectImage(BuiltImageIidFileRelative.NameAndTag()).Id;
1346 + VERIFY_ARE_EQUAL(inspectedId, wsl::windows::common::string::WideToMultiByte(iid));
1347 + }
1348 +
1349 // A failing build must not write the iidfile (matching docker: the file only appears on success).
1350 WSLC_TEST_METHOD(WSLCE2E_Image_Build_IidFile_BuildFailure_NoFileWritten)
1351 {
@@ -1421,6 +1466,7 @@ private:
1466 const TestImage BuiltImageOutputCacheOnly{L"wslc-e2e-build-output-cacheonly", L"latest", L""};
1467 const TestImage BuiltImageIidFile{L"wslc-e2e-build-iidfile", L"latest", L""};
1468 const TestImage BuiltImageIidFileNotWritable{L"wslc-e2e-build-iidfile-readonly", L"latest", L""};
1469 + const TestImage BuiltImageIidFileRelative{L"wslc-e2e-build-iidfile-relative", L"latest", L""};
1470
1471 // Runs `tar.exe -tf <path>` and returns the member listing so tests can assert an exporter produced a
1472 // valid, non-empty archive that contains an expected entry.