diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index d70466d87..7578176d3 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -10,6 +10,7 @@ #include #include // NULL +#include // uint32_t #include #include #include // strdup @@ -18,6 +19,13 @@ #define FQN_MAX_PATH_SEGS 254 #define FQN_MAX_DIR_SEGS 255 +/* Max bytes for a derived project name. The name becomes a filename component + * ("/.db" and sidecars ".db-wal"/".db.corrupt"), so it must stay + * under the filesystem's 255-byte component limit. 200 leaves headroom for the + * longest sidecar suffix. #571 hex-encodes each non-ASCII byte to 2 chars, so a + * deep CJK path can triple past 255 and make the DB file un-openable (#624). */ +#define FQN_MAX_NAME_LEN 200 + /* ── Internal helpers ─────────────────────────────────────────────── */ /* Build a dot-joined string from segments. Returns heap-allocated string. */ @@ -351,6 +359,31 @@ char *cbm_pipeline_fqn_folder(const char *project, const char *rel_dir) { return result; } +/* Bound a derived project name to FQN_MAX_NAME_LEN bytes so "/.db" + * stays within the filesystem's 255-byte filename-component limit (#624). Names + * within the cap are returned UNCHANGED (no drift). Longer names keep their first + * (CAP-9) bytes and get a "-XXXXXXXX" FNV-1a hash of the FULL name appended, so + * two long paths that share a prefix but differ later still map to distinct + * names. The suffix ends in a hex digit, so the result stays validator-safe. */ +static char *fqn_bound_name_len(char *name) { + if (!name) { + return name; + } + size_t n = strlen(name); + if (n <= FQN_MAX_NAME_LEN) { + return name; /* within cap → unchanged, no drift */ + } + uint32_t h = 2166136261u; /* FNV-1a offset basis over the FULL name */ + for (size_t i = 0; i < n; i++) { + h ^= (unsigned char)name[i]; + h *= 16777619u; + } + /* Keep first (CAP-9) bytes + "-" + 8 hex = CAP total. The buffer holds n+1 + * bytes and n > CAP, so writing 9 chars + NUL at offset (CAP-9) fits. */ + snprintf(name + (FQN_MAX_NAME_LEN - 9), 10, "-%08x", h); + return name; +} + char *cbm_project_name_from_path(const char *abs_path) { if (!abs_path || !abs_path[0]) { return strdup("root"); @@ -433,5 +466,8 @@ char *cbm_project_name_from_path(const char *abs_path) { char *result = strdup(start); free(path); + if (result) { + result = fqn_bound_name_len(result); /* #624: cap filename-component length */ + } return result; } diff --git a/tests/test_fqn.c b/tests/test_fqn.c index 798974f13..e7583d012 100644 --- a/tests/test_fqn.c +++ b/tests/test_fqn.c @@ -491,6 +491,63 @@ TEST(project_name_encodes_unicode_segments_issue571) { PASS(); } +/* issue #624: #571 preserves non-ASCII path segments by hex-encoding each byte + * (1 byte -> 2 hex chars), so a DEEP non-ASCII path triples in length and can + * blow past the filesystem's 255-byte filename-component limit. Then + * "/.db" is un-openable (ENAMETOOLONG). The derived name must be + * length-capped (with a hash suffix that disambiguates otherwise-identical + * prefixes) while staying validator-safe — and SHORT names must NOT drift. */ +#define FQN_CAP_UNDER_TEST 200 +TEST(project_name_length_capped_issue624) { + /* 开 = U+5F00, UTF-8 "\xe5\xbc\x80" (3 bytes -> 6 hex chars). 60 copies makes + * the raw hex-encoded name ~360 chars, well past the 200-byte cap. */ + static const char KAI[] = "\xe5\xbc\x80"; + char deepA[512]; + char deepB[512]; + const char *prefix = "/Users/dev/"; + size_t p = strlen(prefix); + memcpy(deepA, prefix, p); + for (int i = 0; i < 60; i++) { + memcpy(deepA + p, KAI, 3); + p += 3; + } + /* deepB shares the entire deep prefix and differs ONLY in the trailing seg */ + memcpy(deepB, deepA, p); + memcpy(deepA + p, "/alpha", 7); /* includes NUL */ + memcpy(deepB + p, "/omega", 7); + + char *nameA = cbm_project_name_from_path(deepA); + char *nameB = cbm_project_name_from_path(deepB); + ASSERT_NOT_NULL(nameA); + ASSERT_NOT_NULL(nameB); + + /* (a) capped: name must fit within the filename-component budget */ + ASSERT_LTE(strlen(nameA), FQN_CAP_UNDER_TEST); + ASSERT_LTE(strlen(nameB), FQN_CAP_UNDER_TEST); + /* (b) still a valid project name (resolve_store must accept it) */ + ASSERT_TRUE(cbm_validate_project_name(nameA)); + ASSERT_TRUE(cbm_validate_project_name(nameB)); + /* (c) two deep paths differing only in the trailing segment must NOT + * collide after capping — the hash suffix disambiguates them. */ + ASSERT_STR_NEQ(nameA, nameB); + + free(nameA); + free(nameB); + + /* Short CJK path (the issue #571 case) must be UNCHANGED — no drift. */ + char *shortName = cbm_project_name_from_path( + "/Users/yunxin/Desktop/\xe5\xbc\x80\xe5\x8f\x91/" + "\xe5\x90\x8e\xe7\xab\xaf/" + "\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf"); + ASSERT_NOT_NULL(shortName); + ASSERT_LTE(strlen(shortName), FQN_CAP_UNDER_TEST); + ASSERT_STR_EQ(shortName, "Users-yunxin-Desktop-e5bc80e58f91-e5908ee7abaf-" + "e4bfa1e7a79fe9a38ee68ea7e9809ae5908ee7abaf"); + free(shortName); + + PASS(); +} + /* ================================================================ * Suite * ================================================================ */ @@ -593,6 +650,7 @@ SUITE(fqn) { RUN_TEST(project_name_deep_path); RUN_TEST(project_name_always_validator_safe_issue349); RUN_TEST(project_name_encodes_unicode_segments_issue571); + RUN_TEST(project_name_length_capped_issue624); RUN_TEST(project_name_colon_only); RUN_TEST(project_name_backslash_only); RUN_TEST(project_name_consecutive_colons);