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