Files
onenote-tools/src/onenote/onepkg/path.ts

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(),
};
}