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
This commit is contained in:
neon
2026-07-07 15:35:49 +00:00
parent 55b99858fc
commit efd3c9f487
2 changed files with 113 additions and 4 deletions
+64 -4
View File
@@ -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<string, string
if (resolved.endsWith("/")) {
const filename = ensureTrailingExtension(resolvedValues["originalFilename"] ?? "upload", dotExt);
return resolved + filename;
return limitPathSegmentBytes(resolved + filename, dotExt);
}
return ensurePathLastSegmentExtension(resolved, dotExt);
return limitPathSegmentBytes(ensurePathLastSegmentExtension(resolved, dotExt), dotExt);
}
/**
@@ -225,5 +285,5 @@ export function resolveDownloadFilename(pattern: string, values: Record<string,
stem = stem.trim();
if (!stem) return null;
return ensureTrailingExtension(stem, dotExt);
return truncatePathSegment(ensureTrailingExtension(stem, dotExt), dotExt);
}