Skip to content

Commit 17e896e

Browse files
committed
Handoff-40 Required #3: behavioral tests for If-Range
- Fixture server logs If-Range header and supports if-range-strict mode (returns 200 on validator mismatch) plus configurable ETag. - piece_sends_if_range_when_validator_set: verifies If-Range header is sent with the session validator. - piece_rejects_200_after_if_range_mismatch: verifies 200 after If-Range yields Changed (not silent acceptance).
1 parent da61755 commit 17e896e

2 files changed

Lines changed: 118 additions & 3 deletions

File tree

‎src/download_tests.rs‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3097,7 +3097,7 @@ fn manager_pause_resume_cancel() {
30973097
fail("bytes differ");
30983098
}
30993099
let ranges = std::fs::read_to_string(dir.join("ranges.log")).unwrap_or_default();
3100-
if !ranges.lines().any(|l| l.starts_with("bytes=")) {
3100+
if !ranges.lines().any(|l| l.contains("range=bytes=")) {
31013101
fail("resume never sent a Range request (206 path untested)");
31023102
}
31033103

@@ -6319,3 +6319,92 @@ fn dropping_manager_removes_scheduler_source() {
63196319
"dropping the manager removes the scheduler source"
63206320
);
63216321
}
6322+
6323+
/// If-Range header is sent when the session validator is set.
6324+
/// The fixture server logs the If-Range header; we assert it matches.
6325+
#[test]
6326+
fn piece_sends_if_range_when_validator_set() {
6327+
let Fixture {
6328+
dir,
6329+
dl,
6330+
payload,
6331+
port,
6332+
server,
6333+
} = spawn_fixture("ifrange", "v.bin", 300_000, "0", &[], 44);
6334+
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
6335+
let if_range = std::sync::Arc::new(std::sync::OnceLock::new());
6336+
let _ = if_range.set("\"test-etag-123\"".to_string());
6337+
let ctx = FetchCtx {
6338+
client: http_client().clone(),
6339+
url: format!("http://127.0.0.1:{port}/v.bin"),
6340+
dest: dl.join("v.bin"),
6341+
opts: DownloadOptions {
6342+
..Default::default()
6343+
},
6344+
cookies: None,
6345+
timeout: Duration::from_secs(30),
6346+
tx,
6347+
if_range,
6348+
};
6349+
let total = payload.len() as u64;
6350+
// Fetch a piece; the server logs the If-Range header.
6351+
match tokio_rt().block_on(fetch_piece(&ctx, 0, 1023, total)) {
6352+
Ok(_) => {}
6353+
Err(_) => abort(&server, "piece fetch failed"),
6354+
}
6355+
// Verify the If-Range header was sent with our validator.
6356+
let log = std::fs::read_to_string(dir.join("ranges.log")).unwrap_or_default();
6357+
assert!(
6358+
log.contains("if-range=\"test-etag-123\""),
6359+
"If-Range header not sent or wrong value. Log:\n{log}"
6360+
);
6361+
cleanup(&server, &dir);
6362+
}
6363+
6364+
/// 200 after If-Range yields Changed: when the server's ETag doesn't match
6365+
/// our If-Range, it returns 200 (full body) instead of 206. fetch_piece must
6366+
/// fail with Changed, not silently accept the wrong bytes.
6367+
#[test]
6368+
fn piece_rejects_200_after_if_range_mismatch() {
6369+
// Use if-range-strict mode: server returns 200 if If-Range != ETAG.
6370+
let Fixture {
6371+
dir,
6372+
dl,
6373+
payload,
6374+
port,
6375+
server,
6376+
} = spawn_fixture(
6377+
"ifrange200",
6378+
"v.bin",
6379+
300_000,
6380+
"0",
6381+
&["if-range-strict", "\"server-etag\""],
6382+
45,
6383+
);
6384+
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
6385+
let if_range = std::sync::Arc::new(std::sync::OnceLock::new());
6386+
// Our validator differs from the server's ETag.
6387+
let _ = if_range.set("\"client-etag\"".to_string());
6388+
let ctx = FetchCtx {
6389+
client: http_client().clone(),
6390+
url: format!("http://127.0.0.1:{port}/v.bin"),
6391+
dest: dl.join("v.bin"),
6392+
opts: DownloadOptions {
6393+
..Default::default()
6394+
},
6395+
cookies: None,
6396+
timeout: Duration::from_secs(30),
6397+
tx,
6398+
if_range,
6399+
};
6400+
let total = payload.len() as u64;
6401+
match tokio_rt().block_on(fetch_piece(&ctx, 0, 1023, total)) {
6402+
Err(AttemptFail::Changed(_)) => {}
6403+
Ok(_) => abort(
6404+
&server,
6405+
"200 after If-Range mismatch must fail Changed, not succeed",
6406+
),
6407+
Err(_) => abort(&server, "wrong error kind"),
6408+
}
6409+
cleanup(&server, &dir);
6410+
}

