diff --git a/src/discover/discover.c b/src/discover/discover.c index fe1e4e19d..c17e8b9fa 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -755,13 +755,22 @@ static int wide_stat(const char *path, struct stat *st) { * (Windows). Returns 0 on success, -1 to skip. Skipping reparse points keeps * discovery from walking through a junction that points outside the project * root, mirroring the POSIX S_ISLNK skip. */ -static int safe_stat(const char *abs_path, struct stat *st) { +/* Fills *st and returns 0 for an entry we index. Returns CBM_NOT_FOUND otherwise, + * setting *is_link when the reason is that the entry is a symlink: symlinks are + * skipped by policy, which is not the same as failing to observe the tree. */ +static int safe_stat(const char *abs_path, struct stat *st, bool *is_link) { + if (is_link) { + *is_link = false; + } #ifdef _WIN32 wchar_t *wpath = cbm_path_to_wide(abs_path); if (wpath) { DWORD attr = GetFileAttributesW(wpath); free(wpath); if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_REPARSE_POINT)) { + if (is_link) { + *is_link = true; + } return CBM_NOT_FOUND; } } @@ -771,6 +780,9 @@ static int safe_stat(const char *abs_path, struct stat *st) { return CBM_NOT_FOUND; } if (S_ISLNK(st->st_mode)) { + if (is_link) { + *is_link = true; + } return CBM_NOT_FOUND; } return 0; @@ -887,8 +899,14 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram } struct stat st; - if (safe_stat(abs_path, &st) != 0) { - if (out->count_only) { + bool entry_is_symlink = false; + if (safe_stat(abs_path, &st, &entry_is_symlink) != 0) { + /* A count aborts when it cannot observe an entry, because its caller + * (auto-index admission) treats an unreliable count as a refusal. A + * symlink is not that case: it is skipped deliberately, on every walk. + * Failing here made one symlink anywhere in a tree refuse the whole + * repo, and the refusal surfaced as "unsafe_or_unavailable_path". */ + if (out->count_only && !entry_is_symlink) { out->failed = true; } return; diff --git a/tests/test_discover.c b/tests/test_discover.c index 7e0007b11..891906b3e 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -8,6 +8,10 @@ #include "discover/discover.h" #include "foundation/platform.h" +#ifndef _WIN32 +#include +#endif + typedef struct { char *home; char *xdg_config_home; @@ -1450,6 +1454,28 @@ static CBMLanguage discover_lang_of(const cbm_file_info_t *files, int count, con } /* Write raw bytes (may contain embedded NUL) to base/rel. Parent must exist. */ +#ifndef _WIN32 +TEST(discover_count_bounded_survives_a_symlink) { + char *base = th_mktempdir("cbm_disc_link"); + ASSERT(base != NULL); + + th_mkdir_p(TH_PATH(base, ".git")); + th_write_file(TH_PATH(base, "main.go"), "package main\n"); + th_write_file(TH_PATH(base, "sub/helper.go"), "package sub\n"); + /* A symlink is skipped by policy. A count must skip it too, not abort: + * aborting made auto-index refuse every repo containing any symlink. */ + ASSERT_EQ(symlink("sub", TH_PATH(base, "linkdir")), 0); + + cbm_discover_opts_t opts = {0}; + int count = -1; + cbm_discover_status_t status = + cbm_discover_count_bounded(base, &opts, 50000, cbm_now_ms() + 5000, &count); + ASSERT_EQ(status, CBM_DISCOVER_OK); + ASSERT_EQ(count, 2); + PASS(); +} +#endif /* _WIN32 */ + static int write_bytes_file(const char *path, const void *data, size_t n) { FILE *f = cbm_fopen(path, "wb"); if (!f) { @@ -1810,4 +1836,9 @@ SUITE(discover) { RUN_TEST(discover_nested_gitignore); RUN_TEST(discover_nested_gitignore_stacks_with_root); RUN_TEST(discover_many_nested_gitignores_do_not_exhaust_matcher_ownership); + + /* A symlink must not abort a bounded count (auto-index admission) */ +#ifndef _WIN32 + RUN_TEST(discover_count_bounded_survives_a_symlink); +#endif }