Files
WSL/test/windows/FilesystemUnitTests.cpp

927 lines
37 KiB
C++

/*++
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<std::wstring>(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<char> 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<void(HANDLE)> Writer() const
{
return [bytes = Build()](HANDLE Handle) {
DWORD written = 0;
VERIFY_IS_TRUE(!!WriteFile(Handle, bytes.data(), static_cast<DWORD>(bytes.size()), &written, nullptr));
VERIFY_ARE_EQUAL(bytes.size(), static_cast<size_t>(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<unsigned char>(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<char> 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<char>(file), std::istreambuf_iterator<char>()};
}
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<size_t>(std::distance(std::filesystem::directory_iterator(Directory), std::filesystem::directory_iterator{}));
}
void VerifyExtractionFails(const std::function<void()>& Extraction)
{
VERIFY_THROWS_SPECIFIC(
Extraction(), wil::ResultException, [](const wil::ResultException& e) { return e.GetErrorCode() == E_FAIL; });
}
void VerifyExtractionThrows(const std::function<void()>& 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<std::wstring, std::wstring> 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<std::wstring, std::wstring> 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"a<b", L"a>b", 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<b", "a?b"};
for (const auto& name : names)
{
const StagingDirectory root(std::filesystem::temp_directory_path());
const auto destination = root.Path() / L"file.txt";
ExtractSingleFileAs(destination, TarBuilder().AddFile(name, "content").Writer());
VERIFY_IS_TRUE(std::filesystem::is_regular_file(destination));
VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(destination));
}
}
TEST_METHOD(ExtractSingleFileAs_EntryNameWindowsCannotAddressFails)
{
const std::string names[] = {"trail.", "trail ", "nul"};
for (const auto& name : names)
{
const StagingDirectory root(std::filesystem::temp_directory_path());
const auto destination = root.Path() / L"file.txt";
VerifyExtractionThrows([&] { ExtractSingleFileAs(destination, TarBuilder().AddFile(name, "content").Writer()); });
VERIFY_IS_FALSE(std::filesystem::exists(destination));
}
}
TEST_METHOD(ExtractSingleFileAs_EntryEscapingTheDestinationIsRejected)
{
const StagingDirectory root(std::filesystem::temp_directory_path());
const auto destination = root.Path() / L"file.txt";
VerifyExtractionFails(
[&] { ExtractSingleFileAs(destination, TarBuilder().AddFile("dir/../escaped.txt", "content").Writer()); });
VERIFY_IS_FALSE(std::filesystem::exists(destination));
VERIFY_IS_FALSE(std::filesystem::exists(root.Path() / L"escaped.txt"));
}
TEST_METHOD(ExtractArchiveInto_AbsoluteEntryNameStaysInsideDestination)
{
const StagingDirectory root(std::filesystem::temp_directory_path());
const auto destination = root.Path() / L"destination";
// The staging directory name is unique, so nothing already at the drive root can share it.
const auto unique = root.Path().filename().string();
const auto rooted = unique + "-rooted.txt";
const auto drive = unique + "-drive.txt";
ExtractArchiveInto(destination, std::nullopt, TarBuilder().AddFile("/" + rooted, "rooted").AddFile("C:/" + drive, "drive").Writer());
VERIFY_ARE_EQUAL(std::string{"rooted"}, ReadFileContent(destination / rooted));
VERIFY_ARE_EQUAL(std::string{"drive"}, ReadFileContent(destination / drive));
VERIFY_IS_FALSE(std::filesystem::exists(std::filesystem::path{L"C:\\"} / rooted));
VERIFY_IS_FALSE(std::filesystem::exists(std::filesystem::path{L"C:\\"} / drive));
}
TEST_METHOD(ExtractArchiveInto_HardLinkOutsideDestinationIsRejected)
{
const StagingDirectory root(std::filesystem::temp_directory_path());
const auto destination = root.Path() / L"destination";
const auto victim = root.Path() / L"victim.txt";
WriteFileContent(victim, "original");
VerifyExtractionFails([&] {
ExtractArchiveInto(
destination, std::nullopt, TarBuilder().AddHardLink("link", "../victim.txt").AddFile("link", "overwritten").Writer());
});
VERIFY_ARE_EQUAL(std::string{"original"}, ReadFileContent(victim));
}
TEST_METHOD(ExtractArchiveInto_EntryUnderSymlinkEntryIsRejected)
{
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(outside);
// Windows treats both separators alike and ignores case.
const std::string names[] = {"link/child.txt", "link\\child.txt", "LINK/child.txt"};
for (const auto& name : names)
{
VerifyExtractionFails([&] {
ExtractArchiveInto(
destination,
std::nullopt,
TarBuilder().AddFile("first.txt", "first").AddSymlink("link", "../outside").AddFile(name, "escaped").Writer());
});
VERIFY_IS_FALSE(std::filesystem::exists(outside / L"child.txt"));
VERIFY_IS_FALSE(std::filesystem::exists(destination / L"first.txt"));
}
}
TEST_METHOD(ExtractArchiveInto_UnicodeCaseVariantUnderSymlinkIsRejected)
{
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(outside);
VerifyExtractionFails([&] {
ExtractArchiveInto(
destination,
std::nullopt,
TarBuilder()
.AddFile("first.txt", "first")
.AddSymlink("\xC3\x84", "../../outside")
.AddFile("\xC3\xA4/child.txt", "escaped")
.Writer());
});
VERIFY_IS_FALSE(std::filesystem::exists(outside / L"child.txt"));
VERIFY_IS_FALSE(std::filesystem::exists(destination / L"first.txt"));
}
TEST_METHOD(ExtractArchiveInto_SymlinkEntryIsExtracted)
{
const StagingDirectory probe(std::filesystem::temp_directory_path());
std::error_code symlinkError;
std::filesystem::create_symlink(L"target", probe.Path() / L"probe", symlinkError);
if (symlinkError)
{
WEX::Logging::Log::Comment(L"Creating a symbolic link is not permitted on this host");
return;
}
const StagingDirectory root(std::filesystem::temp_directory_path());
const auto destination = root.Path() / L"destination";
ExtractArchiveInto(
destination,
std::nullopt,
TarBuilder().AddSymlink("relative", "../outside").AddSymlink("absolute", "/etc/passwd").AddFile("file.txt", "content").Writer());
VERIFY_IS_TRUE(std::filesystem::is_symlink(destination / L"relative"));
VERIFY_IS_TRUE(std::filesystem::is_symlink(destination / L"absolute"));
VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(destination / L"file.txt"));
}
TEST_METHOD(ExtractArchiveInto_EntryUnderDirectoryNamedLikeASymlinkIsExtracted)
{
const StagingDirectory root(std::filesystem::temp_directory_path());
const auto destination = root.Path() / L"destination";
ExtractArchiveInto(destination, std::nullopt, TarBuilder().AddDirectory("link").AddFile("link/child.txt", "content").Writer());
VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(destination / L"link" / L"child.txt"));
}
TEST_METHOD(ExtractSingleFileAs_RemovesStagingDirectory)
{
const StagingDirectory root(std::filesystem::temp_directory_path());
ExtractSingleFileAs(root.Path() / L"file.txt", TarBuilder().AddFile("source.txt", "content").Writer());
VERIFY_ARE_EQUAL(static_cast<size_t>(1), CountEntries(root.Path()));
VerifyExtractionFails([&] { ExtractSingleFileAs(root.Path() / L"other.txt", TarBuilder().Writer()); });
VERIFY_ARE_EQUAL(static_cast<size_t>(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<std::wstring>{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<std::wstring>{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<std::wstring>{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<std::wstring>{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<std::wstring>{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<std::wstring>{L"renamed.txt"}, TarBuilder().AddFile("original.txt", "content").Writer());
VERIFY_ARE_EQUAL(static_cast<size_t>(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<std::wstring>{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<std::wstring, std::wstring> 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<std::wstring>{requested}, TarBuilder().AddFile("original.txt", "content").Writer());
VERIFY_ARE_EQUAL(std::string{"content"}, ReadFileContent(root.Path() / expected));
VERIFY_ARE_EQUAL(static_cast<size_t>(1), CountEntries(root.Path()));
}
}
TEST_METHOD(ExtractArchiveInto_TrimmedRebaseNameGathersSeveralEntries)
{
const StagingDirectory root(std::filesystem::temp_directory_path());
ExtractArchiveInto(
root.Path(),
std::optional<std::wstring>{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