diff --git a/src/discover/discover.c b/src/discover/discover.c index fe1e4e19d..47f136e89 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -754,14 +754,18 @@ static int wide_stat(const char *path, struct stat *st) { /* Stat a path, skipping symlinks (POSIX) and junctions / reparse points * (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) { + * root, mirroring the POSIX S_ISLNK skip. *is_symlink reports whether the + * skip (if any) was specifically the symlink/reparse-point check, as + * opposed to some other stat failure (permissions, a race with a delete). */ +static int safe_stat(const char *abs_path, struct stat *st, bool *is_symlink) { + *is_symlink = 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)) { + *is_symlink = true; return CBM_NOT_FOUND; } } @@ -771,6 +775,7 @@ static int safe_stat(const char *abs_path, struct stat *st) { return CBM_NOT_FOUND; } if (S_ISLNK(st->st_mode)) { + *is_symlink = true; return CBM_NOT_FOUND; } return 0; @@ -887,9 +892,15 @@ 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) { + bool is_symlink = false; + if (safe_stat(abs_path, &st, &is_symlink) != 0) { if (out->count_only) { out->failed = true; + } else if (is_symlink) { + /* Deliberately not indexed (#963): record so callers can + * surface it, matching the directory-exclusion and file + * skip_reason paths a few lines below. */ + file_list_add_ignored(out, rel_path, "symlink"); } return; } diff --git a/tests/test_discover.c b/tests/test_discover.c index 7e0007b11..9227d37ca 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -909,6 +909,46 @@ TEST(discover_symlink_skipped) { #endif } +/* #1815: a symlink skip must be as visible as the other two skip paths in + * the same walk (gitignore/cbmignore/skip-list files, excluded dirs): a + * subtree dropped because it's a symlink must not look identical to a + * complete index. */ +TEST(discover_symlink_skip_is_reported) { +#ifdef _WIN32 + SKIP_PLATFORM("Windows: symlinks need admin / symlink() unavailable"); +#else + char *base = th_mktempdir("cbm_disc_sym_rep"); + ASSERT(base != NULL); + + th_write_file(TH_PATH(base, "real.go"), "package main\n"); + char real_path[512], link_path[512]; + snprintf(real_path, sizeof(real_path), "%s/real.go", base); + snprintf(link_path, sizeof(link_path), "%s/link.go", base); + symlink(real_path, link_path); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + cbm_ignored_file_t *ignored = NULL; + int ignored_count = 0; + int ignored_total = 0; + + int rc = cbm_discover_ex2(base, &opts, &files, &count, NULL, NULL, &ignored, &ignored_count, + &ignored_total); + ASSERT_EQ(rc, 0); + ASSERT_EQ(count, 1); /* real.go only, same as discover_symlink_skipped */ + ASSERT_EQ(ignored_total, 1); + ASSERT_EQ(ignored_count, 1); + ASSERT_STR_EQ(ignored[0].rel_path, "link.go"); + ASSERT_STR_EQ(ignored[0].reason, "symlink"); + + cbm_discover_free(files, count); + cbm_discover_free_ignored(ignored, ignored_count); + th_cleanup(base); + PASS(); +#endif +} + TEST(discover_new_ignore_patterns) { char *base = th_mktempdir("cbm_disc_newign"); ASSERT(base != NULL); @@ -1785,6 +1825,7 @@ SUITE(discover) { RUN_TEST(discover_cbmignore); RUN_TEST(discover_cbmignore_stacks); RUN_TEST(discover_symlink_skipped); + RUN_TEST(discover_symlink_skip_is_reported); RUN_TEST(discover_new_ignore_patterns); RUN_TEST(discover_generic_dirs_full_mode); RUN_TEST(discover_generic_dirs_fast_mode);