cultnet-ts: bound runtime presence detail by UTF-8 bytes

The Rust owner refuses detail over 512 UTF-8 bytes; TypeScript counted
UTF-16 units, so 300 x "é" was signed and then refused, and a slice inside a
surrogate pair produced invalid UTF-8. One helper,
truncateRuntimePresenceDetail, now cuts on a code point boundary at 512
bytes and serves both the publisher and validateRuntimePresence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 01:12:15 +02:00
commit 8b8650221b
3 changed files with 52 additions and 2 deletions

View file

@ -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(),

View file

@ -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) {

View file

@ -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/);
});