@@ -189,6 +189,112 @@ fn dev_update_url_override() -> Option<String> {
189189 None
190190}
191191
192+ /// How many times to (re)attempt the archive download before giving
193+ /// up. Truncation by a middlebox is usually transient, so a couple of
194+ /// fresh attempts clear it; 3 keeps the worst-case wait bounded.
195+ #[ cfg( not( target_os = "macos" ) ) ]
196+ const DOWNLOAD_ATTEMPTS : u32 = 3 ;
197+
198+ /// Verdict on whether a finished download received the whole body.
199+ /// Pure so the truncation rule is unit-testable without a socket. A
200+ /// `None` total means the server sent no `Content-Length` and we
201+ /// can't verify — we accept it rather than reject a legitimate
202+ /// chunked response.
203+ #[ cfg( not( target_os = "macos" ) ) ]
204+ fn verify_complete ( received : u64 , total : Option < u64 > ) -> Result < ( ) , String > {
205+ match total {
206+ // Short read — the common, retryable case: a middlebox cutting
207+ // a large HTTPS download ends the body early with a clean EOF.
208+ Some ( total) if received < total => Err ( format ! (
209+ "Download incomplete: received {received} of {total} bytes \
210+ (connection truncated — check a VPN/proxy or antivirus that \
211+ inspects HTTPS, then retry)"
212+ ) ) ,
213+ // Over-read — the server sent more than it advertised. Not a
214+ // truncation; the archive is suspect either way, so reject with
215+ // an honest, distinct message rather than the "incomplete" one.
216+ Some ( total) if received > total => Err ( format ! (
217+ "Download size mismatch: received {received} bytes but the \
218+ server advertised {total} (unexpected — retry)"
219+ ) ) ,
220+ _ => Ok ( ( ) ) ,
221+ }
222+ }
223+
224+ /// Download `url` into `archive_path`, streaming byte-progress into
225+ /// `state`, with completeness verification and a small retry budget.
226+ /// Returns the user-facing failure message on the final failure.
227+ #[ cfg( not( target_os = "macos" ) ) ]
228+ fn download_archive (
229+ client : & reqwest:: blocking:: Client ,
230+ url : & str ,
231+ archive_path : & std:: path:: Path ,
232+ state : & UpdateState ,
233+ ) -> Result < ( ) , String > {
234+ let mut last_err = String :: from ( "Download failed" ) ;
235+ for attempt in 1 ..=DOWNLOAD_ATTEMPTS {
236+ match download_once ( client, url, archive_path, state) {
237+ Ok ( ( ) ) => return Ok ( ( ) ) ,
238+ Err ( err) => {
239+ last_err = err;
240+ if attempt < DOWNLOAD_ATTEMPTS {
241+ // Linear backoff; the file is recreated (truncated)
242+ // on the next attempt by `download_once`.
243+ std:: thread:: sleep ( std:: time:: Duration :: from_millis (
244+ 750 * attempt as u64 ,
245+ ) ) ;
246+ }
247+ }
248+ }
249+ }
250+ Err ( last_err)
251+ }
252+
253+ /// One download attempt: (re)create the file, stream the body, verify
254+ /// the full length arrived. Each call truncates `archive_path` so a
255+ /// retry never appends onto a partial file.
256+ #[ cfg( not( target_os = "macos" ) ) ]
257+ fn download_once (
258+ client : & reqwest:: blocking:: Client ,
259+ url : & str ,
260+ archive_path : & std:: path:: Path ,
261+ state : & UpdateState ,
262+ ) -> Result < ( ) , String > {
263+ let mut archive_file = std:: fs:: File :: create ( archive_path) . map_err ( |err| {
264+ format ! ( "Could not create archive file {}: {err:#}" , archive_path. display( ) )
265+ } ) ?;
266+
267+ let mut response = client
268+ . get ( url)
269+ . send ( )
270+ . map_err ( |err| format ! ( "Download request failed: {err:#}" ) ) ?;
271+ response
272+ . error_for_status_ref ( )
273+ . map_err ( |err| format ! ( "Download failed: {err:#}" ) ) ?;
274+
275+ let total = response. content_length ( ) ;
276+ let mut received: u64 = 0 ;
277+ * state. write ( ) = UpdateStatus :: Downloading { received, total } ;
278+
279+ let mut buf = [ 0u8 ; 64 * 1024 ] ;
280+ loop {
281+ let n = match std:: io:: Read :: read ( & mut response, & mut buf) {
282+ Ok ( 0 ) => break ,
283+ Ok ( n) => n,
284+ Err ( err) => return Err ( format ! ( "Download read error: {err:#}" ) ) ,
285+ } ;
286+ std:: io:: Write :: write_all ( & mut archive_file, & buf[ ..n] )
287+ . map_err ( |err| format ! ( "Write error: {err:#}" ) ) ?;
288+ received += n as u64 ;
289+ * state. write ( ) = UpdateStatus :: Downloading { received, total } ;
290+ }
291+ std:: io:: Write :: flush ( & mut archive_file)
292+ . map_err ( |err| format ! ( "Flush error: {err:#}" ) ) ?;
293+ drop ( archive_file) ; // close before Extract reads it
294+
295+ verify_complete ( received, total)
296+ }
297+
192298#[ cfg( not( target_os = "macos" ) ) ]
193299fn run_install ( state : & UpdateState , info : ReleaseInfo ) {
194300 // The first Downloading{received, total} state is written once the
@@ -209,19 +315,6 @@ fn run_install(state: &UpdateState, info: ReleaseInfo) {
209315
210316 let archive_path = temp_dir. path ( ) . join ( & info. archive_name ) ;
211317
212- let archive_file = match std:: fs:: File :: create ( & archive_path) {
213- Ok ( f) => f,
214- Err ( err) => {
215- * state. write ( ) = UpdateStatus :: Failed {
216- message : format ! (
217- "Could not create archive file {}: {err:#}" ,
218- archive_path. display( )
219- ) ,
220- } ;
221- return ;
222- }
223- } ;
224-
225318 // Stream the download ourselves so we can drive byte-progress into
226319 // the state slot. self_update::Download has no programmatic progress
227320 // hook in 0.41. reqwest::blocking is safe here — run_install is on a
@@ -239,55 +332,18 @@ fn run_install(state: &UpdateState, info: ReleaseInfo) {
239332 }
240333 } ;
241334
242- let mut response = match client. get ( & download_url) . send ( ) {
243- Ok ( r) => r,
244- Err ( err) => {
245- * state. write ( ) = UpdateStatus :: Failed {
246- message : format ! ( "Download request failed: {err:#}" ) ,
247- } ;
248- return ;
249- }
250- } ;
251- if let Err ( err) = response. error_for_status_ref ( ) {
252- * state. write ( ) = UpdateStatus :: Failed {
253- message : format ! ( "Download failed: {err:#}" ) ,
254- } ;
255- return ;
256- }
257-
258- let total = response. content_length ( ) ;
259- let mut archive_file = archive_file; // rebind mut for write_all
260- let mut received: u64 = 0 ;
261- * state. write ( ) = UpdateStatus :: Downloading { received, total } ;
262-
263- let mut buf = [ 0u8 ; 64 * 1024 ] ;
264- loop {
265- let n = match std:: io:: Read :: read ( & mut response, & mut buf) {
266- Ok ( 0 ) => break ,
267- Ok ( n) => n,
268- Err ( err) => {
269- * state. write ( ) = UpdateStatus :: Failed {
270- message : format ! ( "Download read error: {err:#}" ) ,
271- } ;
272- return ;
273- }
274- } ;
275- if let Err ( err) = std:: io:: Write :: write_all ( & mut archive_file, & buf[ ..n] ) {
276- * state. write ( ) = UpdateStatus :: Failed {
277- message : format ! ( "Write error: {err:#}" ) ,
278- } ;
279- return ;
280- }
281- received += n as u64 ;
282- * state. write ( ) = UpdateStatus :: Downloading { received, total } ;
283- }
284- if let Err ( err) = std:: io:: Write :: flush ( & mut archive_file) {
285- * state. write ( ) = UpdateStatus :: Failed {
286- message : format ! ( "Flush error: {err:#}" ) ,
287- } ;
335+ // Download with completeness verification + a few retries. A
336+ // TLS-inspecting proxy / middlebox cutting a large download short
337+ // ends the body with a clean EOF, so a naive read loop accepts a
338+ // truncated zip and the failure only surfaces later as a baffling
339+ // "Could not find EOCD" extract error (#270-follow-up). Verifying
340+ // received == Content-Length here turns that into an honest
341+ // "download incomplete", and retrying self-heals transient
342+ // truncation.
343+ if let Err ( message) = download_archive ( & client, & download_url, & archive_path, state) {
344+ * state. write ( ) = UpdateStatus :: Failed { message } ;
288345 return ;
289346 }
290- drop ( archive_file) ; // close before Extract reads it
291347
292348 * state. write ( ) = UpdateStatus :: Installing ;
293349
@@ -388,3 +444,38 @@ pub fn open_releases_page() -> anyhow::Result<()> {
388444 ) ;
389445 open:: that ( & url) . map_err ( Into :: into)
390446}
447+
448+ #[ cfg( all( test, not( target_os = "macos" ) ) ) ]
449+ mod tests {
450+ use super :: verify_complete;
451+
452+ #[ test]
453+ fn full_download_is_complete ( ) {
454+ assert ! ( verify_complete( 5_717_213 , Some ( 5_717_213 ) ) . is_ok( ) ) ;
455+ }
456+
457+ #[ test]
458+ fn truncated_download_is_rejected ( ) {
459+ // A middlebox cutting the stream short: fewer bytes than the
460+ // advertised Content-Length must fail here, before extract.
461+ let err = verify_complete ( 1_000 , Some ( 5_717_213 ) ) . unwrap_err ( ) ;
462+ assert ! ( err. contains( "received 1000 of 5717213" ) , "{err}" ) ;
463+ assert ! ( err. to_lowercase( ) . contains( "incomplete" ) , "{err}" ) ;
464+ }
465+
466+ #[ test]
467+ fn unknown_total_cannot_be_verified_so_accepts ( ) {
468+ // No Content-Length header → we can't prove truncation, so we
469+ // don't reject a legitimate chunked response.
470+ assert ! ( verify_complete( 1_000 , None ) . is_ok( ) ) ;
471+ }
472+
473+ #[ test]
474+ fn over_read_is_rejected_with_a_distinct_message ( ) {
475+ // More bytes than advertised must fail, but not as a
476+ // "truncated/incomplete" download — that wording would be wrong.
477+ let err = verify_complete ( 6_000_000 , Some ( 5_717_213 ) ) . unwrap_err ( ) ;
478+ assert ! ( err. contains( "size mismatch" ) , "{err}" ) ;
479+ assert ! ( !err. to_lowercase( ) . contains( "incomplete" ) , "{err}" ) ;
480+ }
481+ }
0 commit comments