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    format!("{RELEASE_BASE}/assay-lib-{name}-v{version}/assay-lib-{name}-{version}.tar.gz")
138}
139
140/// Ensure `plan.cache_path` exists and matches `plan.expected_sha256`.
141pub async fn fetch(
142    plan: &FetchPlan,
143    client: &reqwest::Client,
144    offline: bool,
145) -> Result<(), FetchError> {
146    // 1+2: cache probe.
147    if afs::try_exists(&plan.cache_path).await.unwrap_or(false) {
148        let bytes = afs::read(&plan.cache_path).await.map_err(io_err(plan))?;
149        let actual = sha256_hex(&bytes);
150        if actual == plan.expected_sha256 {
151            return Ok(());
152        }
153        // Bad cache entry: drop it. If a parallel fetch beats us to the
154        // delete, that's fine — `remove_file` racing with itself is benign.
155        let _ = afs::remove_file(&plan.cache_path).await;
156    }
157
158    if offline {
159        return Err(FetchError::OfflineMissing {
160            name: plan.display_name.clone(),
161            cache_path: plan.cache_path.clone(),
162        });
163    }
164
165    // 4: download → tmp → verify → rename.
166    let parent = plan
167        .cache_path
168        .parent()
169        .expect("cache_path always has a parent (it's <cache-dir>/<filename>)");
170    afs::create_dir_all(parent).await.map_err(io_err(plan))?;
171
172    let resp = client
173        .get(&plan.url)
174        .send()
175        .await
176        .map_err(|e| FetchError::Http {
177            name: plan.display_name.clone(),
178            url: plan.url.clone(),
179            source: e,
180        })?;
181    if !resp.status().is_success() {
182        return Err(FetchError::Status {
183            name: plan.display_name.clone(),
184            url: plan.url.clone(),
185            status: resp.status().as_u16(),
186        });
187    }
188    let bytes = resp.bytes().await.map_err(|e| FetchError::Http {
189        name: plan.display_name.clone(),
190        url: plan.url.clone(),
191        source: e,
192    })?;
193
194    let actual = sha256_hex(&bytes);
195    if actual != plan.expected_sha256 {
196        return Err(FetchError::Sha256Mismatch {
197            name: plan.display_name.clone(),
198            expected: plan.expected_sha256.clone(),
199            actual,
200        });
201    }
202
203    let mut tmp_path = plan.cache_path.clone();
204    let mut tmp_name = plan
205        .cache_path
206        .file_name()
207        .expect("cache_path has a filename")
208        .to_os_string();
209    tmp_name.push(".tmp");
210    tmp_path.set_file_name(tmp_name);
211
212    let mut tmp = afs::File::create(&tmp_path).await.map_err(io_err(plan))?;
213    tmp.write_all(&bytes).await.map_err(io_err(plan))?;
214    tmp.flush().await.map_err(io_err(plan))?;
215    drop(tmp);
216    afs::rename(&tmp_path, &plan.cache_path)
217        .await
218        .map_err(io_err(plan))?;
219
220    Ok(())
221}
222
223fn io_err(plan: &FetchPlan) -> impl Fn(io::Error) -> FetchError + '_ {
224    let name = plan.display_name.clone();
225    move |source| FetchError::Io {
226        name: name.clone(),
227        source,
228    }
229}
230
231fn sha256_hex(bytes: &[u8]) -> String {
232    let mut hasher = Sha256::new();
233    hasher.update(bytes);
234    HEXLOWER.encode(&hasher.finalize())
235}