From 8ea652c0894fb2048eb1235e1f7a45eaa7bf966d Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:02:32 +0000 Subject: [PATCH 1/2] Preserve native case policy for symlink names --- CHANGELOG.md | 3 ++ docs/design-expression-program.md | 9 +++- xff/engine/run.cc | 16 +++++- xff/engine/uniform_case_test.cc | 88 +++++++++++++++++++++++++++++-- 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 209f43b866..2803a97e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ # 0.11.0 +- Preserve filesystem-native name matching for dangling and cross-volume symlinks when case + sensitivity is resolved lazily. + - Avoid unnecessary per-entry stat requests for lexical listing templates while preserving required metadata, fuzzy scores and conservative column output. diff --git a/docs/design-expression-program.md b/docs/design-expression-program.md index 99f8ce1cb1..3b3885b412 100644 --- a/docs/design-expression-program.md +++ b/docs/design-expression-program.md @@ -790,8 +790,13 @@ entry's metadata and consults the existing device case cache. Negated character matches under folding, so an exact match alone is not sufficient to skip resolution. Required metadata failures remain unknown results and cannot become matches through negation. Explicit case folding bypasses the resolver; fuzzy matching resolves native case when reached. Deferred -entries retain their existing complete-metadata replay path. This does not infer a directory's -case rule for its children or assume a root's case rule applies across mount boundaries. +entries retain their existing complete-metadata replay path. For a non-followed symlink, a native +path query can follow the target or fail when it is dangling. On a device-cache miss, the resolver +therefore queries the containing directory only after VFS metadata verifies that it is a directory +on the link's own device. A different-device or unavailable parent keeps the original entry query; +an unavailable case answer keeps the conservative exact fallback. This optional parent check does +not introduce a reported metadata error. It does not assume that a root's case rule applies across +mount boundaries, and regular entries retain their existing lazy path. Explicit `-print` and `-print0` actions borrow one coordinator-owned raw record buffer across entries and deferred replay. The action chooses its own terminator independently of the implicit diff --git a/xff/engine/run.cc b/xff/engine/run.cc index b417583a2c..de10630004 100644 --- a/xff/engine/run.cc +++ b/xff/engine/run.cc @@ -4332,6 +4332,20 @@ absl::StatusOr TreeEntryPatch( using MatchedEntryFn = absl::FunctionRef; +absl::StatusOr ProbeEntryCaseSensitivity(const vfs::FileSystem& fs, const Visit& visit) { + if (visit.metadata.type == vfs::FileType::kSymlink) { + // A native path query can follow the link into another volume or fail on a dangling target. + // Use its directory only after verifying that the directory belongs to the link's device. + const auto parent = std::filesystem::path(visit.path).parent_path(); + const std::string parent_path = parent.empty() ? "." : parent.string(); + const auto metadata = fs.Stat(parent_path, true); + if (metadata.ok() && metadata->type == vfs::FileType::kDirectory && metadata->dev == visit.metadata.dev) { + return fs.IsCaseSensitive(parent_path); + } + } + return fs.IsCaseSensitive(visit.path); +} + RunResult RunFindCore( const parser::Command& command, const std::vector& histograms, @@ -6393,7 +6407,7 @@ RunResult RunFindCore( } auto [it, inserted] = case_sensitive_by_dev.try_emplace(visit.metadata.dev, true); if (inserted) { - it->second = walk_fs.IsCaseSensitive(visit.path).value_or(true); + it->second = ProbeEntryCaseSensitivity(walk_fs, visit).value_or(true); } native_fold = !it->second; return *native_fold; diff --git a/xff/engine/uniform_case_test.cc b/xff/engine/uniform_case_test.cc index 19f41dce34..a321211e17 100644 --- a/xff/engine/uniform_case_test.cc +++ b/xff/engine/uniform_case_test.cc @@ -37,6 +37,9 @@ struct CasePolicy { bool fail_leaf_metadata = false; bool fail_case_query = false; bool mixed_devices = false; + bool symlink_leaf = false; + std::optional symlink_target_case; + std::uint64_t leaf_device = 1; }; struct DeviceEntry { @@ -73,7 +76,11 @@ class CaseFs final : public vfs::FileSystem { } return entries; } - return std::vector{{.path = "/fixture/f.txt", .name = "f.txt", .type = vfs::FileType::kRegular}}; + return std::vector{{ + .path = "/fixture/f.txt", + .name = "f.txt", + .type = policy_.symlink_leaf ? vfs::FileType::kSymlink : vfs::FileType::kRegular, + }}; } absl::StatusOr Stat(std::string_view path, bool) const override { @@ -102,7 +109,12 @@ class CaseFs final : public vfs::FileSystem { if (policy_.fail_leaf_metadata) { return absl::PermissionDeniedError("leaf metadata denied"); } - return vfs::Metadata{.type = vfs::FileType::kRegular, .mode = 0644, .ino = 2, .dev = 1}; + return vfs::Metadata{ + .type = policy_.symlink_leaf ? vfs::FileType::kSymlink : vfs::FileType::kRegular, + .mode = 0644, + .ino = 2, + .dev = policy_.leaf_device, + }; } std::optional UniformCaseSensitivity() const override { @@ -113,6 +125,13 @@ class CaseFs final : public vfs::FileSystem { absl::StatusOr IsCaseSensitive(std::string_view path) const override { case_queries_.fetch_add(1, std::memory_order_relaxed); query_paths_.emplace_back(path); + if (policy_.symlink_leaf && path != "/fixture") { + // Darwin pathconf follows its final symlink, so a dangling target cannot answer. + if (policy_.symlink_target_case) { + return *policy_.symlink_target_case; + } + return absl::NotFoundError("dangling case-query target"); + } if (policy_.mixed_devices) { if (path == "/fixture") { return false; @@ -177,11 +196,19 @@ struct UniformCaseTest : ::testing::Test { CasePolicy policy, const std::vector& extra = {}, std::string_view pattern = "F.TXT", - registry::Style style = registry::Style::kXff) { + registry::Style style = registry::Style::kXff, + bool regular_files_only = true) { const CaseFs filesystem(policy); std::vector arguments{ - "--color=never", "--no-ignore", "--jobs=1", "/fixture", "-type", "f", "-name", std::string(pattern), + "--color=never", + "--no-ignore", + "--jobs=1", + "/fixture", }; + if (regular_files_only) { + arguments.append_range(std::vector{"-type", "f"}); + } + arguments.append_range(std::vector{"-name", std::string(pattern)}); arguments.append_range(extra); MBO_ASSIGN_OR_RETURN(auto command, parser::Parse(arguments)); parser::BindMatchers( @@ -305,5 +332,58 @@ TEST_F(UniformCaseTest, UnknownCaseQueryFailureKeepsTheConservativeExactFallback EXPECT_THAT(actual.case_queries, Eq(1)); } +TEST_F(UniformCaseTest, SymlinkNamesKeepTheVerifiedContainingDeviceCasePolicy) { + struct Case { + std::string_view name; + std::optional target_case; + }; + + constexpr auto kCases = std::to_array({ + {.name = "dangling", .target_case = std::nullopt}, + {.name = "folding-target", .target_case = false}, + {.name = "sensitive-target-volume", .target_case = true}, + }); + for (const auto& scenario : kCases) { + SCOPED_TRACE(scenario.name); + for (const bool deferred : {false, true}) { + SCOPED_TRACE(deferred); + std::vector extra{"--archive=none"}; + if (deferred) { + extra.append_range(std::vector{"-fuzzy", "f", "-top", "1", "-print"}); + } + MBO_ASSERT_OK_AND_ASSIGN( + const auto actual, Observe( + {.symlink_leaf = true, .symlink_target_case = scenario.target_case}, extra, "F.TXT", + registry::Style::kXff, false)); + EXPECT_THAT(actual.output, EqualsText("/fixture/f.txt\n")); + EXPECT_THAT(actual.result.errors, Eq(0)); + EXPECT_THAT(actual.diagnostics, IsEmpty()); + EXPECT_THAT(actual.case_paths, ElementsAre("/fixture")); + } + } +} + +TEST_F(UniformCaseTest, SymlinkCaseNeverAssumesItsParentHasTheSameDevice) { + MBO_ASSERT_OK_AND_ASSIGN( + const auto actual, Observe( + {.symlink_leaf = true, .symlink_target_case = true, .leaf_device = 2}, {"--archive=none"}, + "F.TXT", registry::Style::kXff, false)); + EXPECT_THAT(actual.output, IsEmpty()); + EXPECT_THAT(actual.result.errors, Eq(0)); + EXPECT_THAT(actual.diagnostics, IsEmpty()); + EXPECT_THAT(actual.case_paths, ElementsAre("/fixture/f.txt")); +} + +TEST_F(UniformCaseTest, UnavailableVerifiedSymlinkPolicyKeepsTheExactFallback) { + MBO_ASSERT_OK_AND_ASSIGN( + const auto actual, + Observe( + {.fail_case_query = true, .symlink_leaf = true}, {"--archive=none"}, "F.TXT", registry::Style::kXff, false)); + EXPECT_THAT(actual.output, IsEmpty()); + EXPECT_THAT(actual.result.errors, Eq(0)); + EXPECT_THAT(actual.diagnostics, IsEmpty()); + EXPECT_THAT(actual.case_paths, ElementsAre("/fixture")); +} + } // namespace } // namespace xff::engine From 6b165cc8849cea6d9e281245912d87101e07aaa6 Mon Sep 17 00:00:00 2001 From: helly25 <6420169+helly25@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:39:04 +0000 Subject: [PATCH 2/2] Cover optional symlink case-probe boundaries --- xff/engine/uniform_case_test.cc | 62 ++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/xff/engine/uniform_case_test.cc b/xff/engine/uniform_case_test.cc index a321211e17..aaea195166 100644 --- a/xff/engine/uniform_case_test.cc +++ b/xff/engine/uniform_case_test.cc @@ -32,6 +32,8 @@ using ::testing::ElementsAre; using ::testing::Eq; using ::testing::IsEmpty; +enum class ParentObservation { kDirectory, kDenied, kNotDirectory }; + struct CasePolicy { std::optional uniform; bool fail_leaf_metadata = false; @@ -40,6 +42,8 @@ struct CasePolicy { bool symlink_leaf = false; std::optional symlink_target_case; std::uint64_t leaf_device = 1; + bool bare_link_root = false; + ParentObservation parent_observation = ParentObservation::kDirectory; }; struct DeviceEntry { @@ -76,15 +80,26 @@ class CaseFs final : public vfs::FileSystem { } return entries; } - return std::vector{{ - .path = "/fixture/f.txt", - .name = "f.txt", - .type = policy_.symlink_leaf ? vfs::FileType::kSymlink : vfs::FileType::kRegular, - }}; + return std::vector{ + { + .path = "/fixture/f.txt", + .name = "f.txt", + .type = policy_.symlink_leaf ? vfs::FileType::kSymlink : vfs::FileType::kRegular, + }, + }; } absl::StatusOr Stat(std::string_view path, bool) const override { - if (path == "/fixture") { + if (path == "/fixture" || path == ".") { + // Initial root metadata is valid; only the later optional parent observation can fail. + if (leaf_stats_.load(std::memory_order_relaxed) != 0) { + if (policy_.parent_observation == ParentObservation::kDenied) { + return absl::PermissionDeniedError("parent metadata denied after observing link"); + } + if (policy_.parent_observation == ParentObservation::kNotDirectory) { + return vfs::Metadata{.type = vfs::FileType::kRegular, .ino = 1, .dev = 1}; + } + } return vfs::Metadata{.type = vfs::FileType::kDirectory, .ino = 1, .dev = 1}; } if (policy_.mixed_devices) { @@ -102,7 +117,7 @@ class CaseFs final : public vfs::FileSystem { .dev = kDeviceEntries.at(index).device, }; } - if (path != "/fixture/f.txt") { + if (path != (policy_.bare_link_root ? "f.txt" : "/fixture/f.txt")) { return absl::NotFoundError("unknown fixture entry"); } leaf_stats_.fetch_add(1, std::memory_order_relaxed); @@ -125,7 +140,7 @@ class CaseFs final : public vfs::FileSystem { absl::StatusOr IsCaseSensitive(std::string_view path) const override { case_queries_.fetch_add(1, std::memory_order_relaxed); query_paths_.emplace_back(path); - if (policy_.symlink_leaf && path != "/fixture") { + if (policy_.symlink_leaf && path != "/fixture" && path != ".") { // Darwin pathconf follows its final symlink, so a dangling target cannot answer. if (policy_.symlink_target_case) { return *policy_.symlink_target_case; @@ -203,7 +218,7 @@ struct UniformCaseTest : ::testing::Test { "--color=never", "--no-ignore", "--jobs=1", - "/fixture", + policy.bare_link_root ? "f.txt" : "/fixture", }; if (regular_files_only) { arguments.append_range(std::vector{"-type", "f"}); @@ -385,5 +400,34 @@ TEST_F(UniformCaseTest, UnavailableVerifiedSymlinkPolicyKeepsTheExactFallback) { EXPECT_THAT(actual.case_paths, ElementsAre("/fixture")); } +TEST_F(UniformCaseTest, BareRelativeSymlinkRootUsesTheVerifiedCurrentDirectoryPolicy) { + MBO_ASSERT_OK_AND_ASSIGN( + const auto actual, + Observe( + {.symlink_leaf = true, .bare_link_root = true}, {"--archive=none"}, "F.TXT", registry::Style::kXff, false)); + EXPECT_THAT(actual.output, EqualsText("f.txt\n")); + EXPECT_THAT(actual.result.errors, Eq(0)); + EXPECT_THAT(actual.diagnostics, IsEmpty()); + EXPECT_THAT(actual.case_paths, ElementsAre(".")); +} + +TEST_F(UniformCaseTest, UnavailableOrNondirectoryParentKeepsEntryPolicyWithoutAnError) { + constexpr auto kCases = std::to_array({ + ParentObservation::kDenied, + ParentObservation::kNotDirectory, + }); + for (const auto observation : kCases) { + SCOPED_TRACE(static_cast(observation)); + MBO_ASSERT_OK_AND_ASSIGN( + const auto actual, Observe( + {.symlink_leaf = true, .symlink_target_case = false, .parent_observation = observation}, + {"--archive=none"}, "F.TXT", registry::Style::kXff, false)); + EXPECT_THAT(actual.output, EqualsText("/fixture/f.txt\n")); + EXPECT_THAT(actual.result.errors, Eq(0)); + EXPECT_THAT(actual.diagnostics, IsEmpty()); + EXPECT_THAT(actual.case_paths, ElementsAre("/fixture/f.txt")); + } +} + } // namespace } // namespace xff::engine