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}