release_downloader/
lib.rs

1use mashrl::{
2    HTTP::{Headers, ResponseCode},
3    make_get_request,
4};
5use simple_json_parser::{JSONKey, RootJSONValue, parse as parse_json};
6
7use std::fs::File;
8use std::io::Read;
9use std::path::Path;
10
11#[cfg(target_os = "windows")]
12const OS_MATCHER: &str = "windows";
13#[cfg(target_os = "linux")]
14const OS_MATCHER: &str = "linux";
15#[cfg(target_os = "macos")]
16const OS_MATCHER: &str = "macos";
17
18#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
19const ARCH_MATCHER: &str = "x86";
20#[cfg(target_arch = "arm")]
21const ARCH_MATCHER: &str = "arm";
22#[cfg(target_arch = "aarch64")]
23const ARCH_MATCHER: &str = "aarch64";
24
25type BoxedError = Box<dyn std::error::Error>;
26
27#[derive(Debug, Clone, Copy)]
28pub struct DownloadOptions<'a> {
29    pub github_token: Option<&'a str>,
30    pub trace: bool,
31    pub pattern: utilities::Pattern<'a>,
32    pub tag: Option<&'a str>,
33    pub match_architecture: bool,
34}
35
36impl Default for DownloadOptions<'static> {
37    fn default() -> Self {
38        DownloadOptions {
39            github_token: None,
40            trace: false,
41            pattern: utilities::Pattern::all(),
42            tag: None,
43            match_architecture: true,
44        }
45    }
46}
47
48fn get_assets_from_github_releases(
49    owner: &str,
50    repository: &str,
51    options: DownloadOptions<'_>,
52) -> Result<mashrl::HTTP::Response<'static>, BoxedError> {
53    let mut headers = Headers::from_iter([
54        ("Accept", "application/vnd.github+json"),
55        ("X-GitHub-Api-Version", "2022-11-28"),
56        ("User-Agent", "kaleidwave/release-downloader"),
57    ]);
58
59    if let Some(token) = options.github_token {
60        headers.append("Authorization", &format!("Bearer {token}"));
61    }
62
63    // TOOD some releases are included here and so we don't always need to walk
64    let release_id: String = {
65        let path = if let Some(tag) = options.tag {
66            format!("repos/{owner}/{repository}/releases/tags/{tag}")
67        } else {
68            format!("repos/{owner}/{repository}/releases/latest")
69        };
70
71        let mut response = make_get_request("api.github.com", &path, &headers)?;
72
73        let mut body = String::new();
74        response.body.read_to_string(&mut body)?;
75
76        if response.code != ResponseCode::OK {
77            let message = format!(
78                "could not make request, repository ({repository}) or user ({owner}) may not exist. recieved {code:?} from 'api.github.com/{path}'. Recieved body ({body:?})",
79                code = response.code
80            );
81            return Err(message.into());
82        }
83
84        let mut release_id = "".to_owned();
85        let _ = parse_json(&body, |keys, value| {
86            if let [JSONKey::Slice("id")] = keys {
87                let RootJSONValue::Number(value) = value else {
88                    panic!("expect asset label to be string")
89                };
90
91                release_id = value.to_owned();
92
93                // TODO break early
94            }
95        });
96
97        release_id
98    };
99
100    // TODO should walk pages instead
101    const PER_PAGE: u8 = 100;
102
103    let path =
104        format!("repos/{owner}/{repository}/releases/{release_id}/assets?per_page={PER_PAGE}");
105    let response = make_get_request("api.github.com", &path, &headers)?;
106
107    if response.code == ResponseCode::OK {
108        Ok(response)
109    } else {
110        Err(format!(
111            "could not make request for assets. recieved {code:?} from 'api.github.com'",
112            code = response.code
113        )
114        .into())
115    }
116}
117
118/// returns pairs for names and urls
119pub fn get_asset_urls_and_names_from_github_releases(
120    owner: &str,
121    repository: &str,
122    options: DownloadOptions<'_>,
123) -> Result<Vec<(String, String)>, BoxedError> {
124    let mut response = get_assets_from_github_releases(owner, repository, options)?;
125
126    let mut body = String::new();
127    response.body.read_to_string(&mut body)?;
128
129    // Relies on fact keys are in order
130    let mut download_next_release = false;
131    let mut name: &str = "";
132    let mut assets = Vec::new();
133    let mut asset_idx = 0;
134
135    let _ = parse_json(&body, |keys, value| {
136        if let [JSONKey::Index(idx), key] = keys {
137            match key {
138                JSONKey::Slice("label") => {
139                    let RootJSONValue::String(value) = value else {
140                        panic!("expect asset label to be string")
141                    };
142                    // let origin = value.strip_suffix("gz").unwrap_or(value);
143                    // let origin = value.strip_suffix("tar").unwrap_or(value);
144                    // let origin = value.strip_suffix("zip").unwrap_or(value);
145
146                    let name = if value.is_empty() { name } else { value };
147
148                    let architecture = if options.match_architecture {
149                        name.contains(OS_MATCHER) && name.contains(ARCH_MATCHER)
150                    } else {
151                        true
152                    };
153
154                    download_next_release = architecture && options.pattern.matches(name);
155
156                    if options.trace {
157                        let action = if download_next_release {
158                            "downloading"
159                        } else {
160                            "not downloading"
161                        };
162                        let pattern = options.pattern;
163                        eprintln!("{action} {name:?} (pattern = {pattern:?})");
164                    }
165                }
166                JSONKey::Slice("browser_download_url") => {
167                    if download_next_release {
168                        let RootJSONValue::String(url) = value else {
169                            panic!("expected asset url to be string")
170                        };
171
172                        assets.push((name.to_owned(), url.to_owned()));
173
174                        // TODO could exit here
175                    }
176                }
177                JSONKey::Slice("name") => {
178                    if let RootJSONValue::String(name2) = value {
179                        name = name2;
180                    };
181                }
182                _key => {
183                    // eprintln!("{key:?} {value:?}");
184                }
185            }
186            asset_idx = idx + 1;
187        }
188    });
189
190    if options.trace {
191        eprintln!("Scanned {asset_idx} assets");
192    }
193
194    Ok(assets)
195}
196
197pub fn download_from_github(
198    url: &str,
199    github_token: Option<&str>,
200) -> Result<impl Read, BoxedError> {
201    let mut headers = Headers::from_iter([
202        // ("Accept", "application/vnd.github+json"),
203        // ("X-GitHub-Api-Version", "2022-11-28"),
204        ("User-Agent", "kaleidwave/release-downloader"),
205    ]);
206
207    if let Some(token) = github_token {
208        headers.append("Authorization", &format!("Bearer {token}"));
209    }
210
211    let actual_asset_url = {
212        let url = url
213            .strip_prefix("https://github.com")
214            .ok_or_else(|| format!("Asset url {url:?} does not start with 'https://github.com'"))?;
215
216        let response = make_get_request("github.com", url, &headers)?;
217
218        let location = response
219            .headers
220            .iter()
221            .find_map(|(key, value)| (key == "Location").then_some(value.to_owned()));
222
223        location.ok_or("no location")?
224    };
225
226    // Finally do download
227    let parts = actual_asset_url
228        .strip_prefix("https://")
229        .and_then(|url| url.split_once('/'));
230
231    let Some((base, url)) = parts else {
232        return Err("asset url does not start with 'https://' and have path".into());
233    };
234
235    let response = make_get_request(base, url, &headers)?;
236
237    Ok(response.body)
238}
239
240pub fn write_binary(
241    to: &str,
242    name: &str,
243    mut reader: impl Read,
244    only_binaries: bool,
245    trace: bool,
246) -> Result<(), BoxedError> {
247    let p = format!("{to}/{name}");
248    let path = Path::new(&p);
249    let to = Path::new(to);
250
251    #[cfg(feature = "decompress")]
252    if name.ends_with(".tar.gz") {
253        return extract_tar_gz(reader, to, only_binaries, trace);
254    } else if name.ends_with(".tar") {
255        return extract_tar(reader, to, only_binaries, trace);
256    } else if name.ends_with(".zip") {
257        #[cfg(windows)]
258        {
259            // We put into `Cursor` here so reader implements `Seek`
260            let mut buffer = Vec::new();
261            reader.read_to_end(&mut buffer)?;
262            let reader = std::io::Cursor::new(&buffer);
263            return extract_zip(reader, to, only_binaries, trace);
264        };
265
266        #[cfg(not(windows))]
267        panic!("cannot unzip on `not(windows)`")
268    } 
269
270    if trace {
271        eprintln!("Writing to {path:?}");
272    }
273
274    let mut options = File::options();
275    options.write(true).truncate(true).read(true).create(true);
276    let mut file = options.open(path)?;
277
278    #[cfg(unix)]
279    {
280        let mut buf = [0; 4];
281        // Ignore error here, some files have size < 4
282        let _read_result = reader.read_exact(&mut buf);
283        let is_binary = &buf == b"\x7fELF"
284            || u32::from_le_bytes(buf) == 0xFEEDFACFu32
285            || u32::from_le_bytes(buf) == 0xFEEDFACEu32;
286
287        if is_binary {
288            let permission =
289                <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o777);
290            file.set_permissions(permission)?;
291        }
292
293        // Compensate for lost bytes
294        std::io::copy(&mut buf.as_slice(), &mut file)?;
295    }
296
297    std::io::copy(&mut reader, &mut file)?;
298
299    Ok(())
300}
301
302pub fn move_if_binary(
303    mut content: impl Read,
304    path: &Path,
305    only_binaries: bool,
306    trace: bool,
307) -> Result<(), BoxedError> {
308    use std::fs::File;
309    use std::io::{Write, copy};
310
311    let mut buf = [0; 4];
312    // Ignore error here, some files have size < 4
313    let _read_result = content.read_exact(&mut buf);
314    let is_binary = &buf == b"\x7fELF"
315        || u32::from_le_bytes(buf) == 0xFEEDFACFu32
316        || u32::from_le_bytes(buf) == 0xFEEDFACEu32;
317
318    if only_binaries && !is_binary {
319        return Ok(());
320    }
321
322    let mut file = File::create(path)?;
323
324    // Writes bytes that were pulled path
325    let _ = file.write(&buf)?;
326    let _ = copy(&mut content, &mut file)?;
327
328    // Set permissions
329    #[cfg(unix)]
330    if is_binary {
331        let permission =
332            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o777);
333        file.set_permissions(permission)?;
334    }
335
336    if trace {
337        eprintln!("Writing to {path:?}");
338    }
339
340    Ok(())
341}
342
343#[cfg(feature = "decompress")]
344pub fn extract_tar(
345    reader: impl Read,
346    output_dir: &Path,
347    only_binaries: bool,
348    trace: bool,
349) -> Result<(), BoxedError> {
350    let mut archive = tar::Archive::new(reader);
351    // archive.set_preserve_permissions(true);
352    let entries = archive.entries()?;
353    for entry in entries {
354        let entry = entry?;
355        if let Some(name) = entry.path()?.file_name() {
356            let path = output_dir.join(name);
357            move_if_binary(entry, &path, only_binaries, trace)?;
358        }
359    }
360    Ok(())
361}
362
363#[cfg(feature = "decompress")]
364pub fn extract_tar_gz(
365    reader: impl Read,
366    output_dir: &Path,
367    only_binaries: bool,
368    trace: bool,
369) -> Result<(), BoxedError> {
370    let decompressor = flate2::read::GzDecoder::new(reader);
371    extract_tar(decompressor, output_dir, only_binaries, trace)
372}
373
374#[cfg(all(windows, feature = "decompress"))]
375pub fn extract_zip(
376    reader: impl Read + std::io::Seek,
377    output_dir: &Path,
378    only_binaries: bool,
379    trace: bool,
380) -> Result<(), BoxedError> {
381    let mut archive = zip::ZipArchive::new(reader)?;
382    for i in 0..archive.len() {
383        let entry = archive.by_index(i)?;
384        if entry.is_file()
385            && let Some(name) = Path::new(entry.name()).file_name()
386        {
387            let path = output_dir.join(name);
388            move_if_binary(entry, &path, only_binaries, trace)?;
389        }
390    }
391
392    Ok(())
393}
394
395/// For updating the current program
396#[cfg(feature = "self-update")]
397pub fn replace_self(mut content: impl Read) -> Result<(), BoxedError> {
398    let temporary_binary_name = "temporary";
399    let mut file = File::create(temporary_binary_name)?;
400
401    std::io::copy(&mut content, &mut file)?;
402
403    self_replace::self_replace(temporary_binary_name)?;
404    std::fs::remove_file(temporary_binary_name)?;
405
406    Ok(())
407}
408
409/// returns pairs for names and download counts
410pub fn get_statistics(
411    owner: &str,
412    repository: &str,
413    options: DownloadOptions<'_>,
414) -> Result<Vec<(String, usize)>, BoxedError> {
415    let mut response = get_assets_from_github_releases(owner, repository, options)?;
416
417    let mut body = String::new();
418    response.body.read_to_string(&mut body)?;
419
420    let mut name: &str = "";
421
422    let mut items = Vec::new();
423
424    let _ = parse_json(&body, |keys, value| {
425        if let [JSONKey::Index(_idx), key] = keys {
426            match key {
427                JSONKey::Slice("download_count") => {
428                    let RootJSONValue::Number(count) = value else {
429                        panic!("expected download count to be number")
430                    };
431
432                    items.push((name.to_owned(), count.parse().unwrap()));
433                }
434                JSONKey::Slice("name") => {
435                    if let RootJSONValue::String(name2) = value {
436                        name = name2;
437                    };
438                }
439                _key => {
440                    // eprintln!("{key:?} {value:?}");
441                }
442            }
443        }
444    });
445
446    Ok(items)
447}
448
449pub mod utilities {
450    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
451    pub struct Pattern<'a>(&'a str);
452
453    impl<'a> Pattern<'a> {
454        pub fn new(pattern: &'a str) -> Self {
455            Self(pattern)
456        }
457
458        pub fn all() -> Self {
459            Self("*")
460        }
461
462        // TODO temp
463        pub fn matches(&self, value: &str) -> bool {
464            if let "*" = self.0 {
465                true
466            } else {
467                for part in self.0.split('|') {
468                    if value.contains(part) {
469                        return true;
470                    }
471                }
472                false
473            }
474        }
475    }
476}