Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/pipeline/fqn.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

#include <stdbool.h>
#include <stddef.h> // NULL
#include <stdint.h> // uint32_t
#include <stdio.h>
#include <stdlib.h>
#include <string.h> // strdup
Expand All @@ -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
* ("<cache>/<name>.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. */
Expand Down Expand Up @@ -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 "<cache>/<name>.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");
Expand Down Expand Up @@ -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;
}
58 changes: 58 additions & 0 deletions tests/test_fqn.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
* "<cache>/<name>.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
* ================================================================ */
Expand Down Expand Up @@ -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);
Expand Down
Loading