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
15 changes: 15 additions & 0 deletions src/lang/io/include/sourcemeta/core/io.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sourcemeta/core/io.h>
/// #include <cassert>
///
/// 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
Expand Down
39 changes: 23 additions & 16 deletions src/lang/io/io.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
}
Comment on lines +159 to 160

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Empty-prefix handling regressed in lexical path check. Normalizing an empty prefix turns it into ".", changing prefix semantics versus is_under_path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lang/io/io.cc, line 159:

<comment>Empty-prefix handling regressed in lexical path check. Normalizing an empty prefix turns it into ".", changing prefix semantics versus is_under_path.</comment>

<file context>
@@ -133,23 +150,13 @@ auto weakly_canonical(const std::filesystem::path &path)
-  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));
 }
 
</file context>
Suggested change
return path_components_cover(normalize(path), normalize(prefix));
}
return path_components_cover(path.empty() ? path : normalize(path),
prefix.empty() ? prefix : normalize(prefix));


auto strip_path_prefix(const std::filesystem::path &path,
Expand Down
1 change: 1 addition & 0 deletions test/io/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions test/io/io_is_lexically_under_path_test.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#include <gtest/gtest.h>

#include <sourcemeta/core/io.h>

#include <string_view> // 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
Loading