diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index b675d5c3..d55c4dc4 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -9,6 +9,7 @@ import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, runtimePresenceActivationSigningMessage, runtimePresenceProofPayload, + truncateRuntimePresenceDetail, runtimePresenceProviderSigningMessage, encodeRuntimePresenceHealth, type RuntimePresenceHealth, @@ -241,7 +242,7 @@ export function createIdunnRuntimePresencePublisher( const signed = signIdunnRuntimePresence(options.authority, { capabilities: options.capabilities, state, - detail: detail.slice(0, 512), + detail: truncateRuntimePresenceDetail(detail), writeLeaseSha256, publisherSequence: sequence, observedAtUnixMillis: Date.now(), diff --git a/packages/cultnet-ts/src/runtime-presence-health.ts b/packages/cultnet-ts/src/runtime-presence-health.ts index 8d70da8e..4cdccd1e 100644 --- a/packages/cultnet-ts/src/runtime-presence-health.ts +++ b/packages/cultnet-ts/src/runtime-presence-health.ts @@ -171,6 +171,25 @@ function presenceTuple( return fields; } +/** The Rust owner (`cultnet-rs`) bounds presence detail by UTF-8 bytes, not UTF-16 units. */ +export const RUNTIME_PRESENCE_DETAIL_MAX_BYTES = 512; + +/** + * Longest prefix of `detail` within the UTF-8 byte limit, cut on a code point + * boundary. The single rule for both signing and validation: a detail is valid + * exactly when this returns it unchanged. + */ +export function truncateRuntimePresenceDetail(detail: string): string { + let bytes = 0; + let end = 0; + for (const char of detail) { + bytes += Buffer.byteLength(char, "utf8"); + if (bytes > RUNTIME_PRESENCE_DETAIL_MAX_BYTES) break; + end += char.length; + } + return detail.slice(0, end); +} + /** * Reject statements the contract forbids, before they are signed. * @@ -185,7 +204,7 @@ export function validateRuntimePresence(presence: RuntimePresenceHealth): void { if (presence.observedAtUnixMillis <= 0 || !Number.isSafeInteger(presence.observedAtUnixMillis)) { throw new Error("runtime presence observation time must be a positive integer"); } - if (presence.detail.length > 512 || /\p{Cc}/u.test(presence.detail)) { + if (truncateRuntimePresenceDetail(presence.detail) !== presence.detail || /\p{Cc}/u.test(presence.detail)) { throw new Error("runtime presence detail is too long or carries control characters"); } if (presence.state === "warming" && presence.writeLeaseSha256 !== null) { diff --git a/packages/cultnet-ts/test/runtime-presence-health.test.ts b/packages/cultnet-ts/test/runtime-presence-health.test.ts index fa21aeaa..5a4de05f 100644 --- a/packages/cultnet-ts/test/runtime-presence-health.test.ts +++ b/packages/cultnet-ts/test/runtime-presence-health.test.ts @@ -18,6 +18,7 @@ import { RUNTIME_PRESENCE_FIELD_COUNT, encodeRuntimePresenceHealth, runtimePresenceProofPayload, + truncateRuntimePresenceDetail, validateRuntimePresence, type RuntimePresenceHealth, } from "../src/runtime-presence-health"; @@ -121,3 +122,32 @@ test("statements the contract forbids are refused before they are signed", () => const partialLineage = { ...referencePresence(), stateContractSha256: null }; assert.throws(() => validateRuntimePresence(partialLineage), /state lineage is partial/); }); + +const utf8Bytes = (text: string) => Buffer.byteLength(text, "utf8"); +const isValidUtf8 = (text: string) => !Buffer.from(text, "utf8").toString("utf8").includes("\uFFFD") && !text.includes("\uFFFD"); + +test("presence detail is truncated to 512 UTF-8 bytes, not UTF-16 units", () => { + const truncated = truncateRuntimePresenceDetail("é".repeat(300)); + assert.ok(utf8Bytes(truncated) <= 512); + assert.equal(truncated, "é".repeat(256)); + assert.ok(isValidUtf8(truncated)); +}); + +test("presence detail drops a 4-byte character straddling the boundary whole", () => { + const truncated = truncateRuntimePresenceDetail("a".repeat(510) + "😀"); + assert.equal(truncated, "a".repeat(510)); + assert.ok(isValidUtf8(truncated)); + assert.equal(truncateRuntimePresenceDetail("a".repeat(508) + "😀"), "a".repeat(508) + "😀"); +}); + +test("presence detail of exactly 512 ASCII bytes passes unchanged and 513 is cut", () => { + const exact = "a".repeat(512); + assert.equal(truncateRuntimePresenceDetail(exact), exact); + assert.doesNotThrow(() => validateRuntimePresence({ ...referencePresence(), detail: exact })); + assert.equal(truncateRuntimePresenceDetail(exact + "a"), exact); + assert.throws(() => validateRuntimePresence({ ...referencePresence(), detail: exact + "a" }), /too long/); +}); + +test("validation refuses a detail that is short in UTF-16 units but over 512 UTF-8 bytes", () => { + assert.throws(() => validateRuntimePresence({ ...referencePresence(), detail: "é".repeat(300) }), /too long/); +});