Add .tar.bz2 archive support for ACP agent server downloads (#52188) (cherry-pick to preview) (#52412)

zed-zippy[bot] , Vincenzo Palazzo , and Ben Brandt created

Cherry-pick of #52188 to preview

----
## Summary

- Added `TarBz2` variant to `AssetKind` enum for `.tar.bz2` / `.tbz2`
archives
- Implemented `extract_tar_bz2` using the `bzip2` feature of
`async-compression` (already a workspace dependency, just enabled the
feature flag)
- Wired up both streaming and file-based extraction paths in
`github_download.rs`
- Added `.tar.bz2` / `.tbz2` URL detection in both
`LocalExtensionArchiveAgent` and `LocalRegistryArchiveAgent`

This unblocks ACP registry entries (like Goose) that only ship
`.tar.bz2` archives.

Reference: https://github.com/block/goose/issues/8047

## Test plan

- [ ] Verify `cargo check` and `clippy` pass (confirmed locally)
- [ ] Test downloading an ACP agent that ships a `.tar.bz2` archive
(e.g., Goose)
- [ ] Verify existing `.tar.gz` and `.zip` agent downloads still work

Release Notes:

- Added support for `.tar.bz2` archives in ACP agent server downloads,
unblocking registry entries like Goose that only ship bzip2-compressed
tarballs.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Co-authored-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Change summary

Cargo.lock                                | 18 +++++++++++++++++-
Cargo.toml                                |  2 +-
crates/http_client/src/github.rs          |  2 ++
crates/http_client/src/github_download.rs | 22 +++++++++++++++++++---
crates/languages/src/python.rs            |  8 ++++----
crates/languages/src/rust.rs              |  7 ++++---
crates/project/src/agent_server_store.rs  |  4 ++++
script/licenses/zed-licenses.toml         |  1 +
8 files changed, 52 insertions(+), 12 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2428,6 +2428,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "bzip2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
+dependencies = [
+ "libbz2-rs-sys",
+]
+
 [[package]]
 name = "bzip2-sys"
 version = "0.1.13+1.0.8"
@@ -3467,6 +3476,7 @@ version = "0.4.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
 dependencies = [
+ "bzip2 0.6.1",
  "compression-core",
  "deflate64",
  "flate2",
@@ -9670,6 +9680,12 @@ version = "0.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
 
+[[package]]
+name = "libbz2-rs-sys"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
+
 [[package]]
 name = "libc"
 version = "0.2.182"
@@ -22348,7 +22364,7 @@ checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
 dependencies = [
  "aes",
  "byteorder",
- "bzip2",
+ "bzip2 0.4.4",
  "constant_time_eq",
  "crc32fast",
  "crossbeam-utils",

Cargo.toml 🔗

@@ -491,7 +491,7 @@ ashpd = { version = "0.13", default-features = false, features = [
 ] }
 async-channel = "2.5.0"
 async-compat = "0.2.1"
-async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
+async-compression = { version = "0.4", features = ["bzip2", "gzip", "futures-io"] }
 async-dispatcher = "0.1"
 async-fs = "2.1"
 async-lock = "2.1"

crates/http_client/src/github.rs 🔗

@@ -144,6 +144,7 @@ pub async fn get_release_by_tag_name(
 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
 pub enum AssetKind {
     TarGz,
+    TarBz2,
     Gz,
     Zip,
 }
@@ -158,6 +159,7 @@ pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) -
         "{tag}.{extension}",
         extension = match kind {
             AssetKind::TarGz => "tar.gz",
+            AssetKind::TarBz2 => "tar.bz2",
             AssetKind::Gz => "gz",
             AssetKind::Zip => "zip",
         }

crates/http_client/src/github_download.rs 🔗

@@ -5,7 +5,7 @@ use std::{
 };
 
 use anyhow::{Context, Result};
-use async_compression::futures::bufread::GzipDecoder;
+use async_compression::futures::bufread::{BzDecoder, GzipDecoder};
 use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, io::BufReader};
 use sha2::{Digest, Sha256};
 
@@ -119,7 +119,7 @@ async fn extract_to_staging(
 
 fn staging_path(parent: &Path, asset_kind: AssetKind) -> Result<PathBuf> {
     match asset_kind {
-        AssetKind::TarGz | AssetKind::Zip => {
+        AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Zip => {
             let dir = tempfile::Builder::new()
                 .prefix(".tmp-github-download-")
                 .tempdir_in(parent)
@@ -141,7 +141,7 @@ fn staging_path(parent: &Path, asset_kind: AssetKind) -> Result<PathBuf> {
 
 async fn cleanup_staging_path(staging_path: &Path, asset_kind: AssetKind) {
     match asset_kind {
-        AssetKind::TarGz | AssetKind::Zip => {
+        AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Zip => {
             if let Err(err) = async_fs::remove_dir_all(staging_path).await {
                 log::warn!("failed to remove staging directory {staging_path:?}: {err:?}");
             }
@@ -170,6 +170,7 @@ async fn stream_response_archive(
 ) -> Result<()> {
     match asset_kind {
         AssetKind::TarGz => extract_tar_gz(destination_path, url, response).await?,
+        AssetKind::TarBz2 => extract_tar_bz2(destination_path, url, response).await?,
         AssetKind::Gz => extract_gz(destination_path, url, response).await?,
         AssetKind::Zip => {
             util::archive::extract_zip(destination_path, response).await?;
@@ -186,6 +187,7 @@ async fn stream_file_archive(
 ) -> Result<()> {
     match asset_kind {
         AssetKind::TarGz => extract_tar_gz(destination_path, url, file_archive).await?,
+        AssetKind::TarBz2 => extract_tar_bz2(destination_path, url, file_archive).await?,
         AssetKind::Gz => extract_gz(destination_path, url, file_archive).await?,
         #[cfg(not(windows))]
         AssetKind::Zip => {
@@ -213,6 +215,20 @@ async fn extract_tar_gz(
     Ok(())
 }
 
+async fn extract_tar_bz2(
+    destination_path: &Path,
+    url: &str,
+    from: impl AsyncRead + Unpin,
+) -> Result<(), anyhow::Error> {
+    let decompressed_bytes = BzDecoder::new(BufReader::new(from));
+    let archive = async_tar::Archive::new(decompressed_bytes);
+    archive
+        .unpack(&destination_path)
+        .await
+        .with_context(|| format!("extracting {url} to {destination_path:?}"))?;
+    Ok(())
+}
+
 async fn extract_gz(
     destination_path: &Path,
     url: &str,

crates/languages/src/python.rs 🔗

@@ -437,7 +437,7 @@ impl LspInstaller for TyLspAdapter {
         async_fs::create_dir_all(&destination_path).await?;
 
         let server_path = match Self::GITHUB_ASSET_KIND {
-            AssetKind::TarGz | AssetKind::Gz => destination_path
+            AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path
                 .join(Self::build_asset_name()?.0)
                 .join("ty"),
             AssetKind::Zip => destination_path.clone().join("ty.exe"),
@@ -527,7 +527,7 @@ impl LspInstaller for TyLspAdapter {
 
             let path = last.context("no cached binary")?;
             let path = match TyLspAdapter::GITHUB_ASSET_KIND {
-                AssetKind::TarGz | AssetKind::Gz => {
+                AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => {
                     path.join(Self::build_asset_name()?.0).join("ty")
                 }
                 AssetKind::Zip => path.join("ty.exe"),
@@ -2508,7 +2508,7 @@ impl LspInstaller for RuffLspAdapter {
         } = latest_version;
         let destination_path = container_dir.join(format!("ruff-{name}"));
         let server_path = match Self::GITHUB_ASSET_KIND {
-            AssetKind::TarGz | AssetKind::Gz => destination_path
+            AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path
                 .join(Self::build_asset_name()?.0)
                 .join("ruff"),
             AssetKind::Zip => destination_path.clone().join("ruff.exe"),
@@ -2598,7 +2598,7 @@ impl LspInstaller for RuffLspAdapter {
 
             let path = last.context("no cached binary")?;
             let path = match Self::GITHUB_ASSET_KIND {
-                AssetKind::TarGz | AssetKind::Gz => {
+                AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => {
                     path.join(Self::build_asset_name()?.0).join("ruff")
                 }
                 AssetKind::Zip => path.join("ruff.exe"),

crates/languages/src/rust.rs 🔗

@@ -202,6 +202,7 @@ impl RustLspAdapter {
     async fn build_asset_name() -> String {
         let extension = match Self::GITHUB_ASSET_KIND {
             AssetKind::TarGz => "tar.gz",
+            AssetKind::TarBz2 => "tar.bz2",
             AssetKind::Gz => "gz",
             AssetKind::Zip => "zip",
         };
@@ -706,7 +707,7 @@ impl LspInstaller for RustLspAdapter {
         } = version;
         let destination_path = container_dir.join(format!("rust-analyzer-{name}"));
         let server_path = match Self::GITHUB_ASSET_KIND {
-            AssetKind::TarGz | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place.
+            AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place.
             AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe
         };
 
@@ -1280,8 +1281,8 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
             None => return Ok(None),
         };
         let path = match RustLspAdapter::GITHUB_ASSET_KIND {
-            AssetKind::TarGz | AssetKind::Gz => path, // Tar and gzip extract in place.
-            AssetKind::Zip => path.join("rust-analyzer.exe"), // zip contains a .exe
+            AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => path, // Tar and gzip extract in place.
+            AssetKind::Zip => path.join("rust-analyzer.exe"),             // zip contains a .exe
         };
 
         anyhow::Ok(Some(LanguageServerBinary {

crates/project/src/agent_server_store.rs 🔗

@@ -1102,6 +1102,8 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent {
                     AssetKind::Zip
                 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
                     AssetKind::TarGz
+                } else if archive_url.ends_with(".tar.bz2") || archive_url.ends_with(".tbz2") {
+                    AssetKind::TarBz2
                 } else {
                     anyhow::bail!("unsupported archive type in URL: {}", archive_url);
                 };
@@ -1288,6 +1290,8 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent {
                     AssetKind::Zip
                 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
                     AssetKind::TarGz
+                } else if archive_url.ends_with(".tar.bz2") || archive_url.ends_with(".tbz2") {
+                    AssetKind::TarBz2
                 } else {
                     anyhow::bail!("unsupported archive type in URL: {}", archive_url);
                 };