Files
suaveplan/packages/foundation/data/bytes/src/uint8/uint8.ts
2026-05-27 23:47:53 +01:00

196 lines
5.7 KiB
TypeScript

import { trace } from "@opentelemetry/api";
import {
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
hexToBytes,
utf8ToBytes,
} from "../encoding/encoding.js";
import { BYTES_CODES, BytesError } from "../errors/bytes-error.js";
import type { BytesInput, InputFormat, OutputFormat } from "../types/types.js";
const tracer = trace.getTracer("@suaveplan/bytes");
/** Regex for even-length all-hex strings. */
const HEX_PATTERN = /^[0-9a-fA-F]+$/;
/**
* Concatenate any number of `Uint8Array` instances into a new `Uint8Array`.
*
* Returns an empty `Uint8Array` when called with no arguments.
*/
export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
/**
* Return a slice of `arr` from `start` (inclusive) to `end` (exclusive).
*
* Semantics mirror `Array.prototype.slice` — negative indices are not
* supported; pass only non-negative integers.
*/
export function sliceUint8Array(
arr: Uint8Array,
start: number,
end?: number
): Uint8Array {
return arr.slice(start, end);
}
/**
* Return `true` when `a` and `b` have the same length and identical byte
* values at every position.
*
* This comparison is **not** constant-time — do not use it for
* cryptographic MAC verification. Use `@suaveplan/crypto-utils` for
* constant-time equality.
*/
export function uint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Detect the most likely `InputFormat` of `input`.
*
* - `Uint8Array` → `"uint8array"`
* - Even-length string composed entirely of hex characters → `"hex"`
* - Everything else → `"utf8"`
*
* This heuristic is intentionally conservative: a string that looks like hex
* (even length, all hex chars) is classified as hex. If the caller knows the
* format, pass it explicitly to `inputToUint8Array`.
*/
export function detectInputFormat(input: BytesInput): InputFormat {
if (input instanceof Uint8Array) {
return "uint8array";
}
if (input.length > 0 && input.length % 2 === 0 && HEX_PATTERN.test(input)) {
return "hex";
}
return "utf8";
}
/**
* Assert that `input`'s runtime type is compatible with `format`.
*
* - `"uint8array"` requires `input` to be a `Uint8Array`.
* - `"hex"`, `"base64"`, `"utf8"` require `input` to be a `string`.
*
* Throws `BytesError(BYTES.UINT8.FORMAT_MISMATCH)` on mismatch.
*/
export function validateInputTypeFormat(
input: BytesInput,
format: InputFormat
): void {
const isUint8 = input instanceof Uint8Array;
if (format === "uint8array" && !isUint8) {
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "uint8array" requires a Uint8Array input, but received a string.`
);
}
if (format !== "uint8array" && isUint8) {
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "${format}" requires a string input, but received a Uint8Array.`
);
}
}
/**
* Convert `input` to a `Uint8Array`.
*
* When `format` is omitted and `input` is a string, `detectInputFormat` is
* used to infer the encoding.
*
* Throws `BytesError(BYTES.UINT8.INVALID_INPUT)` when `input` is neither a
* `string` nor a `Uint8Array`.
*/
export function inputToUint8Array(
input: BytesInput,
format?: InputFormat
): Uint8Array {
if (input instanceof Uint8Array) {
return input;
}
const resolvedFormat: InputFormat = format ?? detectInputFormat(input);
switch (resolvedFormat) {
case "hex":
return hexToBytes(input);
case "base64":
return base64ToBytes(input);
case "utf8":
return utf8ToBytes(input);
case "uint8array":
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "uint8array" requires a Uint8Array, but received a string.`
);
}
}
/**
* Serialise `bytes` to the requested `OutputFormat`.
*
* - `"uint8array"` returns the same reference unchanged.
* - `"hex"` returns a lowercase hex string.
* - `"base64"` returns a standard base64 string.
* - `"utf8"` returns a UTF-8 decoded string.
*/
export function formatOutput(
bytes: Uint8Array,
format: OutputFormat
): string | Uint8Array {
switch (format) {
case "uint8array":
return bytes;
case "hex":
return bytesToHex(bytes);
case "base64":
return bytesToBase64(bytes);
case "utf8":
return bytesToUtf8(bytes);
}
}
/**
* Convert `input` from `inputFormat` (or auto-detected) to `outputFormat`.
*
* This is the primary façade for cross-format conversion:
*
* ```ts
* convertDataFormat("48656c6c6f", "utf8", "hex") // → "Hello"
* convertDataFormat("Hello", "hex", "utf8") // → "48656c6c6f"
* convertDataFormat(bytes, "base64") // → "SGVsbG8="
* ```
*
* When `inputFormat` is omitted and `input` is a string, `detectInputFormat`
* is called automatically.
*/
export function convertDataFormat(
input: BytesInput,
outputFormat: OutputFormat,
inputFormat?: InputFormat
): string | Uint8Array {
return tracer.startActiveSpan("bytes.uint8.convertDataFormat", (span) => {
try {
const bytes = inputToUint8Array(input, inputFormat);
return formatOutput(bytes, outputFormat);
} finally {
span.end();
}
});
}