1use std::{
2 path::{Path, PathBuf},
3 pin::Pin,
4 task::Poll,
5};
6
7use anyhow::{Context, Result};
8use async_compression::futures::bufread::GzipDecoder;
9use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, io::BufReader};
10use sha2::{Digest, Sha256};
11
12use crate::{HttpClient, github::AssetKind};
13
14#[derive(serde::Deserialize, serde::Serialize, Debug)]
15pub struct GithubBinaryMetadata {
16 pub metadata_version: u64,
17 pub digest: Option<String>,
18}
19
20impl GithubBinaryMetadata {
21 pub async fn read_from_file(metadata_path: &Path) -> Result<GithubBinaryMetadata> {
22 let metadata_content = async_fs::read_to_string(metadata_path)
23 .await
24 .with_context(|| format!("reading metadata file at {metadata_path:?}"))?;
25 serde_json::from_str(&metadata_content)
26 .with_context(|| format!("parsing metadata file at {metadata_path:?}"))
27 }
28
29 pub async fn write_to_file(&self, metadata_path: &Path) -> Result<()> {
30 let metadata_content = serde_json::to_string(self)
31 .with_context(|| format!("serializing metadata for {metadata_path:?}"))?;
32 async_fs::write(metadata_path, metadata_content.as_bytes())
33 .await
34 .with_context(|| format!("writing metadata file at {metadata_path:?}"))?;
35 Ok(())
36 }
37}
38
39pub async fn download_server_binary(
40 http_client: &dyn HttpClient,
41 url: &str,
42 digest: Option<&str>,
43 destination_path: &Path,
44 asset_kind: AssetKind,
45) -> Result<(), anyhow::Error> {
46 log::info!("downloading github artifact from {url}");
47 let Some(destination_parent) = destination_path.parent() else {
48 anyhow::bail!("destination path has no parent: {destination_path:?}");
49 };
50
51 let staging_path = staging_path(destination_parent, asset_kind)?;
52 let mut response = http_client
53 .get(url, Default::default(), true)
54 .await
55 .with_context(|| format!("downloading release from {url}"))?;
56 let body = response.body_mut();
57
58 if let Err(err) = extract_to_staging(body, digest, url, &staging_path, asset_kind).await {
59 cleanup_staging_path(&staging_path, asset_kind).await;
60 return Err(err);
61 }
62
63 if let Err(err) = finalize_download(&staging_path, destination_path).await {
64 cleanup_staging_path(&staging_path, asset_kind).await;
65 return Err(err);
66 }
67
68 Ok(())
69}
70
71async fn extract_to_staging(
72 body: impl AsyncRead + Unpin,
73 digest: Option<&str>,
74 url: &str,
75 staging_path: &Path,
76 asset_kind: AssetKind,
77) -> Result<()> {
78 match digest {
79 Some(expected_sha_256) => {
80 let temp_asset_file = tempfile::NamedTempFile::new()
81 .with_context(|| format!("creating a temporary file for {url}"))?;
82 let (temp_asset_file, _temp_guard) = temp_asset_file.into_parts();
83 let mut writer = HashingWriter {
84 writer: async_fs::File::from(temp_asset_file),
85 hasher: Sha256::new(),
86 };
87 futures::io::copy(&mut BufReader::new(body), &mut writer)
88 .await
89 .with_context(|| {
90 format!("saving archive contents into the temporary file for {url}")
91 })?;
92 let asset_sha_256 = format!("{:x}", writer.hasher.finalize());
93
94 anyhow::ensure!(
95 asset_sha_256 == expected_sha_256,
96 "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}",
97 );
98 writer
99 .writer
100 .seek(std::io::SeekFrom::Start(0))
101 .await
102 .with_context(|| format!("seeking temporary file for {url}"))?;
103 stream_file_archive(&mut writer.writer, url, staging_path, asset_kind)
104 .await
105 .with_context(|| {
106 format!("extracting downloaded asset for {url} into {staging_path:?}")
107 })?;
108 }
109 None => {
110 stream_response_archive(body, url, staging_path, asset_kind)
111 .await
112 .with_context(|| {
113 format!("extracting response for asset {url} into {staging_path:?}")
114 })?;
115 }
116 }
117 Ok(())
118}
119
120fn staging_path(parent: &Path, asset_kind: AssetKind) -> Result<PathBuf> {
121 match asset_kind {
122 AssetKind::TarGz | AssetKind::Zip => {
123 let dir = tempfile::Builder::new()
124 .prefix(".tmp-github-download-")
125 .tempdir_in(parent)
126 .with_context(|| format!("creating staging directory in {parent:?}"))?;
127 Ok(dir.keep())
128 }
129 AssetKind::Gz => {
130 let path = tempfile::Builder::new()
131 .prefix(".tmp-github-download-")
132 .tempfile_in(parent)
133 .with_context(|| format!("creating staging file in {parent:?}"))?
134 .into_temp_path()
135 .keep()
136 .with_context(|| format!("persisting staging file in {parent:?}"))?;
137 Ok(path)
138 }
139 }
140}
141
142async fn cleanup_staging_path(staging_path: &Path, asset_kind: AssetKind) {
143 match asset_kind {
144 AssetKind::TarGz | AssetKind::Zip => {
145 if let Err(err) = async_fs::remove_dir_all(staging_path).await {
146 log::warn!("failed to remove staging directory {staging_path:?}: {err:?}");
147 }
148 }
149 AssetKind::Gz => {
150 if let Err(err) = async_fs::remove_file(staging_path).await {
151 log::warn!("failed to remove staging file {staging_path:?}: {err:?}");
152 }
153 }
154 }
155}
156
157async fn finalize_download(staging_path: &Path, destination_path: &Path) -> Result<()> {
158 async_fs::rename(staging_path, destination_path)
159 .await
160 .with_context(|| format!("renaming {staging_path:?} to {destination_path:?}"))?;
161 Ok(())
162}
163
164async fn stream_response_archive(
165 response: impl AsyncRead + Unpin,
166 url: &str,
167 destination_path: &Path,
168 asset_kind: AssetKind,
169) -> Result<()> {
170 match asset_kind {
171 AssetKind::TarGz => extract_tar_gz(destination_path, url, response).await?,
172 AssetKind::Gz => extract_gz(destination_path, url, response).await?,
173 AssetKind::Zip => {
174 util::archive::extract_zip(destination_path, response).await?;
175 }
176 };
177 Ok(())
178}
179
180async fn stream_file_archive(
181 file_archive: impl AsyncRead + AsyncSeek + Unpin,
182 url: &str,
183 destination_path: &Path,
184 asset_kind: AssetKind,
185) -> Result<()> {
186 match asset_kind {
187 AssetKind::TarGz => extract_tar_gz(destination_path, url, file_archive).await?,
188 AssetKind::Gz => extract_gz(destination_path, url, file_archive).await?,
189 #[cfg(not(windows))]
190 AssetKind::Zip => {
191 util::archive::extract_seekable_zip(destination_path, file_archive).await?;
192 }
193 #[cfg(windows)]
194 AssetKind::Zip => {
195 util::archive::extract_zip(destination_path, file_archive).await?;
196 }
197 };
198 Ok(())
199}
200
201async fn extract_tar_gz(
202 destination_path: &Path,
203 url: &str,
204 from: impl AsyncRead + Unpin,
205) -> Result<(), anyhow::Error> {
206 let decompressed_bytes = GzipDecoder::new(BufReader::new(from));
207 let archive = async_tar::Archive::new(decompressed_bytes);
208 archive
209 .unpack(&destination_path)
210 .await
211 .with_context(|| format!("extracting {url} to {destination_path:?}"))?;
212 Ok(())
213}
214
215async fn extract_gz(
216 destination_path: &Path,
217 url: &str,
218 from: impl AsyncRead + Unpin,
219) -> Result<(), anyhow::Error> {
220 let mut decompressed_bytes = GzipDecoder::new(BufReader::new(from));
221 let mut file = async_fs::File::create(&destination_path)
222 .await
223 .with_context(|| {
224 format!("creating a file {destination_path:?} for a download from {url}")
225 })?;
226 futures::io::copy(&mut decompressed_bytes, &mut file)
227 .await
228 .with_context(|| format!("extracting {url} to {destination_path:?}"))?;
229 Ok(())
230}
231
232struct HashingWriter<W: AsyncWrite + Unpin> {
233 writer: W,
234 hasher: Sha256,
235}
236
237impl<W: AsyncWrite + Unpin> AsyncWrite for HashingWriter<W> {
238 fn poll_write(
239 mut self: Pin<&mut Self>,
240 cx: &mut std::task::Context<'_>,
241 buf: &[u8],
242 ) -> Poll<std::result::Result<usize, std::io::Error>> {
243 match Pin::new(&mut self.writer).poll_write(cx, buf) {
244 Poll::Ready(Ok(n)) => {
245 self.hasher.update(&buf[..n]);
246 Poll::Ready(Ok(n))
247 }
248 other => other,
249 }
250 }
251
252 fn poll_flush(
253 mut self: Pin<&mut Self>,
254 cx: &mut std::task::Context<'_>,
255 ) -> Poll<Result<(), std::io::Error>> {
256 Pin::new(&mut self.writer).poll_flush(cx)
257 }
258
259 fn poll_close(
260 mut self: Pin<&mut Self>,
261 cx: &mut std::task::Context<'_>,
262 ) -> Poll<std::result::Result<(), std::io::Error>> {
263 Pin::new(&mut self.writer).poll_close(cx)
264 }
265}