@@ -9708,6 +9708,108 @@ TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory) {
97089708}
97099709#endif /* !_WIN32 */
97109710
9711+ #ifdef _WIN32
9712+ /* Create or delete a real file named nul in dir, as a Git Bash redirect such
9713+ * as `cmd > nul` leaves behind (#2646). The plain Win32 path <dir>/nul names
9714+ * the NUL device, so the file is reached only through the extended-length
9715+ * namespace, which needs an absolute path with backslashes (CI's TEMP uses
9716+ * forward slashes). */
9717+ static bool pipeline_test_nul_file(const char *dir, bool create) {
9718+ enum { NUL_PATH_CAP = 1024, PREFIX_LEN = 4, LEAF_LEN = 4 };
9719+ static const wchar_t prefix[] = L"\\\\?\\";
9720+ static const wchar_t leaf[] = L"\\nul";
9721+ wchar_t path[NUL_PATH_CAP];
9722+ const DWORD room = NUL_PATH_CAP - PREFIX_LEN - LEAF_LEN;
9723+ wchar_t *wide_dir = cbm_utf8_to_wide(dir);
9724+ DWORD length = wide_dir ? GetFullPathNameW(wide_dir, room, path + PREFIX_LEN, NULL) : 0;
9725+ free(wide_dir);
9726+ if (length == 0 || length >= room) {
9727+ return false;
9728+ }
9729+ wmemcpy(path, prefix, PREFIX_LEN);
9730+ wmemcpy(path + PREFIX_LEN + length, leaf, LEAF_LEN + 1);
9731+ if (!create) {
9732+ return DeleteFileW(path) != 0;
9733+ }
9734+ HANDLE file =
9735+ CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
9736+ if (file == INVALID_HANDLE_VALUE) {
9737+ return false;
9738+ }
9739+ CloseHandle(file);
9740+ return true;
9741+ }
9742+
9743+ /* #2646: the control-file walk cannot inspect a file named nul through the
9744+ * plain path, and it never needs to: only directories and control files can
9745+ * contribute to the manifest. Listing nul in .gitignore does not help, since
9746+ * the walk visits every entry. */
9747+ TEST(pipeline_semantic_manifest_skips_nul_device_file_issue2646) {
9748+ char tmp[256];
9749+ snprintf(tmp, sizeof(tmp), "/tmp/cbm_manifest_nul_XXXXXX");
9750+ ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
9751+ write_temp_file(tmp, "app.py", "def NulNeighbor():\n return 1\n");
9752+ write_temp_file(tmp, ".gitignore", "nul\n");
9753+ bool created = pipeline_test_nul_file(tmp, true);
9754+
9755+ cbm_file_hash_t *manifest = NULL;
9756+ int manifest_count = -1;
9757+ int rc = cbm_pipeline_build_semantic_manifest(NULL, "manifest-skip-nul", tmp, NULL, 0, NULL, 0,
9758+ NULL, NULL, &manifest, &manifest_count);
9759+ bool found_gitignore = false;
9760+ for (int i = 0; i < manifest_count; i++) {
9761+ if (manifest[i].rel_path && strcmp(manifest[i].rel_path, ".gitignore") == 0) {
9762+ found_gitignore = true;
9763+ }
9764+ }
9765+ cbm_pipeline_free_semantic_manifest(manifest, manifest_count > 0 ? manifest_count : 0);
9766+ bool deleted = created && pipeline_test_nul_file(tmp, false);
9767+ th_rmtree(tmp);
9768+
9769+ ASSERT_TRUE(created);
9770+ ASSERT_EQ(rc, 0);
9771+ ASSERT_TRUE(found_gitignore);
9772+ ASSERT_TRUE(deleted);
9773+ PASS();
9774+ }
9775+
9776+ /* The user-visible half of #2646: one such file made every index run return
9777+ * CBM_PIPELINE_ABORT_PRESERVE_DB (aborted_previous_preserved), so a new
9778+ * project never got an index. */
9779+ TEST(pipeline_full_index_survives_nul_device_file_issue2646) {
9780+ char tmp[256];
9781+ snprintf(tmp, sizeof(tmp), "/tmp/cbm_index_nul_XXXXXX");
9782+ ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
9783+ write_temp_file(tmp, "app.py", "def NulNeighbor():\n return 1\n");
9784+ write_temp_file(tmp, ".gitignore", "nul\n");
9785+ bool created = pipeline_test_nul_file(tmp, true);
9786+
9787+ char db_path[512];
9788+ snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
9789+ cbm_pipeline_t *pipeline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
9790+ int run_rc = pipeline ? cbm_pipeline_run(pipeline) : -1;
9791+ char project[256] = "";
9792+ if (pipeline) {
9793+ snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(pipeline));
9794+ cbm_pipeline_free(pipeline);
9795+ }
9796+ int indexed = -1;
9797+ cbm_store_t *store = cbm_store_open_path_query(db_path);
9798+ if (store) {
9799+ indexed = named_node_count(store, project, "NulNeighbor");
9800+ cbm_store_close(store);
9801+ }
9802+ bool deleted = created && pipeline_test_nul_file(tmp, false);
9803+ th_rmtree(tmp);
9804+
9805+ ASSERT_TRUE(created);
9806+ ASSERT_EQ(run_rc, 0);
9807+ ASSERT_EQ(indexed, 1);
9808+ ASSERT_TRUE(deleted);
9809+ PASS();
9810+ }
9811+ #endif /* _WIN32 */
9812+
97119813/* A fully validated staged graph must be able to recover from a definitely
97129814 * non-SQLite destination without deleting evidence or overwriting an earlier
97139815 * quarantine. The replacement happens only after the corrupt bytes are moved. */
@@ -32362,6 +32464,10 @@ SUITE(pipeline_semantic_manifest_repro) {
3236232464 RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root);
3236332465#ifndef _WIN32
3236432466 RUN_TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory);
32467+ #endif
32468+ #ifdef _WIN32
32469+ RUN_TEST(pipeline_semantic_manifest_skips_nul_device_file_issue2646);
32470+ RUN_TEST(pipeline_full_index_survives_nul_device_file_issue2646);
3236532471#endif
3236632472 RUN_TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite);
3236732473 RUN_TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine);
0 commit comments