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 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}