100 lines
2.9 KiB
TypeScript
100 lines
2.9 KiB
TypeScript
import { fail } from './errors.js';
|
|
import type { OnePkgLimits } from './types.js';
|
|
|
|
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
|
|
export function decodeCabFileName(
|
|
bytes: Uint8Array,
|
|
utf8: boolean,
|
|
offset: number
|
|
): string {
|
|
try {
|
|
// OneNote/Joplin exports are observed to store UTF-8 names without setting
|
|
// ATTR_NAME_IS_UTF. Strict UTF-8 is the only deterministic compatibility
|
|
// fallback: malformed input is rejected and no legacy code page is guessed.
|
|
return STRICT_UTF8.decode(bytes);
|
|
} catch (error) {
|
|
fail(
|
|
utf8 ? 'cab-name-invalid-utf8' : 'cab-name-encoding-unsupported',
|
|
utf8
|
|
? `CAB filename is not valid UTF-8${error instanceof Error ? `: ${error.message}` : '.'}`
|
|
: 'A non-ASCII CAB filename is not valid UTF-8; no legacy code-page guess was made.',
|
|
{ offset, structure: 'CFFILE.szName' }
|
|
);
|
|
}
|
|
}
|
|
|
|
export interface NormalizedCabPath {
|
|
path: string;
|
|
normalizedPath: string;
|
|
duplicateKey: string;
|
|
}
|
|
|
|
export function normalizeCabPath(
|
|
path: string,
|
|
limits: OnePkgLimits,
|
|
offset: number
|
|
): NormalizedCabPath {
|
|
if (path.length === 0) {
|
|
fail('cab-path-empty', 'CAB entry has an empty path.', {
|
|
offset,
|
|
structure: 'CFFILE.szName',
|
|
});
|
|
}
|
|
if (path.length > limits.maxPathCharacters) {
|
|
fail(
|
|
'cab-path-too-long',
|
|
`CAB path exceeds the configured ${limits.maxPathCharacters}-character limit.`,
|
|
{ offset, structure: 'CFFILE.szName' }
|
|
);
|
|
}
|
|
if (/^[\\/]/u.test(path) || /^[A-Za-z]:/u.test(path)) {
|
|
fail('cab-path-absolute', `Absolute CAB path is not allowed: ${path}`, {
|
|
offset,
|
|
structure: 'CFFILE.szName',
|
|
});
|
|
}
|
|
|
|
const slashPath = path.replaceAll('\\', '/');
|
|
const rawSegments = slashPath.split('/');
|
|
if (rawSegments.length > limits.maxPathDepth) {
|
|
fail(
|
|
'cab-path-too-deep',
|
|
`CAB path exceeds the configured ${limits.maxPathDepth}-segment limit.`,
|
|
{ offset, structure: 'CFFILE.szName' }
|
|
);
|
|
}
|
|
|
|
const segments = rawSegments.map((segment) => {
|
|
if (segment.length === 0 || segment === '.' || segment === '..') {
|
|
fail(
|
|
'cab-path-invalid-segment',
|
|
`CAB path contains an unsafe or empty segment: ${path}`,
|
|
{ offset, structure: 'CFFILE.szName' }
|
|
);
|
|
}
|
|
if (
|
|
[...segment].some((character) => {
|
|
const codePoint = character.codePointAt(0)!;
|
|
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
})
|
|
) {
|
|
fail(
|
|
'cab-path-control-character',
|
|
`CAB path contains a control character: ${path}`,
|
|
{ offset, structure: 'CFFILE.szName' }
|
|
);
|
|
}
|
|
return segment.normalize('NFC');
|
|
});
|
|
|
|
const normalizedPath = segments.join('/');
|
|
return {
|
|
path,
|
|
normalizedPath,
|
|
// CAB paths have Windows semantics. NFC + lower-case catches the common
|
|
// duplicate aliases without writing any archive path to the filesystem.
|
|
duplicateKey: normalizedPath.toLowerCase(),
|
|
};
|
|
}
|