Skip to main content

open_gpui_http_client/
github_download.rs

1use std::{
2    path::{Path, PathBuf},
3    pin::Pin,
4    task::Poll,
5};
6
7use anyhow::{Context, Result};
8use async_compression::futures::bufread::{BzDecoder, GzipDecoder};
9use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, AsyncWriteExt, io::BufReader};
10use sha2::{Digest, Sha256};
11use tokio_util::compat::FuturesAsyncReadCompatExt as _;
12
13use crate::{HttpClient, github::AssetKind};
14
15#[derive(serde::Deserialize, serde::Serialize, Debug)]
16pub struct GithubBinaryMetadata {
17    pub metadata_version: u64,
18    pub digest: Option<String>,
19}
20
21impl GithubBinaryMetadata {
22    pub async fn read_from_file(metadata_path: &Path) -> Result<GithubBinaryMetadata> {
23        let metadata_content = async_fs::read_to_string(metadata_path)
24            .await
25            .with_context(|| format!("reading metadata file at {metadata_path:?}"))?;
26        serde_json::from_str(&metadata_content)
27            .with_context(|| format!("parsing metadata file at {metadata_path:?}"))
28    }
29
30    pub async fn write_to_file(&self, metadata_path: &Path) -> Result<()> {
31        let metadata_content = serde_json::to_string(self)
32            .with_context(|| format!("serializing metadata for {metadata_path:?}"))?;
33        async_fs::write(metadata_path, metadata_content.as_bytes())
34            .await
35            .with_context(|| format!("writing metadata file at {metadata_path:?}"))?;
36        Ok(())
37    }
38}
39
40pub async fn download_server_binary(
41    http_client: &dyn HttpClient,
42    url: &str,
43    digest: Option<&str>,
44    destination_path: &Path,
45    asset_kind: AssetKind,
46) -> Result<(), anyhow::Error> {
47    log::info!("downloading github artifact from {url}");
48    let Some(destination_parent) = destination_path.parent() else {
49        anyhow::bail!("destination path has no parent: {destination_path:?}");
50    };
51
52    let staging_path = staging_path(destination_parent, asset_kind)?;
53    let mut response = http_client
54        .get(url, Default::default(), true)
55        .await
56        .with_context(|| format!("downloading release from {url}"))?;
57    let body = response.body_mut();
58
59    if let Err(err) = extract_to_staging(body, digest, url, &staging_path, asset_kind).await {
60        cleanup_staging_path(&staging_path, asset_kind).await;
61        return Err(err);
62    }
63
64    if let Err(err) = finalize_download(&staging_path, destination_path).await {
65        cleanup_staging_path(&staging_path, asset_kind).await;
66        return Err(err);
67    }
68
69    Ok(())
70}
71
72pub async fn download_server_raw_binary(
73    http_client: &dyn HttpClient,
74    url: &str,
75    digest: Option<&str>,
76    destination_path: &Path,
77    binary_file_name: &str,
78) -> Result<(), anyhow::Error> {
79    log::info!("downloading raw binary from {url}");
80    let Some(destination_parent) = destination_path.parent() else {
81        anyhow::bail!("destination path has no parent: {destination_path:?}");
82    };
83
84    let staging_path = staging_dir_path(destination_parent)?;
85    let result = async {
86        let mut response = http_client
87            .get(url, Default::default(), true)
88            .await
89            .with_context(|| format!("downloading release from {url}"))?;
90
91        let binary_path = staging_path.join(binary_file_name);
92        let mut writer = HashingWriter {
93            writer: async_fs::File::create(&binary_path)
94                .await
95                .with_context(|| format!("creating a file {binary_path:?} for {url}"))?,
96            hasher: Sha256::new(),
97        };
98        futures::io::copy(&mut BufReader::new(response.body_mut()), &mut writer)
99            .await
100            .with_context(|| format!("saving binary contents from {url}"))?;
101        let asset_sha_256 = writer
102            .finish()
103            .await
104            .with_context(|| format!("flushing binary contents for {url}"))?;
105
106        if let Some(expected_sha_256) = digest {
107            anyhow::ensure!(
108                asset_sha_256 == expected_sha_256,
109                "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}",
110            );
111        }
112
113        open_gpui_util::fs::make_file_executable(&binary_path)
114            .await
115            .with_context(|| format!("marking {binary_path:?} as executable"))?;
116        finalize_download(&staging_path, destination_path).await
117    }
118    .await;
119
120    if let Err(err) = result {
121        if let Err(err) = async_fs::remove_dir_all(&staging_path).await {
122            log::warn!("failed to remove staging directory {staging_path:?}: {err:?}");
123        }
124        return Err(err);
125    }
126
127    Ok(())
128}
129
130async fn extract_to_staging(
131    body: impl AsyncRead + Unpin,
132    digest: Option<&str>,
133    url: &str,
134    staging_path: &Path,
135    asset_kind: AssetKind,
136) -> Result<()> {
137    match digest {
138        Some(expected_sha_256) => {
139            let temp_asset_file = tempfile::NamedTempFile::new()
140                .with_context(|| format!("creating a temporary file for {url}"))?;
141            let (temp_asset_file, _temp_guard) = temp_asset_file.into_parts();
142            let mut writer = HashingWriter {
143                writer: async_fs::File::from(temp_asset_file),
144                hasher: Sha256::new(),
145            };
146            futures::io::copy(&mut BufReader::new(body), &mut writer)
147                .await
148                .with_context(|| {
149                    format!("saving archive contents into the temporary file for {url}")
150                })?;
151            let asset_sha_256 = format!("{:x}", writer.hasher.finalize());
152
153            anyhow::ensure!(
154                asset_sha_256 == expected_sha_256,
155                "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}",
156            );
157            writer
158                .writer
159                .seek(std::io::SeekFrom::Start(0))
160                .await
161                .with_context(|| format!("seeking temporary file for {url}"))?;
162            stream_file_archive(&mut writer.writer, url, staging_path, asset_kind)
163                .await
164                .with_context(|| {
165                    format!("extracting downloaded asset for {url} into {staging_path:?}")
166                })?;
167        }
168        None => {
169            stream_response_archive(body, url, staging_path, asset_kind)
170                .await
171                .with_context(|| {
172                    format!("extracting response for asset {url} into {staging_path:?}")
173                })?;
174        }
175    }
176    Ok(())
177}
178
179fn staging_dir_path(parent: &Path) -> Result<PathBuf> {
180    let dir = tempfile::Builder::new()
181        .prefix(".tmp-github-download-")
182        .tempdir_in(parent)
183        .with_context(|| format!("creating staging directory in {parent:?}"))?;
184    Ok(dir.keep())
185}
186
187fn staging_path(parent: &Path, asset_kind: AssetKind) -> Result<PathBuf> {
188    match asset_kind {
189        AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Zip => staging_dir_path(parent),
190        AssetKind::Gz => {
191            let path = tempfile::Builder::new()
192                .prefix(".tmp-github-download-")
193                .tempfile_in(parent)
194                .with_context(|| format!("creating staging file in {parent:?}"))?
195                .into_temp_path()
196                .keep()
197                .with_context(|| format!("persisting staging file in {parent:?}"))?;
198            Ok(path)
199        }
200    }
201}
202
203async fn cleanup_staging_path(staging_path: &Path, asset_kind: AssetKind) {
204    match asset_kind {
205        AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Zip => {
206            if let Err(err) = async_fs::remove_dir_all(staging_path).await {
207                log::warn!("failed to remove staging directory {staging_path:?}: {err:?}");
208            }
209        }
210        AssetKind::Gz => {
211            if let Err(err) = async_fs::remove_file(staging_path).await {
212                log::warn!("failed to remove staging file {staging_path:?}: {err:?}");
213            }
214        }
215    }
216}
217
218async fn finalize_download(staging_path: &Path, destination_path: &Path) -> Result<()> {
219    _ = async_fs::remove_dir_all(destination_path).await;
220    async_fs::rename(staging_path, destination_path)
221        .await
222        .with_context(|| format!("renaming {staging_path:?} to {destination_path:?}"))?;
223    Ok(())
224}
225
226async fn stream_response_archive(
227    response: impl AsyncRead + Unpin,
228    url: &str,
229    destination_path: &Path,
230    asset_kind: AssetKind,
231) -> Result<()> {
232    match asset_kind {
233        AssetKind::TarGz => extract_tar_gz(destination_path, url, response).await?,
234        AssetKind::TarBz2 => extract_tar_bz2(destination_path, url, response).await?,
235        AssetKind::Gz => extract_gz(destination_path, url, response).await?,
236        AssetKind::Zip => {
237            open_gpui_util::archive::extract_zip(destination_path, response).await?;
238        }
239    };
240    Ok(())
241}
242
243async fn stream_file_archive(
244    file_archive: impl AsyncRead + AsyncSeek + Unpin,
245    url: &str,
246    destination_path: &Path,
247    asset_kind: AssetKind,
248) -> Result<()> {
249    match asset_kind {
250        AssetKind::TarGz => extract_tar_gz(destination_path, url, file_archive).await?,
251        AssetKind::TarBz2 => extract_tar_bz2(destination_path, url, file_archive).await?,
252        AssetKind::Gz => extract_gz(destination_path, url, file_archive).await?,
253        #[cfg(not(windows))]
254        AssetKind::Zip => {
255            open_gpui_util::archive::extract_seekable_zip(destination_path, file_archive).await?;
256        }
257        #[cfg(windows)]
258        AssetKind::Zip => {
259            open_gpui_util::archive::extract_zip(destination_path, file_archive).await?;
260        }
261    };
262    Ok(())
263}
264
265async fn extract_tar_gz(
266    destination_path: &Path,
267    url: &str,
268    from: impl AsyncRead + Unpin,
269) -> Result<(), anyhow::Error> {
270    let decompressed_bytes = GzipDecoder::new(BufReader::new(from));
271    unpack_tar_archive(destination_path, url, decompressed_bytes).await?;
272    Ok(())
273}
274
275async fn extract_tar_bz2(
276    destination_path: &Path,
277    url: &str,
278    from: impl AsyncRead + Unpin,
279) -> Result<(), anyhow::Error> {
280    let decompressed_bytes = BzDecoder::new(BufReader::new(from));
281    unpack_tar_archive(destination_path, url, decompressed_bytes).await?;
282    Ok(())
283}
284
285async fn unpack_tar_archive(
286    destination_path: &Path,
287    url: &str,
288    archive_bytes: impl AsyncRead + Unpin,
289) -> Result<(), anyhow::Error> {
290    // We don't need to set the modified time. It's irrelevant to downloaded
291    // archive verification, and some filesystems return errors when asked to
292    // apply it after extraction.
293    let archive = async_tar::ArchiveBuilder::new(archive_bytes.compat())
294        .set_preserve_mtime(false)
295        .build();
296    archive
297        .unpack(&destination_path)
298        .await
299        .with_context(|| format!("extracting {url} to {destination_path:?}"))?;
300    Ok(())
301}
302
303async fn extract_gz(
304    destination_path: &Path,
305    url: &str,
306    from: impl AsyncRead + Unpin,
307) -> Result<(), anyhow::Error> {
308    let mut decompressed_bytes = GzipDecoder::new(BufReader::new(from));
309    let mut file = async_fs::File::create(&destination_path)
310        .await
311        .with_context(|| {
312            format!("creating a file {destination_path:?} for a download from {url}")
313        })?;
314    futures::io::copy(&mut decompressed_bytes, &mut file)
315        .await
316        .with_context(|| format!("extracting {url} to {destination_path:?}"))?;
317    Ok(())
318}
319
320struct HashingWriter<W: AsyncWrite + Unpin> {
321    writer: W,
322    hasher: Sha256,
323}
324
325impl<W: AsyncWrite + Unpin> HashingWriter<W> {
326    /// Closes and drops the inner writer, returning the hex SHA-256 digest of
327    /// everything written.
328    ///
329    /// Taking `self` by value guarantees the writer is dropped before this
330    /// returns. For file writers this releases the OS handle, which Windows
331    /// requires before an ancestor directory can be renamed or deleted; note
332    /// that closing alone is not enough, as `async_fs::File` holds its handle
333    /// until dropped.
334    async fn finish(mut self) -> std::io::Result<String> {
335        self.writer.close().await?;
336        drop(self.writer);
337        Ok(format!("{:x}", self.hasher.finalize()))
338    }
339}
340
341impl<W: AsyncWrite + Unpin> AsyncWrite for HashingWriter<W> {
342    fn poll_write(
343        mut self: Pin<&mut Self>,
344        cx: &mut std::task::Context<'_>,
345        buf: &[u8],
346    ) -> Poll<std::result::Result<usize, std::io::Error>> {
347        match Pin::new(&mut self.writer).poll_write(cx, buf) {
348            Poll::Ready(Ok(n)) => {
349                self.hasher.update(&buf[..n]);
350                Poll::Ready(Ok(n))
351            }
352            other => other,
353        }
354    }
355
356    fn poll_flush(
357        mut self: Pin<&mut Self>,
358        cx: &mut std::task::Context<'_>,
359    ) -> Poll<Result<(), std::io::Error>> {
360        Pin::new(&mut self.writer).poll_flush(cx)
361    }
362
363    fn poll_close(
364        mut self: Pin<&mut Self>,
365        cx: &mut std::task::Context<'_>,
366    ) -> Poll<std::result::Result<(), std::io::Error>> {
367        Pin::new(&mut self.writer).poll_close(cx)
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::{AsyncBody, Response};
375    use futures::future::BoxFuture;
376    use http::HeaderValue;
377    use url::Url;
378
379    struct StaticResponseClient {
380        body: Vec<u8>,
381    }
382
383    impl HttpClient for StaticResponseClient {
384        fn send(
385            &self,
386            _req: http::Request<AsyncBody>,
387        ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
388            let body = self.body.clone();
389            Box::pin(async move {
390                Ok(Response::builder()
391                    .status(200)
392                    .body(AsyncBody::from(body))
393                    .unwrap())
394            })
395        }
396
397        fn user_agent(&self) -> Option<&HeaderValue> {
398            None
399        }
400
401        fn proxy(&self) -> Option<&Url> {
402            None
403        }
404    }
405
406    #[test]
407    fn downloads_raw_binary_into_destination_dir() {
408        futures::executor::block_on(async {
409            let temp_dir = tempfile::tempdir().unwrap();
410            let destination_path = temp_dir.path().join("v_1");
411            let contents = b"#!/bin/sh\necho hello\n".to_vec();
412            let expected_sha_256 = format!("{:x}", Sha256::digest(&contents));
413            let client = StaticResponseClient { body: contents };
414
415            download_server_raw_binary(
416                &client,
417                "https://example.com/agent-binary",
418                Some(&expected_sha_256),
419                &destination_path,
420                "agent-binary",
421            )
422            .await
423            .unwrap();
424
425            let binary_path = destination_path.join("agent-binary");
426            assert_eq!(
427                std::fs::read(&binary_path).unwrap(),
428                b"#!/bin/sh\necho hello\n"
429            );
430            #[cfg(unix)]
431            {
432                use std::os::unix::fs::PermissionsExt;
433                let mode = std::fs::metadata(&binary_path)
434                    .unwrap()
435                    .permissions()
436                    .mode();
437                assert_eq!(mode & 0o111, 0o111, "binary should be executable");
438            }
439        });
440    }
441
442    #[test]
443    fn raw_binary_digest_mismatch_cleans_up_staging() {
444        futures::executor::block_on(async {
445            let temp_dir = tempfile::tempdir().unwrap();
446            let destination_path = temp_dir.path().join("v_1");
447            let client = StaticResponseClient {
448                body: b"some binary".to_vec(),
449            };
450
451            let error = download_server_raw_binary(
452                &client,
453                "https://example.com/agent-binary",
454                Some("0000000000000000000000000000000000000000000000000000000000000000"),
455                &destination_path,
456                "agent-binary",
457            )
458            .await
459            .unwrap_err();
460
461            assert!(error.to_string().contains("SHA-256 mismatch"));
462            assert!(!destination_path.exists());
463            let leftover_entries = std::fs::read_dir(temp_dir.path()).unwrap().count();
464            assert_eq!(leftover_entries, 0, "staging directory should be removed");
465        });
466    }
467}