fix(downloads): harden filenames and localized surfaces

Bound generated filenames to cross-platform component limits, preserve extensions, and keep duplicate renames unique. Correct light-theme compositing tokens and enforce Persian date formatting while preserving Hebrew locale formatting.

Fixes #29
Refs #31
This commit is contained in:
NimBold
2026-07-30 02:48:29 +03:30
parent 4df6315be1
commit 4e504b8e74
10 changed files with 356 additions and 33 deletions
+37 -5
View File
@@ -17,13 +17,45 @@ describe('date/time formatting', () => {
});
it('supports the opt-in Persian and Hebrew calendars', () => {
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
const hebrewOptions: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian' })).toBe('۱۴۰۵/۰۱/۰۱');
expect(formatDateTime(instant, {
locale: 'fa',
calendar: 'persian',
options: { dateStyle: 'medium', timeStyle: 'short' }
})).toContain('۱۴۰۵/۰۱/۰۱');
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options: hebrewOptions })).toBe(
new Intl.DateTimeFormat('he-u-ca-hebrew', hebrewOptions).format(instant)
);
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
});
it('keeps Persian dates zero-padded when the caller requests weekday or time', () => {
const formatted = formatDateTime(instant, {
locale: 'fa',
calendar: 'persian',
options: {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
});
expect(formatted).toContain('۱۴۰۵/۰۱/۰۱');
});
it('does not drop Persian time-only Intl fields', () => {
const formatted = formatDateTime(
new Date('2026-03-21T14:30:00.123Z'),
{
locale: 'en',
calendar: 'persian',
options: { fractionalSecondDigits: 3 } as Intl.DateTimeFormatOptions
}
);
expect(formatted).toContain('123');
});
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
+101
View File
@@ -28,6 +28,100 @@ const localeWithCalendar = (locale: string | null | undefined, calendar: Calenda
const dateFromInput = (value: DateTimeInput): Date =>
value instanceof Date ? new Date(value.getTime()) : new Date(value);
const DATE_OPTION_KEYS = new Set([
'dateStyle',
'era',
'month',
'day',
'year',
'weekday'
]);
const TIME_OPTION_KEYS = [
'hour',
'hour12',
'hourCycle',
'minute',
'second',
'timeZoneName',
'dayPeriod',
'fractionalSecondDigits'
] as const;
const persianDateParts = (
date: Date,
locale: string,
options: Intl.DateTimeFormatOptions
): string => {
const formatter = new Intl.DateTimeFormat(locale, {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const values = new Map(formatter.formatToParts(date)
.filter(part => part.type === 'year' || part.type === 'month' || part.type === 'day')
.map(part => [part.type, part.value]));
const year = values.get('year');
const month = values.get('month');
const day = values.get('day');
if (!year || !month || !day) return formatter.format(date);
return `${year}/${month}/${day}`;
};
const formatPersianDateTime = (
date: Date,
locale: string,
options: Intl.DateTimeFormatOptions
): string => {
const hasDateOptions = Object.keys(options).some(key => DATE_OPTION_KEYS.has(key));
const hasTimeOptions = options.timeStyle !== undefined ||
TIME_OPTION_KEYS.some(key => (options as unknown as Record<string, unknown>)[key] !== undefined);
const includeDate = Object.keys(options).length === 0 || hasDateOptions;
const parts: string[] = [];
if (includeDate) {
let dateText = persianDateParts(date, locale, options);
if (options.weekday) {
const weekday = new Intl.DateTimeFormat(locale, {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone,
weekday: options.weekday
}).format(date);
dateText = `${weekday}, ${dateText}`;
}
parts.push(dateText);
}
if (hasTimeOptions) {
const timeOptions: Intl.DateTimeFormatOptions = {
calendar: 'persian',
numberingSystem: options.numberingSystem,
timeZone: options.timeZone
};
for (const key of TIME_OPTION_KEYS) {
const value = (options as unknown as Record<string, unknown>)[key];
if (value !== undefined) Object.assign(timeOptions, { [key]: value });
}
if (options.timeStyle) {
timeOptions.hour = 'numeric';
timeOptions.minute = '2-digit';
if (options.timeStyle === 'medium' || options.timeStyle === 'long' || options.timeStyle === 'full') {
timeOptions.second = '2-digit';
}
if (options.timeStyle === 'long' || options.timeStyle === 'full') {
timeOptions.timeZoneName = options.timeStyle === 'full' ? 'long' : 'short';
}
}
parts.push(new Intl.DateTimeFormat(locale, timeOptions).format(date));
}
return parts.join(', ');
};
/**
* Format a user-facing timestamp with an explicit calendar. Gregorian is
* passed explicitly because some localized browser defaults use a regional
@@ -46,6 +140,13 @@ export const formatDateTime = (
const options = config.options ?? {};
try {
if (calendar === 'persian') {
return formatPersianDateTime(
date,
localeWithCalendar(config.locale, calendar),
options
);
}
return new Intl.DateTimeFormat(
localeWithCalendar(config.locale, calendar),
options
+26
View File
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import {
downloadFileNamesMatch,
downloadFileNameWithSuffix,
downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName,
redactDownloadForPersistence,
resolveDownloadConnections
} from './downloads';
@@ -63,6 +66,29 @@ describe('download connection resolution', () => {
});
describe('download filename matching', () => {
it('truncates long names by UTF-8 bytes while preserving the extension', () => {
const filename = canonicalizeDownloadFileName(`${'title '.repeat(100)}.mp4`);
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith('.mp4')).toBe(true);
expect(filename).toContain('…');
});
it('does not split a multibyte character at the filesystem boundary', () => {
const filename = canonicalizeDownloadFileName(`${'😀'.repeat(100)}.mkv`);
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith('.mkv')).toBe(true);
expect([...filename].every(character => character !== '\uFFFD')).toBe(true);
});
it('keeps alternate names unique and bounded after long-name truncation', () => {
const filename = downloadFileNameWithSuffix(`${'title '.repeat(100)}.mp4`, ' (1)');
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
expect(filename.endsWith(' (1).mp4')).toBe(true);
});
it('matches case and path spelling while preserving the actual filename', () => {
expect(downloadFileNamesMatch(
'Media\\Example.Show.S01E01.MKV',
+65 -2
View File
@@ -43,6 +43,27 @@ export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16;
// Keep every filename component within the common cross-platform filesystem
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
// bound is also conservative for Windows filename components.
export const MAX_DOWNLOAD_FILENAME_BYTES = 255;
const FILENAME_TRUNCATION_MARKER = '…';
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length;
const truncateUtf8ToBytes = (value: string, maxBytes: number): string => {
if (maxBytes <= 0) return '';
let bytes = 0;
let result = '';
for (const character of value) {
const characterBytes = utf8ByteLength(character);
if (bytes + characterBytes > maxBytes) break;
result += character;
bytes += characterBytes;
}
return result;
};
/**
* Resolve persisted/user-entered connection values before they cross into the
* backend. Older rows may omit the value, while malformed rows can contain
@@ -126,10 +147,52 @@ export const fileNameFromUrl = (rawUrl: string): string => {
export const canonicalizeDownloadFileName = (fileName: string): string => {
const leaf = fileName.replace(/\\/g, '/').split('/').pop() || 'download';
const sanitized = leaf
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-')
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
.trim()
.replace(/[. ]+$/g, '');
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
const canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
if (utf8ByteLength(canonical) <= MAX_DOWNLOAD_FILENAME_BYTES) return canonical;
const extensionStart = canonical.lastIndexOf('.');
const hasExtension = extensionStart > 0;
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
const extension = hasExtension ? canonical.slice(extensionStart) : '';
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
- utf8ByteLength(extension)
- utf8ByteLength(FILENAME_TRUNCATION_MARKER);
if (baseBudget <= 0) {
return truncateUtf8ToBytes(canonical, MAX_DOWNLOAD_FILENAME_BYTES);
}
return `${truncateUtf8ToBytes(base, baseBudget)}${FILENAME_TRUNCATION_MARKER}${extension}`;
};
/**
* Create a deterministic alternate filename without exceeding the same
* component limit as canonicalizeDownloadFileName. The suffix is intended for
* trusted generated values such as " (1)".
*/
export const downloadFileNameWithSuffix = (fileName: string, suffix: string): string => {
const canonical = canonicalizeDownloadFileName(fileName);
const safeSuffix = suffix
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
.replace(/[. ]+$/g, '');
if (!safeSuffix.trim()) return canonical;
const extensionStart = canonical.lastIndexOf('.');
const hasExtension = extensionStart > 0;
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
const extension = hasExtension ? canonical.slice(extensionStart) : '';
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
- utf8ByteLength(safeSuffix)
- utf8ByteLength(extension);
if (baseBudget <= 0) {
return truncateUtf8ToBytes(`${base}${safeSuffix}${extension}`, MAX_DOWNLOAD_FILENAME_BYTES);
}
return `${truncateUtf8ToBytes(base, baseBudget)}${safeSuffix}${extension}`;
};
/**