Skip to main content

assay/install/
fetch.rs

1//! Fetch + cache + sha256-verify for declared deps.
2//!
3//! Cache flow per dep:
4//! 1. If `cache_path` exists and its sha256 matches `expected_sha256` → cache
5//!    hit; return (zero HTTP).
6//! 2. If `cache_path` exists with a wrong sha → drop it and fall through.
7//! 3. If `offline` is true → return [`FetchError::OfflineMissing`].
8//! 4. Otherwise: HTTPS GET → write to `<file>.tmp` → sha-verify → atomic
9//!    rename to `cache_path`.
10
11use std::io;
12use std::path::{Path, PathBuf};
13
14use data_encoding::HEXLOWER;
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17use tokio::fs as afs;
18use tokio::io::AsyncWriteExt;
19
20use super::manifest::{Extension, Lib};
21
22const RELEASE_BASE: &str = "https://github.com/developerinlondon/assay/releases/download";
23
24#[derive(Debug, Error)]
25pub enum FetchError {
26    #[error("{name}: no sha256 declared for arch `{arch}` (have: {available:?})")]
27    NoArchHash {
28        name: String,
29        arch: String,
30        available: Vec<String>,
31    },
32
33    #[error("{name}: HTTP request to {url} failed: {source}")]
34    Http {
35        name: String,
36        url: String,
37        #[source]
38        source: reqwest::Error,
39    },
40
41    #[error("{name}: HTTP {status} from {url}")]
42    Status {
43        name: String,
44        url: String,
45        status: u16,
46    },
47
48    #[error("{name}: I/O error: {source}")]
49    Io {
50        name: String,
51        #[source]
52        source: io::Error,
53    },
54
55    #[error("{name}: sha256 mismatch (expected {expected}, got {actual})")]
56    Sha256Mismatch {
57        name: String,
58        expected: String,
59        actual: String,
60    },
61
62    #[error("{name}: not in cache and --offline mode (expected at {})", cache_path.display())]
63    OfflineMissing { name: String, cache_path: PathBuf },
64}
65
66/// Resolved per-dep fetch parameters: where to download from, where to
67/// cache the bytes, and what sha256 to verify against.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct FetchPlan {
70    /// Display name for progress / errors, e.g. "assay-engine 0.4.1".
71    pub display_name: String,
72    /// Resolved URL (default release convention or the manifest's `source`).
73    pub url: String,
74    /// Where the artifact lands in the cache.
75    pub cache_path: PathBuf,
76    /// Expected sha256 hex (lowercase).
77    pub expected_sha256: String,
78}
79
80impl FetchPlan {
81    /// Build a plan for an extension binary on `arch`.
82    pub fn for_extension(
83        ext: &Extension,
84        arch: &str,
85        cache_dir: &Path,
86    ) -> Result<Self, FetchError> {
87        let display_name = format!("{} {}", ext.name, ext.version);
88        let expected_sha256 = ext
89            .sha256
90            .get(arch)
91            .ok_or_else(|| FetchError::NoArchHash {
92                name: display_name.clone(),
93                arch: arch.to_string(),
94                available: ext.sha256.keys().cloned().collect(),
95            })?
96            .clone();
97
98        let filename = format!("{}-{}-{}.tar.gz", ext.name, ext.version, arch);
99        let url = ext
100            .source
101            .clone()
102            .unwrap_or_else(|| default_extension_url(&ext.name, &ext.version, arch));
103        let cache_path = cache_dir.join(&filename);
104        Ok(FetchPlan {
105            display_name,
106            url,
107            cache_path,
108            expected_sha256,
109        })
110    }
111
112    /// Build a plan for a Lua library tarball (arch-neutral).
113    pub fn for_lib(lib: &Lib, cache_dir: &Path) -> Self {
114        let display_name = format!("{} {}", lib.name, lib.version);
115        let filename = format!("assay-lib-{}-{}.tar.gz", lib.name, lib.version);
116        let url = lib
117            .source
118            .clone()
119            .unwrap_or_else(|| default_lib_url(&lib.name, &lib.version));
120        let cache_path = cache_dir.join(&filename);
121        FetchPlan {
122            display_name,
123            url,
124            cache_path,
125            expected_sha256: lib.sha256.clone(),
126        }
127    }
128}
129
130fn default_extension_url(name: &str, version: &str, arch: &str) -> String {
131    // Convention: `<RELEASE_BASE>/v<version>/<name>-<version>-<arch>.tar.gz`.
132    // Subject to refinement in plan 21 phase 5 (release pipeline).
133    format!("{RELEASE_BASE}/v{version}/{name}-{version}-{arch}.tar.gz")
134}
135
136fn default_lib_url(name: &str, version: &str) -> String {
137    // Convention: `<RELEASE_BASE>/v<version>/assay-lib-<name>-<version>.tar.gz`.
138    // Where `v<version>` is the lib's own tag; finalised in phase 5.
139    format!("{RELEASE_BASE}/v{version}/assay-lib-{name}-{version}.tar.gz")
140}
141
142/// Ensure `plan.cache_path` exists and matches `plan.expected_sha256`.
143pub async fn fetch(
144    plan: &FetchPlan,
145    client: &reqwest::Client,
146    offline: bool,
147) -> Result<(), FetchError> {
148    // 1+2: cache probe.
149    if afs::try_exists(&plan.cache_path).await.unwrap_or(false) {
150        let bytes = afs::read(&plan.cache_path).await.map_err(io_err(plan))?;
151        let actual = sha256_hex(&bytes);
152        if actual == plan.expected_sha256 {
153            return Ok(());
154        }
155        // Bad cache entry: drop it. If a parallel fetch beats us to the
156        // delete, that's fine — `remove_file` racing with itself is benign.
157        let _ = afs::remove_file(&plan.cache_path).await;
158    }
159
160    if offline {
161        return Err(FetchError::OfflineMissing {
162            name: plan.display_name.clone(),
163            cache_path: plan.cache_path.clone(),
164        });
165    }
166
167    // 4: download → tmp → verify → rename.
168    let parent = plan
169        .cache_path
170        .parent()
171        .expect("cache_path always has a parent (it's <cache-dir>/<filename>)");
172    afs::create_dir_all(parent).await.map_err(io_err(plan))?;
173
174    let resp = client
175        .get(&plan.url)
176        .send()
177        .await
178        .map_err(|e| FetchError::Http {
179            name: plan.display_name.clone(),
180            url: plan.url.clone(),
181            source: e,
182        })?;
183    if !resp.status().is_success() {
184        return Err(FetchError::Status {
185            name: plan.display_name.clone(),
186            url: plan.url.clone(),
187            status: resp.status().as_u16(),
188        });
189    }
190    let bytes = resp.bytes().await.map_err(|e| FetchError::Http {
191        name: plan.display_name.clone(),
192        url: plan.url.clone(),
193        source: e,
194    })?;
195
196    let actual = sha256_hex(&bytes);
197    if actual != plan.expected_sha256 {
198        return Err(FetchError::Sha256Mismatch {
199            name: plan.display_name.clone(),
200            expected: plan.expected_sha256.clone(),
201            actual,
202        });
203    }
204
205    let mut tmp_path = plan.cache_path.clone();
206    let mut tmp_name = plan
207        .cache_path
208        .file_name()
209        .expect("cache_path has a filename")
210        .to_os_string();
211    tmp_name.push(".tmp");
212    tmp_path.set_file_name(tmp_name);
213
214    let mut tmp = afs::File::create(&tmp_path).await.map_err(io_err(plan))?;
215    tmp.write_all(&bytes).await.map_err(io_err(plan))?;
216    tmp.flush().await.map_err(io_err(plan))?;
217    drop(tmp);
218    afs::rename(&tmp_path, &plan.cache_path)
219        .await
220        .map_err(io_err(plan))?;
221
222    Ok(())
223}
224
225fn io_err(plan: &FetchPlan) -> impl Fn(io::Error) -> FetchError + '_ {
226    let name = plan.display_name.clone();
227    move |source| FetchError::Io {
228        name: name.clone(),
229        source,
230    }
231}
232
233fn sha256_hex(bytes: &[u8]) -> String {
234    let mut hasher = Sha256::new();
235    hasher.update(bytes);
236    HEXLOWER.encode(&hasher.finalize())
237}