1use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14
15use anyhow::{Context, Result, bail};
16use rusqlite::{Connection, OptionalExtension, params};
17
18pub const OPEN_END: u64 = i64::MAX as u64;
21
22const SCHEMA_VERSION: i64 = 1;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum Status {
27 Pending,
28 Downloading,
29 Paused,
30 Verifying,
31 Complete,
32 Failed,
33}
34
35impl Status {
36 pub fn as_str(&self) -> &'static str {
37 match self {
38 Status::Pending => "pending",
39 Status::Downloading => "downloading",
40 Status::Paused => "paused",
41 Status::Verifying => "verifying",
42 Status::Complete => "complete",
43 Status::Failed => "failed",
44 }
45 }
46
47 fn parse(s: &str) -> Status {
48 match s {
49 "downloading" => Status::Downloading,
50 "paused" => Status::Paused,
51 "verifying" => Status::Verifying,
52 "complete" => Status::Complete,
53 "failed" => Status::Failed,
54 _ => Status::Pending,
55 }
56 }
57
58 pub fn is_resumable(&self) -> bool {
60 matches!(
61 self,
62 Status::Pending | Status::Downloading | Status::Paused | Status::Failed
63 )
64 }
65}
66
67impl std::fmt::Display for Status {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.write_str(self.as_str())
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
74#[serde(rename_all = "snake_case")]
75pub enum RangeState {
76 Pending,
77 Downloading,
78 Complete,
79 Failed,
80}
81
82impl RangeState {
83 pub fn as_str(&self) -> &'static str {
84 match self {
85 RangeState::Pending => "pending",
86 RangeState::Downloading => "downloading",
87 RangeState::Complete => "complete",
88 RangeState::Failed => "failed",
89 }
90 }
91
92 fn parse(s: &str) -> RangeState {
93 match s {
94 "downloading" => RangeState::Downloading,
95 "complete" => RangeState::Complete,
96 "failed" => RangeState::Failed,
97 _ => RangeState::Pending,
98 }
99 }
100}
101
102#[derive(Debug, Clone, serde::Serialize)]
103pub struct DownloadRecord {
104 pub id: String,
105 pub original_url: String,
106 pub resolved_url: Option<String>,
107 pub mirrors: Vec<String>,
108 pub destination: String,
109 pub filename: String,
110 pub total_size: Option<u64>,
111 pub etag: Option<String>,
112 pub last_modified: Option<String>,
113 pub content_type: Option<String>,
114 pub accept_ranges: bool,
115 pub expected_checksum: Option<String>,
116 pub checksum_algorithm: Option<String>,
117 pub file_cookie: String,
120 pub file_dev: Option<u64>,
121 pub file_ino: Option<u64>,
122 pub durable_bytes: u64,
123 pub status: Status,
124 pub error: Option<String>,
125 pub created_at: i64,
126 pub updated_at: i64,
127 pub completed_at: Option<i64>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
131pub struct RangeRecord {
132 pub idx: u64,
133 pub start: u64,
134 pub end: u64,
136 pub state: RangeState,
137 pub bytes_written: u64,
139}
140
141impl RangeRecord {
142 pub fn size(&self) -> u64 {
145 self.end.saturating_sub(self.start).saturating_add(1)
146 }
147
148 pub fn is_open_ended(&self) -> bool {
149 self.end >= OPEN_END
150 }
151
152 pub fn resume_at(&self) -> u64 {
154 self.start + self.bytes_written
155 }
156
157 pub fn remaining(&self) -> u64 {
158 self.size().saturating_sub(self.bytes_written)
159 }
160}
161
162#[derive(Debug, Clone, Copy)]
164pub struct ProgressUpdate {
165 pub idx: u64,
166 pub bytes_written: u64,
167 pub state: RangeState,
168}
169
170pub struct Store {
171 conn: Mutex<Connection>,
172 path: PathBuf,
173}
174
175impl Store {
176 pub fn default_path() -> Result<PathBuf> {
179 if let Some(p) = std::env::var_os("RGET_DB") {
180 return Ok(PathBuf::from(p));
181 }
182 let dirs = directories::ProjectDirs::from("", "", "rget")
183 .context("cannot determine a data directory for this platform")?;
184 Ok(dirs.data_dir().join("downloads.db"))
185 }
186
187 pub fn open_default() -> Result<Self> {
188 Self::open(&Self::default_path()?)
189 }
190
191 pub fn open(path: &Path) -> Result<Self> {
192 if let Some(parent) = path.parent() {
193 if !parent.as_os_str().is_empty() {
194 std::fs::create_dir_all(parent).with_context(|| {
195 format!("cannot create state directory {}", parent.display())
196 })?;
197 }
198 }
199 let conn = Connection::open(path)
200 .with_context(|| format!("cannot open state database {}", path.display()))?;
201
202 conn.pragma_update(None, "journal_mode", "WAL")?;
204 conn.pragma_update(None, "synchronous", "FULL")?;
205 conn.pragma_update(None, "foreign_keys", "ON")?;
206 conn.busy_timeout(std::time::Duration::from_secs(5))?;
207
208 let store = Self {
209 conn: Mutex::new(conn),
210 path: path.to_path_buf(),
211 };
212 store.migrate()?;
213 Ok(store)
214 }
215
216 pub fn open_in_memory() -> Result<Self> {
217 let conn = Connection::open_in_memory()?;
218 conn.pragma_update(None, "foreign_keys", "ON")?;
219 let store = Self {
220 conn: Mutex::new(conn),
221 path: PathBuf::from(":memory:"),
222 };
223 store.migrate()?;
224 Ok(store)
225 }
226
227 pub fn path(&self) -> &Path {
228 &self.path
229 }
230
231 fn migrate(&self) -> Result<()> {
232 let conn = self.lock();
233 conn.execute_batch(
234 r#"
235 CREATE TABLE IF NOT EXISTS meta (
236 key TEXT PRIMARY KEY,
237 value TEXT NOT NULL
238 );
239 CREATE TABLE IF NOT EXISTS downloads (
240 id TEXT PRIMARY KEY,
241 original_url TEXT NOT NULL,
242 resolved_url TEXT,
243 mirrors TEXT NOT NULL DEFAULT '[]',
244 destination TEXT NOT NULL,
245 filename TEXT NOT NULL,
246 total_size INTEGER,
247 etag TEXT,
248 last_modified TEXT,
249 content_type TEXT,
250 accept_ranges INTEGER NOT NULL DEFAULT 0,
251 expected_checksum TEXT,
252 checksum_algorithm TEXT,
253 file_cookie TEXT NOT NULL,
254 file_dev INTEGER,
255 file_ino INTEGER,
256 durable_bytes INTEGER NOT NULL DEFAULT 0,
257 status TEXT NOT NULL,
258 error TEXT,
259 created_at INTEGER NOT NULL,
260 updated_at INTEGER NOT NULL,
261 completed_at INTEGER
262 );
263 CREATE TABLE IF NOT EXISTS ranges (
264 download_id TEXT NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
265 idx INTEGER NOT NULL,
266 start INTEGER NOT NULL,
267 end INTEGER NOT NULL,
268 state TEXT NOT NULL,
269 bytes_written INTEGER NOT NULL DEFAULT 0,
270 updated_at INTEGER NOT NULL,
271 PRIMARY KEY (download_id, idx)
272 );
273 CREATE INDEX IF NOT EXISTS idx_downloads_dest ON downloads(destination);
274 CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
275 "#,
276 )?;
277 conn.execute(
278 "INSERT INTO meta(key, value) VALUES('schema_version', ?1)
279 ON CONFLICT(key) DO UPDATE SET value = excluded.value
280 WHERE CAST(value AS INTEGER) < CAST(excluded.value AS INTEGER)",
281 params![SCHEMA_VERSION.to_string()],
282 )?;
283 Ok(())
284 }
285
286 fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
287 self.conn.lock().unwrap_or_else(|e| e.into_inner())
291 }
292
293 pub fn get_meta(&self, key: &str) -> Result<Option<String>> {
298 let conn = self.lock();
299 Ok(conn
300 .query_row(
301 "SELECT value FROM meta WHERE key = ?1",
302 params![key],
303 |row| row.get(0),
304 )
305 .optional()?)
306 }
307
308 pub fn set_meta(&self, key: &str, value: &str) -> Result<()> {
309 let conn = self.lock();
310 conn.execute(
311 "INSERT INTO meta(key, value) VALUES(?1, ?2)
312 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
313 params![key, value],
314 )?;
315 Ok(())
316 }
317
318 pub fn clear_meta(&self, key: &str) -> Result<()> {
319 let conn = self.lock();
320 conn.execute("DELETE FROM meta WHERE key = ?1", params![key])?;
321 Ok(())
322 }
323
324 pub fn get(&self, id: &str) -> Result<Option<DownloadRecord>> {
327 let conn = self.lock();
328 let mut stmt = conn.prepare(SELECT_DOWNLOAD)?;
329 Ok(stmt.query_row(params![id], row_to_download).optional()?)
330 }
331
332 pub fn resolve_id(&self, prefix: &str) -> Result<DownloadRecord> {
334 let conn = self.lock();
335 let mut stmt = conn.prepare(&format!("{SELECT_DOWNLOAD_ALL} WHERE id LIKE ?1 || '%'"))?;
336 let matches: Vec<DownloadRecord> = stmt
337 .query_map(params![prefix], row_to_download)?
338 .collect::<rusqlite::Result<_>>()?;
339 match matches.len() {
340 0 => bail!("no download matching id `{prefix}`"),
341 1 => Ok(matches.into_iter().next().unwrap()),
342 n => {
343 let ids: Vec<_> = matches.iter().map(|m| m.id.as_str()).collect();
344 bail!("`{prefix}` matches {n} downloads: {}", ids.join(", "))
345 }
346 }
347 }
348
349 pub fn find_for(&self, url: &str, destination: &Path) -> Result<Option<DownloadRecord>> {
353 let conn = self.lock();
354 let mut stmt = conn.prepare(&format!(
355 "{SELECT_DOWNLOAD_ALL} WHERE destination = ?1
356 AND (original_url = ?2 OR resolved_url = ?2)
357 ORDER BY updated_at DESC LIMIT 1"
358 ))?;
359 Ok(stmt
360 .query_row(params![destination.to_string_lossy(), url], row_to_download)
361 .optional()?)
362 }
363
364 pub fn find_by_destination(&self, destination: &Path) -> Result<Option<DownloadRecord>> {
367 let conn = self.lock();
368 let mut stmt = conn.prepare(&format!(
369 "{SELECT_DOWNLOAD_ALL} WHERE destination = ?1 ORDER BY updated_at DESC LIMIT 1"
370 ))?;
371 Ok(stmt
372 .query_row(params![destination.to_string_lossy()], row_to_download)
373 .optional()?)
374 }
375
376 pub fn list(&self) -> Result<Vec<DownloadRecord>> {
377 let conn = self.lock();
378 let mut stmt = conn.prepare(&format!("{SELECT_DOWNLOAD_ALL} ORDER BY created_at DESC"))?;
379 Ok(stmt
380 .query_map([], row_to_download)?
381 .collect::<rusqlite::Result<_>>()?)
382 }
383
384 pub fn list_resumable(&self) -> Result<Vec<DownloadRecord>> {
385 Ok(self
386 .list()?
387 .into_iter()
388 .filter(|d| d.status.is_resumable())
389 .collect())
390 }
391
392 pub fn insert(&self, rec: &DownloadRecord) -> Result<()> {
395 let conn = self.lock();
396 conn.execute(
397 "INSERT INTO downloads (
398 id, original_url, resolved_url, mirrors, destination, filename,
399 total_size, etag, last_modified, content_type, accept_ranges,
400 expected_checksum, checksum_algorithm, file_cookie, file_dev,
401 file_ino, durable_bytes, status, error, created_at, updated_at,
402 completed_at
403 ) VALUES (
404 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
405 ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22
406 )",
407 params![
408 rec.id,
409 rec.original_url,
410 rec.resolved_url,
411 serde_json::to_string(&rec.mirrors)?,
412 rec.destination,
413 rec.filename,
414 rec.total_size.map(|v| v as i64),
415 rec.etag,
416 rec.last_modified,
417 rec.content_type,
418 rec.accept_ranges as i64,
419 rec.expected_checksum,
420 rec.checksum_algorithm,
421 rec.file_cookie,
422 rec.file_dev.map(|v| v as i64),
423 rec.file_ino.map(|v| v as i64),
424 rec.durable_bytes as i64,
425 rec.status.as_str(),
426 rec.error,
427 rec.created_at,
428 rec.updated_at,
429 rec.completed_at,
430 ],
431 )?;
432 Ok(())
433 }
434
435 pub fn update_remote_metadata(&self, rec: &DownloadRecord) -> Result<()> {
437 let conn = self.lock();
438 conn.execute(
439 "UPDATE downloads SET resolved_url = ?2, total_size = ?3, etag = ?4,
440 last_modified = ?5, content_type = ?6, accept_ranges = ?7,
441 mirrors = ?8, expected_checksum = ?9, checksum_algorithm = ?10,
442 file_dev = ?11, file_ino = ?12, updated_at = ?13
443 WHERE id = ?1",
444 params![
445 rec.id,
446 rec.resolved_url,
447 rec.total_size.map(|v| v as i64),
448 rec.etag,
449 rec.last_modified,
450 rec.content_type,
451 rec.accept_ranges as i64,
452 serde_json::to_string(&rec.mirrors)?,
453 rec.expected_checksum,
454 rec.checksum_algorithm,
455 rec.file_dev.map(|v| v as i64),
456 rec.file_ino.map(|v| v as i64),
457 now(),
458 ],
459 )?;
460 Ok(())
461 }
462
463 pub fn set_status(&self, id: &str, status: Status, error: Option<&str>) -> Result<()> {
464 let conn = self.lock();
465 let completed_at = if status == Status::Complete {
466 Some(now())
467 } else {
468 None
469 };
470 conn.execute(
471 "UPDATE downloads SET status = ?2, error = ?3, updated_at = ?4,
472 completed_at = COALESCE(?5, completed_at)
473 WHERE id = ?1",
474 params![id, status.as_str(), error, now(), completed_at],
475 )?;
476 Ok(())
477 }
478
479 pub fn replace_ranges(&self, id: &str, ranges: &[RangeRecord]) -> Result<()> {
481 let mut conn = self.lock();
482 let tx = conn.transaction()?;
483 tx.execute("DELETE FROM ranges WHERE download_id = ?1", params![id])?;
484 {
485 let mut stmt = tx.prepare(
486 "INSERT INTO ranges (download_id, idx, start, end, state, bytes_written, updated_at)
487 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
488 )?;
489 for r in ranges {
490 stmt.execute(params![
491 id,
492 r.idx as i64,
493 r.start as i64,
494 r.end as i64,
495 r.state.as_str(),
496 r.bytes_written as i64,
497 now(),
498 ])?;
499 }
500 }
501 let durable: u64 = ranges.iter().map(|r| r.bytes_written).sum();
502 tx.execute(
503 "UPDATE downloads SET durable_bytes = ?2, updated_at = ?3 WHERE id = ?1",
504 params![id, durable as i64, now()],
505 )?;
506 tx.commit()?;
507 Ok(())
508 }
509
510 pub fn load_ranges(&self, id: &str) -> Result<Vec<RangeRecord>> {
511 let conn = self.lock();
512 let mut stmt = conn.prepare(
513 "SELECT idx, start, end, state, bytes_written FROM ranges
514 WHERE download_id = ?1 ORDER BY idx",
515 )?;
516 Ok(stmt
517 .query_map(params![id], |row| {
518 Ok(RangeRecord {
519 idx: row.get::<_, i64>(0)? as u64,
520 start: row.get::<_, i64>(1)? as u64,
521 end: row.get::<_, i64>(2)? as u64,
522 state: RangeState::parse(&row.get::<_, String>(3)?),
523 bytes_written: row.get::<_, i64>(4)? as u64,
524 })
525 })?
526 .collect::<rusqlite::Result<_>>()?)
527 }
528
529 pub fn commit_progress(&self, id: &str, updates: &[ProgressUpdate]) -> Result<u64> {
535 let mut conn = self.lock();
536 let tx = conn.transaction()?;
537 {
538 let mut stmt = tx.prepare(
539 "UPDATE ranges SET bytes_written = ?3, state = ?4, updated_at = ?5
540 WHERE download_id = ?1 AND idx = ?2",
541 )?;
542 for u in updates {
543 stmt.execute(params![
544 id,
545 u.idx as i64,
546 u.bytes_written as i64,
547 u.state.as_str(),
548 now(),
549 ])?;
550 }
551 }
552 let durable: i64 = tx.query_row(
553 "SELECT COALESCE(SUM(bytes_written), 0) FROM ranges WHERE download_id = ?1",
554 params![id],
555 |row| row.get(0),
556 )?;
557 tx.execute(
558 "UPDATE downloads SET durable_bytes = ?2, updated_at = ?3 WHERE id = ?1",
559 params![id, durable, now()],
560 )?;
561 tx.commit()?;
562 Ok(durable as u64)
563 }
564
565 pub fn apply_split(&self, id: &str, shrunk: RangeRecord, added: RangeRecord) -> Result<()> {
568 let mut conn = self.lock();
569 let tx = conn.transaction()?;
570 tx.execute(
571 "UPDATE ranges SET end = ?3, updated_at = ?4 WHERE download_id = ?1 AND idx = ?2",
572 params![id, shrunk.idx as i64, shrunk.end as i64, now()],
573 )?;
574 tx.execute(
575 "INSERT INTO ranges (download_id, idx, start, end, state, bytes_written, updated_at)
576 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
577 ON CONFLICT(download_id, idx) DO UPDATE SET
578 start = excluded.start, end = excluded.end,
579 state = excluded.state, bytes_written = excluded.bytes_written,
580 updated_at = excluded.updated_at",
581 params![
582 id,
583 added.idx as i64,
584 added.start as i64,
585 added.end as i64,
586 added.state.as_str(),
587 added.bytes_written as i64,
588 now(),
589 ],
590 )?;
591 tx.commit()?;
592 Ok(())
593 }
594
595 pub fn reset(&self, id: &str) -> Result<()> {
597 let mut conn = self.lock();
598 let tx = conn.transaction()?;
599 tx.execute("DELETE FROM ranges WHERE download_id = ?1", params![id])?;
600 tx.execute(
601 "UPDATE downloads SET durable_bytes = 0, status = ?2, error = NULL,
602 completed_at = NULL, updated_at = ?3 WHERE id = ?1",
603 params![id, Status::Pending.as_str(), now()],
604 )?;
605 tx.commit()?;
606 Ok(())
607 }
608
609 pub fn forget(&self, id: &str) -> Result<()> {
611 let conn = self.lock();
612 conn.execute("DELETE FROM downloads WHERE id = ?1", params![id])?;
613 Ok(())
614 }
615
616 pub fn mint_id(&self, seed: &str) -> Result<String> {
618 for salt in 0..1000u32 {
619 let mut hasher = <sha2::Sha256 as sha2::Digest>::new();
620 sha2::Digest::update(&mut hasher, seed.as_bytes());
621 sha2::Digest::update(&mut hasher, salt.to_le_bytes());
622 sha2::Digest::update(&mut hasher, now().to_le_bytes());
623 sha2::Digest::update(
624 &mut hasher,
625 std::time::SystemTime::now()
626 .duration_since(std::time::UNIX_EPOCH)
627 .map(|d| d.subsec_nanos())
628 .unwrap_or(0)
629 .to_le_bytes(),
630 );
631 let digest = sha2::Digest::finalize(hasher);
632 let id: String = digest[..3].iter().map(|b| format!("{b:02x}")).collect();
633 if self.get(&id)?.is_none() {
634 return Ok(id);
635 }
636 }
637 bail!("could not allocate a free download id")
638 }
639}
640
641const SELECT_DOWNLOAD_ALL: &str = "SELECT id, original_url, resolved_url, mirrors, destination,
642 filename, total_size, etag, last_modified, content_type, accept_ranges,
643 expected_checksum, checksum_algorithm, file_cookie, file_dev, file_ino,
644 durable_bytes, status, error, created_at, updated_at, completed_at
645 FROM downloads";
646
647const SELECT_DOWNLOAD: &str = "SELECT id, original_url, resolved_url, mirrors, destination,
648 filename, total_size, etag, last_modified, content_type, accept_ranges,
649 expected_checksum, checksum_algorithm, file_cookie, file_dev, file_ino,
650 durable_bytes, status, error, created_at, updated_at, completed_at
651 FROM downloads WHERE id = ?1";
652
653fn row_to_download(row: &rusqlite::Row<'_>) -> rusqlite::Result<DownloadRecord> {
654 let mirrors: String = row.get(3)?;
655 Ok(DownloadRecord {
656 id: row.get(0)?,
657 original_url: row.get(1)?,
658 resolved_url: row.get(2)?,
659 mirrors: serde_json::from_str(&mirrors).unwrap_or_default(),
660 destination: row.get(4)?,
661 filename: row.get(5)?,
662 total_size: row.get::<_, Option<i64>>(6)?.map(|v| v as u64),
663 etag: row.get(7)?,
664 last_modified: row.get(8)?,
665 content_type: row.get(9)?,
666 accept_ranges: row.get::<_, i64>(10)? != 0,
667 expected_checksum: row.get(11)?,
668 checksum_algorithm: row.get(12)?,
669 file_cookie: row.get(13)?,
670 file_dev: row.get::<_, Option<i64>>(14)?.map(|v| v as u64),
671 file_ino: row.get::<_, Option<i64>>(15)?.map(|v| v as u64),
672 durable_bytes: row.get::<_, i64>(16)? as u64,
673 status: Status::parse(&row.get::<_, String>(17)?),
674 error: row.get(18)?,
675 created_at: row.get(19)?,
676 updated_at: row.get(20)?,
677 completed_at: row.get(21)?,
678 })
679}
680
681pub fn now() -> i64 {
682 std::time::SystemTime::now()
683 .duration_since(std::time::UNIX_EPOCH)
684 .map(|d| d.as_secs() as i64)
685 .unwrap_or(0)
686}
687
688pub fn mint_cookie() -> String {
690 let mut hasher = <sha2::Sha256 as sha2::Digest>::new();
691 sha2::Digest::update(
692 &mut hasher,
693 std::time::SystemTime::now()
694 .duration_since(std::time::UNIX_EPOCH)
695 .map(|d| d.as_nanos())
696 .unwrap_or(0)
697 .to_le_bytes(),
698 );
699 sha2::Digest::update(&mut hasher, std::process::id().to_le_bytes());
700 let local = 0u8;
702 sha2::Digest::update(&mut hasher, (&local as *const u8 as usize).to_le_bytes());
703 let digest = sha2::Digest::finalize(hasher);
704 digest[..16].iter().map(|b| format!("{b:02x}")).collect()
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 fn record(id: &str, dest: &str) -> DownloadRecord {
712 DownloadRecord {
713 id: id.to_string(),
714 original_url: "https://example.com/f.iso".into(),
715 resolved_url: Some("https://cdn.example.com/f.iso".into()),
716 mirrors: vec!["https://m2.example.com/f.iso".into()],
717 destination: dest.into(),
718 filename: "f.iso".into(),
719 total_size: Some(1000),
720 etag: Some("\"abc\"".into()),
721 last_modified: None,
722 content_type: Some("application/octet-stream".into()),
723 accept_ranges: true,
724 expected_checksum: None,
725 checksum_algorithm: None,
726 file_cookie: mint_cookie(),
727 file_dev: Some(1),
728 file_ino: Some(2),
729 durable_bytes: 0,
730 status: Status::Downloading,
731 error: None,
732 created_at: now(),
733 updated_at: now(),
734 completed_at: None,
735 }
736 }
737
738 fn plan() -> Vec<RangeRecord> {
739 vec![
740 RangeRecord {
741 idx: 0,
742 start: 0,
743 end: 499,
744 state: RangeState::Pending,
745 bytes_written: 0,
746 },
747 RangeRecord {
748 idx: 1,
749 start: 500,
750 end: 999,
751 state: RangeState::Pending,
752 bytes_written: 0,
753 },
754 ]
755 }
756
757 #[test]
758 fn round_trips_a_download() {
759 let s = Store::open_in_memory().unwrap();
760 let rec = record("aa11bb", "/tmp/f.iso");
761 s.insert(&rec).unwrap();
762 let got = s.get("aa11bb").unwrap().unwrap();
763 assert_eq!(got.original_url, rec.original_url);
764 assert_eq!(got.mirrors, rec.mirrors);
765 assert_eq!(got.total_size, Some(1000));
766 assert!(got.accept_ranges);
767 assert_eq!(got.status, Status::Downloading);
768 }
769
770 #[test]
771 fn resolves_id_prefixes() {
772 let s = Store::open_in_memory().unwrap();
773 s.insert(&record("aa11bb", "/tmp/a")).unwrap();
774 s.insert(&record("aa22cc", "/tmp/b")).unwrap();
775 assert_eq!(s.resolve_id("aa11").unwrap().id, "aa11bb");
776 let err = s.resolve_id("aa").unwrap_err().to_string();
778 assert!(err.contains("matches 2"), "{err}");
779 assert!(s.resolve_id("zz").is_err());
780 }
781
782 #[test]
783 fn finds_by_url_and_destination() {
784 let s = Store::open_in_memory().unwrap();
785 s.insert(&record("aa11bb", "/tmp/f.iso")).unwrap();
786 assert!(
787 s.find_for("https://example.com/f.iso", Path::new("/tmp/f.iso"))
788 .unwrap()
789 .is_some()
790 );
791 assert!(
793 s.find_for("https://cdn.example.com/f.iso", Path::new("/tmp/f.iso"))
794 .unwrap()
795 .is_some()
796 );
797 assert!(
799 s.find_for("https://example.com/f.iso", Path::new("/tmp/other.iso"))
800 .unwrap()
801 .is_none()
802 );
803 }
804
805 #[test]
806 fn commit_progress_sums_durable_bytes() {
807 let s = Store::open_in_memory().unwrap();
808 s.insert(&record("aa11bb", "/tmp/f.iso")).unwrap();
809 s.replace_ranges("aa11bb", &plan()).unwrap();
810
811 let durable = s
812 .commit_progress(
813 "aa11bb",
814 &[
815 ProgressUpdate {
816 idx: 0,
817 bytes_written: 500,
818 state: RangeState::Complete,
819 },
820 ProgressUpdate {
821 idx: 1,
822 bytes_written: 100,
823 state: RangeState::Downloading,
824 },
825 ],
826 )
827 .unwrap();
828 assert_eq!(durable, 600);
829 assert_eq!(s.get("aa11bb").unwrap().unwrap().durable_bytes, 600);
830
831 let ranges = s.load_ranges("aa11bb").unwrap();
832 assert_eq!(ranges[0].state, RangeState::Complete);
833 assert_eq!(ranges[1].bytes_written, 100);
834 assert_eq!(ranges[1].resume_at(), 600);
835 }
836
837 #[test]
838 fn split_is_atomic_and_leaves_no_gap() {
839 let s = Store::open_in_memory().unwrap();
840 s.insert(&record("aa11bb", "/tmp/f.iso")).unwrap();
841 s.replace_ranges("aa11bb", &plan()).unwrap();
842
843 s.apply_split(
844 "aa11bb",
845 RangeRecord {
846 idx: 1,
847 start: 500,
848 end: 699,
849 state: RangeState::Downloading,
850 bytes_written: 100,
851 },
852 RangeRecord {
853 idx: 2,
854 start: 700,
855 end: 999,
856 state: RangeState::Pending,
857 bytes_written: 0,
858 },
859 )
860 .unwrap();
861
862 let ranges = s.load_ranges("aa11bb").unwrap();
863 assert_eq!(ranges.len(), 3);
864 let mut cursor = 0;
865 for r in &ranges {
866 assert_eq!(r.start, cursor, "gap or overlap before range {}", r.idx);
867 cursor = r.end + 1;
868 }
869 assert_eq!(cursor, 1000);
870 }
871
872 #[test]
873 fn forget_removes_ranges_but_reset_keeps_the_row() {
874 let s = Store::open_in_memory().unwrap();
875 s.insert(&record("aa11bb", "/tmp/f.iso")).unwrap();
876 s.replace_ranges("aa11bb", &plan()).unwrap();
877
878 s.reset("aa11bb").unwrap();
879 assert!(s.load_ranges("aa11bb").unwrap().is_empty());
880 assert_eq!(s.get("aa11bb").unwrap().unwrap().status, Status::Pending);
881
882 s.replace_ranges("aa11bb", &plan()).unwrap();
883 s.forget("aa11bb").unwrap();
884 assert!(s.get("aa11bb").unwrap().is_none());
885 assert!(s.load_ranges("aa11bb").unwrap().is_empty());
887 }
888
889 #[test]
890 fn lists_only_resumable() {
891 let s = Store::open_in_memory().unwrap();
892 let mut a = record("aa11bb", "/tmp/a");
893 a.status = Status::Complete;
894 let mut b = record("bb22cc", "/tmp/b");
895 b.status = Status::Paused;
896 s.insert(&a).unwrap();
897 s.insert(&b).unwrap();
898 let resumable = s.list_resumable().unwrap();
899 assert_eq!(resumable.len(), 1);
900 assert_eq!(resumable[0].id, "bb22cc");
901 assert_eq!(s.list().unwrap().len(), 2);
902 }
903
904 #[test]
905 fn ids_and_cookies_are_distinct() {
906 let s = Store::open_in_memory().unwrap();
907 let a = s.mint_id("https://example.com/x").unwrap();
908 s.insert(&record(&a, "/tmp/a")).unwrap();
909 let b = s.mint_id("https://example.com/x").unwrap();
910 assert_ne!(a, b);
911 assert_eq!(a.len(), 6);
912 assert_ne!(mint_cookie(), mint_cookie());
913 assert_eq!(mint_cookie().len(), 32);
914 }
915
916 #[test]
917 fn open_end_ranges_round_trip() {
918 let s = Store::open_in_memory().unwrap();
919 s.insert(&record("aa11bb", "/tmp/f.iso")).unwrap();
920 s.replace_ranges(
921 "aa11bb",
922 &[RangeRecord {
923 idx: 0,
924 start: 0,
925 end: OPEN_END,
926 state: RangeState::Pending,
927 bytes_written: 0,
928 }],
929 )
930 .unwrap();
931 let r = s.load_ranges("aa11bb").unwrap();
932 assert!(r[0].is_open_ended());
933 }
934
935 #[test]
936 fn survives_reopen() {
937 let dir = std::env::temp_dir().join(format!("rget-store-{}", std::process::id()));
938 std::fs::create_dir_all(&dir).unwrap();
939 let path = dir.join("downloads.db");
940 {
941 let s = Store::open(&path).unwrap();
942 s.insert(&record("aa11bb", "/tmp/f.iso")).unwrap();
943 s.replace_ranges("aa11bb", &plan()).unwrap();
944 s.commit_progress(
945 "aa11bb",
946 &[ProgressUpdate {
947 idx: 0,
948 bytes_written: 123,
949 state: RangeState::Downloading,
950 }],
951 )
952 .unwrap();
953 }
954 {
955 let s = Store::open(&path).unwrap();
956 assert_eq!(s.get("aa11bb").unwrap().unwrap().durable_bytes, 123);
957 }
958 std::fs::remove_dir_all(&dir).ok();
959 }
960}