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