‎tests/throttled_server.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
Serves one file slowly (~160KB/s) so pause() always lands mid-transfer.
44
Honors Range requests with 206 (exercises real resume); logs each
55
request's range (or "full") for assertions.
6-
Usage: throttled_server.py PORT FILE RANGELOG
6+
Supports If-Range: logs the header, sends ETag, and in "if-range-strict"
7+
mode returns 200 if If-Range doesn't match (simulating a changed file).
8+
Usage: throttled_server.py PORT FILE RANGELOG [SLEEP] [DISPOSITION] [MODE] [ETAG]
79
"""
810
import sys
911
import time
@@ -16,14 +18,23 @@
1618
DISPOSITION = sys.argv[5] if len(sys.argv) > 5 else None
1719
# "throttle-ranges": 206 only for the bytes=0-0 probe, 403 for every other
1820
# Range request (simulates per-IP connection limits on file hosts).
21+
# "if-range-strict": if If-Range is present and doesn't match ETAG, return 200
22+
# (simulates a changed file); otherwise honor ranges normally.
1923
MODE = sys.argv[6] if len(sys.argv) > 6 else "normal"
24+
# ETag to send; defaults to a fixed value for If-Range testing.
25+
ETAG = sys.argv[7] if len(sys.argv) > 7 else '"test-etag-123"'
2026

2127

2228
class H(BaseHTTPRequestHandler):
2329
def do_GET(self):
2430
requested = self.headers.get("Range")
31+
if_range = self.headers.get("If-Range")
2532
with open(RANGELOG, "a") as log:
26-
log.write((requested or "full") + "\n")
33+
# Log format: "range=<range>|if-range=<value>" for assertions.
34+
# "full" if no Range header.
35+
range_part = requested or "full"
36+
if_range_part = if_range or "none"
37+
log.write(f"range={range_part}|if-range={if_range_part}\n")
2738
start, end, is_range = 0, len(DATA) - 1, False
2839
if requested and requested.startswith("bytes="):
2940
is_range = True
@@ -46,6 +57,20 @@ def do_GET(self):
4657
self.send_response(403)
4758
self.end_headers()
4859
return
60+
# If-Range strict mode: mismatch means the file changed → return 200
61+
# with full body (client must detect Changed).
62+
if MODE == "if-range-strict" and if_range and if_range != ETAG:
63+
self.send_response(200)
64+
self.send_header("Content-Length", str(len(DATA)))
65+
self.send_header("Content-Type", "application/octet-stream")
66+
self.send_header("ETag", ETAG)
67+
self.end_headers()
68+
try:
69+
self.wfile.write(DATA)
70+
self.wfile.flush()
71+
except (BrokenPipeError, ConnectionResetError):
72+
pass
73+
return
4974
body = DATA[start : end + 1]
5075
if is_range:
5176
self.send_response(206)
@@ -54,6 +79,7 @@ def do_GET(self):
5479
self.send_response(200)
5580
self.send_header("Content-Length", str(len(body)))
5681
self.send_header("Content-Type", "application/octet-stream")
82+
self.send_header("ETag", ETAG)
5783
if DISPOSITION:
5884
self.send_header("Content-Disposition", DISPOSITION)
5985
self.end_headers()

0 commit comments

Comments
 (0)