Files
reverse-engineering-anything/src/domain/analysisView/environmentStateWriteValues.ts
T
morluto 54c666a5ae fix(environment): reject out-of-range clock offsets
Reject invalid ISO offsets while preserving valid boundary values. Validate stored clock-format offsets through the same parser so malformed imported formats cannot reach rendering.

Fixes #6524
2026-10-23 18:07:45 +08:00

359 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import type { JsonValue } from "../jsonValue.js";
/** How a recorded clock value is spelled: ISO-8601 with its separator, fraction digits and zone, or an epoch number. */
export type ClockFormat =
| {
readonly kind: "iso";
readonly separator: "T" | " ";
readonly fraction: number;
readonly zone: string;
}
| { readonly kind: "epoch"; readonly unit: "s" | "ms" };
interface ClockReading {
readonly ms: number;
/** Nanoseconds after the floored millisecond, so distinct recorded instants stay distinct. */
readonly subMillisecond: number;
readonly format: ClockFormat;
}
const ISO_INSTANT =
/^(\d{4})-(\d{2})-(\d{2})([T ])(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/u;
const MINUTE_MS = 60_000;
/** Minutes east of UTC that a zone suffix names; none and `Z` are UTC. */
const zoneMinutes = (zone: string): number | undefined => {
if (zone === "" || zone === "Z") return 0;
if (!/^[+-]\d{2}:?\d{2}$/u.test(zone)) return undefined;
const digits = zone.slice(1).replace(":", "");
const hours = Number(digits.slice(0, 2));
const remainder = Number(digits.slice(2, 4));
if (hours > 23 || remainder > 59) return undefined;
const minutes = hours * 60 + remainder;
return zone.startsWith("-") ? -minutes : minutes;
};
/** Whether a stored ISO clock format has a supported RFC 3339 offset suffix. */
export const isClockZone = (zone: string): boolean =>
zoneMinutes(zone) !== undefined;
const zoneMinutesForFormat = (zone: string): number => {
const minutes = zoneMinutes(zone);
if (minutes === undefined)
throw new TypeError(`Invalid clock zone in a derived format: ${zone}`);
return minutes;
};
/** The instant of an ISO-8601 date-time and how it is spelled, or undefined when it is not one. */
const readIsoClock = (value: string): ClockReading | undefined => {
const match = ISO_INSTANT.exec(value);
if (match === null) return undefined;
const [year, month, day, separator, hour, minute, second] = match
.slice(1, 8)
.map((part) => (part === "T" || part === " " ? part : Number(part)));
const fraction = match[8] ?? "";
const zone = match[9] ?? "";
const offsetMinutes = zoneMinutes(zone);
if (offsetMinutes === undefined) return undefined;
const local = Date.UTC(
year as number,
(month as number) - 1,
day as number,
hour as number,
minute as number,
second as number,
Number(`${fraction}000`.slice(0, 3)),
);
// Date.UTC rolls invalid fields over; the round trip refuses them.
const rolled = new Date(local);
if (
rolled.getUTCFullYear() !== year ||
rolled.getUTCMonth() !== (month as number) - 1 ||
rolled.getUTCDate() !== day ||
rolled.getUTCHours() !== hour ||
rolled.getUTCMinutes() !== minute
)
return undefined;
return {
ms: local - offsetMinutes * MINUTE_MS,
subMillisecond: Number(fraction.padEnd(9, "0").slice(3)),
format: {
kind: "iso",
separator: separator as "T" | " ",
fraction: fraction.length,
zone,
},
};
};
/** The instant a clock value names and its format: an ISO-8601 date-time, or epoch seconds or milliseconds. */
export const readClock = (
value: JsonValue | undefined,
): ClockReading | undefined => {
if (typeof value === "string") return readIsoClock(value);
if (typeof value !== "number" || !Number.isSafeInteger(value))
return undefined;
if (value >= 1e9 && value < 1e10)
return {
ms: value * 1000,
subMillisecond: 0,
format: { kind: "epoch", unit: "s" },
};
if (value >= 1e12 && value < 1e13)
return {
ms: value,
subMillisecond: 0,
format: { kind: "epoch", unit: "ms" },
};
return undefined;
};
const pad = (value: number, width: number): string =>
String(value).padStart(width, "0");
/** An ISO-8601 date-time from the UTC fields of `at`, with fraction digits and a zone suffix. */
const isoText = (
at: Date,
separator: string,
digits: string,
zone: string,
): string =>
`${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${digits === "" ? "" : `.${digits}`}${zone}`;
/** An instant spelled in a recorded clock format; fraction digits past milliseconds are zeros. */
export const formatClock = (ms: number, format: ClockFormat): JsonValue => {
if (format.kind === "epoch")
return format.unit === "s" ? Math.floor(ms / 1000) : ms;
const at = new Date(ms + zoneMinutesForFormat(format.zone) * MINUTE_MS);
const digits = `${pad(at.getUTCMilliseconds(), 3)}${"0".repeat(9)}`.slice(
0,
format.fraction,
);
return isoText(at, format.separator, digits, format.zone);
};
/**
* A clock value spelled in another ISO format: its wall clock in the format's zone, under the format's separator, with
* the value's own fraction digits padded to the format's width, so no digit is lost; an epoch format is computed from
* the instant.
*/
export const respellClock = (
value: JsonValue,
format: ClockFormat,
): JsonValue => {
const clock = readClock(value);
if (clock === undefined) return value;
if (format.kind === "epoch") return formatClock(clock.ms, format);
const millis = ((clock.ms % 1000) + 1000) % 1000;
// An epoch value's fraction is its milliseconds, without trailing zeros.
const digits =
clock.format.kind === "epoch"
? pad(millis, 3).replace(/0+$/u, "")
: `${pad(millis, 3)}${pad(clock.subMillisecond, 6)}`.slice(
0,
clock.format.fraction,
);
const at = new Date(
clock.ms - millis + zoneMinutesForFormat(format.zone) * MINUTE_MS,
);
return isoText(
at,
format.separator,
digits.padEnd(format.fraction, "0"),
format.zone,
);
};
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
export const isUuid = (value: JsonValue | undefined): value is string =>
typeof value === "string" && UUID.test(value);
/** An integer value as a number, from a JSON number or a string of digits. */
export const integerOf = (value: JsonValue | undefined): number | undefined =>
typeof value === "number" && Number.isSafeInteger(value)
? value
: typeof value === "string" && /^\d{1,15}$/u.test(value)
? Number(value)
: undefined;
/**
* The next value of a sequence. With nothing taken yet, that is the next held integer, or 1 when
* the scope holds none. Otherwise it is one past the later of that and the last value taken.
* A missing side leaves a negative value negative.
*/
export const nextSequence = (
held: number | undefined,
taken: number | undefined,
): number =>
taken === undefined ? (held ?? 1) : Math.max(held ?? taken + 1, taken + 1);
/** An integer spelled as the recorded value spells integers: a number, or a string of digits. */
export const spelledLike = (exemplar: JsonValue, value: number): JsonValue =>
maskedCount(exemplar) !== undefined
? fillMask(exemplar as string, value)
: typeof exemplar === "string"
? String(value)
: value;
const MASKED_DIGITS = /^\d{2,}(?:-\d{2,})+$/u;
/** The count `-`-separated digit groups of one width spell, such as an asset number `000-005`; dates, versions and addresses are not counts. */
const maskedCount = (value: JsonValue | undefined): number | undefined => {
if (typeof value !== "string" || !MASKED_DIGITS.test(value)) return undefined;
const groups = value.split("-");
if (groups.some((group) => group.length !== groups[0]!.length))
return undefined;
const digits = groups.join("");
return digits.length <= 15 ? Number(digits) : undefined;
};
/** A count that outgrew the exemplar's mask, read only when it is spelled as serving spells it, so dates never match. */
const outgrownMask = (
value: JsonValue | undefined,
exemplar: JsonValue | undefined,
): number | undefined => {
if (typeof value !== "string" || maskedCount(exemplar) === undefined)
return undefined;
const digits = value.replaceAll("-", "");
if (!/^\d{1,15}$/u.test(digits)) return undefined;
const count = Number(digits);
return fillMask(exemplar as string, count) === value ? count : undefined;
};
/** A sequence's number: an integer, digit groups that count, or with the sequence's exemplar a count that outgrew its mask. */
export const sequenceNumber = (
value: JsonValue | undefined,
exemplar?: JsonValue,
): number | undefined =>
integerOf(value) ?? maskedCount(value) ?? outgrownMask(value, exemplar);
/** A number written into a mask's digit positions, zero-padded; digits that outgrow the mask lead it. */
const fillMask = (mask: string, value: number): string => {
const padded = String(value).padStart(mask.replace(/\D/gu, "").length, "0");
let next = padded.length - 1;
let out = "";
for (let at = mask.length - 1; at >= 0; at -= 1) {
const character = mask[at]!;
if (/\d/u.test(character)) {
out = padded[next]! + out;
next -= 1;
} else out = character + out;
}
return padded.slice(0, next + 1) + out;
};
/** Lower case, with every run of other characters joined by one `-` and none at either end. */
export const slugOf = (text: string): string =>
text
.normalize("NFKD")
.replace(/[̀-ͯ]/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "");
const isAlphanumeric = (character: string | undefined): boolean =>
character !== undefined && /[\p{L}\p{N}]/u.test(character);
/** Start offsets of `token` in `text` where neither neighbour is a letter or digit. */
export const delimitedOffsets = (text: string, token: string): number[] => {
const offsets: number[] = [];
if (token.length === 0) return offsets;
let at = text.indexOf(token);
while (at >= 0) {
const delimited =
!isAlphanumeric(text[at - 1]) && !isAlphanumeric(text[at + token.length]);
if (delimited) offsets.push(at);
// A rejected match may overlap a delimited one, so the search moves on by one character.
at = text.indexOf(token, at + (delimited ? token.length : 1));
}
return offsets;
};
/** `text` with every delimited occurrence of `token` replaced. */
export const replaceDelimited = (
text: string,
token: string,
replacement: string,
): string => {
let result = "";
let from = 0;
for (const at of delimitedOffsets(text, token)) {
result += text.slice(from, at) + replacement;
from = at + token.length;
}
return result + text.slice(from);
};
/** Whether any string inside a value holds `token` delimited. */
export const containsDelimited = (value: JsonValue, token: string): boolean => {
const pending: JsonValue[] = [value];
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === "string") {
if (delimitedOffsets(next, token).length > 0) return true;
} else if (typeof next === "object" && next !== null)
// One at a time: spreading a wide array into push overflows the call stack.
for (const child of Array.isArray(next) ? next : Object.values(next))
pending.push(child);
}
return false;
};
/** SHA-256 bytes of a seed, extended by counter blocks to the requested length. */
const seededBytes = (seed: string, length: number): Uint8Array => {
const bytes = new Uint8Array(length);
for (let block = 0; block * 32 < length; block += 1) {
const digest = createHash("sha256")
.update(`${seed}#${String(block)}`)
.digest();
bytes.set(
digest.subarray(0, Math.min(32, length - block * 32)),
block * 32,
);
}
return bytes;
};
/** A version 4 UUID derived from a seed, in the recorded value's letter case. */
export const seededUuid = (seed: string, exemplar: string): string => {
const bytes = seededBytes(seed, 16);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Buffer.from(bytes).toString("hex");
const uuid = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
return exemplar === exemplar.toUpperCase() && /[A-F]/u.test(exemplar)
? uuid.toUpperCase()
: uuid;
};
const DIGITS = "0123456789";
const LOWER = "abcdefghijklmnopqrstuvwxyz";
const UPPER = LOWER.toUpperCase();
/** The characters a token of this spelling draws from: hexadecimal, or the digit and letter cases it uses. */
const tokenAlphabet = (exemplar: string): string => {
if (/^[0-9a-f]+$/u.test(exemplar) && /[0-9]/u.test(exemplar))
return `${DIGITS}abcdef`;
if (/^[0-9A-F]+$/u.test(exemplar) && /[0-9]/u.test(exemplar))
return `${DIGITS}ABCDEF`;
const alphabet =
(/[0-9]/u.test(exemplar) ? DIGITS : "") +
(/[a-z]/u.test(exemplar) ? LOWER : "") +
(/[A-Z]/u.test(exemplar) ? UPPER : "");
return alphabet === "" ? LOWER : alphabet;
};
/** A token as long as the recorded one, from its alphabet, keeping its other characters in place. */
export const seededToken = (seed: string, exemplar: string): string => {
const alphabet = tokenAlphabet(exemplar);
const bytes = seededBytes(seed, exemplar.length);
return [...exemplar]
.map((character, index) =>
/[0-9A-Za-z]/u.test(character)
? alphabet[bytes[index]! % alphabet.length]!
: character,
)
.join("");
};