Skip to content

Commit db20e53

Browse files
DeusDatamvanhorn
andcommitted
fix(fqn): cap derived project-name length to avoid ENAMETOOLONG
cbm_project_name_from_path hex-encodes non-ASCII path bytes (#571), but had no length cap -- a deep CJK / non-ASCII path triples in length past the filesystem's 255-byte filename-component limit, so <cache>/<name>.db becomes un-openable (ENAMETOOLONG). Cap the derived name at FQN_MAX_NAME_LEN (200): names within the cap are returned UNCHANGED (no drift for already-indexed projects), while a longer name keeps its first (CAP-9) bytes plus an FNV-1a hash of the full name as a "-%08x" suffix, so distinct long paths stay distinct. The hex encoding scheme is unchanged. Distilled from #624 by Matt Van Horn (the ENAMETOOLONG catch); the rest of that PR was redundant with the #571 fix already on main and would have swapped the encoding scheme, causing project-name drift. Reproduce-first: project_name_length_capped_issue624 builds a deep CJK path (~376-byte raw name) and asserts the result is <= 200, validator-safe, and that two long paths differing only in the trailing segment map to different names; a short CJK path stays byte-identical (no drift). RED (376 > 200) -> GREEN. Full suite 5738/0. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com> Co-Authored-By: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
1 parent 7cf1e4d commit db20e53

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

src/pipeline/fqn.c

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
#include <stdbool.h>
1212
#include <stddef.h> // NULL
13+
#include <stdint.h> // uint32_t
1314
#include <stdio.h>
1415
#include <stdlib.h>
1516
#include <string.h> // strdup
@@ -18,6 +19,13 @@
1819
#define FQN_MAX_PATH_SEGS 254
1920
#define FQN_MAX_DIR_SEGS 255
2021

22+
/* Max bytes for a derived project name. The name becomes a filename component
23+
* ("<cache>/<name>.db" and sidecars ".db-wal"/".db.corrupt"), so it must stay
24+
* under the filesystem's 255-byte component limit. 200 leaves headroom for the
25+
* longest sidecar suffix. #571 hex-encodes each non-ASCII byte to 2 chars, so a
26+
* deep CJK path can triple past 255 and make the DB file un-openable (#624). */
27+
#define FQN_MAX_NAME_LEN 200
28+
2129
/* ── Internal helpers ─────────────────────────────────────────────── */
2230

2331
/* 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) {
351359
return result;
352360
}
353361

362+
/* Bound a derived project name to FQN_MAX_NAME_LEN bytes so "<cache>/<name>.db"
363+
* stays within the filesystem's 255-byte filename-component limit (#624). Names
364+
* within the cap are returned UNCHANGED (no drift). Longer names keep their first
365+
* (CAP-9) bytes and get a "-XXXXXXXX" FNV-1a hash of the FULL name appended, so
366+
* two long paths that share a prefix but differ later still map to distinct
367+
* names. The suffix ends in a hex digit, so the result stays validator-safe. */
368+
static char *fqn_bound_name_len(char *name) {
369+
if (!name) {
370+
return name;
371+
}
372+
size_t n = strlen(name);
373+
if (n <= FQN_MAX_NAME_LEN) {
374+
return name; /* within cap → unchanged, no drift */
375+
}
376+
uint32_t h = 2166136261u; /* FNV-1a offset basis over the FULL name */
377+
for (size_t i = 0; i < n; i++) {
378+
h ^= (unsigned char)name[i];
379+
h *= 16777619u;
380+
}
381+
/* Keep first (CAP-9) bytes + "-" + 8 hex = CAP total. The buffer holds n+1
382+
* bytes and n > CAP, so writing 9 chars + NUL at offset (CAP-9) fits. */
383+
snprintf(name + (FQN_MAX_NAME_LEN - 9), 10, "-%08x", h);
384+
return name;
385+
}
386+
354387
char *cbm_project_name_from_path(const char *abs_path) {
355388
if (!abs_path || !abs_path[0]) {
356389
return strdup("root");
@@ -433,5 +466,8 @@ char *cbm_project_name_from_path(const char *abs_path) {
433466

434467
char *result = strdup(start);
435468
free(path);
469+
if (result) {
470+
result = fqn_bound_name_len(result); /* #624: cap filename-component length */
471+
}
436472
return result;
437473
}

tests/test_fqn.c

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,63 @@ TEST(project_name_encodes_unicode_segments_issue571) {
491491
PASS();
492492
}
493493

494+
/* issue #624: #571 preserves non-ASCII path segments by hex-encoding each byte
495+
* (1 byte -> 2 hex chars), so a DEEP non-ASCII path triples in length and can
496+
* blow past the filesystem's 255-byte filename-component limit. Then
497+
* "<cache>/<name>.db" is un-openable (ENAMETOOLONG). The derived name must be
498+
* length-capped (with a hash suffix that disambiguates otherwise-identical
499+
* prefixes) while staying validator-safe — and SHORT names must NOT drift. */
500+
#define FQN_CAP_UNDER_TEST 200
501+
TEST(project_name_length_capped_issue624) {
502+
/* 开 = U+5F00, UTF-8 "\xe5\xbc\x80" (3 bytes -> 6 hex chars). 60 copies makes
503+
* the raw hex-encoded name ~360 chars, well past the 200-byte cap. */
504+
static const char KAI[] = "\xe5\xbc\x80";
505+
char deepA[512];
506+
char deepB[512];
507+
const char *prefix = "/Users/dev/";
508+
size_t p = strlen(prefix);
509+
memcpy(deepA, prefix, p);
510+
for (int i = 0; i < 60; i++) {
511+
memcpy(deepA + p, KAI, 3);
512+
p += 3;
513+
}
514+
/* deepB shares the entire deep prefix and differs ONLY in the trailing seg */
515+
memcpy(deepB, deepA, p);
516+
memcpy(deepA + p, "/alpha", 7); /* includes NUL */
517+
memcpy(deepB + p, "/omega", 7);
518+
519+
char *nameA = cbm_project_name_from_path(deepA);
520+
char *nameB = cbm_project_name_from_path(deepB);
521+
ASSERT_NOT_NULL(nameA);
522+
ASSERT_NOT_NULL(nameB);
523+
524+
/* (a) capped: name must fit within the filename-component budget */
525+
ASSERT_LTE(strlen(nameA), FQN_CAP_UNDER_TEST);
526+
ASSERT_LTE(strlen(nameB), FQN_CAP_UNDER_TEST);
527+
/* (b) still a valid project name (resolve_store must accept it) */
528+
ASSERT_TRUE(cbm_validate_project_name(nameA));
529+
ASSERT_TRUE(cbm_validate_project_name(nameB));
530+
/* (c) two deep paths differing only in the trailing segment must NOT
531+
* collide after capping — the hash suffix disambiguates them. */
532+
ASSERT_STR_NEQ(nameA, nameB);
533+
534+
free(nameA);
535+
free(nameB);
536+
537+
/* Short CJK path (the issue #571 case) must be UNCHANGED — no drift. */
538+
char *shortName = cbm_project_name_from_path(
539+
"/Users/yunxin/Desktop/\xe5\xbc\x80\xe5\x8f\x91/"
540+
"\xe5\x90\x8e\xe7\xab\xaf/"
541+
"\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf");
542+
ASSERT_NOT_NULL(shortName);
543+
ASSERT_LTE(strlen(shortName), FQN_CAP_UNDER_TEST);
544+
ASSERT_STR_EQ(shortName, "Users-yunxin-Desktop-e5bc80e58f91-e5908ee7abaf-"
545+
"e4bfa1e7a79fe9a38ee68ea7e9809ae5908ee7abaf");
546+
free(shortName);
547+
548+
PASS();
549+
}
550+
494551
/* ================================================================
495552
* Suite
496553
* ================================================================ */
@@ -593,6 +650,7 @@ SUITE(fqn) {
593650
RUN_TEST(project_name_deep_path);
594651
RUN_TEST(project_name_always_validator_safe_issue349);
595652
RUN_TEST(project_name_encodes_unicode_segments_issue571);
653+
RUN_TEST(project_name_length_capped_issue624);
596654
RUN_TEST(project_name_colon_only);
597655
RUN_TEST(project_name_backslash_only);
598656
RUN_TEST(project_name_consecutive_colons);

0 commit comments

Comments
 (0)