Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:49:21.870Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

github_download.rs

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