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:
parent
542ddd3e58
commit
8b8650221b
3 changed files with 52 additions and 2 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue