Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/lang/io/include/sourcemeta/core/io.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ auto read_file(const std::filesystem::path &path)
return stream;
}

/// @ingroup io
///
/// Recursively mirror a directory tree using hard links for regular files.
/// Directories are created, regular files are hard-linked. Both paths must
/// reside on the same filesystem. The destination must not be inside the
/// source tree, as that would cause infinite recursion.
///
/// ```cpp
/// #include <sourcemeta/core/io.h>
///
/// sourcemeta::core::hardlink_directory("/source", "/destination");
/// ```
SOURCEMETA_CORE_IO_EXPORT
auto hardlink_directory(const std::filesystem::path &source,
const std::filesystem::path &destination) -> void;

/// @ingroup io
///
/// Flush an existing file to disk, beyond just to the operating system. For
Expand Down
19 changes: 19 additions & 0 deletions src/lang/io/io.cc
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ auto starts_with(const std::filesystem::path &path,
return true;
}

auto hardlink_directory(const std::filesystem::path &source,
const std::filesystem::path &destination) -> void {
assert(std::filesystem::is_directory(source));
assert(!std::filesystem::exists(destination) ||
std::filesystem::is_directory(destination));
assert(!starts_with(destination, source));
std::filesystem::create_directories(destination);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardlink_directory creates destination before walking source; if destination is inside source (or equal to it), the recursive iterator can traverse the newly created tree and lead to unbounded recursion/work. Consider explicitly rejecting that case.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

for (const auto &entry :
std::filesystem::recursive_directory_iterator{source}) {
const auto target{destination /
std::filesystem::relative(entry.path(), source)};
if (entry.is_directory()) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

directory_entry::is_directory() / is_regular_file() follow symlinks, so a symlink-to-dir/file could be treated as a real dir/file and end up mirrored incorrectly (e.g., creating a directory instead of preserving the symlink). If symlinks should be skipped/preserved, this likely needs an explicit is_symlink()/symlink_status() check.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

std::filesystem::create_directories(target);
} else if (entry.is_regular_file()) {
std::filesystem::create_hard_link(entry.path(), target);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since destination is allowed to already exist, create_hard_link will throw if target already exists (non-empty destination or re-run). It might be worth documenting whether destination must be empty or handling collisions deterministically.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

}
}
}

auto flush(const std::filesystem::path &path) -> void {
#if defined(_WIN32)
HANDLE hFile =
Expand Down
6 changes: 4 additions & 2 deletions test/io/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME io
io_starts_with_test.cc
io_read_file_test.cc
io_fileview_test.cc
io_temporary_test.cc)
io_temporary_test.cc
io_hardlink_directory_test.cc)

target_link_libraries(sourcemeta_core_io_unit
PRIVATE sourcemeta::core::io)
target_compile_definitions(sourcemeta_core_io_unit
PRIVATE STUBS_DIRECTORY="${CMAKE_CURRENT_SOURCE_DIR}/stubs")
PRIVATE STUBS_DIRECTORY="${CMAKE_CURRENT_SOURCE_DIR}/stubs"
PRIVATE BUILD_DIRECTORY="${CMAKE_CURRENT_BINARY_DIR}")
101 changes: 101 additions & 0 deletions test/io/io_hardlink_directory_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#include <gtest/gtest.h>

#include <sourcemeta/core/io.h>

#include <filesystem> // std::filesystem
#include <fstream> // std::ofstream, std::ifstream
#include <string> // std::string

