mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-02 05:57:59 +00:00
fix(torrents): harden post-release lifecycle boundaries
- make Torrent journals and metadata caches atomic across platforms - reject unsafe Torrent cache and web-seed inputs - validate magnet trackers through the shared policy - preserve embedded web seeds during explicit installation - correct 1-based Torrent file indices in renderer state and Properties UI
This commit is contained in:
@@ -1420,7 +1420,7 @@ export const PropertiesWindowApp = () => {
|
||||
|
||||
{activeTab === 'files' && isTorrent && <div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('files', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
|
||||
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index + 1} ${file.relativePath}`} /></td><td className="p-2">{file.index + 1}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
|
||||
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
|
||||
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressLoading)}</p>}
|
||||
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
|
||||
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (selector: (catalog: { properties: Record<string, string> }) => string) => selector({
|
||||
properties: {
|
||||
torrentWebSeedsFile: 'File',
|
||||
torrentWebSeedsUri: 'URI',
|
||||
torrentWebSeedsRemove: 'Remove',
|
||||
torrentWebSeedsAdd: 'Add',
|
||||
torrentWebSeedsInvalid: 'Invalid',
|
||||
torrentWebSeedsEmpty: 'Empty',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
||||
|
||||
describe('TorrentWebSeedEditor file indices', () => {
|
||||
it('renders the native one-based file indices without adding an offset', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<TorrentWebSeedEditor
|
||||
files={[
|
||||
{ index: 1, path: 'first.bin' },
|
||||
{ index: 2, path: 'second.bin' },
|
||||
]}
|
||||
rows={[{ fileIndex: 2, uri: 'https://mirror.example/torrent/' }]}
|
||||
onChange={() => undefined}
|
||||
idPrefix="test"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('1: first.bin');
|
||||
expect(markup).toContain('2: second.bin');
|
||||
expect(markup).not.toContain('3: second.bin');
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
||||
aria-invalid={!rowIsValid}
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
|
||||
>
|
||||
<option value={files[0].index}>{files[0].index + 1}: {filePath(files[0])}</option>
|
||||
<option value={files[0].index}>{files[0].index}: {filePath(files[0])}</option>
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
@@ -81,7 +81,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
||||
<option value="" disabled>{t($ => $.properties.torrentWebSeedsFile)}</option>
|
||||
{files.map(file => (
|
||||
<option key={file.index} value={file.index}>
|
||||
{file.index + 1}: {filePath(file)}
|
||||
{file.index}: {filePath(file)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -993,6 +993,33 @@ describe('useDownloadStore', () => {
|
||||
expect(normalized.torrentEncryptionPolicy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops zero-based persisted Torrent web-seed indices', () => {
|
||||
const normalized = normalizePersistedDownloadProgress({
|
||||
id: 'torrent-web-seed-indexes',
|
||||
url: 'magnet:?xt=urn:btih:bad',
|
||||
fileName: 'payload',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
isTorrent: true,
|
||||
torrentWebSeeds: [
|
||||
{ fileIndex: 0, uri: 'https://mirror.example/zero' },
|
||||
{ fileIndex: 1, uri: 'https://mirror.example/one' },
|
||||
],
|
||||
torrentWebSeedsNative: [
|
||||
{ fileIndex: 0, uri: 'https://mirror.example/native-zero' },
|
||||
{ fileIndex: 1, uri: 'https://mirror.example/native-one' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalized.torrentWebSeeds).toEqual([
|
||||
{ fileIndex: 1, uri: 'https://mirror.example/one' },
|
||||
]);
|
||||
expect(normalized.torrentWebSeedsNative).toEqual([
|
||||
{ fileIndex: 1, uri: 'https://mirror.example/native-one' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('migrates legacy Torrent credential context before restart resume', () => {
|
||||
const normalized = normalizePersistedDownloadProgress({
|
||||
id: 'legacy-torrent-credentials',
|
||||
|
||||
@@ -751,7 +751,7 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
!!seed && typeof seed === 'object' &&
|
||||
typeof (seed as { fileIndex?: unknown }).fileIndex === 'number' &&
|
||||
Number.isInteger((seed as { fileIndex: number }).fileIndex) &&
|
||||
(seed as { fileIndex: number }).fileIndex >= 0 &&
|
||||
(seed as { fileIndex: number }).fileIndex >= 1 &&
|
||||
typeof (seed as { uri?: unknown }).uri === 'string' &&
|
||||
(seed as { uri: string }).uri.length <= 2048
|
||||
).slice(0, 256)
|
||||
@@ -762,7 +762,7 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
!!seed && typeof seed === 'object' &&
|
||||
typeof (seed as { fileIndex?: unknown }).fileIndex === 'number' &&
|
||||
Number.isInteger((seed as { fileIndex: number }).fileIndex) &&
|
||||
(seed as { fileIndex: number }).fileIndex >= 0 &&
|
||||
(seed as { fileIndex: number }).fileIndex >= 1 &&
|
||||
typeof (seed as { uri?: unknown }).uri === 'string' &&
|
||||
(seed as { uri: string }).uri.length <= 2048
|
||||
).slice(0, 256)
|
||||
|
||||
Reference in New Issue
Block a user