From 4e504b8e742cf7fe2af0b0a76fd5c68dcf9015de Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 30 Jul 2026 02:48:29 +0330 Subject: [PATCH] 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 --- CHANGELOG.md | 5 +- src-tauri/src/download_ownership.rs | 62 +++++++++++++++- src/components/AddDownloadsModal.tsx | 6 +- src/index.css | 11 +-- src/store/useDownloadStore.test.ts | 25 ++++++- src/store/useDownloadStore.ts | 44 +++++++++--- src/utils/dateTime.test.ts | 42 +++++++++-- src/utils/dateTime.ts | 101 +++++++++++++++++++++++++++ src/utils/downloads.test.ts | 26 +++++++ src/utils/downloads.ts | 67 +++++++++++++++++- 10 files changed, 356 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fef906..f55970f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,12 +30,9 @@ This release focuses on smoother queue control, a more comfortable interface, an - Prevent stale pause, resume, retry, completion, and remove actions from reviving a download, reporting the wrong result, or leaving a row stuck in a misleading state. - Keep queue limits, connection recovery, missing completion events, and retry cleanup from leaving downloads stranded or exceeding the user’s settings. - Keep paused work behind pending downloads and make queue controls behave correctly when several actions happen close together. +- Automatically shorten media filenames that exceed the cross-platform per-file-name limit, addressing [#29](https://github.com/nimbold/Firelink/issues/29). - Refresh dependencies and bundled engines, and strengthen release, package, portable-mode, and cross-platform verification. -### Known limitation - -- Very long media-generated filenames can still exceed an operating system’s filename limit. For [#29](https://github.com/nimbold/Firelink/issues/29), the current workaround is to shorten the filename in **Properties** before starting the download. - ## [1.2.0] - 2026-07-22 This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension. diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index 82c02ea..80350e1 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -10,6 +10,9 @@ struct DownloadOwnershipRecord { } pub fn canonical_download_filename(filename: &str) -> String { + const MAX_FILENAME_BYTES: usize = 255; + const TRUNCATION_MARKER: &str = "…"; + let leaf = filename.replace('\\', "/"); let leaf = Path::new(&leaf) .file_name() @@ -31,7 +34,7 @@ pub fn canonical_download_filename(filename: &str) -> String { }) .collect::(); let sanitized = sanitized.trim().trim_end_matches(['.', ' ']); - if sanitized.is_empty() || matches!(sanitized, "." | "..") { + let canonical = if sanitized.is_empty() || matches!(sanitized, "." | "..") { "download".to_string() } else if crate::platform::is_windows_reserved_filename(sanitized) { let path = Path::new(sanitized); @@ -45,7 +48,46 @@ pub fn canonical_download_filename(filename: &str) -> String { } } else { sanitized.to_string() + }; + + if canonical.len() <= MAX_FILENAME_BYTES { + return canonical; } + + let extension = Path::new(&canonical) + .extension() + .and_then(|value| value.to_str()) + .map(|value| format!(".{value}")) + .unwrap_or_default(); + let base = canonical + .strip_suffix(&extension) + .unwrap_or(canonical.as_str()); + let base_budget = MAX_FILENAME_BYTES + .saturating_sub(extension.len()) + .saturating_sub(TRUNCATION_MARKER.len()); + + if base_budget == 0 { + return truncate_utf8_to_bytes(&canonical, MAX_FILENAME_BYTES); + } + + format!( + "{}{}{}", + truncate_utf8_to_bytes(base, base_budget), + TRUNCATION_MARKER, + extension + ) +} + +fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String { + let mut end = 0; + for (index, character) in value.char_indices() { + let next = index + character.len_utf8(); + if next > max_bytes { + break; + } + end = next; + } + value[..end].to_string() } pub fn expected_primary_path( @@ -256,6 +298,24 @@ mod tests { assert_eq!(canonical_download_filename("lpt9"), "lpt9-"); } + #[test] + fn truncates_long_filenames_by_utf8_bytes_and_preserves_extension() { + let filename = canonical_download_filename(&format!("{}.mp4", "title ".repeat(100))); + + assert!(filename.len() <= 255); + assert!(filename.ends_with(".mp4")); + assert!(filename.contains('…')); + } + + #[test] + fn truncates_multibyte_filenames_at_character_boundaries() { + let filename = canonical_download_filename(&format!("{}.mkv", "😀".repeat(100))); + + assert!(filename.len() <= 255); + assert!(filename.ends_with(".mkv")); + assert!(!filename.contains('\u{fffd}')); + } + #[test] fn malformed_legacy_download_does_not_block_valid_ownership_records() { let valid = json!({ diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 570033e..e332223 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, @@ -1078,8 +1078,6 @@ export const AddDownloadsModal = () => { ); let count = 1; - const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile; - const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : ''; let newName = finalFile; let exists = true; const batchTargets: Array<{ location: string; fileName: string }> = []; @@ -1098,7 +1096,7 @@ export const AddDownloadsModal = () => { } while (exists && count < 1000) { - newName = `${base} (${count})${ext}`; + newName = downloadFileNameWithSuffix(finalFile, ` (${count})`); let storeHas = false; const currentSettings = useSettingsStore.getState(); for (const download of useDownloadStore.getState().downloads) { diff --git a/src/index.css b/src/index.css index fb8d2f0..a10bdd5 100644 --- a/src/index.css +++ b/src/index.css @@ -22,7 +22,8 @@ --bg-input: 0 0% 100%; --border-modal: 0 0% 85%; --surface-raised: 0 0% 100%; - --surface-overlay: 0 0% 100% / 0.95; + /* Keep this token alpha-free because some consumers apply their own /alpha. */ + --surface-overlay: 0 0% 100%; --shadow-color: 220 10% 20% / 0.1; --sidebar-shell-bg: 0 0% 92%; --sidebar-panel-bg: 0 0% 96%; @@ -66,7 +67,7 @@ --bg-input: 0 0% 100%; --border-modal: 0 0% 85%; --surface-raised: 0 0% 100%; - --surface-overlay: 0 0% 100% / 0.95; + --surface-overlay: 0 0% 100%; --shadow-color: 220 10% 20% / 0.1; --sidebar-shell-bg: 0 0% 92%; --sidebar-panel-bg: 0 0% 96%; @@ -911,7 +912,7 @@ html[data-list-density="relaxed"] { .add-download-playlist-media-type-option.is-selected { background: hsl(var(--surface-raised) / 0.9); - box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.35); + box-shadow: 0 1px 2px hsl(var(--shadow-color)); color: hsl(var(--text-primary)); } @@ -2508,7 +2509,7 @@ html[data-list-density="relaxed"] { align-items: center; justify-content: center; border: 1px solid hsl(var(--border-color)); - border-bottom-color: hsl(var(--border-color) / 0.72); + border-bottom-color: hsl(var(--border-color)); border-radius: 4px; background: hsl(var(--item-hover)); color: hsl(var(--text-secondary)); @@ -3106,7 +3107,7 @@ body.is-queue-dragging * { .add-download-nested-fields { padding-inline-start: 1.25rem; - border-inline-start: 2px solid hsl(var(--border-modal) / 0.5); + border-inline-start: 2px solid hsl(var(--border-modal)); } .add-download-advanced-fields { diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 0ce1956..d8aa39c 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -3,6 +3,7 @@ import { dispatchItem, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimat import { useDownloadProgressStore } from './downloadProgressStore'; import { useSettingsStore } from './useSettingsStore'; import * as ipc from '../ipc'; +import { MAX_DOWNLOAD_FILENAME_BYTES } from '../utils/downloads'; vi.mock('../ipc', () => ({ invokeCommand: vi.fn(), @@ -113,6 +114,27 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2); }); + it('normalizes an overlong filename edited in Properties before persisting it', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'properties-long-name', + url: 'https://example.com/video', + fileName: 'video.mp4', + status: 'ready', + category: 'Movies', + dateAdded: '' + }] as any[] + }); + + await useDownloadStore.getState().applyProperties('properties-long-name', { + fileName: `${'title '.repeat(100)}.mp4` + }); + + const fileName = useDownloadStore.getState().downloads[0].fileName; + expect(new TextEncoder().encode(fileName).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES); + expect(fileName.endsWith('.mp4')).toBe(true); + }); + it('replaces stale media intent when an appended handoff reuses a URL', () => { useDownloadStore.getState().openAddModalWithUrls( 'https://example.com/file.bin', '', '', '', '', true @@ -1563,7 +1585,7 @@ describe('useDownloadStore', () => { })]; } if (cmd === 'enqueue_many') { - return [{ id: 'startup-accepted', success: true, filename: 'file.bin' }]; + return [{ id: 'startup-accepted', success: true, filename: 'normalized.bin' }]; } if (cmd === 'get_pending_order') throw new Error('queue state unavailable'); if (cmd === 'resume_download') return true; @@ -1575,6 +1597,7 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true); expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + fileName: 'normalized.bin', status: 'queued', hasBeenDispatched: true }); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 833a005..375ccc4 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; -import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; @@ -809,13 +809,16 @@ export const useDownloadStore = create((set, get) => { const state = get(); const item = state.downloads.find(d => d.id === id); if (!item) return; + const normalizedUpdates = updates.fileName === undefined + ? updates + : { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) }; if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') { throw new Error(i18n.t($ => $.downloadTable.transferActive)); } if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') { - state.updateDownload(id, updates); + state.updateDownload(id, normalizedUpdates); return; } @@ -828,7 +831,7 @@ export const useDownloadStore = create((set, get) => { state.unregisterBackendIds([id]); set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) })); } - state.updateDownload(id, updates); + state.updateDownload(id, normalizedUpdates); if (isRegistered || wasDispatching) { const dispatched = await dispatchItemInternal(id); if (dispatched) { @@ -847,7 +850,7 @@ export const useDownloadStore = create((set, get) => { } state.unregisterBackendIds([id]); } - state.updateDownload(id, updates); + state.updateDownload(id, normalizedUpdates); } }; @@ -1306,21 +1309,25 @@ export const useDownloadStore = create((set, get) => { setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), addDownload: async (item, action) => { const settings = useSettingsStore.getState(); - const destPath = await effectiveDestinationForItem(item, settings); + const normalizedItem = { + ...item, + fileName: canonicalizeDownloadFileName(item.fileName) + }; + const destPath = await effectiveDestinationForItem(normalizedItem, settings); const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID; const queueItems = get().downloads.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId ); const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); const queuePosition = maxPos + 1; - const { sizeBytes, ...downloadDraft } = item; + const { sizeBytes, ...downloadDraft } = normalizedItem; const ownedItem: DownloadItem = { ...downloadDraft, - totalBytes: item.totalBytes ?? sizeBytes, - totalIsEstimate: item.totalIsEstimate ?? ( - item.isMedia === true && item.size?.trim().startsWith('~') + totalBytes: normalizedItem.totalBytes ?? sizeBytes, + totalIsEstimate: normalizedItem.totalIsEstimate ?? ( + normalizedItem.isMedia === true && normalizedItem.size?.trim().startsWith('~') ), - connections: resolveDownloadConnections(item.connections, settings.perServerConnections), + connections: resolveDownloadConnections(normalizedItem.connections, settings.perServerConnections), destination: destPath, status: action.type === 'add-to-queue' ? 'staged' : 'ready', queueId, @@ -2055,6 +2062,11 @@ export const useDownloadStore = create((set, get) => { .filter(result => !result.success) .map(result => [result.id, result.error || 'Backend rejected the queued download.']) ); + const acceptedFilenames = new Map( + results + .filter(result => result.success && Boolean(result.filename)) + .map(result => [result.id, result.filename!]) + ); const acceptedIdSet = new Set(registeredIds); const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation])); @@ -2089,7 +2101,17 @@ export const useDownloadStore = create((set, get) => { lastError: failedErrors.get(download.id) } : liveAcceptedIds.has(download.id) - ? { ...download, hasBeenDispatched: true, lastError: undefined } + ? { + ...download, + ...(acceptedFilenames.has(download.id) + ? { + fileName: acceptedFilenames.get(download.id), + category: categoryForFileName(acceptedFilenames.get(download.id)!) + } + : {}), + hasBeenDispatched: true, + lastError: undefined + } : download ) }; diff --git a/src/utils/dateTime.test.ts b/src/utils/dateTime.test.ts index 2b4490b..508343f 100644 --- a/src/utils/dateTime.test.ts +++ b/src/utils/dateTime.test.ts @@ -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', () => { diff --git a/src/utils/dateTime.ts b/src/utils/dateTime.ts index 82e778a..0ff50c0 100644 --- a/src/utils/dateTime.ts +++ b/src/utils/dateTime.ts @@ -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)[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)[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 diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index f9d0471..9021494 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -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', diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index ac3a32c..dbe73e6 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -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}`; }; /**