fix(extension): support browser-local torrent attachments

- Cache signed browser-local torrent bytes in the desktop handoff server.\n- Preserve managed torrent identity through Add-window inspection and rekey.\n- Advance the tested browser companion submodule to the published capture fix.
This commit is contained in:
NimBold
2026-09-08 21:15:31 +03:30
parent 5f76277aaf
commit 8fd73a145e
8 changed files with 294 additions and 27 deletions
+158 -9
View File
@@ -7,6 +7,7 @@ use axum::{
routing::{get, post},
Router,
};
use base64::Engine as _;
use hmac::{Hmac, KeyInit, Mac};
use reqwest::Url;
use serde::{Deserialize, Serialize};
@@ -27,7 +28,11 @@ use ts_rs::TS;
pub const EXTENSION_SERVER_PORT: u16 = 6412;
pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION_SERVER_PORT..=6422;
const MAX_URL_COUNT: usize = 200;
const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024;
const MAX_NON_TORRENT_REQUEST_BODY_BYTES: usize = 256 * 1024;
const MAX_ENCODED_TORRENT_BYTES: usize =
((crate::torrent::MAX_TORRENT_BYTES + 2) / 3) * 4;
const MAX_REQUEST_BODY_BYTES: usize =
MAX_ENCODED_TORRENT_BYTES + MAX_NON_TORRENT_REQUEST_BODY_BYTES;
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
const SERVER_HEADER: &str = "x-firelink-server";
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
@@ -36,7 +41,7 @@ const SERVER_PROOF_HEADER: &str = "x-firelink-server-proof";
const SERVER_PORT_HEADER: &str = "x-firelink-server-port";
const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id";
const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n";
const PROTOCOL_VERSION: &str = "5";
const PROTOCOL_VERSION: &str = "6";
const MAX_PENDING_EXTENSION_ACKS: usize = 64;
const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
@@ -80,6 +85,8 @@ struct ExtensionRequest {
batch: bool,
#[serde(default)]
batch_name: Option<String>,
#[serde(default)]
torrent_bytes_base64: Option<String>,
}
#[derive(Clone, Deserialize, Serialize, TS)]
@@ -105,6 +112,11 @@ pub struct ExtensionDownload {
torrent: bool,
batch: bool,
batch_name: Option<String>,
#[ts(optional)]
torrent_path: Option<String>,
#[serde(skip)]
#[ts(skip)]
torrent_bytes: Option<Vec<u8>>,
}
pub async fn start_server(
@@ -303,11 +315,25 @@ async fn download_handler(
Err(_) => return Err(StatusCode::BAD_REQUEST),
};
let download = match normalize_download(payload) {
let mut download = match normalize_download(payload) {
Some(v) => v,
None => return Err(StatusCode::BAD_REQUEST),
};
let request_id = uuid::Uuid::new_v4().simple().to_string();
if let Some(torrent_bytes) = download.torrent_bytes.take() {
let torrent_path = crate::torrent::cache_torrent_bytes(
&state.app_handle,
&request_id,
&torrent_bytes,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
download.urls = vec![torrent_path.clone()];
download.torrent_path = Some(torrent_path);
}
let cached_torrent = download.torrent_path.is_some();
let is_hidden = state
.app_handle
.get_webview_window("main")
@@ -321,13 +347,19 @@ async fn download_handler(
}
if !wait_for_frontend(&state.frontend_ready).await {
if cached_torrent {
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
}
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
let request_id = uuid::Uuid::new_v4().simple().to_string();
let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone())
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let mut download = download;
let Some(ack_receiver) = register_extension_ack(&state.extension_acks, request_id.clone())
else {
if cached_torrent {
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
}
return Err(StatusCode::SERVICE_UNAVAILABLE);
};
download.request_id = Some(request_id.clone());
if state
@@ -336,6 +368,9 @@ async fn download_handler(
.is_err()
{
remove_extension_ack(&state.extension_acks, &request_id);
if cached_torrent {
crate::torrent::remove_managed_torrent(&state.app_handle, &request_id).await;
}
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
@@ -405,11 +440,33 @@ fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) {
}
}
fn decode_torrent_bytes(encoded: &str) -> Option<Vec<u8>> {
if encoded.is_empty()
|| encoded.len() > MAX_ENCODED_TORRENT_BYTES
|| encoded.len() % 4 != 0
{
return None;
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()?;
if bytes.is_empty() || bytes.len() > crate::torrent::MAX_TORRENT_BYTES {
return None;
}
crate::torrent::parse_torrent_bytes(&bytes).ok()?;
Some(bytes)
}
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
if payload.urls.len() > MAX_URL_COUNT {
return None;
}
let torrent_bytes = match payload.torrent_bytes_base64.as_deref() {
Some(encoded) => Some(decode_torrent_bytes(encoded)?),
None => None,
};
let mut seen = HashSet::new();
let urls = payload
.urls
@@ -420,6 +477,16 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
if urls.is_empty() {
return None;
}
if torrent_bytes.is_some()
&& (payload.media
|| !payload.torrent
|| urls.len() != 1
|| Url::parse(&urls[0])
.ok()
.is_none_or(|url| !matches!(url.scheme(), "http" | "https")))
{
return None;
}
if payload.media
&& urls.iter().any(|url| {
Url::parse(url)
@@ -437,6 +504,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
}
matches!(url.scheme(), "http" | "https")
&& (payload.torrent
|| torrent_bytes.is_some()
|| filename_is_torrent(payload.filename.as_deref())
|| url.path().to_ascii_lowercase().ends_with(".torrent"))
});
@@ -500,6 +568,8 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload
torrent,
batch,
batch_name,
torrent_path: None,
torrent_bytes,
})
}
@@ -730,7 +800,7 @@ fn is_allowed_origin(origin: &str) -> bool {
mod tests {
use super::{
acknowledge_extension_download, add_server_identity, claim_request_at,
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
decode_torrent_bytes, has_allowed_request_origin, is_valid_client_nonce, normalize_download,
normalize_url, required_client_nonce, same_origin_url, sanitize_filename,
sign_server_proof, ExtensionCookieScope, ExtensionRequest, MAX_URL_COUNT,
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
@@ -741,6 +811,7 @@ mod tests {
routing::get,
Router,
};
use base64::Engine as _;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::collections::HashMap;
@@ -767,7 +838,7 @@ mod tests {
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
"5"
"6"
);
server.abort();
@@ -834,6 +905,7 @@ mod tests {
torrent: false,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
});
assert!(download.is_none());
@@ -855,6 +927,7 @@ mod tests {
torrent: false,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
});
assert!(download.is_none());
@@ -917,6 +990,7 @@ mod tests {
torrent: false,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
})
.expect("valid media handoff");
@@ -942,6 +1016,7 @@ mod tests {
torrent: false,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
})
.expect("valid download handoff");
@@ -972,6 +1047,7 @@ mod tests {
torrent: false,
batch: true,
batch_name: Some("batch".to_string()),
torrent_bytes_base64: None,
})
.expect("valid multi-url handoff");
@@ -999,6 +1075,7 @@ mod tests {
torrent: true,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
})
.expect("valid magnet torrent handoff");
@@ -1017,6 +1094,7 @@ mod tests {
torrent: true,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
})
.expect("explicit opaque torrent handoff");
assert!(opaque.torrent);
@@ -1035,11 +1113,78 @@ mod tests {
torrent: false,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
})
.expect("legacy magnet handoff");
assert!(legacy_magnet.torrent);
}
#[test]
fn browser_local_torrent_bytes_are_normalized_as_a_single_http_sourced_torrent() {
let bytes = b"d4:infod6:lengthi5e4:name4:testee".to_vec();
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
let download = normalize_download(ExtensionRequest {
urls: vec!["https://privatebin.example/paste".to_string()],
referer: Some("https://privatebin.example/paste".to_string()),
silent: true,
filename: Some("TerraScape.TORRENT".to_string()),
headers: None,
cookies: None,
cookie_scopes: None,
media: false,
torrent: true,
batch: false,
batch_name: None,
torrent_bytes_base64: Some(encoded),
})
.expect("browser-local torrent bytes should be accepted");
assert!(download.torrent);
assert_eq!(download.urls, vec!["https://privatebin.example/paste"]);
assert_eq!(download.filename.as_deref(), Some("TerraScape.TORRENT"));
assert_eq!(download.torrent_bytes.as_deref(), Some(bytes.as_slice()));
assert!(download.torrent_path.is_none());
}
#[test]
fn browser_local_torrent_bytes_require_valid_bencoded_metadata_and_http_source() {
let valid = base64::engine::general_purpose::STANDARD
.encode(b"d4:infod6:lengthi5e4:name4:testee");
let invalid = base64::engine::general_purpose::STANDARD.encode(b"not a torrent");
assert!(decode_torrent_bytes(&invalid).is_none());
assert!(normalize_download(ExtensionRequest {
urls: vec!["blob:https://privatebin.example/attachment".to_string()],
referer: None,
silent: true,
filename: Some("download.torrent".to_string()),
headers: None,
cookies: None,
cookie_scopes: None,
media: false,
torrent: true,
batch: false,
batch_name: None,
torrent_bytes_base64: Some(valid.clone()),
})
.is_none());
assert!(normalize_download(ExtensionRequest {
urls: vec!["https://privatebin.example/paste".to_string()],
referer: None,
silent: true,
filename: Some("download.torrent".to_string()),
headers: None,
cookies: None,
cookie_scopes: None,
media: true,
torrent: true,
batch: false,
batch_name: None,
torrent_bytes_base64: Some(valid),
})
.is_none());
}
#[test]
fn regular_capture_normalizes_host_scoped_cookie_headers() {
let download = normalize_download(ExtensionRequest {
@@ -1067,6 +1212,7 @@ mod tests {
torrent: false,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
})
.expect("valid download handoff");
@@ -1098,6 +1244,7 @@ mod tests {
torrent: false,
batch: false,
batch_name: None,
torrent_bytes_base64: None,
})
.expect("valid multi-url handoff");
@@ -1122,6 +1269,7 @@ mod tests {
torrent: false,
batch: true,
batch_name: Some("Example Gallery / Chapter: 1".to_string()),
torrent_bytes_base64: None,
})
.expect("valid selected-link batch");
@@ -1146,6 +1294,7 @@ mod tests {
torrent: false,
batch: true,
batch_name: Some("Example Gallery".to_string()),
torrent_bytes_base64: None,
})
.expect("valid single-link handoff");
+1 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, };
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, torrent: boolean, batch: boolean, batch_name: string | null, torrent_path?: string, };
+21 -2
View File
@@ -215,11 +215,18 @@ export const AddDownloadsModal = () => {
if (!row.isTorrent) continue;
activeDraftIds.add(row.torrentCacheId || row.id);
activeDraftIds.add(`${row.id}-${row.generation}`);
const requestContext = pendingAddRequestContexts[normalizeComparableUrl(row.sourceUrl)];
if (requestContext?.torrentPath
&& requestContext.torrentCacheId
&& requestContext.torrentPath === row.torrentPath
&& requestContext?.torrentCacheId === row.torrentCacheId) {
cachedTorrentDraftIdsRef.current.add(requestContext.torrentCacheId);
}
}
const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current)
.filter(id => !activeDraftIds.has(id));
if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds);
}, [cleanupDraftTorrentCache, parsedItems]);
}, [cleanupDraftTorrentCache, parsedItems, pendingAddRequestContexts]);
useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]);
@@ -579,6 +586,16 @@ export const AddDownloadsModal = () => {
Object.entries(pendingAddRequestContexts)
.map(([url, context]) => [url, context.version])
);
const requestTorrentPaths = Object.fromEntries(
Object.entries(pendingAddRequestContexts)
.filter(([, context]) => Boolean(context.torrentPath))
.map(([url, context]) => [url, context.torrentPath as string])
);
const requestTorrentCacheIds = Object.fromEntries(
Object.entries(pendingAddRequestContexts)
.filter(([, context]) => Boolean(context.torrentCacheId))
.map(([url, context]) => [url, context.torrentCacheId as string])
);
setParsedItems(current => {
const selectedBySourceUrl = Object.fromEntries(
current.map(row => [row.sourceUrl, row.selected !== false])
@@ -598,7 +615,9 @@ export const AddDownloadsModal = () => {
requestContextVersions,
playlistExpansions,
selectedBySourceUrl,
forcedTorrentUrls
forcedTorrentUrls,
requestTorrentPaths,
requestTorrentCacheIds
);
});
}, [
+55
View File
@@ -4591,6 +4591,61 @@ describe('useDownloadStore', () => {
expect(state.pendingAddMediaUrls).toEqual([]);
});
it('routes a browser-local torrent handoff with its managed cache identity', async () => {
const torrentPath = '/Users/test/Library/Application Support/Firelink/torrents/request-id.torrent';
await useDownloadStore.getState().handleExtensionDownload({
request_id: 'request-id',
urls: [torrentPath],
torrent_path: torrentPath,
referer: 'https://example.com/page',
silent: true,
filename: 'sample.torrent',
headers: null,
cookies: null,
cookie_scopes: null,
media: false,
torrent: true,
batch: false,
batch_name: null
});
const state = useDownloadStore.getState();
expect(state.pendingAddUrls).toBe(torrentPath);
expect(state.pendingAddTorrentUrls).toEqual([torrentPath]);
expect(state.pendingAddRequestContexts[torrentPath]).toMatchObject({
media: false,
torrent: true,
torrentPath,
torrentCacheId: 'request-id'
});
});
it('retains a Windows managed torrent path as the request context key', async () => {
const torrentPath = 'C:\\Users\\test\\AppData\\Roaming\\Firelink\\torrents\\request-id.torrent';
await useDownloadStore.getState().handleExtensionDownload({
request_id: 'request-id',
urls: [torrentPath],
torrent_path: torrentPath,
referer: 'https://example.com/page',
silent: true,
filename: 'sample.torrent',
headers: null,
cookies: null,
cookie_scopes: null,
media: false,
torrent: true,
batch: false,
batch_name: null
});
const state = useDownloadStore.getState();
expect(state.pendingAddRequestContexts[torrentPath]).toMatchObject({
torrentPath,
torrentCacheId: 'request-id'
});
expect(state.pendingAddRequestContexts).not.toHaveProperty(`c:${torrentPath.slice(1)}`);
});
it('does not reuse stale extension metadata for a later single-link handoff', async () => {
useDownloadStore.setState({
isAddModalOpen: true,
+21 -8
View File
@@ -1122,6 +1122,8 @@ export type PendingAddRequestContext = {
cookieScopes?: ExtensionCookieScope[];
media: boolean;
torrent?: boolean;
torrentPath?: string;
torrentCacheId?: string;
};
export type DeleteModalState = {
@@ -1178,7 +1180,9 @@ interface DownloadState {
cookieScopes?: ExtensionCookieScope[] | null,
batch?: boolean,
batchName?: string | null,
torrent?: boolean
torrent?: boolean,
torrentPath?: string,
torrentCacheId?: string
) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
deleteModalState: DeleteModalState;
@@ -1928,7 +1932,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
cookieScopes,
batch = false,
batchName,
torrent = false
torrent = false,
torrentPath,
torrentCacheId
) => set((state) => {
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
const existingUrls = isAppending ? state.pendingAddUrls : '';
@@ -1967,10 +1973,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const trimmedUrl = rawUrl.trim();
if (!trimmedUrl) continue;
let key = trimmedUrl;
try {
key = new URL(trimmedUrl).href;
} catch {
// The Add modal will mark malformed input invalid; retain its original key here.
const isLocalPath = trimmedUrl.startsWith('/') || /^[a-z]:[\\/]/i.test(trimmedUrl);
if (!isLocalPath) {
try {
key = new URL(trimmedUrl).href;
} catch {
// The Add modal will mark malformed input invalid; retain its original key here.
}
}
const isItemMedia = isExplicitMedia || isMediaUrl(trimmedUrl);
pendingAddRequestContexts[key] = {
@@ -1981,7 +1990,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
cookies: isItemMedia ? '' : cleanCookies,
...(cleanCookieScopes?.length && !isItemMedia ? { cookieScopes: cleanCookieScopes } : {}),
media: isItemMedia,
...(torrent ? { torrent: true } : {})
...(torrent ? { torrent: true } : {}),
...(torrentPath ? { torrentPath } : {}),
...(torrentCacheId ? { torrentCacheId } : {})
};
}
const pendingAddMediaUrls = Object.entries(pendingAddRequestContexts)
@@ -2027,7 +2038,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
request.media === true ? undefined : request.cookie_scopes,
request.batch === true && urls.length >= 2,
request.batch_name,
request.torrent === true
request.torrent === true,
request.torrent_path || undefined,
request.request_id || undefined
);
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
+26
View File
@@ -152,6 +152,32 @@ describe('add download metadata workflow', () => {
});
});
it('retains the native-managed path and cache identity for a browser-local torrent', () => {
const source = '/Users/test/Library/Application Support/Firelink/torrents/request-id.torrent';
const rows = reconcileDownloadRows(
source,
[],
undefined,
new Set(),
undefined,
{},
{ [source]: 1 },
{},
{},
new Set(),
{ [source]: source },
{ [source]: 'request-id' }
);
expect(rows[0]).toMatchObject({
sourceUrl: source,
isTorrent: true,
status: 'loading',
torrentPath: source,
torrentCacheId: 'request-id'
});
});
it('gives refreshed torrent metadata a new cache identity', () => {
const existing = row({
id: 'torrent-row',
+11 -6
View File
@@ -272,7 +272,9 @@ export const reconcileDownloadRows = (
requestContextVersions: Readonly<Record<string, number>> = {},
playlistExpansions: PlaylistExpansions = {},
selectedBySourceUrl: Readonly<Record<string, boolean>> = {},
forceTorrentUrls: ReadonlySet<string> = new Set()
forceTorrentUrls: ReadonlySet<string> = new Set(),
requestTorrentPaths: Readonly<Record<string, string>> = {},
requestTorrentCacheIds: Readonly<Record<string, string>> = {}
): AddDownloadDraftRow[] => {
const inputs = parseInputLines(
rawText,
@@ -331,8 +333,9 @@ export const reconcileDownloadRows = (
playlistEntryTitle: input.playlistEntryTitle,
playlistError: undefined,
metadataBlockedReason: undefined,
torrentPath: undefined,
torrentCacheId: input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
torrentPath: requestTorrentPaths[input.sourceUrl],
torrentCacheId: requestTorrentCacheIds[input.sourceUrl]
|| (input.isTorrent || forcedTorrent ? `${preserved.id}-${nextGeneration}` : undefined),
torrentInfoHash: undefined,
torrentFiles: undefined,
selectedTorrentFileIndices: undefined
@@ -400,9 +403,11 @@ export const reconcileDownloadRows = (
playlistCount: input.playlistCount,
playlistEntryTitle: input.playlistEntryTitle,
metadataBlockedReason: undefined,
torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl))
? `${id}-${generation}`
: undefined,
torrentPath: requestTorrentPaths[input.sourceUrl],
torrentCacheId: requestTorrentCacheIds[input.sourceUrl]
|| (input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl))
? `${id}-${generation}`
: undefined),
torrentMetadataStatus: input.valid && input.isTorrent && isMagnetUrl(input.sourceUrl)
? 'loading'
: undefined,