Skip to main content

soar_dl/
zsync.rs

1//! Delta downloads over zsync.
2//!
3//! A zsync control file describes a remote artifact block by block, which
4//! answers two questions cheaply: whether the artifact differs from the copy
5//! already installed, and which parts of it have to be fetched to catch up.
6//! Both matter for an AppImage, where a release changes a fraction of a file
7//! measured in tens of megabytes.
8
9use std::{fs::File, path::Path};
10
11use tracing::debug;
12use zsync_rs::{checksum::calc_sha1_stream, ControlFile, HttpClient, ZsyncAssembly};
13
14use crate::{error::DownloadError, types::Progress};
15
16/// What a control file says the remote artifact is.
17#[derive(Debug, Clone)]
18pub struct ZsyncTarget {
19    /// SHA-1 of the whole artifact, which is what tells two builds apart.
20    pub sha1: Option<String>,
21    /// Length of the artifact in bytes.
22    pub length: u64,
23    /// Filename the artifact is published under, where one is recorded.
24    pub filename: Option<String>,
25    /// When it was published, in HTTP-date form.
26    pub mtime: Option<String>,
27    /// Where the artifact itself is published, as the control file records it.
28    /// Relative entries are resolved against the control file's own location.
29    pub urls: Vec<String>,
30}
31
32impl ZsyncTarget {
33    /// Where the artifact this describes can be downloaded from.
34    ///
35    /// A control file names its artifact, but by convention it also sits
36    /// beside it under the same name, so a feed that names nothing still
37    /// resolves.
38    pub fn artifact_url(&self, control_url: &str) -> Option<String> {
39        let base = control_url.rsplit_once('/').map(|(dir, _)| dir)?;
40        match self.urls.first() {
41            Some(url) if url.starts_with("http://") || url.starts_with("https://") => {
42                Some(url.clone())
43            }
44            Some(url) => Some(format!("{base}/{url}")),
45            None => control_url.strip_suffix(".zsync").map(str::to_string),
46        }
47    }
48}
49
50impl From<ControlFile> for ZsyncTarget {
51    fn from(control: ControlFile) -> Self {
52        Self {
53            sha1: control.sha1,
54            length: control.length,
55            filename: control.filename,
56            mtime: control.mtime,
57            urls: control.urls,
58        }
59    }
60}
61
62/// The zsync feed published beside an artifact, where there is one.
63///
64/// A publisher that offers zsync puts the control file next to the artifact
65/// under the same name, so asking for it is how to find out.
66pub fn feed_beside(artifact_url: &str) -> Option<String> {
67    let feed = format!("{artifact_url}.zsync");
68    crate::http::Http::head(&feed).ok().map(|_| feed)
69}
70
71/// Read the control file at `url` without downloading the artifact.
72pub fn fetch_target(url: &str) -> Result<ZsyncTarget, DownloadError> {
73    let http = HttpClient::new();
74    let control = http
75        .fetch_control_file(url)
76        .map_err(|e| DownloadError::Zsync(format!("fetching zsync control file: {e}")))?;
77    Ok(control.into())
78}
79
80/// The SHA-1 of a file already on disk, in the same hex form a control file
81/// records.
82pub fn file_sha1(path: impl AsRef<Path>) -> Result<String, DownloadError> {
83    let mut file = File::open(path)?;
84    let digest = calc_sha1_stream(&mut file)?;
85    Ok(digest.iter().map(|b| format!("{b:02x}")).collect())
86}
87
88/// Whether the artifact the control file describes differs from `installed`.
89///
90/// A control file without a SHA-1 leaves nothing to compare, so the artifact
91/// is treated as changed rather than silently assumed current.
92pub fn differs_from(target: &ZsyncTarget, installed: impl AsRef<Path>) -> bool {
93    let Some(ref remote) = target.sha1 else {
94        return true;
95    };
96    match file_sha1(installed) {
97        Ok(local) => !local.eq_ignore_ascii_case(remote),
98        Err(_) => true,
99    }
100}
101
102/// Build `output` from the remote artifact, reusing every block `seed` already
103/// holds and fetching only the rest.
104///
105/// The result is verified against the control file's checksums before it is
106/// moved into place, so a mismatched or truncated transfer fails here rather
107/// than producing a broken package.
108pub fn download<F>(
109    url: &str,
110    seed: &Path,
111    output: &Path,
112    on_progress: Option<F>,
113) -> Result<(), DownloadError>
114where
115    F: Fn(Progress) + Send + Sync + 'static,
116{
117    let mut assembly = ZsyncAssembly::from_url(url, output)
118        .map_err(|e| DownloadError::Zsync(format!("reading zsync control file: {e}")))?;
119
120    if let Some(callback) = on_progress {
121        let total = 0;
122        callback(Progress::Starting {
123            total,
124        });
125        assembly.set_progress_callback(move |done, total| {
126            callback(Progress::Chunk {
127                total,
128                current: done,
129            });
130        });
131    }
132
133    // Everything the installed copy already holds is taken from disk; only
134    // what it does not is fetched.
135    if seed.exists() {
136        assembly
137            .submit_source_file(seed)
138            .map_err(|e| DownloadError::Zsync(format!("reading {}: {e}", seed.display())))?;
139        let (reused, total) = assembly.block_stats();
140        debug!("zsync: {reused}/{total} blocks taken from the installed copy");
141    }
142
143    while !assembly.is_complete() {
144        let fetched = assembly
145            .download_missing_blocks()
146            .map_err(|e| DownloadError::Zsync(format!("fetching blocks: {e}")))?;
147        // No progress and still incomplete means the remote will not serve
148        // what is missing, and looping would spin forever.
149        if fetched == 0 {
150            return Err(DownloadError::Zsync(
151                "zsync transfer stalled with blocks still missing".to_string(),
152            ));
153        }
154    }
155
156    assembly
157        .complete()
158        .map_err(|e| DownloadError::Zsync(format!("verifying zsync result: {e}")))
159}