Skip to main content

fc_gpui_http_client/
github_download.rs

1use std::{fmt::Write as _, path::Path, pin::Pin, task::Poll};
2
3use anyhow::{Context, Result};
4use async_compression::futures::bufread::GzipDecoder;
5use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, io::BufReader};
6use sha2::{Digest, Sha256};
7
8use crate::{HttpClient, github::AssetKind};
9
10#[derive(serde::Deserialize, serde::Serialize, Debug)]
11pub struct GithubBinaryMetadata {
12    pub metadata_version: u64,
13    pub digest: Option<String>,
14}
15
16impl GithubBinaryMetadata {
17    pub async fn read_from_file(metadata_path: &Path) -> Result<GithubBinaryMetadata> {
18        let metadata_content = async_fs::read_to_string(metadata_path)
19            .await
20            .with_context(|| format!("reading metadata file at {metadata_path:?}"))?;
21        serde_json::from_str(&metadata_content)
22            .with_context(|| format!("parsing metadata file at {metadata_path:?}"))
23    }
24
25    pub async fn write_to_file(&self, metadata_path: &Path) -> Result<()> {
26        let metadata_content = serde_json::to_string(self)
27            .with_context(|| format!("serializing metadata for {metadata_path:?}"))?;
28        async_fs::write(metadata_path, metadata_content.as_bytes())
29            .await
30            .with_context(|| format!("writing metadata file at {metadata_path:?}"))?;
31        Ok(())
32    }
33}
34
35pub async fn download_server_binary(
36    http_client: &dyn HttpClient,
37    url: &str,
38    digest: Option<&str>,
39    destination_path: &Path,
40    asset_kind: AssetKind,
41) -> Result<(), anyhow::Error> {
42    log::info!("downloading github artifact from {url}");
43    let mut response = http_client
44        .get(url, Default::default(), true)
45        .await
46        .with_context(|| format!("downloading release from {url}"))?;
47    let body = response.body_mut();
48    match digest {
49        Some(expected_sha_256) => {
50            let temp_asset_file = tempfile::NamedTempFile::new()
51                .with_context(|| format!("creating a temporary file for {url}"))?;
52            let (temp_asset_file, _temp_guard) = temp_asset_file.into_parts();
53            let mut writer = HashingWriter {
54                writer: async_fs::File::from(temp_asset_file),
55                hasher: Sha256::new(),
56            };
57            futures::io::copy(&mut BufReader::new(body), &mut writer)
58                .await
59                .with_context(|| {
60                    format!("saving archive contents into the temporary file for {url}",)
61                })?;
62            let digest = writer.hasher.finalize();
63            let asset_sha_256 =
64                digest
65                    .iter()
66                    .fold(String::with_capacity(digest.len() * 2), |mut hex, byte| {
67                        let _ = write!(hex, "{byte:02x}");
68                        hex
69                    });
70
71            anyhow::ensure!(
72                asset_sha_256 == expected_sha_256,
73                "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}",
74            );
75            writer
76                .writer
77                .seek(std::io::SeekFrom::Start(0))
78                .await
79                .with_context(|| format!("seeking temporary file {destination_path:?}",))?;
80            stream_file_archive(&mut writer.writer, url, destination_path, asset_kind)
81                .await
82                .with_context(|| {
83                    format!("extracting downloaded asset for {url} into {destination_path:?}",)
84                })?;
85        }
86        None => stream_response_archive(body, url, destination_path, asset_kind)
87            .await
88            .with_context(|| {
89                format!("extracting response for asset {url} into {destination_path:?}",)
90            })?,
91    }
92    Ok(())
93}
94
95async fn stream_response_archive(
96    response: impl AsyncRead + Unpin,
97    url: &str,
98    destination_path: &Path,
99    asset_kind: AssetKind,
100) -> Result<()> {
101    match asset_kind {
102        AssetKind::TarGz => extract_tar_gz(destination_path, url, response).await?,
103        AssetKind::Gz => extract_gz(destination_path, url, response).await?,
104        AssetKind::Zip => {
105            util::archive::extract_zip(destination_path, response).await?;
106        }
107    };
108    Ok(())
109}
110
111async fn stream_file_archive(
112    file_archive: impl AsyncRead + AsyncSeek + Unpin,
113    url: &str,
114    destination_path: &Path,
115    asset_kind: AssetKind,
116) -> Result<()> {
117    match asset_kind {
118        AssetKind::TarGz => extract_tar_gz(destination_path, url, file_archive).await?,
119        AssetKind::Gz => extract_gz(destination_path, url, file_archive).await?,
120        #[cfg(not(windows))]
121        AssetKind::Zip => {
122            util::archive::extract_seekable_zip(destination_path, file_archive).await?;
123        }
124        #[cfg(windows)]
125        AssetKind::Zip => {
126            util::archive::extract_zip(destination_path, file_archive).await?;
127        }
128    };
129    Ok(())
130}
131
132async fn extract_tar_gz(
133    destination_path: &Path,
134    url: &str,
135    from: impl AsyncRead + Unpin,
136) -> Result<(), anyhow::Error> {
137    let mut decompressed_bytes = GzipDecoder::new(BufReader::new(from));
138    let temp_file = tempfile::NamedTempFile::new()
139        .with_context(|| format!("creating a temporary file for {url}"))?;
140    let mut temp_writer = async_fs::File::from(temp_file.reopen()?);
141    futures::io::copy(&mut decompressed_bytes, &mut temp_writer)
142        .await
143        .with_context(|| format!("decompressing {url}"))?;
144    drop(temp_writer);
145
146    let destination = destination_path.to_path_buf();
147    blocking::unblock(move || {
148        let mut archive = tar::Archive::new(std::io::BufReader::new(temp_file.reopen()?));
149        archive.set_preserve_mtime(false);
150        archive.unpack(&destination)
151    })
152    .await
153    .with_context(|| format!("extracting {url} to {destination_path:?}"))?;
154    Ok(())
155}
156
157async fn extract_gz(
158    destination_path: &Path,
159    url: &str,
160    from: impl AsyncRead + Unpin,
161) -> Result<(), anyhow::Error> {
162    let mut decompressed_bytes = GzipDecoder::new(BufReader::new(from));
163    let mut file = async_fs::File::create(&destination_path)
164        .await
165        .with_context(|| {
166            format!("creating a file {destination_path:?} for a download from {url}")
167        })?;
168    futures::io::copy(&mut decompressed_bytes, &mut file)
169        .await
170        .with_context(|| format!("extracting {url} to {destination_path:?}"))?;
171    Ok(())
172}
173
174struct HashingWriter<W: AsyncWrite + Unpin> {
175    writer: W,
176    hasher: Sha256,
177}
178
179impl<W: AsyncWrite + Unpin> AsyncWrite for HashingWriter<W> {
180    fn poll_write(
181        mut self: Pin<&mut Self>,
182        cx: &mut std::task::Context<'_>,
183        buf: &[u8],
184    ) -> Poll<std::result::Result<usize, std::io::Error>> {
185        match Pin::new(&mut self.writer).poll_write(cx, buf) {
186            Poll::Ready(Ok(n)) => {
187                self.hasher.update(&buf[..n]);
188                Poll::Ready(Ok(n))
189            }
190            other => other,
191        }
192    }
193
194    fn poll_flush(
195        mut self: Pin<&mut Self>,
196        cx: &mut std::task::Context<'_>,
197    ) -> Poll<Result<(), std::io::Error>> {
198        Pin::new(&mut self.writer).poll_flush(cx)
199    }
200
201    fn poll_close(
202        mut self: Pin<&mut Self>,
203        cx: &mut std::task::Context<'_>,
204    ) -> Poll<std::result::Result<(), std::io::Error>> {
205        Pin::new(&mut self.writer).poll_close(cx)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use async_compression::futures::write::GzipEncoder;
212    use futures::{AsyncWriteExt, io::Cursor};
213    use tar::{EntryType, Header};
214
215    use super::extract_tar_gz;
216
217    fn append_entry(
218        archive: &mut Vec<u8>,
219        path: &str,
220        entry_type: EntryType,
221        mode: u32,
222        body: &[u8],
223    ) {
224        let mut header = Header::new_gnu();
225        header.set_path(path).unwrap();
226        header.set_entry_type(entry_type);
227        header.set_mode(mode);
228        header.set_uid(0);
229        header.set_gid(0);
230        header.set_mtime(0);
231        header.set_size(body.len() as u64);
232        header.set_cksum();
233        archive.extend_from_slice(header.as_bytes());
234        archive.extend_from_slice(body);
235        let padding = (512 - body.len() % 512) % 512;
236        archive.extend(std::iter::repeat_n(0, padding));
237    }
238
239    async fn gzip_tar(archive: Vec<u8>) -> Cursor<Vec<u8>> {
240        let mut encoder = GzipEncoder::new(Cursor::new(Vec::new()));
241        encoder.write_all(&archive).await.unwrap();
242        encoder.close().await.unwrap();
243        let mut cursor = encoder.into_inner();
244        cursor.set_position(0);
245        cursor
246    }
247
248    #[test]
249    fn test_extract_tar_gz_preserves_content_and_permissions() {
250        futures::executor::block_on(async {
251            let content: Vec<u8> = (0u16..1024).map(|b| (b % 256) as u8).collect();
252            let mut archive = Vec::new();
253            append_entry(
254                &mut archive,
255                "nested/file.txt",
256                EntryType::Regular,
257                0o755,
258                &content,
259            );
260            archive.extend_from_slice(&[0u8; 1024]);
261            let reader = gzip_tar(archive).await;
262
263            let dir = tempfile::tempdir().unwrap();
264            extract_tar_gz(dir.path(), "test://archive.tar.gz", reader)
265                .await
266                .unwrap();
267
268            let extracted = dir.path().join("nested/file.txt");
269            assert_eq!(std::fs::read(&extracted).unwrap(), content);
270            #[cfg(unix)]
271            {
272                use std::os::unix::fs::PermissionsExt;
273                let mode = std::fs::metadata(&extracted).unwrap().permissions().mode();
274                assert_eq!(mode & 0o777, 0o755);
275            }
276        });
277    }
278
279    #[test]
280    fn test_extract_tar_gz_pax_size_before_gnu_longname() {
281        futures::executor::block_on(async {
282            let mut archive = Vec::new();
283            append_entry(
284                &mut archive,
285                "PaxHeader",
286                EntryType::XHeader,
287                0o644,
288                b"13 size=1024\n",
289            );
290            append_entry(
291                &mut archive,
292                "././@LongLink",
293                EntryType::GNULongName,
294                0o644,
295                b"nested/pax-size.txt\0",
296            );
297            append_entry(
298                &mut archive,
299                "placeholder",
300                EntryType::Regular,
301                0o644,
302                &vec![b'A'; 1024],
303            );
304            // A later entry with its own size. The pax `size` above must not leak
305            // onto this header or body.
306            append_entry(
307                &mut archive,
308                "nested/after.txt",
309                EntryType::Regular,
310                0o644,
311                b"ok",
312            );
313            archive.extend_from_slice(&[0u8; 1024]);
314            let reader = gzip_tar(archive).await;
315
316            let dir = tempfile::tempdir().unwrap();
317            extract_tar_gz(dir.path(), "test://archive.tar.gz", reader)
318                .await
319                .unwrap();
320
321            let extracted = dir.path().join("nested/pax-size.txt");
322            assert!(extracted.exists(), "nested/pax-size.txt not extracted");
323            assert_eq!(std::fs::read(&extracted).unwrap(), vec![b'A'; 1024]);
324            assert!(
325                !dir.path().join("placeholder").exists(),
326                "placeholder file must not be created"
327            );
328            assert_eq!(
329                std::fs::read(dir.path().join("nested/after.txt")).unwrap(),
330                b"ok"
331            );
332        });
333    }
334
335    fn append_symlink(archive: &mut Vec<u8>, path: &str, target: &str) {
336        let mut header = Header::new_gnu();
337        header.set_path(path).unwrap();
338        header.set_link_name(target).unwrap();
339        header.set_entry_type(EntryType::Symlink);
340        header.set_mode(0o777);
341        header.set_uid(0);
342        header.set_gid(0);
343        header.set_mtime(0);
344        header.set_size(0);
345        header.set_cksum();
346        archive.extend_from_slice(header.as_bytes());
347    }
348
349    #[cfg(unix)]
350    #[test]
351    fn test_extract_tar_gz_symlink() {
352        futures::executor::block_on(async {
353            let mut archive = Vec::new();
354            append_entry(
355                &mut archive,
356                "nested/file.txt",
357                EntryType::Regular,
358                0o644,
359                b"target",
360            );
361            append_symlink(&mut archive, "nested/link.txt", "file.txt");
362            archive.extend_from_slice(&[0u8; 1024]);
363            let reader = gzip_tar(archive).await;
364
365            let dir = tempfile::tempdir().unwrap();
366            extract_tar_gz(dir.path(), "test://archive.tar.gz", reader)
367                .await
368                .unwrap();
369
370            let link = dir.path().join("nested/link.txt");
371            assert_eq!(
372                std::fs::read_link(&link).unwrap(),
373                std::path::Path::new("file.txt")
374            );
375            assert_eq!(std::fs::read(&link).unwrap(), b"target");
376        });
377    }
378}