ext_agent_tests.rs

  1use anyhow::Result;
  2use collections::HashMap;
  3use gpui::{AsyncApp, SharedString, Task};
  4use project::agent_server_store::*;
  5use std::{any::Any, collections::HashSet, fmt::Write as _, path::PathBuf};
  6// A simple fake that implements ExternalAgentServer without needing async plumbing.
  7struct NoopExternalAgent;
  8
  9impl ExternalAgentServer for NoopExternalAgent {
 10    fn get_command(
 11        &mut self,
 12        _extra_env: HashMap<String, String>,
 13        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
 14        _cx: &mut AsyncApp,
 15    ) -> Task<Result<AgentServerCommand>> {
 16        Task::ready(Ok(AgentServerCommand {
 17            path: PathBuf::from("noop"),
 18            args: Vec::new(),
 19            env: None,
 20        }))
 21    }
 22
 23    fn as_any(&self) -> &dyn Any {
 24        self
 25    }
 26
 27    fn as_any_mut(&mut self) -> &mut dyn Any {
 28        self
 29    }
 30}
 31
 32#[test]
 33fn external_agent_server_name_display() {
 34    let name = AgentId(SharedString::from("Ext: Tool"));
 35    let mut s = String::new();
 36    write!(&mut s, "{name}").unwrap();
 37    assert_eq!(s, "Ext: Tool");
 38}
 39
 40#[test]
 41fn sync_extension_agents_removes_previous_extension_entries() {
 42    let mut store = AgentServerStore::collab();
 43
 44    // Seed with a couple of agents that will be replaced by extensions
 45    store.external_agents.insert(
 46        AgentId(SharedString::from("foo-agent")),
 47        ExternalAgentEntry::new(
 48            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 49            ExternalAgentSource::Custom,
 50            None,
 51            None,
 52        ),
 53    );
 54    store.external_agents.insert(
 55        AgentId(SharedString::from("bar-agent")),
 56        ExternalAgentEntry::new(
 57            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 58            ExternalAgentSource::Custom,
 59            None,
 60            None,
 61        ),
 62    );
 63    store.external_agents.insert(
 64        AgentId(SharedString::from("custom")),
 65        ExternalAgentEntry::new(
 66            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 67            ExternalAgentSource::Custom,
 68            None,
 69            None,
 70        ),
 71    );
 72
 73    // Simulate the removal phase: if we're syncing extensions that provide
 74    // "foo-agent" and "bar-agent", those should be removed first
 75    let extension_agent_names: HashSet<String> = ["foo-agent".to_string(), "bar-agent".to_string()]
 76        .into_iter()
 77        .collect();
 78
 79    let keys_to_remove: Vec<_> = store
 80        .external_agents
 81        .keys()
 82        .filter(|name| extension_agent_names.contains(name.0.as_ref()))
 83        .cloned()
 84        .collect();
 85
 86    for key in keys_to_remove {
 87        store.external_agents.remove(&key);
 88    }
 89
 90    // Only the custom entry should remain.
 91    let remaining: Vec<_> = store
 92        .external_agents
 93        .keys()
 94        .map(|k| k.0.to_string())
 95        .collect();
 96    assert_eq!(remaining, vec!["custom".to_string()]);
 97}
 98
 99#[test]
