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}