Skip to main content

rget/
storage.rs

1//! Central SQLite state store (PRD §10, §11).
2//!
3//! One database for every download, in the platform's data directory — not a
4//! sidecar `.part.json` next to each file. That is what makes `rget list`,
5//! `rget resume --all` and cross-run recovery possible.
6//!
7//! Durability rules live in `docs/CRASH_CONSISTENCY.md`. The one that matters
8//! here: [`Store::commit_progress`] is the *only* way range progress becomes
9//! persistent, it is a single transaction, and the engine calls it only after
10//! an `fdatasync` of the destination file.
11
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14
15use anyhow::{Context, Result, bail};
16use rusqlite::{Connection, OptionalExtension, params};
17
18/// Sentinel `end` for a range whose length is unknown (no `Content-Length`).
19/// Round-trips through SQLite's i64 columns unlike `u64::MAX`.
20pub 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    /// Was this download left mid-flight by a previous process?
59    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    /// Random token minted when the destination file is created. Together with
118    /// dev/ino it proves the file on disk is the one we were downloading into.
119    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    /// Inclusive. [`OPEN_END`] when the total size is unknown.
135    pub end: u64,
136    pub state: RangeState,
137    /// Durable prefix, in bytes, from `start`. Never in-flight bytes.
138    pub bytes_written: u64,
139}
140
141impl RangeRecord {
142    /// Byte length of the range. Named `size` rather than `len` because a range
143    /// is never empty, so an `is_empty` counterpart would be meaningless.
144    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    /// Where a worker picking this range up should ask the server to start.
153    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/// One durable progress update, produced by the committer after its barrier.
163#[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    /// `$RGET_DB` overrides the location — used by the test suite so tests
177    /// never touch a developer's real download list.
178    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        // See docs/CRASH_CONSISTENCY.md for why these exact values.
203        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        // A poisoned connection mutex means another thread panicked mid-query.
288        // Recovering the guard is correct here: SQLite itself is consistent
289        // (transactions are atomic), so the next caller can proceed.
290        self.conn.lock().unwrap_or_else(|e| e.into_inner())
291    }
292
293    // -- settings ----------------------------------------------------------
294
295    /// Read a persisted setting. Settings live in the same database as the
296    /// downloads so there is exactly one piece of state to back up or delete.
297    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    // -- lookup ------------------------------------------------------------
325
326    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    /// Resolve a user-typed short id. Ambiguity is an error, not a coin flip.
333    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    /// Find an existing download for this URL landing at this destination.
350    /// Matching on both is what makes resume automatic (PRD §5) without
351    /// accidentally resuming into a different file.
352    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    /// Any download already targeting this destination, regardless of URL —
365    /// used to refuse clobbering an unrelated in-flight download.
366    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    // -- mutation ----------------------------------------------------------
393
394    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    /// Refresh the validators and shape we learned from a fresh probe.
436    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    /// Install a fresh range plan, replacing any previous one, atomically.
480    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    /// The one durable-progress entry point. Called by the committer *after*
530    /// `fdatasync` of the destination file, never before.
531    ///
532    /// One transaction, so a kill at any instruction leaves either the whole
533    /// batch or none of it (PRD Invariant 7).
534    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    /// Record a split: the victim shrinks and the remainder becomes a new
566    /// range, in one transaction so Invariant 3 (no gaps) always holds on disk.
567    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    /// Drop all progress for a download but keep its identity (`--restart`).
596    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    /// Forget metadata. Never touches the downloaded file (PRD §20).
610    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    /// Mint a short, human-typeable id that is free in this database.
617    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
688/// A random 128-bit token, used as the destination file's identity cookie.
689pub 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    // Stack address varies per run under ASLR; cheap extra entropy.
701    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        // Ambiguous prefixes must fail loudly rather than pick one.
777        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        // Resolved URL also matches, so a redirect chain still resumes.
792        assert!(
793            s.find_for("https://cdn.example.com/f.iso", Path::new("/tmp/f.iso"))
794                .unwrap()
795                .is_some()
796        );
797        // Same URL, different destination is a different download.
798        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        // FK cascade cleaned the orphans up.
886        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}