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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion clients/store/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions clients/store/src/derivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions clients/store/tests/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
2 changes: 1 addition & 1 deletion crates/subc-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "subc-core"
version = "0.17.45"
version = "0.17.46"
edition = "2021"
license = "MIT"
publish = false
Expand Down
37 changes: 37 additions & 0 deletions crates/subc-core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
]
}