From 170176884a5bcd701f0dd79422b10583c4f04563 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:17:49 +0200 Subject: [PATCH] fix(module-id): refuse oversized path components CONSUMER-IMPACT: ids longer than 255 UTF-8 bytes are now refused at config/HELLO instead of failing later at store-path derivation. --- Cargo.lock | 2 +- clients/store/package.json | 2 +- clients/store/src/derivation.ts | 4 ++ clients/store/tests/storage.test.ts | 28 ++++++++++++++ crates/subc-core/Cargo.toml | 2 +- crates/subc-core/src/registry.rs | 37 +++++++++++++++++++ .../module_id_path_component_refusals.json | 26 +++++++++++++ 7 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 crates/subc-core/tests/golden/module_id_path_component_refusals.json diff --git a/Cargo.lock b/Cargo.lock index 1a71d785..fe7dcfd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1250,7 +1250,7 @@ dependencies = [ [[package]] name = "subc-core" -version = "0.17.45" +version = "0.17.46" dependencies = [ "base64", "cortexkit-log", diff --git a/clients/store/package.json b/clients/store/package.json index 9f5d9e0a..5e1b649b 100644 --- a/clients/store/package.json +++ b/clients/store/package.json @@ -1,6 +1,6 @@ { "name": "@cortexkit/store", - "version": "0.1.1", + "version": "0.1.2", "description": "TypeScript storage descriptor and derivation parity library for CortexKit storage.", "type": "module", "exports": { diff --git a/clients/store/src/derivation.ts b/clients/store/src/derivation.ts index 27b1d30f..58d7919f 100644 --- a/clients/store/src/derivation.ts +++ b/clients/store/src/derivation.ts @@ -98,6 +98,10 @@ function assertPathSafeModuleId(moduleId: string): void { if (moduleId === "." || moduleId === "..") refuse("is a dot path component"); // eslint-disable-next-line no-control-regex if (/[\u0000-\u001f\u007f]/.test(moduleId)) refuse("contains a control character"); + // `module_id` is already one literal store-path component, so this reports + // the existing failure before derivation instead of adding a restriction. + // NAME_MAX is 255 UTF-8 bytes; String.length is UTF-16 code units instead. + if (textEncoder.encode(moduleId).length > 255) refuse("is longer than 255 bytes"); } function postgresSlug(moduleId: string): string { diff --git a/clients/store/tests/storage.test.ts b/clients/store/tests/storage.test.ts index bdf80570..c0a4dff5 100644 --- a/clients/store/tests/storage.test.ts +++ b/clients/store/tests/storage.test.ts @@ -118,6 +118,34 @@ describe("sqliteStorePath path-hazard refusal (issue #32)", () => { ); expect(sqliteStorePath("/data", "v1.2-module")).toBe("/data/cortexkit/v1.2-module/store.db"); }); + + test("matches the shared UTF-8 path-component length refusal vectors", () => { + const golden = require("../../../crates/subc-core/tests/golden/module_id_path_component_refusals.json") as { + vectors: { + name: string; + module_id: { unit: string; repeat: number }; + utf8_bytes: number; + utf16_code_units: number; + expect_reason: string | null; + }[]; + }; + + for (const vector of golden.vectors) { + const moduleId = vector.module_id.unit.repeat(vector.module_id.repeat); + expect(moduleId.length, `${vector.name} UTF-16 code units`).toBe(vector.utf16_code_units); + expect(new TextEncoder().encode(moduleId).length, `${vector.name} UTF-8 bytes`).toBe( + vector.utf8_bytes, + ); + + if (vector.expect_reason === null) { + expect(sqliteStorePath("/data", moduleId), vector.name).toBe( + `/data/cortexkit/${moduleId}/store.db`, + ); + } else { + expect(() => sqliteStorePath("/data", moduleId), vector.name).toThrow(vector.expect_reason); + } + } + }); }); describe("data-home resolver (mirror of subc default_data_home)", () => { diff --git a/crates/subc-core/Cargo.toml b/crates/subc-core/Cargo.toml index 3e84dba1..373b94d5 100644 --- a/crates/subc-core/Cargo.toml +++ b/crates/subc-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "subc-core" -version = "0.17.45" +version = "0.17.46" edition = "2021" license = "MIT" publish = false diff --git a/crates/subc-core/src/registry.rs b/crates/subc-core/src/registry.rs index aa287e24..55029313 100644 --- a/crates/subc-core/src/registry.rs +++ b/crates/subc-core/src/registry.rs @@ -266,6 +266,12 @@ pub fn module_id_path_hazard(module_id: &str) -> Result<(), String> { if module_id.chars().any(|c| c.is_control()) { return Err("contains a control character".to_string()); } + // `module_id` is already one literal store-path component, so this reports + // the existing failure before derivation instead of adding a restriction. + // NAME_MAX is 255 UTF-8 bytes, which `str::len()` measures. + if module_id.len() > 255 { + return Err("is longer than 255 bytes".to_string()); + } Ok(()) } @@ -308,6 +314,37 @@ mod path_hazard_tests { assert_eq!(registry.generation().unwrap(), 0); } + #[test] + fn module_id_path_component_length_matches_shared_refusal_vectors() { + let doc: serde_json::Value = serde_json::from_str(include_str!( + "../tests/golden/module_id_path_component_refusals.json" + )) + .expect("refusal fixture parses"); + + for case in doc["vectors"].as_array().expect("vectors array") { + let name = case["name"].as_str().expect("name"); + let module_id = case["module_id"]["unit"] + .as_str() + .expect("module_id unit") + .repeat( + case["module_id"]["repeat"] + .as_u64() + .expect("module_id repeat") as usize, + ); + assert_eq!( + module_id.len(), + case["utf8_bytes"].as_u64().expect("utf8 bytes") as usize + ); + + let expected = case["expect_reason"].as_str().map(str::to_owned); + assert_eq!( + module_id_path_hazard(&module_id).err(), + expected, + "shared refusal vector {name:?} diverged" + ); + } + } + #[test] fn working_id_shapes_register_including_namespace_colons() { let registry = Registry::default(); diff --git a/crates/subc-core/tests/golden/module_id_path_component_refusals.json b/crates/subc-core/tests/golden/module_id_path_component_refusals.json new file mode 100644 index 00000000..2faec0ae --- /dev/null +++ b/crates/subc-core/tests/golden/module_id_path_component_refusals.json @@ -0,0 +1,26 @@ +{ + "authority": "crates/subc-core/src/registry.rs module_id_path_hazard -- module_id is one literal component in the existing store-path derivation; this fixture pins the shared Rust and TypeScript refusal boundary in UTF-8 bytes", + "vectors": [ + { + "name": "ascii_255_bytes_accepted", + "module_id": { "unit": "a", "repeat": 255 }, + "utf8_bytes": 255, + "utf16_code_units": 255, + "expect_reason": null + }, + { + "name": "ascii_256_bytes_refused", + "module_id": { "unit": "a", "repeat": 256 }, + "utf8_bytes": 256, + "utf16_code_units": 256, + "expect_reason": "is longer than 255 bytes" + }, + { + "name": "emoji_256_utf8_bytes_refused_despite_128_utf16_code_units", + "module_id": { "unit": "😀", "repeat": 64 }, + "utf8_bytes": 256, + "utf16_code_units": 128, + "expect_reason": "is longer than 255 bytes" + } + ] +}