diff --git a/packages/types/src/pattern-resolver.ts b/packages/types/src/pattern-resolver.ts index 46f96a6db..f00460663 100644 --- a/packages/types/src/pattern-resolver.ts +++ b/packages/types/src/pattern-resolver.ts @@ -1,4 +1,5 @@ -export const DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE = "<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. ><{title}|{originalFilename}>< ({year})>"; +export const DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE = + "<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. ><{title}|{originalFilename}>< ({year})>"; export const DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER = "<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. ><{title}|{originalFilename}>< ({year})>/<{seriesIndex}. ><{title}|{originalFilename}>< ({year})>"; export const DEFAULT_DOWNLOAD_PATTERN = "{originalFilename}"; @@ -37,6 +38,8 @@ export type PathResolverOptions = { replacementCharacter?: "_" | "-"; }; +export const MAX_PATH_SEGMENT_BYTES = 255; + const MODIFIER_PLACEHOLDER_REGEX = /\{([^}:]+)(?::([^}]+))?}/g; const WINDOWS_RESERVED_NAMES = new Set([ "CON", @@ -183,6 +186,63 @@ function ensurePathLastSegmentExtension(path: string, dotExt: string): string { return segments.join("/"); } +function utf8ByteLength(value: string): number { + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else bytes += 4; + } + return bytes; +} + +function truncateUtf8(value: string, maxBytes: number): string { + let bytes = 0; + let result = ""; + + for (const char of value) { + const charBytes = utf8ByteLength(char); + if (bytes + charBytes > maxBytes) break; + result += char; + bytes += charBytes; + } + + return result; +} + +function hashSegment(value: string): string { + let hash = 0x811c9dc5; + for (const char of value) { + hash = Math.imul(hash ^ (char.codePointAt(0) ?? 0), 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +function truncatePathSegment(segment: string, suffixToPreserve = ""): string { + if (!segment || utf8ByteLength(segment) <= MAX_PATH_SEGMENT_BYTES) return segment; + + const preservedSuffix = + suffixToPreserve && segment.toLowerCase().endsWith(suffixToPreserve.toLowerCase()) ? segment.slice(-suffixToPreserve.length) : ""; + const stem = preservedSuffix ? segment.slice(0, -preservedSuffix.length) : segment; + const truncationSuffix = `~${hashSegment(segment)}`; + const suffixBytes = utf8ByteLength(truncationSuffix) + utf8ByteLength(preservedSuffix); + const stemBudget = MAX_PATH_SEGMENT_BYTES - suffixBytes; + + if (stemBudget <= 0) { + return truncateUtf8(segment, MAX_PATH_SEGMENT_BYTES); + } + + return `${truncateUtf8(stem, stemBudget)}${truncationSuffix}${preservedSuffix}`; +} + +function limitPathSegmentBytes(path: string, dotExt: string): string { + const segments = path.split("/"); + const lastIdx = segments.length - 1; + return segments.map((segment, index) => truncatePathSegment(segment, index === lastIdx ? dotExt : "")).join("/"); +} + /** * Resolves a file naming pattern to a relative path (no leading slash). * - Pattern ending with '/' → folder path; original filename is used as the file stem. @@ -200,10 +260,10 @@ export function resolveUploadPath(pattern: string, values: Record { }); }); +describe('path segment byte limits', () => { + function expectSegmentsWithinLimit(path: string) { + for (const segment of path.split('/').filter(Boolean)) { + expect(Buffer.byteLength(segment, 'utf8')).toBeLessThanOrEqual(MAX_PATH_SEGMENT_BYTES); + } + } + + it('truncates long generated upload path segments and preserves the file extension', () => { + const result = resolveUploadPath('{authors}/{title}', { ...FULL, authors: 'A'.repeat(300), title: 'B'.repeat(300) }, 'epub'); + + expect(result).not.toBeNull(); + expectSegmentsWithinLimit(result!); + expect(result!.split('/')).toHaveLength(2); + expect(result!.endsWith('.epub')).toBe(true); + expect(result).toContain('~'); + }); + + it('truncates download filenames before the filesystem segment limit', () => { + const result = resolveDownloadFilename('{title}', { ...FULL, title: 'Long Title '.repeat(40) }, 'epub'); + + expect(result).not.toBeNull(); + expect(Buffer.byteLength(result!, 'utf8')).toBeLessThanOrEqual(MAX_PATH_SEGMENT_BYTES); + expect(result!.endsWith('.epub')).toBe(true); + expect(result).toContain('~'); + }); + + it('does not split multibyte characters while truncating', () => { + const result = resolveDownloadFilename('{title}', { ...FULL, title: 'é'.repeat(200) }, 'epub'); + + expect(result).not.toBeNull(); + expect(Buffer.byteLength(result!, 'utf8')).toBeLessThanOrEqual(MAX_PATH_SEGMENT_BYTES); + expect(result!.endsWith('.epub')).toBe(true); + + const [stem] = result!.split('~'); + expect([...stem].every((char) => char === 'é')).toBe(true); + }); + + it('uses a deterministic suffix so distinct long values do not collapse to the same filename', () => { + const prefix = 'Same prefix '.repeat(30); + const first = resolveDownloadFilename('{title}', { ...FULL, title: `${prefix}one` }, 'epub'); + const second = resolveDownloadFilename('{title}', { ...FULL, title: `${prefix}two` }, 'epub'); + + expect(first).not.toBe(second); + expect(Buffer.byteLength(first!, 'utf8')).toBeLessThanOrEqual(MAX_PATH_SEGMENT_BYTES); + expect(Buffer.byteLength(second!, 'utf8')).toBeLessThanOrEqual(MAX_PATH_SEGMENT_BYTES); + }); +}); + // ── validatePattern ─────────────────────────────────────────────────────────── describe('validatePattern', () => {