100fn resolve_extension_icon_path_allows_valid_paths() {
101    // Create a temporary directory structure for testing
102    let temp_dir = tempfile::tempdir().unwrap();
103    let extensions_dir = temp_dir.path();
104    let ext_dir = extensions_dir.join("my-extension");
105    std::fs::create_dir_all(&ext_dir).unwrap();
106
107    // Create a valid icon file
108    let icon_path = ext_dir.join("icon.svg");
109    std::fs::write(&icon_path, "<svg></svg>").unwrap();
110
111    // Test that a valid relative path works
112    let result = project::agent_server_store::resolve_extension_icon_path(
113        extensions_dir,
114        "my-extension",
115        "icon.svg",
116    );
117    assert!(result.is_some());
118    assert!(result.unwrap().ends_with("icon.svg"));
119}
120
121#[test]
122fn resolve_extension_icon_path_allows_nested_paths() {
123    let temp_dir = tempfile::tempdir().unwrap();
124    let extensions_dir = temp_dir.path();
125    let ext_dir = extensions_dir.join("my-extension");
126    let icons_dir = ext_dir.join("assets").join("icons");
127    std::fs::create_dir_all(&icons_dir).unwrap();
128
129    let icon_path = icons_dir.join("logo.svg");
130    std::fs::write(&icon_path, "<svg></svg>").unwrap();
131
132    let result = project::agent_server_store::resolve_extension_icon_path(
133        extensions_dir,
134        "my-extension",
135        "assets/icons/logo.svg",
136    );
137    assert!(result.is_some());
138    assert!(result.unwrap().ends_with("logo.svg"));
139}
140
141#[test]
142fn resolve_extension_icon_path_blocks_path_traversal() {
143    let temp_dir = tempfile::tempdir().unwrap();
144    let extensions_dir = temp_dir.path();
145
146    // Create two extension directories
147    let ext1_dir = extensions_dir.join("extension1");
148    let ext2_dir = extensions_dir.join("extension2");
149    std::fs::create_dir_all(&ext1_dir).unwrap();
150    std::fs::create_dir_all(&ext2_dir).unwrap();
151
152    // Create a file in extension2
153    let secret_file = ext2_dir.join("secret.svg");
154    std::fs::write(&secret_file, "<svg>secret</svg>").unwrap();
155
156    // Try to access extension2's file from extension1 using path traversal
157    let result = project::agent_server_store::resolve_extension_icon_path(
158        extensions_dir,
159        "extension1",
160        "../extension2/secret.svg",
161    );
162    assert!(
163        result.is_none(),
164        "Path traversal to sibling extension should be blocked"
165    );
166}
167
168#[test]
169fn resolve_extension_icon_path_blocks_absolute_escape() {
170    let temp_dir = tempfile::tempdir().unwrap();
171    let extensions_dir = temp_dir.path();
172    let ext_dir = extensions_dir.join("my-extension");
173    std::fs::create_dir_all(&ext_dir).unwrap();
174
175    // Create a file outside the extensions directory
176    let outside_file = temp_dir.path().join("outside.svg");
177    std::fs::write(&outside_file, "<svg>outside</svg>").unwrap();
178
179    // Try to escape to parent directory
180    let result = project::agent_server_store::resolve_extension_icon_path(
181        extensions_dir,
182        "my-extension",
183        "../outside.svg",
184    );
185    assert!(
186        result.is_none(),
187        "Path traversal to parent directory should be blocked"
188    );
189}
190
191#[test]
192fn resolve_extension_icon_path_blocks_deep_traversal() {
193    let temp_dir = tempfile::tempdir().unwrap();
194    let extensions_dir = temp_dir.path();
195    let ext_dir = extensions_dir.join("my-extension");
196    std::fs::create_dir_all(&ext_dir).unwrap();
197
198    // Try deep path traversal
199    let result = project::agent_server_store::resolve_extension_icon_path(
200        extensions_dir,
201        "my-extension",
202        "../../../../../../etc/passwd",
203    );
204    assert!(
205        result.is_none(),
206        "Deep path traversal should be blocked (file doesn't exist)"
207    );
208}
209
210#[test]
211fn resolve_extension_icon_path_returns_none_for_nonexistent() {
212    let temp_dir = tempfile::tempdir().unwrap();
213    let extensions_dir = temp_dir.path();
214    let ext_dir = extensions_dir.join("my-extension");
215    std::fs::create_dir_all(&ext_dir).unwrap();
216
217    // Try to access a file that doesn't exist
218    let result = project::agent_server_store::resolve_extension_icon_path(
219        extensions_dir,
220        "my-extension",
221        "nonexistent.svg",
222    );
223    assert!(result.is_none(), "Nonexistent file should return None");
224}