blob: 6b2e41393bc5d45ec44222bad2ae9fb15a254201 [file]
import { Buffer } from 'node:buffer';
import { isValidUnicodeString, SessionBundleFileError } from './session-bundle-contract.js';
export const SESSION_BUNDLE_USTAR_BLOCK_BYTES = 512;
const USTAR_NAME_BYTES = 100;
const USTAR_PREFIX_BYTES = 155;
const USTAR_SIZE_MAX = 0o77_777_777_777;
const ASCII_ZERO = '0'.charCodeAt(0);
const ASCII_SPACE = ' '.charCodeAt(0);
const WINDOWS_FORBIDDEN_PATH_CHARACTERS = /[\u0000-\u001f<>:"|?*]/u;
const WINDOWS_RESERVED_PATH_SEGMENT =
/^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\..*)?$/iu;
export interface SessionBundleUstarHeader {
kind: 'directory' | 'file';
path: string;
mode: 0o644 | 0o755;
size: number;
}
/**
* Encode the exact POSIX USTAR header admitted by Session Bundle codec V1.
* Paths use a portable subset on every host: Windows device names, forbidden
* characters, and trailing dots/spaces are rejected even when running on POSIX.
*/
export function encodeSessionBundleUstarHeaderV1(value: SessionBundleUstarHeader): Uint8Array {
const path = splitUstarPath(value.path);
if (value.kind === 'directory') {
if (value.mode !== 0o755 || value.size !== 0 || !value.path.endsWith('/')) {
throw unsupportedEntry('Session bundle directory metadata is not canonical');
}
} else if (
(value.mode !== 0o644 && value.mode !== 0o755) ||
!Number.isSafeInteger(value.size) ||
value.size < 0 ||
value.size > USTAR_SIZE_MAX ||
value.path.endsWith('/')
) {
throw unsupportedEntry('Session bundle regular-file metadata is not representable in V1');
}
const header = Buffer.alloc(SESSION_BUNDLE_USTAR_BLOCK_BYTES);
Buffer.from(path.name, 'utf8').copy(header, 0);
writeOctal(header, 100, 8, value.mode);
writeOctal(header, 108, 8, 0);
writeOctal(header, 116, 8, 0);
writeOctal(header, 124, 12, value.size);
writeOctal(header, 136, 12, 0);
header.fill(ASCII_SPACE, 148, 156);
header[156] = value.kind === 'directory' ? '5'.charCodeAt(0) : '0'.charCodeAt(0);
Buffer.from('ustar\0', 'ascii').copy(header, 257);
Buffer.from('00', 'ascii').copy(header, 263);
writeOctal(header, 329, 8, 0);
writeOctal(header, 337, 8, 0);
Buffer.from(path.prefix, 'utf8').copy(header, 345);
const checksum = header.reduce((sum, byte) => sum + byte, 0);
writeChecksum(header, checksum);
return header;
}
/** Decode a header and reject every representation outside the exact V1 subset. */
export function decodeSessionBundleUstarHeaderV1(value: Uint8Array): SessionBundleUstarHeader {
if (!(value instanceof Uint8Array) || value.byteLength !== SESSION_BUNDLE_USTAR_BLOCK_BYTES) {
throw integrityError('Session bundle USTAR header is truncated');
}
const header = Buffer.from(value);
const expectedChecksum = parseOctal(header.subarray(148, 156));
const checksumHeader = Buffer.from(header);
checksumHeader.fill(ASCII_SPACE, 148, 156);
const actualChecksum = checksumHeader.reduce((sum, byte) => sum + byte, 0);
if (expectedChecksum !== actualChecksum) {
throw integrityError('Session bundle USTAR checksum does not match');
}
const name = decodeNulTerminatedUtf8(header.subarray(0, 100));
const prefix = decodeNulTerminatedUtf8(header.subarray(345, 500));
const path = prefix.length === 0 ? name : `${prefix}/${name}`;
if (path.length === 0) throw unsafePath('Session bundle USTAR path is empty');
const type = header[156];
const kind =
type === '0'.charCodeAt(0) ? 'file' : type === '5'.charCodeAt(0) ? 'directory' : undefined;
if (kind === undefined) {
throw unsupportedEntry('Session bundle USTAR entry type is not supported');
}
const mode = parseOctal(header.subarray(100, 108));
const size = parseOctal(header.subarray(124, 136));
const decoded: SessionBundleUstarHeader = {
kind,
path,
mode: mode as 0o644 | 0o755,
size,
};
const canonical = encodeSessionBundleUstarHeaderV1(decoded);
if (!header.equals(canonical)) {
throw unsupportedEntry('Session bundle USTAR metadata is not canonical');
}
return decoded;
}
export function sessionBundleUstarPaddingBytes(size: number): number {
if (!Number.isSafeInteger(size) || size < 0) {
throw unsupportedEntry('Session bundle USTAR entry size is invalid');
}
return (
(SESSION_BUNDLE_USTAR_BLOCK_BYTES - (size % SESSION_BUNDLE_USTAR_BLOCK_BYTES)) %
SESSION_BUNDLE_USTAR_BLOCK_BYTES
);
}
export function isSessionBundleUstarZeroBlock(value: Uint8Array): boolean {
return value.byteLength === SESSION_BUNDLE_USTAR_BLOCK_BYTES && value.every((byte) => byte === 0);
}
function splitUstarPath(path: string): { name: string; prefix: string } {
if (
!isValidUnicodeString(path) ||
path.length === 0 ||
path.includes('\0') ||
path.includes('\\') ||
path.startsWith('/') ||
/^[A-Za-z]:/.test(path)
) {
throw unsafePath('Session bundle path is not valid for USTAR');
}
const logicalPath = path.endsWith('/') ? path.slice(0, -1) : path;
const segments = logicalPath.split('/');
if (
logicalPath.length === 0 ||
segments.some(
(segment) =>
segment.length === 0 ||
segment === '.' ||
segment === '..' ||
WINDOWS_FORBIDDEN_PATH_CHARACTERS.test(segment) ||
segment.endsWith('.') ||
segment.endsWith(' ') ||
WINDOWS_RESERVED_PATH_SEGMENT.test(segment),
)
) {
throw unsafePath('Session bundle path is not valid for USTAR');
}
if (Buffer.byteLength(path, 'utf8') <= USTAR_NAME_BYTES) return { name: path, prefix: '' };
for (let index = path.length - 1; index > 0; index = path.lastIndexOf('/', index - 1)) {
const prefix = path.slice(0, index);
const name = path.slice(index + 1);
if (
name.length > 0 &&
Buffer.byteLength(prefix, 'utf8') <= USTAR_PREFIX_BYTES &&
Buffer.byteLength(name, 'utf8') <= USTAR_NAME_BYTES
) {
return { name, prefix };
}
}
throw unsafePath('Session bundle path is not representable by USTAR V1');
}
function writeOctal(target: Buffer, offset: number, length: number, value: number): void {
if (!Number.isSafeInteger(value) || value < 0) {
throw unsupportedEntry('Session bundle USTAR numeric field is invalid');
}
const octal = value.toString(8);
if (octal.length > length - 1) {
throw unsupportedEntry('Session bundle USTAR numeric field exceeds V1');
}
target.fill(ASCII_ZERO, offset, offset + length - 1);
target.write(octal, offset + length - 1 - octal.length, octal.length, 'ascii');
target[offset + length - 1] = 0;
}
function writeChecksum(target: Buffer, value: number): void {
const octal = value.toString(8);
if (octal.length > 6) throw integrityError('Session bundle USTAR checksum exceeds its field');
target.fill(ASCII_ZERO, 148, 154);
target.write(octal, 154 - octal.length, octal.length, 'ascii');
target[154] = 0;
target[155] = ASCII_SPACE;
}
function parseOctal(value: Buffer): number {
const nul = value.indexOf(0);
const body = value
.subarray(0, nul < 0 ? value.length : nul)
.toString('ascii')
.trim();
if (body.length === 0 || !/^[0-7]+$/.test(body)) {
throw unsupportedEntry('Session bundle USTAR numeric field is malformed');
}
if (nul >= 0 && value.subarray(nul + 1).some((byte) => byte !== 0 && byte !== ASCII_SPACE)) {
throw unsupportedEntry('Session bundle USTAR numeric field has unsupported trailing bytes');
}
const parsed = Number.parseInt(body, 8);
return parsed;
}
function decodeNulTerminatedUtf8(value: Buffer): string {
const nul = value.indexOf(0);
const bytes = value.subarray(0, nul < 0 ? value.length : nul);
if (nul >= 0 && value.subarray(nul + 1).some((byte) => byte !== 0)) {
throw unsupportedEntry('Session bundle USTAR string field has unsupported trailing bytes');
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
throw unsafePath('Session bundle USTAR path is not valid UTF-8');
}
}
function unsafePath(message: string): SessionBundleFileError {
return new SessionBundleFileError('unsafe_path', message);
}
function unsupportedEntry(message: string): SessionBundleFileError {
return new SessionBundleFileError('unsupported_entry', message);
}
function integrityError(message: string): SessionBundleFileError {
return new SessionBundleFileError('integrity_mismatch', message);
}