diff --git a/src/lang/io/include/sourcemeta/core/io.h b/src/lang/io/include/sourcemeta/core/io.h index 771b4d0b8..fb2736f71 100644 --- a/src/lang/io/include/sourcemeta/core/io.h +++ b/src/lang/io/include/sourcemeta/core/io.h @@ -85,6 +85,21 @@ SOURCEMETA_CORE_IO_EXPORT auto is_under_path(const std::filesystem::path &path, const std::filesystem::path &prefix) -> bool; +/// @ingroup io +/// +/// Check whether a path lies under another path lexically, comparing component +/// by component without resolving against the filesystem. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::is_lexically_under_path("foo/bar/baz", "foo")); +/// ``` +SOURCEMETA_CORE_IO_EXPORT +auto is_lexically_under_path(const std::filesystem::path &path, + const std::filesystem::path &prefix) -> bool; + /// @ingroup io /// /// Return the portion of a path that follows a given prefix, or the path diff --git a/src/lang/io/io.cc b/src/lang/io/io.cc index 6bf742eac..322e7967b 100644 --- a/src/lang/io/io.cc +++ b/src/lang/io/io.cc @@ -84,6 +84,23 @@ auto normalize(const std::filesystem::path &canonical_path) return normalized; } +auto path_components_cover(const std::filesystem::path &path, + const std::filesystem::path &prefix) -> bool { + auto path_iterator{path.begin()}; + auto prefix_iterator{prefix.begin()}; + + while (prefix_iterator != prefix.end()) { + if (path_iterator == path.end() || *path_iterator != *prefix_iterator) { + return false; + } + + ++path_iterator; + ++prefix_iterator; + } + + return true; +} + } // namespace namespace sourcemeta::core { @@ -133,23 +150,13 @@ auto weakly_canonical(const std::filesystem::path &path) auto is_under_path(const std::filesystem::path &path, const std::filesystem::path &prefix) -> bool { - const auto canonical_path{sourcemeta::core::weakly_canonical(path)}; - const auto canonical_prefix{sourcemeta::core::weakly_canonical(prefix)}; - - auto path_iterator{canonical_path.begin()}; - auto prefix_iterator{canonical_prefix.begin()}; - - while (prefix_iterator != canonical_prefix.end()) { - if (path_iterator == canonical_path.end() || - *path_iterator != *prefix_iterator) { - return false; - } - - ++path_iterator; - ++prefix_iterator; - } + return path_components_cover(sourcemeta::core::weakly_canonical(path), + sourcemeta::core::weakly_canonical(prefix)); +} - return true; +auto is_lexically_under_path(const std::filesystem::path &path, + const std::filesystem::path &prefix) -> bool { + return path_components_cover(normalize(path), normalize(prefix)); } auto strip_path_prefix(const std::filesystem::path &path, diff --git a/test/io/CMakeLists.txt b/test/io/CMakeLists.txt index a80e0dac4..acfec5a89 100644 --- a/test/io/CMakeLists.txt +++ b/test/io/CMakeLists.txt @@ -12,6 +12,7 @@ sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME io io_read_file_test.cc io_read_to_string_test.cc io_is_under_path_test.cc + io_is_lexically_under_path_test.cc io_strip_path_prefix_test.cc io_temporary_test.cc io_weakly_canonical_test.cc diff --git a/test/io/io_is_lexically_under_path_test.cc b/test/io/io_is_lexically_under_path_test.cc new file mode 100644 index 000000000..8d298f534 --- /dev/null +++ b/test/io/io_is_lexically_under_path_test.cc @@ -0,0 +1,123 @@ +#include + +#include + +#include // std::string_view + +TEST(IO_is_lexically_under_path, relative_exact_match) { + const std::filesystem::path path{"foo/bar"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_nested) { + const std::filesystem::path path{"foo/bar/baz"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_sibling) { + const std::filesystem::path path{"foo/bar"}; + const std::filesystem::path prefix{"foo/baz"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_unrelated_branches) { + const std::filesystem::path path{"foo/bar"}; + const std::filesystem::path prefix{"baz"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_same_chars_not_component) { + const std::filesystem::path path{"foo/barbaz"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_prefix_longer_than_path) { + const std::filesystem::path path{"foo"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_trailing_slash_prefix) { + const std::filesystem::path path{"foo/bar"}; + const std::filesystem::path prefix{"foo/bar/"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_repeated_slashes) { + const std::filesystem::path path{"foo//bar//baz"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_dot_normalization) { + const std::filesystem::path path{"foo/./bar"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_dotdot_net_under_prefix) { + const std::filesystem::path path{"foo/x/../bar"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_dotdot_escapes_prefix) { + const std::filesystem::path path{"foo/bar/../../etc"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_dotdot_smuggled_inside_after_match) { + const std::filesystem::path path{"foo/bar/x/../../baz"}; + const std::filesystem::path prefix{"foo/bar"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_empty_prefix) { + const std::filesystem::path path{"foo/bar"}; + const std::filesystem::path prefix{}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_string_view_arguments) { + const std::string_view path{"foo/bar/baz"}; + const std::string_view prefix{"foo/bar"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, relative_string_view_not_under) { + const std::string_view path{"foo/barbaz"}; + const std::string_view prefix{"foo/bar"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +#ifndef _WIN32 + +TEST(IO_is_lexically_under_path, posix_absolute_nested) { + const std::filesystem::path path{"/foo/bar/baz"}; + const std::filesystem::path prefix{"/foo/bar"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, posix_absolute_traversal_into_etc) { + const std::filesystem::path path{"/foo/../../etc/passwd"}; + const std::filesystem::path prefix{"/foo"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, posix_root_prefix_matches_everything) { + const std::filesystem::path path{"/foo/bar/baz"}; + const std::filesystem::path prefix{"/"}; + EXPECT_TRUE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +TEST(IO_is_lexically_under_path, posix_relative_vs_absolute) { + const std::filesystem::path path{"foo/bar"}; + const std::filesystem::path prefix{"/foo"}; + EXPECT_FALSE(sourcemeta::core::is_lexically_under_path(path, prefix)); +} + +#endif