From efd3c9f487db151e2c948835d1d2582c859c005d Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:35:49 +0000 Subject: [PATCH] fix(types): truncate pattern path segments to 255 bytes Truncate long path segments generated during upload pattern resolution and download filename generation to prevent ENAMETOOLONG errors. Keep the file extension and suffix of the segment, and use a deterministic hash of the original segment to ensure unique truncations. Closes #549 --- packages/types/src/pattern-resolver.ts | 68 +++++++++++++++++-- .../modules/upload/pattern-resolver.test.ts | 49 +++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) 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', () => {