@@ -6384,7 +6384,7 @@ fn piece_rejects_200_after_if_range_mismatch() {
63846384 "v.bin" ,
63856385 300_000 ,
63866386 "0" ,
6387- & [ "if-range-strict" , "\" server-etag\" " ] ,
6387+ & [ "" , " if-range-strict", "\" server-etag\" " ] ,
63886388 45 ,
63896389 ) ;
63906390 let ( tx, _rx) = tokio:: sync:: mpsc:: unbounded_channel ( ) ;
@@ -6412,5 +6412,188 @@ fn piece_rejects_200_after_if_range_mismatch() {
64126412 ) ,
64136413 Err ( _) => abort ( & server, "wrong error kind" ) ,
64146414 }
6415+ // Prove the 200 path was taken: the server logged our If-Range header,
6416+ // and in strict mode a mismatched If-Range yields 200 (not 206). The
6417+ // 206-validator-mismatch path cannot trigger here.
6418+ let log = std:: fs:: read_to_string ( dir. join ( "ranges.log" ) ) . unwrap_or_default ( ) ;
6419+ assert ! (
6420+ log. contains( "if-range=\" client-etag\" " ) ,
6421+ "server never saw our If-Range; strict mode not engaged. Log:\n {log}"
6422+ ) ;
64156423 cleanup ( & server, & dir) ;
64166424}
6425+
6426+ /// 206 with mismatched validator yields Changed: when the server ignores
6427+ /// If-Range (returns 206 anyway) but the response ETag differs from our
6428+ /// session validator, fetch_piece must fail with Changed, not splice versions.
6429+ #[ test]
6430+ fn piece_rejects_206_with_mismatched_validator ( ) {
6431+ // Normal mode (not strict): server returns 206 with its ETag even when
6432+ // If-Range doesn't match. Our validator differs from the server's ETag.
6433+ let Fixture {
6434+ dir : _dir,
6435+ dl,
6436+ payload,
6437+ port,
6438+ server,
6439+ } = spawn_fixture (
6440+ "ifrange206" ,
6441+ "v.bin" ,
6442+ 300_000 ,
6443+ "0" ,
6444+ & [ "" , "" , "\" server-etag-v2\" " ] ,
6445+ 46 ,
6446+ ) ;
6447+ let ( tx, _rx) = tokio:: sync:: mpsc:: unbounded_channel ( ) ;
6448+ let if_range = std:: sync:: Arc :: new ( std:: sync:: OnceLock :: new ( ) ) ;
6449+ // Our validator differs from the server's ETag ("server-etag-v2").
6450+ let _ = if_range. set ( "\" client-etag-v1\" " . to_string ( ) ) ;
6451+ let ctx = FetchCtx {
6452+ client : http_client ( ) . clone ( ) ,
6453+ url : format ! ( "http://127.0.0.1:{port}/v.bin" ) ,
6454+ dest : dl. join ( "v.bin" ) ,
6455+ opts : DownloadOptions {
6456+ ..Default :: default ( )
6457+ } ,
6458+ cookies : None ,
6459+ timeout : Duration :: from_secs ( 30 ) ,
6460+ tx,
6461+ if_range,
6462+ } ;
6463+ let total = payload. len ( ) as u64 ;
6464+ match tokio_rt ( ) . block_on ( fetch_piece ( & ctx, 0 , 1023 , total) ) {
6465+ Err ( AttemptFail :: Changed ( _) ) => { }
6466+ Ok ( _) => abort (
6467+ & server,
6468+ "206 with mismatched validator must fail Changed, not succeed" ,
6469+ ) ,
6470+ Err ( e) => abort ( & server, & format ! ( "wrong error kind: {e:?}" ) ) ,
6471+ }
6472+ cleanup ( & server, & _dir) ;
6473+ }
6474+
6475+ /// Probe validator is persisted via EngineMsg::Validator: when a fresh
6476+ /// segmented download starts, the probe's ETag must be sent through the
6477+ /// channel before workers proceed, so pause/restart can resume with If-Range.
6478+ #[ test]
6479+ fn probe_validator_sent_before_workers ( ) {
6480+ let Fixture {
6481+ dir,
6482+ dl,
6483+ payload : _payload,
6484+ port,
6485+ server,
6486+ } = spawn_fixture (
6487+ "probeval" ,
6488+ "v.bin" ,
6489+ 5_000_000 ,
6490+ "0" ,
6491+ & [ "" , "" , "\" probe-etag-456\" " ] ,
6492+ 47 ,
6493+ ) ;
6494+ let ( tx, mut rx) = tokio:: sync:: mpsc:: unbounded_channel ( ) ;
6495+ let ctx = FetchCtx {
6496+ client : http_client ( ) . clone ( ) ,
6497+ url : format ! ( "http://127.0.0.1:{port}/v.bin" ) ,
6498+ dest : dl. join ( "v.bin" ) ,
6499+ opts : DownloadOptions {
6500+ ..Default :: default ( )
6501+ } ,
6502+ cookies : None ,
6503+ timeout : Duration :: from_secs ( 30 ) ,
6504+ tx,
6505+ if_range : std:: sync:: Arc :: new ( std:: sync:: OnceLock :: new ( ) ) ,
6506+ } ;
6507+ // Run the download in the background; we only care about the Validator message.
6508+ let dl_clone = dl. clone ( ) ;
6509+ let handle = std:: thread:: spawn ( move || {
6510+ tokio_rt ( ) . block_on ( run_download ( ctx, 4 , StartMode :: Fresh ) ) ;
6511+ let _ = std:: fs:: remove_dir_all ( dl_clone) ;
6512+ } ) ;
6513+ // Wait for the Validator message (probe must send it before workers start).
6514+ let validator = tokio_rt ( ) . block_on ( async {
6515+ let timeout = tokio:: time:: timeout ( Duration :: from_secs ( 30 ) , async {
6516+ while let Some ( msg) = rx. recv ( ) . await {
6517+ if let EngineMsg :: Validator ( info) = msg {
6518+ return Some ( info) ;
6519+ }
6520+ // Stop waiting once the download finishes or fails.
6521+ if matches ! ( msg, EngineMsg :: Finished { .. } | EngineMsg :: Failed ( _) ) {
6522+ return None ;
6523+ }
6524+ }
6525+ None
6526+ } ) ;
6527+ timeout. await . unwrap_or ( None )
6528+ } ) ;
6529+ let _ = handle. join ( ) ;
6530+ let info = validator. unwrap_or_else ( || abort ( & server, "probe never sent EngineMsg::Validator" ) ) ;
6531+ assert_eq ! (
6532+ info. etag. as_deref( ) ,
6533+ Some ( "\" probe-etag-456\" " ) ,
6534+ "validator ETag must match the probe response"
6535+ ) ;
6536+ cleanup ( & server, & dir) ;
6537+ }
6538+
6539+ /// StoredItem validator persistence: etag/last_modified round-trip through
6540+ /// JSON serialization, so cross-session resume has the validator.
6541+ #[ test]
6542+ fn stored_item_validator_roundtrip ( ) {
6543+ let mut item = stored_row (
6544+ "http://example.com/f.bin" ,
6545+ "/tmp" ,
6546+ "f.bin" ,
6547+ DownloadStatus :: Paused ,
6548+ ) ;
6549+ item. etag = Some ( "\" test-etag-789\" " . to_string ( ) ) ;
6550+ item. last_modified = Some ( "Wed, 21 Oct 2015 07:28:00 GMT" . to_string ( ) ) ;
6551+
6552+ let json = serde_json:: to_string ( & item) . expect ( "serialize" ) ;
6553+ assert ! ( json. contains( "test-etag-789" ) , "etag not persisted: {json}" ) ;
6554+ assert ! (
6555+ json. contains( "Wed, 21 Oct 2015" ) ,
6556+ "last_modified not persisted: {json}"
6557+ ) ;
6558+
6559+ let restored: StoredItem = serde_json:: from_str ( & json) . expect ( "deserialize" ) ;
6560+ assert_eq ! (
6561+ restored. etag. as_deref( ) ,
6562+ Some ( "\" test-etag-789\" " ) ,
6563+ "etag not restored"
6564+ ) ;
6565+ assert_eq ! (
6566+ restored. last_modified. as_deref( ) ,
6567+ Some ( "Wed, 21 Oct 2015 07:28:00 GMT" ) ,
6568+ "last_modified not restored"
6569+ ) ;
6570+ }
6571+
6572+ /// Old queue compatibility: a queue file predating the validator fields
6573+ /// (no etag/last_modified keys) must deserialize with None, not fail.
6574+ #[ test]
6575+ fn old_queue_without_validators_deserializes ( ) {
6576+ // Simulate an old queue file: StoredItem JSON without etag/last_modified.
6577+ let old_json = r#"{
6578+ "id": null,
6579+ "url": "http://example.com/old.bin",
6580+ "dest_dir": "/tmp",
6581+ "filename": "old.bin",
6582+ "status": "Paused",
6583+ "progress": 0.5,
6584+ "segments": null,
6585+ "selected_files": null,
6586+ "output_dir": null,
6587+ "video_source": null,
6588+ "started": true,
6589+ "scheduled_at": null
6590+ }"# ;
6591+ let item: StoredItem =
6592+ serde_json:: from_str ( old_json) . expect ( "old queue format must deserialize" ) ;
6593+ assert_eq ! ( item. etag, None , "missing etag must default to None" ) ;
6594+ assert_eq ! (
6595+ item. last_modified, None ,
6596+ "missing last_modified must default to None"
6597+ ) ;
6598+ assert_eq ! ( item. url, "http://example.com/old.bin" ) ;
6599+ }
0 commit comments