Skip to main content

interscript_rs/
fetch.rs

1//! Fetch + unpack the raw maps corpus from the interscript/maps GitHub
2//! Releases data channel.
3//!
4//! The `interscript-maps-isc-<version>.tar.gz` asset (maps/ + libs/ at
5//! the tagged commit) and its `.sha256` sidecar download into a local
6//! directory usable directly as an `iscrs` load path:
7//!
8//! ```text
9//! iscrs maps-fetch 3.0.0 --dest ./maps-corpus
10//! iscrs test-all --root ./maps-corpus/maps
11//! ```
12
13use crate::error::{Error, Result};
14use std::fs;
15use std::io::Read;
16use std::path::{Path, PathBuf};
17
18const RELEASE_BASE: &str = "https://github.com/interscript/maps/releases/download";
19
20fn asset_url(version: &str, name: &str) -> String {
21    format!("{RELEASE_BASE}/v{version}/{name}")
22}
23
24/// sha256 of a file, streamed.
25fn sha256_file(path: &Path) -> Result<String> {
26    use sha2::{Digest, Sha256};
27    let mut file = fs::File::open(path)?;
28    let mut hasher = Sha256::new();
29    let mut buf = [0u8; 65_536];
30    loop {
31        let n = file.read(&mut buf)?;
32        if n == 0 {
33            break;
34        }
35        hasher.update(&buf[..n]);
36    }
37    Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
38}
39
40/// Download `url` into `dest`, streaming.
41fn download(url: &str, dest: &Path) -> Result<()> {
42    let response = ureq::get(url).call().map_err(|e| {
43        Error::MapLogic(format!("download {url}: {e}"))
44    })?;
45    let mut reader = response.into_body().into_reader();
46    let mut file = fs::File::create(dest)?;
47    std::io::copy(&mut reader, &mut file)?;
48    Ok(())
49}
50
51/// Fetch the corpus archive for `version` into `dest`, verify its sha256
52/// against the release sidecar, and unpack maps/ + libs/ under it.
53///
54/// Returns the unpacked load-path directory (`dest/maps`).
55pub fn fetch_corpus(version: &str, dest: &Path) -> Result<PathBuf> {
56    let name = format!("interscript-maps-isc-{version}.tar.gz");
57    fs::create_dir_all(dest)?;
58
59    let archive_path = dest.join(&name);
60    if !archive_path.exists() {
61        download(&asset_url(version, &name), &archive_path)?;
62    }
63
64    // sidecar verify
65    let sidecar_path = dest.join(format!("{name}.sha256"));
66    if !sidecar_path.exists() {
67        download(&asset_url(version, &format!("{name}.sha256")), &sidecar_path)?;
68    }
69    let expected = fs::read_to_string(&sidecar_path)?
70        .split_whitespace()
71        .next()
72        .ok_or_else(|| Error::MapLogic(format!("{sidecar_path:?}: empty sidecar")))?
73        .to_string();
74    let actual = sha256_file(&archive_path)?;
75    if actual != expected {
76        return Err(Error::MapLogic(format!(
77            "{name}: sha256 mismatch (expected {expected}, got {actual})"
78        )));
79    }
80
81    // unpack
82    let gz = fs::File::open(&archive_path)?;
83    let gz = flate2::read::GzDecoder::new(gz);
84    let mut archive = tar::Archive::new(gz);
85    archive.unpack(dest).map_err(|e| {
86        Error::MapLogic(format!("unpack {name}: {e}"))
87    })?;
88
89    let maps_dir = dest.join("maps");
90    if !maps_dir.is_dir() {
91        return Err(Error::MapLogic(format!(
92            "{name}: unpacked but no maps/ directory found"
93        )));
94    }
95    Ok(maps_dir)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn sha_mismatch_is_rejected() {
104        let dir = std::env::temp_dir().join(format!(
105            "iscrs-fetch-test-{}",
106            std::process::id()
107        ));
108        fs::create_dir_all(&dir).unwrap();
109        let name = "interscript-maps-isc-9.9.9.tar.gz";
110        let archive = dir.join(name);
111        fs::write(&archive, b"not a real archive").unwrap();
112        fs::write(dir.join(format!("{name}.sha256")), format!("{}  {name}\n", "0".repeat(64)))
113            .unwrap();
114
115        match fetch_corpus("9.9.9", &dir) {
116            Err(Error::MapLogic(msg)) => {
117                assert!(msg.contains("sha256 mismatch"), "{msg}");
118            }
119            other => panic!("expected sha256 mismatch error, got {other:?}"),
120        }
121        fs::remove_dir_all(&dir).ok();
122    }
123}