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