TEST(IO_hardlink_directory, mirrors_structure) {
const sourcemeta::core::TemporaryDirectory workspace{
std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"};
const auto source{workspace.path() / "source"};
const auto destination{workspace.path() / "destination"};

std::filesystem::create_directories(source / "subdir");
std::ofstream{source / "a.txt"} << "alpha";
std::ofstream{source / "subdir" / "b.txt"} << "beta";

sourcemeta::core::hardlink_directory(source, destination);

EXPECT_TRUE(std::filesystem::exists(destination / "a.txt"));
EXPECT_TRUE(std::filesystem::exists(destination / "subdir" / "b.txt"));
EXPECT_TRUE(std::filesystem::is_directory(destination / "subdir"));
}

TEST(IO_hardlink_directory, files_share_inodes) {
const sourcemeta::core::TemporaryDirectory workspace{
std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"};
const auto source{workspace.path() / "source"};
const auto destination{workspace.path() / "destination"};

std::filesystem::create_directory(source);
std::ofstream{source / "file.txt"} << "content";

sourcemeta::core::hardlink_directory(source, destination);

EXPECT_EQ(std::filesystem::hard_link_count(source / "file.txt"), 2);
EXPECT_EQ(std::filesystem::hard_link_count(destination / "file.txt"), 2);
EXPECT_TRUE(std::filesystem::equivalent(source / "file.txt",
destination / "file.txt"));
}

TEST(IO_hardlink_directory, unlink_then_write_independence) {
const sourcemeta::core::TemporaryDirectory workspace{
std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"};
const auto source{workspace.path() / "source"};
const auto destination{workspace.path() / "destination"};

std::filesystem::create_directory(source);
std::ofstream{source / "file.txt"} << "original";

sourcemeta::core::hardlink_directory(source, destination);

std::filesystem::remove(destination / "file.txt");
std::ofstream{destination / "file.txt"} << "modified";

std::ifstream source_stream{source / "file.txt"};
std::string source_content;
std::getline(source_stream, source_content);
EXPECT_EQ(source_content, "original");

std::ifstream destination_stream{destination / "file.txt"};
std::string destination_content;
std::getline(destination_stream, destination_content);
EXPECT_EQ(destination_content, "modified");
}

TEST(IO_hardlink_directory, empty_source) {
const sourcemeta::core::TemporaryDirectory workspace{
std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"};
const auto source{workspace.path() / "source"};
const auto destination{workspace.path() / "destination"};

std::filesystem::create_directory(source);

sourcemeta::core::hardlink_directory(source, destination);

EXPECT_TRUE(std::filesystem::exists(destination));
EXPECT_TRUE(std::filesystem::is_directory(destination));
EXPECT_TRUE(std::filesystem::is_empty(destination));
}

TEST(IO_hardlink_directory, deeply_nested) {
const sourcemeta::core::TemporaryDirectory workspace{
std::filesystem::path{BUILD_DIRECTORY}, ".test-hardlink-"};
const auto source{workspace.path() / "source"};
const auto destination{workspace.path() / "destination"};

std::filesystem::create_directories(source / "a" / "b" / "c");
std::ofstream{source / "a" / "b" / "c" / "deep.txt"} << "deep";

sourcemeta::core::hardlink_directory(source, destination);

EXPECT_TRUE(
std::filesystem::exists(destination / "a" / "b" / "c" / "deep.txt"));

std::ifstream stream{destination / "a" / "b" / "c" / "deep.txt"};
std::string content;
std::getline(stream, content);
EXPECT_EQ(content, "deep");
}
18 changes: 9 additions & 9 deletions test/io/io_temporary_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@
#include <string> // std::string

TEST(IO_TemporaryDirectory, creates_directory) {
const auto parent{std::filesystem::temp_directory_path()};
const auto parent{std::filesystem::path{BUILD_DIRECTORY}};
const sourcemeta::core::TemporaryDirectory temporary{parent, ".test-"};
EXPECT_TRUE(std::filesystem::exists(temporary.path()));
EXPECT_TRUE(std::filesystem::is_directory(temporary.path()));
}

TEST(IO_TemporaryDirectory, unique_names) {
const auto parent{std::filesystem::temp_directory_path()};
const auto parent{std::filesystem::path{BUILD_DIRECTORY}};
const sourcemeta::core::TemporaryDirectory first{parent, ".test-"};
const sourcemeta::core::TemporaryDirectory second{parent, ".test-"};
EXPECT_NE(first.path(), second.path());
}

TEST(IO_TemporaryDirectory, custom_prefix) {
const auto parent{std::filesystem::temp_directory_path()};
const auto parent{std::filesystem::path{BUILD_DIRECTORY}};
const sourcemeta::core::TemporaryDirectory temporary{parent,
".my-test-prefix-"};
const auto filename{temporary.path().filename().string()};
Expand All @@ -32,7 +32,7 @@ TEST(IO_TemporaryDirectory, removed_on_destruction) {
std::filesystem::path path_copy;
{
const sourcemeta::core::TemporaryDirectory temporary{
std::filesystem::temp_directory_path(), ".test-"};
std::filesystem::path{BUILD_DIRECTORY}, ".test-"};
path_copy = temporary.path();
EXPECT_TRUE(std::filesystem::exists(path_copy));
}
Expand All @@ -43,7 +43,7 @@ TEST(IO_TemporaryDirectory, removed_on_destruction_non_empty) {
std::filesystem::path path_copy;
{
const sourcemeta::core::TemporaryDirectory temporary{
std::filesystem::temp_directory_path(), ".test-"};
std::filesystem::path{BUILD_DIRECTORY}, ".test-"};
path_copy = temporary.path();
std::filesystem::create_directory(path_copy / "subdir");
std::ofstream{path_copy / "file.txt"} << "hello";
Expand All @@ -55,14 +55,14 @@ TEST(IO_TemporaryDirectory, removed_on_destruction_non_empty) {

TEST(IO_TemporaryDirectory, inside_parent) {
const auto parent{
std::filesystem::canonical(std::filesystem::temp_directory_path())};
std::filesystem::canonical(std::filesystem::path{BUILD_DIRECTORY})};
const sourcemeta::core::TemporaryDirectory temporary{parent, ".test-"};
EXPECT_EQ(temporary.path().parent_path(), parent);
}

TEST(IO_TemporaryDirectory, creates_nonexistent_parent) {
const sourcemeta::core::TemporaryDirectory unique_parent{
std::filesystem::temp_directory_path(), ".test-parent-"};
std::filesystem::path{BUILD_DIRECTORY}, ".test-parent-"};
const auto parent{unique_parent.path() / "nonexistent"};
EXPECT_FALSE(std::filesystem::exists(parent));
const sourcemeta::core::TemporaryDirectory temporary{parent, ".test-"};
Expand All @@ -72,7 +72,7 @@ TEST(IO_TemporaryDirectory, creates_nonexistent_parent) {

TEST(IO_TemporaryDirectory, creates_nested_nonexistent_parents) {
const sourcemeta::core::TemporaryDirectory unique_parent{
std::filesystem::temp_directory_path(), ".test-parent-"};
std::filesystem::path{BUILD_DIRECTORY}, ".test-parent-"};
const auto parent{unique_parent.path() / "nested_a" / "nested_b"};
EXPECT_FALSE(std::filesystem::exists(parent));
const sourcemeta::core::TemporaryDirectory temporary{parent, ".test-"};
Expand All @@ -82,7 +82,7 @@ TEST(IO_TemporaryDirectory, creates_nested_nonexistent_parents) {

TEST(IO_TemporaryDirectory, parent_is_a_file) {
const sourcemeta::core::TemporaryDirectory unique_parent{
std::filesystem::temp_directory_path(), ".test-parent-"};
std::filesystem::path{BUILD_DIRECTORY}, ".test-parent-"};
const auto file_path{unique_parent.path() / "test_parent_file"};
std::ofstream{file_path};
EXPECT_TRUE(std::filesystem::exists(file_path));
Expand Down