@@ -15,11 +15,11 @@ import {
1515} from "./protocol/description.js"
1616import type { FileInfo } from "./protocol/commands.js"
1717import {
18- getXFTPServerClient , createXFTPChunk , uploadXFTPChunk , downloadXFTPChunk ,
18+ getXFTPServerClient , createXFTPChunk , uploadXFTPChunk , downloadXFTPChunk , downloadXFTPChunkRaw ,
1919 ackXFTPChunk , deleteXFTPChunk , type XFTPClientAgent
2020} from "./client.js"
2121export { newXFTPAgent , closeXFTPAgent , type XFTPClientAgent } from "./client.js"
22- import { processDownloadedFile } from "./download.js"
22+ import { processDownloadedFile , decryptReceivedChunk } from "./download.js"
2323import type { XFTPServer } from "./protocol/address.js"
2424import { formatXFTPServer , parseXFTPServer } from "./protocol/address.js"
2525import { concatBytes } from "./protocol/encoding.js"
@@ -38,14 +38,17 @@ interface SentChunk {
3838 server : XFTPServer
3939}
4040
41- export interface EncryptedFileInfo {
42- encData : Uint8Array
41+ export interface EncryptedFileMetadata {
4342 digest : Uint8Array // SHA-512 of encData
4443 key : Uint8Array // 32B SbKey
4544 nonce : Uint8Array // 24B CbNonce
4645 chunkSizes : number [ ]
4746}
4847
48+ export interface EncryptedFileInfo extends EncryptedFileMetadata {
49+ encData : Uint8Array
50+ }
51+
4952export interface UploadResult {
5053 rcvDescription : FileDescription
5154 sndDescription : FileDescription
@@ -93,13 +96,25 @@ export function encryptFileForUpload(source: Uint8Array, fileName: string): Encr
9396
9497const DEFAULT_REDIRECT_THRESHOLD = 400
9598
99+ export interface UploadOptions {
100+ onProgress ?: ( uploaded : number , total : number ) => void
101+ redirectThreshold ?: number
102+ readChunk ?: ( offset : number , size : number ) => Promise < Uint8Array >
103+ }
104+
96105export async function uploadFile (
97106 agent : XFTPClientAgent ,
98107 server : XFTPServer ,
99- encrypted : EncryptedFileInfo ,
100- onProgress ?: ( uploaded : number , total : number ) => void ,
101- redirectThreshold ?: number
108+ encrypted : EncryptedFileMetadata ,
109+ options ?: UploadOptions
102110) : Promise < UploadResult > {
111+ const { onProgress, redirectThreshold, readChunk : readChunkOpt } = options ?? { }
112+ const readChunk : ( offset : number , size : number ) => Promise < Uint8Array > = readChunkOpt
113+ ? readChunkOpt
114+ : ( 'encData' in encrypted
115+ ? ( off , sz ) => Promise . resolve ( ( encrypted as EncryptedFileInfo ) . encData . subarray ( off , off + sz ) )
116+ : ( ) => { throw new Error ( "uploadFile: readChunk required when encData is absent" ) } )
117+ const total = encrypted . chunkSizes . reduce ( ( a , b ) => a + b , 0 )
103118 const specs = prepareChunkSpecs ( encrypted . chunkSizes )
104119 const client = await getXFTPServerClient ( agent , server )
105120 const sentChunks : SentChunk [ ] = [ ]
@@ -109,7 +124,7 @@ export async function uploadFile(
109124 const chunkNo = i + 1
110125 const sndKp = generateEd25519KeyPair ( )
111126 const rcvKp = generateEd25519KeyPair ( )
112- const chunkData = encrypted . encData . subarray ( spec . chunkOffset , spec . chunkOffset + spec . chunkSize )
127+ const chunkData = await readChunk ( spec . chunkOffset , spec . chunkSize )
113128 const chunkDigest = getChunkDigest ( chunkData )
114129 const fileInfo : FileInfo = {
115130 sndKey : encodePubKeyEd25519 ( sndKp . publicKey ) ,
@@ -126,7 +141,7 @@ export async function uploadFile(
126141 chunkSize : spec . chunkSize , digest : chunkDigest , server
127142 } )
128143 uploaded += spec . chunkSize
129- onProgress ?.( uploaded , encrypted . encData . length )
144+ onProgress ?.( uploaded , total )
130145 }
131146 const rcvDescription = buildDescription ( "recipient" , encrypted , sentChunks )
132147 const sndDescription = buildDescription ( "sender" , encrypted , sentChunks )
@@ -142,7 +157,7 @@ export async function uploadFile(
142157
143158function buildDescription (
144159 party : "recipient" | "sender" ,
145- enc : EncryptedFileInfo ,
160+ enc : EncryptedFileMetadata ,
146161 chunks : SentChunk [ ]
147162) : FileDescription {
148163 const defChunkSize = enc . chunkSizes [ 0 ]
@@ -223,39 +238,71 @@ async function uploadRedirectDescription(
223238
224239// ── Download ────────────────────────────────────────────────────
225240
226- export async function downloadFile (
241+ export interface RawDownloadedChunk {
242+ chunkNo : number
243+ dhSecret : Uint8Array
244+ nonce : Uint8Array
245+ body : Uint8Array
246+ digest : Uint8Array
247+ }
248+
249+ export interface DownloadRawOptions {
250+ onProgress ?: ( downloaded : number , total : number ) => void
251+ concurrency ?: number
252+ }
253+
254+ export async function downloadFileRaw (
227255 agent : XFTPClientAgent ,
228256 fd : FileDescription ,
229- onProgress ?: ( downloaded : number , total : number ) => void
230- ) : Promise < DownloadResult > {
257+ onRawChunk : ( chunk : RawDownloadedChunk ) => Promise < void > ,
258+ options ?: DownloadRawOptions
259+ ) : Promise < FileDescription > {
231260 const err = validateFileDescription ( fd )
232- if ( err ) throw new Error ( "downloadFile: " + err )
261+ if ( err ) throw new Error ( "downloadFileRaw: " + err )
262+ const { onProgress, concurrency = 1 } = options ?? { }
263+ // Resolve redirect on main thread (redirect data is small)
233264 if ( fd . redirect !== null ) {
234- return downloadWithRedirect ( agent , fd , onProgress )
265+ fd = await resolveRedirect ( agent , fd )
235266 }
236- const plaintextChunks : Uint8Array [ ] = new Array ( fd . chunks . length )
267+ const resolvedFd = fd
268+ // Pre-connect to avoid race condition under concurrency
269+ const servers = new Set ( resolvedFd . chunks . map ( c => c . replicas [ 0 ] ?. server ) . filter ( Boolean ) as string [ ] )
270+ for ( const s of servers ) {
271+ await getXFTPServerClient ( agent , parseXFTPServer ( s ) )
272+ }
273+ // Sliding-window parallel download
237274 let downloaded = 0
238- for ( const chunk of fd . chunks ) {
239- const replica = chunk . replicas [ 0 ]
240- if ( ! replica ) throw new Error ( "downloadFile: chunk has no replicas" )
241- const client = await getXFTPServerClient ( agent , parseXFTPServer ( replica . server ) )
242- const seed = decodePrivKeyEd25519 ( replica . replicaKey )
243- const kp = ed25519KeyPairFromSeed ( seed )
244- const data = await downloadXFTPChunk ( client , kp . privateKey , replica . replicaId , chunk . digest )
245- plaintextChunks [ chunk . chunkNo - 1 ] = data
246- downloaded += chunk . chunkSize
247- onProgress ?.( downloaded , fd . size )
275+ const queue = resolvedFd . chunks . slice ( )
276+ let idx = 0
277+ async function worker ( ) {
278+ while ( idx < queue . length ) {
279+ const i = idx ++
280+ const chunk = queue [ i ]
281+ const replica = chunk . replicas [ 0 ]
282+ if ( ! replica ) throw new Error ( "downloadFileRaw: chunk has no replicas" )
283+ const client = await getXFTPServerClient ( agent , parseXFTPServer ( replica . server ) )
284+ const seed = decodePrivKeyEd25519 ( replica . replicaKey )
285+ const kp = ed25519KeyPairFromSeed ( seed )
286+ const raw = await downloadXFTPChunkRaw ( client , kp . privateKey , replica . replicaId )
287+ await onRawChunk ( {
288+ chunkNo : chunk . chunkNo ,
289+ dhSecret : raw . dhSecret ,
290+ nonce : raw . nonce ,
291+ body : raw . body ,
292+ digest : chunk . digest
293+ } )
294+ downloaded += chunk . chunkSize
295+ onProgress ?.( downloaded , resolvedFd . size )
296+ }
248297 }
249- // Verify file size
250- const totalSize = plaintextChunks . reduce ( ( s , c ) => s + c . length , 0 )
251- if ( totalSize !== fd . size ) throw new Error ( "downloadFile: file size mismatch" )
252- // Verify file digest (SHA-512 of encrypted file data)
253- const combined = plaintextChunks . length === 1 ? plaintextChunks [ 0 ] : concatBytes ( ...plaintextChunks )
254- const digest = sha512 ( combined )
255- if ( ! digestEqual ( digest , fd . digest ) ) throw new Error ( "downloadFile: file digest mismatch" )
256- // Decrypt
257- const result = processDownloadedFile ( fd , plaintextChunks )
258- // ACK all chunks (best-effort)
298+ const workers = Array . from ( { length : Math . min ( concurrency , queue . length ) } , ( ) => worker ( ) )
299+ await Promise . all ( workers )
300+ return resolvedFd
301+ }
302+
303+ export async function ackFileChunks (
304+ agent : XFTPClientAgent , fd : FileDescription
305+ ) : Promise < void > {
259306 for ( const chunk of fd . chunks ) {
260307 const replica = chunk . replicas [ 0 ]
261308 if ( ! replica ) continue
@@ -266,47 +313,56 @@ export async function downloadFile(
266313 await ackXFTPChunk ( client , kp . privateKey , replica . replicaId )
267314 } catch ( _ ) { }
268315 }
269- return result
270316}
271317
272- async function downloadWithRedirect (
318+ export async function downloadFile (
273319 agent : XFTPClientAgent ,
274320 fd : FileDescription ,
275321 onProgress ?: ( downloaded : number , total : number ) => void
276322) : Promise < DownloadResult > {
323+ const chunks : Uint8Array [ ] = [ ]
324+ const resolvedFd = await downloadFileRaw ( agent , fd , async ( raw ) => {
325+ chunks [ raw . chunkNo - 1 ] = decryptReceivedChunk (
326+ raw . dhSecret , raw . nonce , raw . body , raw . digest
327+ )
328+ } , { onProgress} )
329+ const combined = chunks . length === 1 ? chunks [ 0 ] : concatBytes ( ...chunks )
330+ if ( combined . length !== resolvedFd . size ) throw new Error ( "downloadFile: file size mismatch" )
331+ const digest = sha512 ( combined )
332+ if ( ! digestEqual ( digest , resolvedFd . digest ) ) throw new Error ( "downloadFile: file digest mismatch" )
333+ const result = processDownloadedFile ( resolvedFd , chunks )
334+ await ackFileChunks ( agent , resolvedFd )
335+ return result
336+ }
337+
338+ async function resolveRedirect (
339+ agent : XFTPClientAgent ,
340+ fd : FileDescription
341+ ) : Promise < FileDescription > {
277342 const plaintextChunks : Uint8Array [ ] = new Array ( fd . chunks . length )
278343 for ( const chunk of fd . chunks ) {
279344 const replica = chunk . replicas [ 0 ]
280- if ( ! replica ) throw new Error ( "downloadWithRedirect : chunk has no replicas" )
345+ if ( ! replica ) throw new Error ( "resolveRedirect : chunk has no replicas" )
281346 const client = await getXFTPServerClient ( agent , parseXFTPServer ( replica . server ) )
282347 const seed = decodePrivKeyEd25519 ( replica . replicaKey )
283348 const kp = ed25519KeyPairFromSeed ( seed )
284349 const data = await downloadXFTPChunk ( client , kp . privateKey , replica . replicaId , chunk . digest )
285350 plaintextChunks [ chunk . chunkNo - 1 ] = data
286351 }
287352 const totalSize = plaintextChunks . reduce ( ( s , c ) => s + c . length , 0 )
288- if ( totalSize !== fd . size ) throw new Error ( "downloadWithRedirect : redirect file size mismatch" )
353+ if ( totalSize !== fd . size ) throw new Error ( "resolveRedirect : redirect file size mismatch" )
289354 const combined = plaintextChunks . length === 1 ? plaintextChunks [ 0 ] : concatBytes ( ...plaintextChunks )
290355 const digest = sha512 ( combined )
291- if ( ! digestEqual ( digest , fd . digest ) ) throw new Error ( "downloadWithRedirect : redirect file digest mismatch" )
356+ if ( ! digestEqual ( digest , fd . digest ) ) throw new Error ( "resolveRedirect : redirect file digest mismatch" )
292357 const { content : yamlBytes } = processDownloadedFile ( fd , plaintextChunks )
293358 const innerFd = decodeFileDescription ( new TextDecoder ( ) . decode ( yamlBytes ) )
294359 const innerErr = validateFileDescription ( innerFd )
295- if ( innerErr ) throw new Error ( "downloadWithRedirect : inner description invalid: " + innerErr )
296- if ( innerFd . size !== fd . redirect ! . size ) throw new Error ( "downloadWithRedirect : redirect size mismatch" )
297- if ( ! digestEqual ( innerFd . digest , fd . redirect ! . digest ) ) throw new Error ( "downloadWithRedirect : redirect digest mismatch" )
360+ if ( innerErr ) throw new Error ( "resolveRedirect : inner description invalid: " + innerErr )
361+ if ( innerFd . size !== fd . redirect ! . size ) throw new Error ( "resolveRedirect : redirect size mismatch" )
362+ if ( ! digestEqual ( innerFd . digest , fd . redirect ! . digest ) ) throw new Error ( "resolveRedirect : redirect digest mismatch" )
298363 // ACK redirect chunks (best-effort)
299- for ( const chunk of fd . chunks ) {
300- const replica = chunk . replicas [ 0 ]
301- if ( ! replica ) continue
302- try {
303- const client = await getXFTPServerClient ( agent , parseXFTPServer ( replica . server ) )
304- const seed = decodePrivKeyEd25519 ( replica . replicaKey )
305- const kp = ed25519KeyPairFromSeed ( seed )
306- await ackXFTPChunk ( client , kp . privateKey , replica . replicaId )
307- } catch ( _ ) { }
308- }
309- return downloadFile ( agent , innerFd , onProgress )
364+ await ackFileChunks ( agent , fd )
365+ return innerFd
310366}
311367
312368// ── Delete ──────────────────────────────────────────────────────
0 commit comments