/*++ Copyright (c) Microsoft. All rights reserved. Module Name: FilesystemUnitTests.cpp Abstract: This file contains unit tests for the helpers in src/windows/common/filesystem.cpp. These tests only read from the local filesystem so they do not require an installed distribution. --*/ #include "precomp.h" #include "Common.h" using wsl::windows::common::filesystem::ExtractArchiveInto; using wsl::windows::common::filesystem::ExtractSingleFileAs; using wsl::windows::common::filesystem::GetCanonicalPath; using wsl::windows::common::filesystem::IsRepresentableFileName; using wsl::windows::common::filesystem::MakeStagingDirectory; using wsl::windows::common::filesystem::PosixBaseName; using wsl::windows::common::filesystem::StagingDirectory; using wsl::windows::common::filesystem::StripTrailingSeparators; namespace { // Returns a file name that does not exist in the given directory. std::wstring UniqueMissingName(const std::filesystem::path& Directory) { static int counter = 0; const auto name = std::format(L"wsl_ut_canonical_{}_{}.txt", GetCurrentProcessId(), ++counter); VERIFY_IS_FALSE(std::filesystem::exists(Directory / name)); return name; } // The canonical form of the current directory, which is what a relative path is expected to resolve // against. std::filesystem::canonical is used rather than weakly_canonical so the expected value is // computed independently of the API under test. std::filesystem::path CanonicalCurrentDirectory() { return std::filesystem::canonical(std::filesystem::current_path()); } // A path that std::filesystem::absolute is guaranteed to reject, because it exceeds the longest path // Win32 can express. An empty path is not used: whether absolute rejects one is implementation // defined, and some standard library versions accept it. std::filesystem::path UnresolvablePath() { return {L"C:\\" + std::wstring(40000, L'a')}; } // A file that is known to exist, used to cover paths that resolve to a real filesystem entry. The // test module itself is used so that no file has to be created. std::filesystem::path ExistingFile() { return {wil::GetModuleFileNameW(wil::GetModuleInstanceHandle())}; } // Builds ustar archives in memory, so tests control entry names, types and order, including empty // archives and names that cannot exist as Windows files. class TarBuilder { public: TarBuilder& AddFile(std::string_view Name, std::string_view Content) { AddEntry(Name, '0', Content, {}); return *this; } TarBuilder& AddDirectory(std::string_view Name) { AddEntry(std::string{Name} + "/", '5', {}, {}); return *this; } TarBuilder& AddHardLink(std::string_view Name, std::string_view Target) { AddEntry(Name, '1', {}, Target); return *this; } TarBuilder& AddSymlink(std::string_view Name, std::string_view Target) { AddEntry(Name, '2', {}, Target); return *this; } // An archive ends with two zero blocks. An archive with no entries is just those blocks. std::vector Build() const { auto bytes = m_bytes; bytes.resize(bytes.size() + (2 * c_blockSize), '\0'); return bytes; } // Matches the callback the extraction helpers invoke to obtain the archive. std::function Writer() const { return [bytes = Build()](HANDLE Handle) { DWORD written = 0; VERIFY_IS_TRUE(!!WriteFile(Handle, bytes.data(), static_cast(bytes.size()), &written, nullptr)); VERIFY_ARE_EQUAL(bytes.size(), static_cast(written)); }; } private: static constexpr size_t c_blockSize = 512; void AddEntry(std::string_view Name, char TypeFlag, std::string_view Content, std::string_view LinkTarget) { const auto headerOffset = m_bytes.size(); m_bytes.resize(headerOffset + c_blockSize, '\0'); auto* header = m_bytes.data() + headerOffset; WriteText(header + 0, 100, Name); WriteOctal(header + 100, 8, TypeFlag == '5' ? 0755 : 0644); WriteOctal(header + 108, 8, 0); WriteOctal(header + 116, 8, 0); WriteOctal(header + 124, 12, Content.size()); WriteOctal(header + 136, 12, 0); header[156] = TypeFlag; WriteText(header + 157, 100, LinkTarget); std::memcpy(header + 257, "ustar", 5); std::memcpy(header + 263, "00", 2); // The checksum covers the whole header with its own field read as spaces. std::memset(header + 148, ' ', 8); unsigned int sum = 0; for (size_t i = 0; i < c_blockSize; ++i) { sum += static_cast(header[i]); } const auto checksum = std::format("{:06o}", sum); std::memcpy(header + 148, checksum.data(), checksum.size()); header[154] = '\0'; header[155] = ' '; // Content is padded out to a whole number of blocks. if (!Content.empty()) { const auto padded = ((Content.size() + c_blockSize - 1) / c_blockSize) * c_blockSize; const auto contentOffset = m_bytes.size(); m_bytes.resize(contentOffset + padded, '\0'); std::memcpy(m_bytes.data() + contentOffset, Content.data(), Content.size()); } } static void WriteText(char* Field, size_t Size, std::string_view Value) { VERIFY_IS_TRUE(Value.size() < Size); std::memcpy(Field, Value.data(), Value.size()); } static void WriteOctal(char* Field, size_t Size, size_t Value) { const auto text = std::format("{:0{}o}", Value, Size - 1); VERIFY_IS_TRUE(text.size() < Size); std::memcpy(Field, text.data(), text.size()); } std::vector m_bytes; }; std::string ReadFileContent(const std::filesystem::path& Path) { std::ifstream file(Path, std::ios::binary); VERIFY_IS_TRUE(file.is_open()); return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; } void WriteFileContent(const std::filesystem::path& Path, std::string_view Content) { std::ofstream file(Path, std::ios::binary); VERIFY_IS_TRUE(file.is_open()); file << Content; } size_t CountEntries(const std::filesystem::path& Directory) { return static_cast(std::distance(std::filesystem::directory_iterator(Directory), std::filesystem::directory_iterator{})); } void VerifyExtractionFails(const std::function& Extraction) { VERIFY_THROWS_SPECIFIC( Extraction(), wil::ResultException, [](const wil::ResultException& e) { return e.GetErrorCode() == E_FAIL; }); } void VerifyExtractionThrows(const std::function& Extraction) { VERIFY_THROWS_SPECIFIC(Extraction(), wil::ResultException, [](const wil::ResultException&) { return true; }); } } // namespace namespace FilesystemUnitTests { class FilesystemUnitTests { WSL_TEST_CLASS(FilesystemUnitTests) // A relative path naming a file that does not exist must still resolve to an absolute path. // std::filesystem::weakly_canonical cannot do this on its own: it builds its result from the // longest leading sequence of elements that exist, so a bare missing file name has nothing to // canonicalize and is returned unchanged. TEST_METHOD(GetCanonicalPath_RelativeMissingPathIsMadeAbsolute) { const auto name = UniqueMissingName(std::filesystem::current_path()); VERIFY_IS_FALSE(std::filesystem::weakly_canonical(name).is_absolute()); const auto result = GetCanonicalPath(name); VERIFY_IS_TRUE(result.is_absolute()); VERIFY_ARE_EQUAL((CanonicalCurrentDirectory() / name).wstring(), result.wstring()); } // The same resolution must happen for a relative path whose target already exists. TEST_METHOD(GetCanonicalPath_RelativeExistingPathIsMadeAbsolute) { const auto existing = ExistingFile(); const auto relativePath = std::filesystem::relative(existing, std::filesystem::current_path()); VERIFY_IS_FALSE(relativePath.empty()); VERIFY_IS_FALSE(relativePath.is_absolute()); const auto result = GetCanonicalPath(relativePath); VERIFY_IS_TRUE(result.is_absolute()); VERIFY_ARE_EQUAL(std::filesystem::canonical(existing).wstring(), result.wstring()); } // '.' and '..' components must be collapsed even when the intermediate directory does not exist. TEST_METHOD(GetCanonicalPath_CollapsesDotSegments) { const auto name = UniqueMissingName(std::filesystem::current_path()); const auto result = GetCanonicalPath(L".\\nonexistent\\..\\" + name); VERIFY_ARE_EQUAL((CanonicalCurrentDirectory() / name).wstring(), result.wstring()); } // An already absolute path must be returned unchanged. TEST_METHOD(GetCanonicalPath_AbsolutePathIsUnchanged) { const auto expected = CanonicalCurrentDirectory() / UniqueMissingName(std::filesystem::current_path()); VERIFY_ARE_EQUAL(expected.wstring(), GetCanonicalPath(expected).wstring()); } // A failure from std::filesystem::absolute must be reported. absolute returns an empty path when // it fails, and weakly_canonical succeeds on an empty path and clears the error_code, so calling // the two in sequence without checking in between silently turns the failure into success. TEST_METHOD(GetCanonicalPath_ErrorOverloadReportsFailure) { std::error_code error; const auto result = GetCanonicalPath(UnresolvablePath(), error); VERIFY_ARE_NOT_EQUAL(std::error_code{}, error); VERIFY_IS_TRUE(result.empty()); } // Error must be cleared when the call succeeds so callers can reuse the same variable. TEST_METHOD(GetCanonicalPath_ErrorOverloadClearsErrorOnSuccess) { const auto expected = CanonicalCurrentDirectory() / UniqueMissingName(std::filesystem::current_path()); auto error = std::make_error_code(std::errc::permission_denied); const auto result = GetCanonicalPath(expected, error); VERIFY_ARE_EQUAL(std::error_code{}, error); VERIFY_ARE_EQUAL(expected.wstring(), result.wstring()); } // The throwing overload must surface the same failure the non-throwing overload reports. TEST_METHOD(GetCanonicalPath_ThrowingOverloadSurfacesFailure) { std::error_code error; (void)GetCanonicalPath(UnresolvablePath(), error); VERIFY_ARE_NOT_EQUAL(std::error_code{}, error); const auto expectedResult = HRESULT_FROM_WIN32(error.value()); VERIFY_THROWS_SPECIFIC(GetCanonicalPath(UnresolvablePath()), wil::ResultException, [&](const wil::ResultException& e) { return e.GetErrorCode() == expectedResult; }); } // The last component of a POSIX container path is what a copy is named after. TEST_METHOD(PosixBaseName_ReturnsLastComponent) { VERIFY_ARE_EQUAL(std::string{"thelink.txt"}, PosixBaseName("/tmp/linkdir/thelink.txt")); VERIFY_ARE_EQUAL(std::string{"file.txt"}, PosixBaseName("file.txt")); VERIFY_ARE_EQUAL(std::string{"tmp"}, PosixBaseName("/tmp")); } // A trailing separator names the same entry, so it cannot change the result. TEST_METHOD(PosixBaseName_IgnoresTrailingSeparators) { VERIFY_ARE_EQUAL(std::string{"dir"}, PosixBaseName("/tmp/dir/")); VERIFY_ARE_EQUAL(std::string{"dir"}, PosixBaseName("/tmp/dir///")); } // The root and the dot components carry no name of their own. A caller uses the empty result to fall // back to the names the archive already carries. TEST_METHOD(PosixBaseName_PathsWithNoNameYieldEmpty) { const char* const paths[] = {"/", "//", ".", "..", "/tmp/.", "/tmp/..", ""}; for (const auto* path : paths) { VERIFY_ARE_EQUAL(std::string{}, PosixBaseName(path)); } } // Only '/' and NUL are barred from a POSIX name, so a basename can hold characters that no Windows // file name can. The name is returned as it stands: judging it against a Windows destination belongs // to the caller, which otherwise could not tell a rejected name from a path that has no name at all. TEST_METHOD(PosixBaseName_KeepsNamesWindowsCannotRepresent) { const std::string colonName = "a:b"; const std::string colonPath = "/tmp/a:b"; VERIFY_ARE_EQUAL(colonName, PosixBaseName(colonPath)); const std::string backslashName = "a\\b"; const std::string backslashPath = "/tmp/a\\b"; VERIFY_ARE_EQUAL(backslashName, PosixBaseName(backslashPath)); const std::string wildcardName = "a*b"; const std::string wildcardPath = "/tmp/a*b"; VERIFY_ARE_EQUAL(wildcardName, PosixBaseName(wildcardPath)); } TEST_METHOD(StripTrailingSeparators_RemovesTrailingSeparators) { const std::pair cases[] = { {L"C:\\dir\\", L"C:\\dir"}, {L"C:\\dir\\\\", L"C:\\dir"}, {L"C:/dir/", L"C:/dir"}, {L"C:\\dir", L"C:\\dir"}, {L"\\\\server\\share\\", L"\\\\server\\share"}, {L"relative\\", L"relative"}, }; for (const auto& [input, expected] : cases) { VERIFY_ARE_EQUAL(expected, StripTrailingSeparators(input)); } } TEST_METHOD(StripTrailingSeparators_KeepsRootSeparatorEscaped) { const std::pair cases[] = { {L"C:\\", L"C:\\\\"}, {L"C:/", L"C:\\\\"}, {L"C:\\\\", L"C:\\\\"}, {L"\\", L"\\\\"}, }; for (const auto& [input, expected] : cases) { VERIFY_ARE_EQUAL(expected, StripTrailingSeparators(input)); } } // Staging goes under the parent it was given so that moving entries out of it afterwards stays on one // volume, and each directory has to be distinct so concurrent copies cannot collide. TEST_METHOD(MakeStagingDirectory_CreatesUniqueDirectoryUnderParent) { const auto parent = std::filesystem::current_path(); const auto first = MakeStagingDirectory(parent); auto removeFirst = wil::scope_exit([&] { std::error_code error; std::filesystem::remove_all(first, error); }); VERIFY_IS_TRUE(std::filesystem::is_directory(first)); VERIFY_ARE_EQUAL(parent.wstring(), first.parent_path().wstring()); const auto second = MakeStagingDirectory(parent); auto removeSecond = wil::scope_exit([&] { std::error_code error; std::filesystem::remove_all(second, error); }); VERIFY_IS_TRUE(std::filesystem::is_directory(second)); VERIFY_ARE_NOT_EQUAL(first.wstring(), second.wstring()); } TEST_METHOD(IsRepresentableFileName_AcceptsNamesWindowsCanCreate) { VERIFY_IS_TRUE(IsRepresentableFileName(L"thelink.txt")); VERIFY_IS_TRUE(IsRepresentableFileName(L"name with spaces")); VERIFY_IS_TRUE(IsRepresentableFileName(L"dots.in.name")); VERIFY_IS_TRUE(IsRepresentableFileName(L"")); } TEST_METHOD(IsRepresentableFileName_RejectsReservedCharacters) { const std::wstring reserved[] = {L"ab", L"a:b", L"a\"b", L"a/b", L"a\\b", L"a|b", L"a?b", L"a*b"}; for (const auto& name : reserved) { VERIFY_IS_FALSE(IsRepresentableFileName(name), name.c_str()); } } TEST_METHOD(IsRepresentableFileName_RejectsControlCharacters) { const std::wstring control = L"a\x01z"; VERIFY_IS_FALSE(IsRepresentableFileName(control)); const std::wstring newline = L"a\nz"; VERIFY_IS_FALSE(IsRepresentableFileName(newline)); } TEST_METHOD(IsRepresentableFileName_RejectsTrailingSpaceOrDot) { const std::wstring stripped[] = {L"file.", L"file ", L"file...", L"file ", L".", L".."}; for (const auto& name : stripped) { VERIFY_IS_FALSE(IsRepresentableFileName(name), name.c_str()); } // A dot or space anywhere else survives untouched. VERIFY_IS_TRUE(IsRepresentableFileName(L".hidden")); VERIFY_IS_TRUE(IsRepresentableFileName(L"..two dots")); } TEST_METHOD(IsRepresentableFileName_RejectsReservedDeviceNames) { const std::wstring devices[] = {L"con", L"CON", L"Prn", L"aux", L"NUL", L"com1", L"COM9", L"lpt1", L"lpt9", L"LPT9"}; for (const auto& name : devices) { VERIFY_IS_FALSE(IsRepresentableFileName(name), name.c_str()); } } TEST_METHOD(IsRepresentableFileName_AcceptsNamesThatOnlyLookReserved) { const std::wstring allowed[] = {L"con2", L"com0", L"lpt0", L"com10", L"console", L"nuls", L"prnt", L"auxiliary"}; for (const auto& name : allowed) { VERIFY_IS_TRUE(IsRepresentableFileName(name), name.c_str()); } // Names with an extension are left to Win32. const std::wstring extensions[] = {L"con.txt", L"NUL.tar.gz", L"conin$", L"CONOUT$"}; for (const auto& name : extensions) { VERIFY_IS_TRUE(IsRepresentableFileName(name), name.c_str()); } } TEST_METHOD(StagingDirectory_RemovesItselfWhenScopeEnds) { std::filesystem::path recorded; { const wsl::windows::common::filesystem::StagingDirectory staging(std::filesystem::temp_directory_path()); recorded = staging.Path(); VERIFY_IS_TRUE(std::filesystem::is_directory(recorded)); // Content under it goes away with it. std::ofstream file(recorded / L"entry.txt"); file << "content"; file.close(); VERIFY_IS_TRUE(std::filesystem::exists(recorded / L"entry.txt")); } VERIFY_IS_FALSE(std::filesystem::exists(recorded)); } TEST_METHOD(ExtractSingleFileAs_PlacesEntryUnderRequestedName) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"renamed.txt"; ExtractSingleFileAs(destination, TarBuilder().AddFile("original.txt", "content").Writer()); VERIFY_IS_TRUE(std::filesystem::is_regular_file(destination)); VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(destination)); VERIFY_IS_FALSE(std::filesystem::exists(root.Path() / L"original.txt")); } TEST_METHOD(ExtractSingleFileAs_PreservesEmptyFile) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"empty.txt"; ExtractSingleFileAs(destination, TarBuilder().AddFile("source.txt", "").Writer()); VERIFY_IS_TRUE(std::filesystem::is_regular_file(destination)); VERIFY_ARE_EQUAL(std::string{}, ReadFileContent(destination)); } TEST_METHOD(ExtractSingleFileAs_CreatesMissingParentDirectories) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"missing" / L"nested" / L"file.txt"; ExtractSingleFileAs(destination, TarBuilder().AddFile("source.txt", "content").Writer()); VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(destination)); } TEST_METHOD(ExtractSingleFileAs_OverwritesExistingDestination) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"file.txt"; WriteFileContent(destination, "stale"); ExtractSingleFileAs(destination, TarBuilder().AddFile("source.txt", "fresh").Writer()); VERIFY_ARE_EQUAL(std::string{"fresh"}, ReadFileContent(destination)); } TEST_METHOD(ExtractSingleFileAs_EmptyArchiveIsRejected) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"file.txt"; VerifyExtractionFails([&] { ExtractSingleFileAs(destination, TarBuilder().Writer()); }); VERIFY_IS_FALSE(std::filesystem::exists(destination)); } TEST_METHOD(ExtractSingleFileAs_SeveralEntriesAreRejected) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"file.txt"; VerifyExtractionFails([&] { ExtractSingleFileAs(destination, TarBuilder().AddFile("first.txt", "one").AddFile("second.txt", "two").Writer()); }); VERIFY_IS_FALSE(std::filesystem::exists(destination)); } TEST_METHOD(ExtractSingleFileAs_CollidingEntriesAreRejected) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"file.txt"; VerifyExtractionFails( [&] { ExtractSingleFileAs(destination, TarBuilder().AddFile("name", "one").AddFile("NAME", "two").Writer()); }); VERIFY_IS_FALSE(std::filesystem::exists(destination)); } TEST_METHOD(ExtractSingleFileAs_DirectoryEntryIsRejected) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"file.txt"; VerifyExtractionFails([&] { ExtractSingleFileAs(destination, TarBuilder().AddDirectory("adirectory").Writer()); }); VERIFY_IS_FALSE(std::filesystem::exists(destination)); } TEST_METHOD(ExtractSingleFileAs_PopulatedDirectoryIsRejected) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"file.txt"; VerifyExtractionFails([&] { ExtractSingleFileAs(destination, TarBuilder().AddDirectory("adirectory").AddFile("adirectory/inner.txt", "content").Writer()); }); VERIFY_IS_FALSE(std::filesystem::exists(destination)); } TEST_METHOD(ExtractSingleFileAs_LeavesExistingDestinationIntactWhenRejected) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"file.txt"; WriteFileContent(destination, "original"); VerifyExtractionFails([&] { ExtractSingleFileAs(destination, TarBuilder().AddFile("first.txt", "one").AddFile("second.txt", "two").Writer()); }); VERIFY_ARE_EQUAL(std::string{"original"}, ReadFileContent(destination)); } TEST_METHOD(ExtractSingleFileAs_SanitizedEntryNameDoesNotReachTheResult) { const std::string names[] = {"a:b", "a*b", "a|b", "a(1), CountEntries(root.Path())); VerifyExtractionFails([&] { ExtractSingleFileAs(root.Path() / L"other.txt", TarBuilder().Writer()); }); VERIFY_ARE_EQUAL(static_cast(1), CountEntries(root.Path())); } TEST_METHOD(ExtractArchiveInto_WithoutRebaseNameKeepsEntryNames) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto(root.Path(), std::nullopt, TarBuilder().AddFile("first.txt", "one").AddFile("second.txt", "two").Writer()); VERIFY_ARE_EQUAL(std::string{"one"}, ReadFileContent(root.Path() / L"first.txt")); VERIFY_ARE_EQUAL(std::string{"two"}, ReadFileContent(root.Path() / L"second.txt")); } TEST_METHOD(ExtractArchiveInto_MergesExistingDirectories) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto existing = root.Path() / L"dir"; std::filesystem::create_directories(existing); WriteFileContent(existing / L"old.txt", "old"); ExtractArchiveInto(root.Path(), std::nullopt, TarBuilder().AddFile("dir/new.txt", "new").Writer()); VERIFY_ARE_EQUAL(std::string{"old"}, ReadFileContent(existing / L"old.txt")); VERIFY_ARE_EQUAL(std::string{"new"}, ReadFileContent(existing / L"new.txt")); } TEST_METHOD(ExtractArchiveInto_DoesNotFollowExistingDestinationSymlink) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"destination"; const auto outside = root.Path() / L"outside"; std::filesystem::create_directories(destination); std::filesystem::create_directories(outside); std::error_code error; std::filesystem::create_directory_symlink(outside, destination / L"link", error); if (error) { WEX::Logging::Log::Comment(L"Creating a directory symbolic link is not permitted on this host"); return; } VerifyExtractionFails( [&] { ExtractArchiveInto(destination, std::nullopt, TarBuilder().AddFile("link/child.txt", "content").Writer()); }); VERIFY_IS_FALSE(std::filesystem::exists(outside / L"child.txt")); VERIFY_IS_TRUE(std::filesystem::is_symlink(destination / L"link")); } TEST_METHOD(ExtractArchiveInto_RebasesLoneEntry) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto( root.Path(), std::optional{L"renamed.txt"}, TarBuilder().AddFile("original.txt", "content").Writer()); VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(root.Path() / L"renamed.txt")); VERIFY_IS_FALSE(std::filesystem::exists(root.Path() / L"original.txt")); } TEST_METHOD(ExtractArchiveInto_GathersSeveralEntriesUnderRebaseName) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto( root.Path(), std::optional{L"gathered"}, TarBuilder().AddFile("first.txt", "one").AddFile("second.txt", "two").Writer()); VERIFY_IS_TRUE(std::filesystem::is_directory(root.Path() / L"gathered")); VERIFY_ARE_EQUAL(std::string{"one"}, ReadFileContent(root.Path() / L"gathered" / L"first.txt")); VERIFY_ARE_EQUAL(std::string{"two"}, ReadFileContent(root.Path() / L"gathered" / L"second.txt")); } TEST_METHOD(ExtractArchiveInto_CollidingRootNamesStillGatherUnderRebaseName) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto( root.Path(), std::optional{L"gathered"}, TarBuilder().AddFile("name", "one").AddFile("NAME", "two").Writer()); VERIFY_IS_TRUE(std::filesystem::is_directory(root.Path() / L"gathered")); VERIFY_ARE_EQUAL(std::string{"two"}, ReadFileContent(root.Path() / L"gathered" / L"name")); } TEST_METHOD(ExtractArchiveInto_RebasesDirectoryWithSeveralArchiveMembers) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto( root.Path(), std::optional{L"renamed"}, TarBuilder().AddDirectory("original").AddFile("original/child.txt", "content").Writer()); VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(root.Path() / L"renamed" / L"child.txt")); } TEST_METHOD(ExtractArchiveInto_EmptyRebaseNameMergesEntries) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto( root.Path(), std::optional{L""}, TarBuilder().AddFile("first.txt", "one").AddFile("second.txt", "two").Writer()); VERIFY_ARE_EQUAL(std::string{"one"}, ReadFileContent(root.Path() / L"first.txt")); VERIFY_ARE_EQUAL(std::string{"two"}, ReadFileContent(root.Path() / L"second.txt")); } TEST_METHOD(ExtractArchiveInto_CreatesMissingDestination) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"missing" / L"nested"; ExtractArchiveInto(destination, std::nullopt, TarBuilder().AddFile("file.txt", "content").Writer()); VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(destination / L"file.txt")); } TEST_METHOD(ExtractArchiveInto_RemovesStagingDirectory) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto( root.Path(), std::optional{L"renamed.txt"}, TarBuilder().AddFile("original.txt", "content").Writer()); VERIFY_ARE_EQUAL(static_cast(1), CountEntries(root.Path())); } TEST_METHOD(ExtractArchiveInto_RebaseNameWindowsCannotHoldIsRejected) { const StagingDirectory root(std::filesystem::temp_directory_path()); const auto destination = root.Path() / L"destination"; // "." and ".." trim to an empty name. const std::wstring rejected[] = {L"nul", L"COM1", L"has:colon", L".", L"..", L" ", L"sub/dir"}; for (const auto& name : rejected) { VERIFY_THROWS_SPECIFIC( ExtractArchiveInto(destination, std::optional{name}, TarBuilder().AddFile("file.txt", "content").Writer()), wil::ResultException, [](const wil::ResultException& e) { return e.GetErrorCode() == E_INVALIDARG; }); VERIFY_IS_FALSE(std::filesystem::exists(destination), name.c_str()); } } TEST_METHOD(ExtractArchiveInto_RebaseNameTrailingDotsAndSpacesAreTrimmed) { const std::pair trimmed[] = { {L"trail.", L"trail"}, {L"trail ", L"trail"}, {L"trail...", L"trail"}, {L"trail . . ", L"trail"}}; for (const auto& [requested, expected] : trimmed) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto( root.Path(), std::optional{requested}, TarBuilder().AddFile("original.txt", "content").Writer()); VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(root.Path() / expected)); VERIFY_ARE_EQUAL(static_cast(1), CountEntries(root.Path())); } } TEST_METHOD(ExtractArchiveInto_TrimmedRebaseNameGathersSeveralEntries) { const StagingDirectory root(std::filesystem::temp_directory_path()); ExtractArchiveInto( root.Path(), std::optional{L"gathered. "}, TarBuilder().AddFile("first.txt", "one").AddFile("second.txt", "two").Writer()); VERIFY_IS_TRUE(std::filesystem::is_directory(root.Path() / L"gathered")); VERIFY_ARE_EQUAL(std::string{"one"}, ReadFileContent(root.Path() / L"gathered" / L"first.txt")); VERIFY_ARE_EQUAL(std::string{"two"}, ReadFileContent(root.Path() / L"gathered" / L"second.txt")); } }; } // namespace FilesystemUnitTests