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 &self,
12 _extra_args: Vec<String>,
13 _extra_env: HashMap<String, 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}