extension_host: Add capability for downloading files (#35141)

Marshall Bowers created

This PR adds a new capability for downloading files in extensions.

Currently all file downloads are allowed.

Release Notes:

- N/A

Change summary

Cargo.lock                                              |   2 
crates/extension/Cargo.toml                             |   4 
crates/extension/src/extension_manifest.rs              | 127 ++++++++++
crates/extension_host/src/capability_granter.rs         |  20 +
crates/extension_host/src/wasm_host.rs                  |  20 +
crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs |   4 
6 files changed, 165 insertions(+), 12 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5385,11 +5385,13 @@ dependencies = [
  "log",
  "lsp",
  "parking_lot",
+ "pretty_assertions",
  "semantic_version",
  "serde",
  "serde_json",
  "task",
  "toml 0.8.20",
+ "url",
  "util",
  "wasm-encoder 0.221.3",
  "wasmparser 0.221.3",

crates/extension/Cargo.toml 🔗

@@ -32,7 +32,11 @@ serde.workspace = true
 serde_json.workspace = true
 task.workspace = true
 toml.workspace = true
+url.workspace = true
 util.workspace = true
 wasm-encoder.workspace = true
 wasmparser.workspace = true
 workspace-hack.workspace = true
+
+[dev-dependencies]
+pretty_assertions.workspace = true

crates/extension/src/extension_manifest.rs 🔗

@@ -11,6 +11,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
+use url::Url;
 
 /// This is the old version of the extension manifest, from when it was `extension.json`.
 #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
@@ -103,6 +104,7 @@ impl ExtensionManifest {
             ExtensionCapability::ProcessExec(capability) => {
                 capability.allows(desired_command, desired_args)
             }
+            _ => false,
         });
 
         if !is_allowed {
@@ -133,10 +135,11 @@ pub fn build_debug_adapter_schema_path(
 
 /// A capability for an extension.
 #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
-#[serde(tag = "kind")]
+#[serde(tag = "kind", rename_all = "snake_case")]
 pub enum ExtensionCapability {
     #[serde(rename = "process:exec")]
     ProcessExec(ProcessExecCapability),
+    DownloadFile(DownloadFileCapability),
 }
 
 #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
@@ -182,6 +185,51 @@ impl ProcessExecCapability {
     }
 }
 
+#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct DownloadFileCapability {
+    pub host: String,
+    pub path: Vec<String>,
+}
+
+impl DownloadFileCapability {
+    /// Returns whether the capability allows downloading a file from the given URL.
+    pub fn allows(&self, url: &Url) -> bool {
+        let Some(desired_host) = url.host_str() else {
+            return false;
+        };
+
+        let Some(desired_path) = url.path_segments() else {
+            return false;
+        };
+        let desired_path = desired_path.collect::<Vec<_>>();
+
+        if self.host != desired_host && self.host != "*" {
+            return false;
+        }
+
+        for (ix, path_segment) in self.path.iter().enumerate() {
+            if path_segment == "**" {
+                return true;
+            }
+
+            if ix >= desired_path.len() {
+                return false;
+            }
+
+            if path_segment != "*" && path_segment != desired_path[ix] {
+                return false;
+            }
+        }
+
+        if self.path.len() < desired_path.len() {
+            return false;
+        }
+
+        true
+    }
+}
+
 #[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
 pub struct LibManifestEntry {
     pub kind: Option<ExtensionLibraryKind>,
@@ -329,6 +377,8 @@ fn manifest_from_old_manifest(
 
 #[cfg(test)]
 mod tests {
+    use pretty_assertions::assert_eq;
+
     use super::*;
 
     fn extension_manifest() -> ExtensionManifest {
@@ -380,7 +430,7 @@ mod tests {
     }
 
     #[test]
-    fn test_allow_exact_match() {
+    fn test_allow_exec_exact_match() {
         let manifest = ExtensionManifest {
             capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
                 command: "ls".to_string(),
@@ -395,7 +445,7 @@ mod tests {
     }
 
     #[test]
-    fn test_allow_wildcard_arg() {
+    fn test_allow_exec_wildcard_arg() {
         let manifest = ExtensionManifest {
             capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
                 command: "git".to_string(),
@@ -411,7 +461,7 @@ mod tests {
     }
 
     #[test]
-    fn test_allow_double_wildcard() {
+    fn test_allow_exec_double_wildcard() {
         let manifest = ExtensionManifest {
             capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
                 command: "cargo".to_string(),
@@ -431,7 +481,7 @@ mod tests {
     }
 
     #[test]
-    fn test_allow_mixed_wildcards() {
+    fn test_allow_exec_mixed_wildcards() {
         let manifest = ExtensionManifest {
             capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
                 command: "docker".to_string(),
@@ -454,4 +504,71 @@ mod tests {
         );
         assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
     }
+
+    #[test]
+    fn test_download_file_capability_allows() {
+        let capability = DownloadFileCapability {
+            host: "*".to_string(),
+            path: vec!["**".to_string()],
+        };
+        assert_eq!(
+            capability.allows(&"https://example.com/some/path".parse().unwrap()),
+            true
+        );
+
+        let capability = DownloadFileCapability {
+            host: "github.com".to_string(),
+            path: vec!["**".to_string()],
+        };
+        assert_eq!(
+            capability.allows(&"https://github.com/some-owner/some-repo".parse().unwrap()),
+            true
+        );
+        assert_eq!(
+            capability.allows(
+                &"https://fake-github.com/some-owner/some-repo"
+                    .parse()
+                    .unwrap()
+            ),
+            false
+        );
+
+        let capability = DownloadFileCapability {
+            host: "github.com".to_string(),
+            path: vec!["specific-owner".to_string(), "*".to_string()],
+        };
+        assert_eq!(
+            capability.allows(&"https://github.com/some-owner/some-repo".parse().unwrap()),
+            false
+        );
+        assert_eq!(
+            capability.allows(
+                &"https://github.com/specific-owner/some-repo"
+                    .parse()
+                    .unwrap()
+            ),
+            true
+        );
+
+        let capability = DownloadFileCapability {
+            host: "github.com".to_string(),
+            path: vec!["specific-owner".to_string(), "*".to_string()],
+        };
+        assert_eq!(
+            capability.allows(
+                &"https://github.com/some-owner/some-repo/extra"
+                    .parse()
+                    .unwrap()
+            ),
+            false
+        );
+        assert_eq!(
+            capability.allows(
+                &"https://github.com/specific-owner/some-repo/extra"
+                    .parse()
+                    .unwrap()
+            ),
+            false
+        );
+    }
 }

crates/extension_host/src/capability_granter.rs 🔗

@@ -2,6 +2,7 @@ use std::sync::Arc;
 
 use anyhow::{Result, bail};
 use extension::{ExtensionCapability, ExtensionManifest};
+use url::Url;
 
 pub struct CapabilityGranter {
     granted_capabilities: Vec<ExtensionCapability>,
@@ -33,6 +34,7 @@ impl CapabilityGranter {
                 ExtensionCapability::ProcessExec(capability) => {
                     capability.allows(desired_command, desired_args)
                 }
+                _ => false,
             });
 
         if !is_allowed {
@@ -43,6 +45,24 @@ impl CapabilityGranter {
 
         Ok(())
     }
+
+    pub fn grant_download_file(&self, desired_url: &Url) -> Result<()> {
+        let is_allowed = self
+            .granted_capabilities
+            .iter()
+            .any(|capability| match capability {
+                ExtensionCapability::DownloadFile(capability) => capability.allows(desired_url),
+                _ => false,
+            });
+
+        if !is_allowed {
+            bail!(
+                "capability for download_file {desired_url} is not granted by the extension host",
+            );
+        }
+
+        Ok(())
+    }
 }
 
 #[cfg(test)]

crates/extension_host/src/wasm_host.rs 🔗

@@ -7,9 +7,9 @@ use async_trait::async_trait;
 use dap::{DebugRequest, StartDebuggingRequestArgumentsRequest};
 use extension::{
     CodeLabel, Command, Completion, ContextServerConfiguration, DebugAdapterBinary,
-    DebugTaskDefinition, ExtensionCapability, ExtensionHostProxy, KeyValueStoreDelegate,
-    ProcessExecCapability, ProjectDelegate, SlashCommand, SlashCommandArgumentCompletion,
-    SlashCommandOutput, Symbol, WorktreeDelegate,
+    DebugTaskDefinition, DownloadFileCapability, ExtensionCapability, ExtensionHostProxy,
+    KeyValueStoreDelegate, ProcessExecCapability, ProjectDelegate, SlashCommand,
+    SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, WorktreeDelegate,
 };
 use fs::{Fs, normalize_path};
 use futures::future::LocalBoxFuture;
@@ -576,10 +576,16 @@ impl WasmHost {
             node_runtime,
             proxy,
             release_channel: ReleaseChannel::global(cx),
-            granted_capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
-                command: "*".to_string(),
-                args: vec!["**".to_string()],
-            })],
+            granted_capabilities: vec![
+                ExtensionCapability::ProcessExec(ProcessExecCapability {
+                    command: "*".to_string(),
+                    args: vec!["**".to_string()],
+                }),
+                ExtensionCapability::DownloadFile(DownloadFileCapability {
+                    host: "*".to_string(),
+                    path: vec!["**".to_string()],
+                }),
+            ],
             _main_thread_message_task: task,
             main_thread_message_tx: tx,
         })

crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs 🔗

@@ -30,6 +30,7 @@ use std::{
     sync::{Arc, OnceLock},
 };
 use task::{SpawnInTerminal, ZedDebugConfig};
+use url::Url;
 use util::{archive::extract_zip, fs::make_file_executable, maybe};
 use wasmtime::component::{Linker, Resource};
 
@@ -1011,6 +1012,9 @@ impl ExtensionImports for WasmState {
         file_type: DownloadedFileType,
     ) -> wasmtime::Result<Result<(), String>> {
         maybe!(async {
+            let parsed_url = Url::parse(&url)?;
+            self.capability_granter.grant_download_file(&parsed_url)?;
+
             let path = PathBuf::from(path);
             let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref());