diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index bf71a1f84..aa7d04321 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -338,7 +338,10 @@ static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder, } cbm_dir_t *dir = cbm_opendir(abs_dir); if (!dir) { - return CBM_NOT_FOUND; + /* A nested directory we can't open has no control files to + * contribute; skip it like discover.c's walk_dir already does. + * The root call (depth 0) still fails closed. */ + return depth == 0 ? CBM_NOT_FOUND : 0; } int rc = 0; cbm_dirent_t *entry; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 5608ee559..eae749bc1 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4123,6 +4123,45 @@ TEST(pipeline_semantic_manifest_rejects_non_directory_root) { PASS(); } +#ifndef _WIN32 +/* A nested directory the process cannot open (permission-denied) must not + * abort the whole manifest walk: it has no control files to contribute, so + * it is skipped, and control files in sibling directories still get found. + * Root-not-a-directory (above) is a different, still fail-closed case. */ +TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_manifest_locked_dir_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "readable.py", "def Readable():\n return 1\n"); + write_temp_file(tmp, "locked/secret.txt", "should never be opened\n"); + write_temp_file(tmp, "sibling/.gitignore", "*.log\n"); + + char locked_path[512]; + snprintf(locked_path, sizeof(locked_path), "%s/locked", tmp); + ASSERT_EQ(chmod(locked_path, 0), 0); + + cbm_file_hash_t *manifest = NULL; + int manifest_count = -1; + int rc = cbm_pipeline_build_semantic_manifest("manifest-skip-locked", tmp, NULL, 0, NULL, 0, + NULL, NULL, &manifest, &manifest_count); + + bool found_sibling_gitignore = false; + for (int i = 0; i < manifest_count; i++) { + if (manifest[i].rel_path && strcmp(manifest[i].rel_path, "sibling/.gitignore") == 0) { + found_sibling_gitignore = true; + } + } + + chmod(locked_path, 0755); + cbm_pipeline_free_semantic_manifest(manifest, manifest_count > 0 ? manifest_count : 0); + th_rmtree(tmp); + + ASSERT_EQ(rc, 0); + ASSERT_TRUE(found_sibling_gitignore); + PASS(); +} +#endif /* !_WIN32 */ + /* A fully validated staged graph must be able to recover from a definitely * non-SQLite destination without deleting evidence or overwriting an earlier * quarantine. The replacement happens only after the corrupt bytes are moved. */ @@ -13862,6 +13901,9 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_incremental_successful_publication_preserves_adr); RUN_TEST(pipeline_full_adr_capture_failure_preserves_previous_generation); RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root); +#ifndef _WIN32 + RUN_TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory); +#endif RUN_TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite); RUN_TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine); #endif