1use crate::file::DestFile;
13use crate::http::RemoteInfo;
14use crate::storage::{DownloadRecord, RangeRecord, RangeState};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Validators {
18 pub etag: Option<String>,
19 pub last_modified: Option<String>,
20 pub size: Option<u64>,
21}
22
23impl Validators {
24 pub fn of_record(rec: &DownloadRecord) -> Self {
25 Self {
26 etag: rec.etag.clone(),
27 last_modified: rec.last_modified.clone(),
28 size: rec.total_size,
29 }
30 }
31
32 pub fn of_remote(info: &RemoteInfo) -> Self {
33 Self {
34 etag: info.etag.clone(),
35 last_modified: info.last_modified.clone(),
36 size: info.size,
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Validation {
43 Unchanged,
45 Unverifiable(String),
48 Changed {
50 reason: String,
51 previous: Validators,
52 current: Validators,
53 },
54}
55
56pub fn validate(rec: &DownloadRecord, info: &RemoteInfo) -> Validation {
58 let previous = Validators::of_record(rec);
59 let current = Validators::of_remote(info);
60
61 let changed = |reason: String| Validation::Changed {
62 reason,
63 previous: previous.clone(),
64 current: current.clone(),
65 };
66
67 if let (Some(before), Some(now)) = (previous.size, current.size) {
69 if before != now {
70 return changed(format!("size changed from {before} to {now} bytes"));
71 }
72 }
73
74 let strong_before = previous
75 .etag
76 .as_deref()
77 .filter(|t| !t.trim_start().starts_with("W/"));
78 let strong_now = current
79 .etag
80 .as_deref()
81 .filter(|t| !t.trim_start().starts_with("W/"));
82
83 match (strong_before, strong_now) {
84 (Some(a), Some(b)) if a == b => return Validation::Unchanged,
85 (Some(a), Some(b)) => {
86 return changed(format!("ETag changed from {a} to {b}"));
87 }
88 (Some(_), None) => {
89 return Validation::Unverifiable(
90 "the server no longer sends a strong ETag, so we cannot confirm the file is \
91 unchanged"
92 .into(),
93 );
94 }
95 _ => {}
96 }
97
98 match (
99 previous.last_modified.as_deref(),
100 current.last_modified.as_deref(),
101 ) {
102 (Some(a), Some(b)) if a == b => return Validation::Unchanged,
103 (Some(a), Some(b)) => {
104 return changed(format!("Last-Modified changed from {a} to {b}"));
105 }
106 _ => {}
107 }
108
109 match (previous.etag.as_deref(), current.etag.as_deref()) {
112 (Some(a), Some(b)) if a == b => {
113 return Validation::Unverifiable(
114 "only a weak ETag is available, which does not guarantee identical bytes".into(),
115 );
116 }
117 (Some(a), Some(b)) => {
118 return changed(format!("ETag changed from {a} to {b}"));
119 }
120 _ => {}
121 }
122
123 if previous.size.is_some() && previous.size == current.size {
124 return Validation::Unverifiable(
125 "the server sends no ETag or Last-Modified; only the size matches".into(),
126 );
127 }
128
129 Validation::Unverifiable("the server provides no validators at all".into())
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Identity {
134 Same,
136 Replaced,
138 Unrecorded,
140}
141
142pub fn check_identity(rec: &DownloadRecord, file: &DestFile) -> Identity {
147 let (Some(dev), Some(ino)) = (rec.file_dev, rec.file_ino) else {
148 return Identity::Unrecorded;
149 };
150 match file.identity() {
151 Ok(id) if id.dev == dev && id.ino == ino => Identity::Same,
152 Ok(_) => Identity::Replaced,
153 Err(_) => Identity::Unrecorded,
154 }
155}
156
157#[derive(Debug, Clone)]
158pub struct Reconciled {
159 pub ranges: Vec<RangeRecord>,
160 pub trusted_bytes: u64,
162 pub discarded_bytes: u64,
164 pub notes: Vec<String>,
165}
166
167pub fn reconcile(ranges: &[RangeRecord], file_len: u64, total: Option<u64>) -> Reconciled {
176 let mut out = Vec::with_capacity(ranges.len());
177 let mut trusted = 0u64;
178 let mut discarded = 0u64;
179 let mut notes = Vec::new();
180
181 for r in ranges {
182 let mut r = *r;
183
184 if let Some(total) = total {
186 if r.start >= total {
187 discarded += r.bytes_written;
188 notes.push(format!(
189 "range {} starts past the end of the file ({} >= {total}); dropping it",
190 r.idx, r.start
191 ));
192 continue;
193 }
194 if !r.is_open_ended() && r.end >= total {
195 r.end = total - 1;
196 if r.bytes_written > r.size() {
197 discarded += r.bytes_written - r.size();
198 r.bytes_written = r.size();
199 }
200 }
201 }
202
203 let claimed_end = r.start + r.bytes_written;
204 if claimed_end > file_len {
205 let keep = file_len.saturating_sub(r.start);
207 discarded += r.bytes_written - keep;
208 notes.push(format!(
209 "range {} claimed {} bytes but the file is only {file_len} bytes; keeping {keep}",
210 r.idx, r.bytes_written
211 ));
212 r.bytes_written = keep;
213 r.state = RangeState::Pending;
214 }
215
216 if r.state == RangeState::Complete && r.bytes_written < r.size() {
217 notes.push(format!(
219 "range {} was marked complete with only {} of {} bytes; re-downloading",
220 r.idx,
221 r.bytes_written,
222 r.size()
223 ));
224 discarded += r.bytes_written;
225 r.bytes_written = 0;
226 r.state = RangeState::Pending;
227 }
228
229 if r.state == RangeState::Complete {
230 trusted += r.size();
231 } else {
232 r.state = RangeState::Pending;
235 trusted += r.bytes_written;
236 }
237 out.push(r);
238 }
239
240 Reconciled {
241 ranges: out,
242 trusted_bytes: trusted,
243 discarded_bytes: discarded,
244 notes,
245 }
246}
247
248pub fn plan_is_intact(ranges: &[RangeRecord], total: u64) -> bool {
251 if ranges.is_empty() {
252 return total == 0;
253 }
254 let mut sorted: Vec<&RangeRecord> = ranges.iter().collect();
255 sorted.sort_by_key(|r| r.start);
256 let mut cursor = 0u64;
257 for r in sorted {
258 if r.start != cursor || r.end < r.start {
259 return false;
260 }
261 cursor = r.end + 1;
262 }
263 cursor == total
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::storage::{Status, mint_cookie, now};
270
271 fn rec(etag: Option<&str>, last_modified: Option<&str>, size: Option<u64>) -> DownloadRecord {
272 DownloadRecord {
273 id: "aa11bb".into(),
274 original_url: "https://x.example/f".into(),
275 resolved_url: None,
276 mirrors: vec![],
277 destination: "/tmp/f".into(),
278 filename: "f".into(),
279 total_size: size,
280 etag: etag.map(String::from),
281 last_modified: last_modified.map(String::from),
282 content_type: None,
283 accept_ranges: true,
284 expected_checksum: None,
285 checksum_algorithm: None,
286 file_cookie: mint_cookie(),
287 file_dev: Some(1),
288 file_ino: Some(2),
289 durable_bytes: 0,
290 status: Status::Paused,
291 error: None,
292 created_at: now(),
293 updated_at: now(),
294 completed_at: None,
295 }
296 }
297
298 fn info(etag: Option<&str>, last_modified: Option<&str>, size: Option<u64>) -> RemoteInfo {
299 RemoteInfo {
300 final_url: url::Url::parse("https://x.example/f").unwrap(),
301 size,
302 accept_ranges: true,
303 etag: etag.map(String::from),
304 last_modified: last_modified.map(String::from),
305 content_type: None,
306 content_disposition: None,
307 content_encoding: None,
308 }
309 }
310
311 #[test]
312 fn same_strong_etag_is_unchanged() {
313 let v = validate(
314 &rec(Some("\"abc\""), None, Some(100)),
315 &info(Some("\"abc\""), None, Some(100)),
316 );
317 assert_eq!(v, Validation::Unchanged);
318 }
319
320 #[test]
321 fn changed_etag_is_refused() {
322 let v = validate(
323 &rec(Some("\"abc\""), None, Some(100)),
324 &info(Some("\"def\""), None, Some(100)),
325 );
326 match v {
327 Validation::Changed {
328 reason,
329 previous,
330 current,
331 } => {
332 assert!(reason.contains("ETag"), "{reason}");
333 assert_eq!(previous.etag.as_deref(), Some("\"abc\""));
334 assert_eq!(current.etag.as_deref(), Some("\"def\""));
335 }
336 other => panic!("expected Changed, got {other:?}"),
337 }
338 }
339
340 #[test]
341 fn size_change_is_decisive_even_with_matching_etag() {
342 let v = validate(
345 &rec(Some("\"abc\""), None, Some(100)),
346 &info(Some("\"abc\""), None, Some(101)),
347 );
348 assert!(matches!(v, Validation::Changed { .. }));
349 }
350
351 #[test]
352 fn changed_last_modified_is_refused() {
353 let v = validate(
354 &rec(None, Some("Mon, 01 Jan 2024 00:00:00 GMT"), Some(100)),
355 &info(None, Some("Tue, 02 Jan 2024 00:00:00 GMT"), Some(100)),
356 );
357 assert!(matches!(v, Validation::Changed { .. }));
358 }
359
360 #[test]
361 fn weak_etags_are_never_proof() {
362 let v = validate(
363 &rec(Some("W/\"abc\""), None, Some(100)),
364 &info(Some("W/\"abc\""), None, Some(100)),
365 );
366 assert!(matches!(v, Validation::Unverifiable(_)), "{v:?}");
367 }
368
369 #[test]
370 fn vanished_etag_is_unverifiable_not_unchanged() {
371 let v = validate(
372 &rec(Some("\"abc\""), None, Some(100)),
373 &info(None, None, Some(100)),
374 );
375 assert!(matches!(v, Validation::Unverifiable(_)), "{v:?}");
376 }
377
378 #[test]
379 fn no_validators_at_all_is_unverifiable() {
380 let v = validate(&rec(None, None, Some(100)), &info(None, None, Some(100)));
381 assert!(matches!(v, Validation::Unverifiable(_)), "{v:?}");
382 let v = validate(&rec(None, None, None), &info(None, None, None));
383 assert!(matches!(v, Validation::Unverifiable(_)), "{v:?}");
384 }
385
386 fn ranges() -> Vec<RangeRecord> {
387 vec![
388 RangeRecord {
389 idx: 0,
390 start: 0,
391 end: 499,
392 state: RangeState::Complete,
393 bytes_written: 500,
394 },
395 RangeRecord {
396 idx: 1,
397 start: 500,
398 end: 999,
399 state: RangeState::Downloading,
400 bytes_written: 200,
401 },
402 ]
403 }
404
405 #[test]
406 fn reconcile_keeps_durable_progress() {
407 let r = reconcile(&ranges(), 1000, Some(1000));
408 assert_eq!(r.trusted_bytes, 700);
409 assert_eq!(r.discarded_bytes, 0);
410 assert_eq!(r.ranges[0].state, RangeState::Complete);
411 assert_eq!(r.ranges[1].state, RangeState::Pending);
413 assert_eq!(r.ranges[1].bytes_written, 200);
414 }
415
416 #[test]
417 fn reconcile_trusts_a_short_file_over_the_database() {
418 let r = reconcile(&ranges(), 600, Some(1000));
420 assert_eq!(r.ranges[1].bytes_written, 100);
421 assert_eq!(r.discarded_bytes, 100);
422 assert!(!r.notes.is_empty());
423 assert_eq!(r.trusted_bytes, 600);
424 }
425
426 #[test]
427 fn reconcile_rejects_complete_but_short_ranges() {
428 let mut rs = ranges();
429 rs[0].bytes_written = 10; let r = reconcile(&rs, 1000, Some(1000));
431 assert_eq!(r.ranges[0].state, RangeState::Pending);
432 assert_eq!(r.ranges[0].bytes_written, 0);
433 assert_eq!(r.discarded_bytes, 10);
434 }
435
436 #[test]
437 fn reconcile_clips_ranges_past_a_shrunken_resource() {
438 let r = reconcile(&ranges(), 1000, Some(700));
439 assert_eq!(r.ranges.len(), 2);
440 assert_eq!(r.ranges[1].end, 699);
441 assert_eq!(r.ranges[0].state, RangeState::Complete);
443
444 let r = reconcile(&ranges(), 1000, Some(400));
447 assert_eq!(r.ranges.len(), 1);
448 assert_eq!(r.ranges[0].end, 399);
449 assert_eq!(r.discarded_bytes, 300);
450 assert_eq!(r.trusted_bytes, 400);
451 }
452
453 #[test]
454 fn plan_integrity_check() {
455 assert!(plan_is_intact(&ranges(), 1000));
456 assert!(!plan_is_intact(&ranges(), 1001));
457 assert!(!plan_is_intact(&[], 10));
458 assert!(plan_is_intact(&[], 0));
459
460 let gapped = vec![
461 RangeRecord {
462 idx: 0,
463 start: 0,
464 end: 99,
465 state: RangeState::Pending,
466 bytes_written: 0,
467 },
468 RangeRecord {
469 idx: 1,
470 start: 200,
471 end: 999,
472 state: RangeState::Pending,
473 bytes_written: 0,
474 },
475 ];
476 assert!(!plan_is_intact(&gapped, 1000));
477 }
478
479 #[test]
480 fn identity_detects_a_swapped_file() {
481 let dir = std::env::temp_dir().join(format!("rget-resume-{}", std::process::id()));
482 std::fs::create_dir_all(&dir).unwrap();
483 let path = dir.join("f");
484 std::fs::write(&path, b"x").unwrap();
485 let file = DestFile::open(&path).unwrap();
486 let id = file.identity().unwrap();
487
488 let mut r = rec(None, None, Some(1));
489 r.file_dev = Some(id.dev);
490 r.file_ino = Some(id.ino);
491 assert_eq!(check_identity(&r, &file), Identity::Same);
492
493 r.file_ino = Some(id.ino.wrapping_add(1));
494 assert_eq!(check_identity(&r, &file), Identity::Replaced);
495
496 r.file_dev = None;
497 assert_eq!(check_identity(&r, &file), Identity::Unrecorded);
498
499 std::fs::remove_dir_all(&dir).ok();
500 }
501}