fix(workspace): do not follow links in the path-alias config walk

find_alias_files descended into every entry whose is_dir flag was set.
On Windows that flag is also set for a directory junction and a directory
symbolic link, so the tsconfig/jsconfig walk could leave the repository
through a link. Discovery and the incremental walker already skip links
(safe_stat, cbm_path_info_utf8().is_symlink); the alias walk now does the
same on every platform, and skips a child whose metadata cannot be read.

On POSIX the walk already stopped at links because readdir reports them
as DT_LNK rather than DT_DIR; the symbolic-link test pins that contract.
The junction test (cmd.exe mklink /J, no privilege needed) is the case
that changes and is compiled on Windows only.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-02 05:34:41 +02:00
1 parent 96c3f41cf3
commit 154aba0062
2 files changed
+156

No files matched your search

+9
View File
@@ -433,6 +433,15 @@ static void find_alias_files(const char *abs_dir, const char *rel_dir, alias_con
if (cbm_pipeline_relpath_is_excluded(child_rel, excluded_dirs, excluded_count)) {
continue;
}
/* A link is not followed, as discovery does not follow one. is_dir is
* also true for a junction or a directory symbolic link on Windows,
* and either can lead out of the repository. A child whose metadata
* cannot be read is skipped the same way. */
cbm_path_info_t child_info;
if (cbm_path_info_utf8(child_abs, &child_info) != CBM_PATH_INFO_OK ||
child_info.is_symlink) {
continue;
}
find_alias_files(child_abs, child_rel, out, count, max_count, depth + 1, excluded_dirs,
excluded_count);
}
+147
View File
@@ -9,6 +9,7 @@
#include "test_framework.h"
#include "../src/pipeline/path_alias.h"
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include <stdarg.h>
#include <stdio.h>
@@ -407,6 +408,148 @@ TEST(path_alias_loader_no_configs) {
PASS();
}
/* ── Loader does not follow a linked directory ─────────────────── */
/* The walk must not leave the repository through a link, as discovery does
* not. A config in a real subdirectory is collected; one reachable only
* through a link to a directory outside the tree is not. */
TEST(path_alias_loader_skips_linked_directory) {
#ifdef _WIN32
/* symlink() does not exist on Windows, and creating a symbolic link there
* needs a privilege an ordinary account (and the CI runner) does not hold.
* The Windows shape of this case is a directory junction (cmd.exe mklink
* /J), which this file has no cmd.exe fixture for; the junction case is
* not covered here. */
SKIP_PLATFORM("Windows: symlink() unavailable; links need a privilege");
#else
char tmpl[256];
snprintf(tmpl, sizeof(tmpl), "/tmp/cbm_palias_link_XXXXXX");
char *root = cbm_mkdtemp(tmpl);
ASSERT_NOT_NULL(root);
char outside_tmpl[256];
snprintf(outside_tmpl, sizeof(outside_tmpl), "/tmp/cbm_palias_outside_XXXXXX");
char *outside = cbm_mkdtemp(outside_tmpl);
ASSERT_NOT_NULL(outside);
char sub[512];
snprintf(sub, sizeof(sub), "%s/pkg", root);
cbm_mkdir(sub);
char path[512];
snprintf(path, sizeof(path), "%s/pkg/tsconfig.json", root);
ASSERT_EQ(write_file(path,
"{\n \"compilerOptions\": {\n \"paths\": {\n"
" \"@pkg/*\": [\"./src/*\"]\n }\n }\n}\n"),
0);
snprintf(path, sizeof(path), "%s/tsconfig.json", outside);
ASSERT_EQ(write_file(path,
"{\n \"compilerOptions\": {\n \"paths\": {\n"
" \"@out/*\": [\"./src/*\"]\n }\n }\n}\n"),
0);
char link[512];
snprintf(link, sizeof(link), "%s/linked", root);
ASSERT_EQ(symlink(outside, link), 0);
cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root);
ASSERT_NOT_NULL(coll);
/* Only the real subdirectory contributes a scope. */
ASSERT_EQ(coll->count, 1);
ASSERT_STR_EQ(coll->scopes[0].dir_prefix, "pkg");
ASSERT_NULL(cbm_path_alias_find_for_file(coll, "linked/src/x.ts"));
cbm_path_alias_collection_free(coll);
unlink(link);
snprintf(path, sizeof(path), "%s/pkg/tsconfig.json", root);
unlink(path);
snprintf(path, sizeof(path), "%s/pkg", root);
rmdir(path);
rmdir(root);
snprintf(path, sizeof(path), "%s/tsconfig.json", outside);
unlink(path);
rmdir(outside);
PASS();
#endif
}
#ifdef _WIN32
/* ── Loader does not follow a directory junction (Windows) ─────── */
/* The Windows shape of the linked-directory case. A junction needs no
* privilege (a symbolic link does), and the directory listing reports it
* with the directory attribute, so is_dir alone would descend through it.
* Compiled out elsewhere: cmd.exe and junctions exist only on Windows, so
* this case runs on the Windows leg and nowhere else. */
TEST(path_alias_loader_skips_junction) {
char root_tmpl[512];
char outside_tmpl[512];
snprintf(root_tmpl, sizeof(root_tmpl), "%s/cbm_palias_junction_XXXXXX", cbm_tmpdir());
snprintf(outside_tmpl, sizeof(outside_tmpl), "%s/cbm_palias_outside_XXXXXX", cbm_tmpdir());
char *root = cbm_mkdtemp(root_tmpl);
ASSERT_NOT_NULL(root);
char *outside = cbm_mkdtemp(outside_tmpl);
ASSERT_NOT_NULL(outside);
char sub[600];
snprintf(sub, sizeof(sub), "%s/pkg", root);
cbm_mkdir(sub);
char path[700];
snprintf(path, sizeof(path), "%s/pkg/tsconfig.json", root);
ASSERT_EQ(write_file(path,
"{\n \"compilerOptions\": {\n \"paths\": {\n"
" \"@pkg/*\": [\"./src/*\"]\n }\n }\n}\n"),
0);
snprintf(path, sizeof(path), "%s/tsconfig.json", outside);
ASSERT_EQ(write_file(path,
"{\n \"compilerOptions\": {\n \"paths\": {\n"
" \"@out/*\": [\"./src/*\"]\n }\n }\n}\n"),
0);
/* cbm_tmpdir() can expose the MSYS spelling C:/msys64/...; cmd's mklink
* builtin treats the slash before "msys64" as another option delimiter.
* Native backslashes are required only at this cmd.exe fixture boundary. */
char junction[600];
snprintf(junction, sizeof(junction), "%s/linked", root);
char junction_native[sizeof(junction)];
char outside_native[sizeof(outside_tmpl)];
snprintf(junction_native, sizeof(junction_native), "%s", junction);
snprintf(outside_native, sizeof(outside_native), "%s", outside);
for (char *cursor = junction_native; *cursor; cursor++) {
if (*cursor == '/') {
*cursor = '\\';
}
}
for (char *cursor = outside_native; *cursor; cursor++) {
if (*cursor == '/') {
*cursor = '\\';
}
}
const char *junction_argv[] = {"cmd.exe", "/d", "/c", "mklink", "/J",
junction_native, outside_native, NULL};
ASSERT_EQ(cbm_exec_no_shell(junction_argv), 0);
cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root);
ASSERT_NOT_NULL(coll);
/* Only the real subdirectory contributes a scope. */
ASSERT_EQ(coll->count, 1);
ASSERT_STR_EQ(coll->scopes[0].dir_prefix, "pkg");
ASSERT_NULL(cbm_path_alias_find_for_file(coll, "linked/src/x.ts"));
cbm_path_alias_collection_free(coll);
/* Removing the junction removes the link, not its target. */
cbm_rmdir(junction);
snprintf(path, sizeof(path), "%s/pkg/tsconfig.json", root);
cbm_unlink(path);
cbm_rmdir(sub);
cbm_rmdir(root);
snprintf(path, sizeof(path), "%s/tsconfig.json", outside);
cbm_unlink(path);
cbm_rmdir(outside);
PASS();
}
#endif
void suite_path_alias(void);
void suite_path_alias(void) {
RUN_TEST(path_alias_at_wildcard);
@@ -421,4 +564,8 @@ void suite_path_alias(void) {
RUN_TEST(path_alias_loader_monorepo_dotdot_climb);
RUN_TEST(path_alias_loader_honors_discovery_exclusions);
RUN_TEST(path_alias_loader_no_configs);
RUN_TEST(path_alias_loader_skips_linked_directory);
#ifdef _WIN32
RUN_TEST(path_alias_loader_skips_junction);
#endif
}