1use alloy_primitives::{Keccak256, keccak256};
4use eyre::{Context, Result, ensure, eyre};
5use flate2::read::GzDecoder;
6use foundry_config::Config;
7use std::{
8 fs,
9 io::{self, Write},
10 path::{Path, PathBuf},
11 process::{Command, Stdio},
12};
13
14#[cfg(unix)]
15use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt, symlink};
16
17const CLIENT: &[u8] = include_bytes!("../../../../../editors/vscode/dist/extension.js.gz");
18const ASSETS: &[(&str, &[u8])] = &[
19 ("package.json", include_bytes!("../../../../../editors/vscode/package.json")),
20 (
21 "language-configuration.json",
22 include_bytes!("../../../../../editors/vscode/language-configuration.json"),
23 ),
24 (
25 "syntaxes/solidity.json",
26 include_bytes!("../../../../../editors/vscode/syntaxes/solidity.json"),
27 ),
28 (
29 "syntaxes/solidity-markdown-injection.json",
30 include_bytes!("../../../../../editors/vscode/syntaxes/solidity-markdown-injection.json"),
31 ),
32 ("syntaxes/LICENSE", include_bytes!("../../../../../editors/vscode/syntaxes/LICENSE")),
33 ("LICENSE", include_bytes!("../../../../../editors/vscode/LICENSE")),
34 ("LICENSE-MIT", include_bytes!("../../../../../editors/vscode/LICENSE-MIT")),
35 ("LICENSE-APACHE", include_bytes!("../../../../../editors/vscode/LICENSE-APACHE")),
36 ("NOTICE.md", include_bytes!("../../../../../editors/vscode/NOTICE.md")),
37 (
38 "THIRD_PARTY_NOTICES.txt",
39 include_bytes!("../../../../../editors/vscode/dist/THIRD_PARTY_NOTICES.txt"),
40 ),
41];
42
43pub(super) fn launch(path: Option<&Path>, code_path: Option<&Path>) -> Result<()> {
44 let project =
45 dunce::canonicalize(path.map(Path::to_path_buf).unwrap_or(std::env::current_dir()?))
46 .wrap_err("Could not open the project directory")?;
47 ensure!(project.is_dir(), "Project path must be a directory: {}", project.display());
48 let forge = std::env::current_exe()?;
49 let profile = Config::selected_profile().to_string();
50 let code = code_path.map(Path::to_path_buf).unwrap_or_else(default_code_path);
51 let launch_error = || {
52 format!(
53 "Could not launch VS Code using {}. Install VS Code and its `code` command, \
54 or pass --code-path <PATH> to the VS Code CLI. \
55 Use `forge lsp --stdio` for another editor.",
56 code.display()
57 )
58 };
59 let code = which::which(&code).wrap_err_with(launch_error)?;
60 let code_target = dunce::canonicalize(&code).wrap_err_with(launch_error)?;
61 let cache = Config::foundry_cache_dir()
62 .ok_or_else(|| eyre!("Could not find the Foundry cache directory"))?
63 .join("lsp");
64 let extension = prepare_extension(&cache)?;
65
66 let session_key =
69 keccak256(serde_json::to_vec(&(&project, &forge, &profile, &code, &code_target))?);
70 let session =
71 vscode_session_dir(&Config::data_dir()?.join("lsp"), &format!("{session_key:x}")[..16])?;
72 #[cfg(unix)]
75 let session = vscode_session_link(&session, Path::new("/tmp"))?;
76 let user_data = session.join("user-data");
77 let extensions = session.join("extensions");
78 fs::create_dir_all(user_data.join("User"))?;
79 fs::create_dir_all(&extensions)?;
80 let settings = user_data.join("User/settings.json");
81 if !settings.exists() {
82 let mut file = tempfile::NamedTempFile::new_in(user_data.join("User"))?;
83 serde_json::to_writer_pretty(
84 &mut file,
85 &serde_json::json!({
86 "solarLsp.forgePath": forge,
87 "workbench.startupEditor": "none",
88 }),
89 )?;
90 file.write_all(b"\n")?;
91 if let Err(error) = file.persist_noclobber(&settings)
92 && error.error.kind() != io::ErrorKind::AlreadyExists
93 {
94 return Err(error.into());
95 }
96 }
97
98 sh_status!("Opening VS Code with Forge Solidity support: {}", project.display())?;
99 let status = Command::new(&code)
100 .arg("--new-window")
101 .arg("--extensionDevelopmentPath")
102 .arg(&extension)
103 .arg("--user-data-dir")
104 .arg(&user_data)
105 .arg("--extensions-dir")
106 .arg(&extensions)
107 .arg(&project)
108 .env_remove("VSCODE_APPDATA")
109 .env_remove("VSCODE_EXTENSIONS")
110 .env("VSCODE_PORTABLE", &session)
112 .env_remove("VSCODE_IPC_HOOK_CLI")
113 .env("FOUNDRY_LSP_FORGE", &forge)
114 .env("FOUNDRY_PROFILE", &profile)
115 .stdin(Stdio::null())
116 .stdout(Stdio::null())
117 .stderr(Stdio::inherit())
118 .status()
119 .wrap_err_with(launch_error)?;
120 ensure!(status.success(), "VS Code launcher exited with {status}");
121 Ok(())
122}
123
124fn vscode_session_dir(data: &Path, key: &str) -> Result<PathBuf> {
125 let root = data.join("vscode");
126 let session = root.join(key);
127 #[cfg(unix)]
128 {
129 fs::create_dir_all(data)?;
130 let uid = rustix::process::geteuid().as_raw();
131 create_private_dir(&root, uid)?;
132 create_private_dir(&session, uid)?;
133 }
134 #[cfg(not(unix))]
135 fs::create_dir_all(&session)?;
136 Ok(session)
137}
138
139#[cfg(unix)]
140fn vscode_session_link(session: &Path, temp: &Path) -> Result<PathBuf> {
141 let session = dunce::canonicalize(session)?;
142 let uid = rustix::process::geteuid().as_raw();
143 let root = temp.join(format!("foundry-lsp-{uid}"));
144 create_private_dir(&root, uid)?;
145 let key = keccak256(session.as_os_str().as_encoded_bytes());
148 let link = root.join(format!("p-{}", &format!("{key:x}")[..16]));
149 if let Err(error) = symlink(&session, &link)
150 && error.kind() != io::ErrorKind::AlreadyExists
151 {
152 return Err(error.into());
153 }
154 let metadata = fs::symlink_metadata(&link)?;
155 ensure!(
156 metadata.file_type().is_symlink() && metadata.uid() == uid,
157 "VS Code session link is not a symlink owned by the current user: {}",
158 link.display()
159 );
160 ensure!(
161 fs::read_link(&link)? == session,
162 "VS Code session link points to an unexpected directory: {}",
163 link.display()
164 );
165 Ok(link)
166}
167
168#[cfg(unix)]
169fn create_private_dir(path: &Path, uid: u32) -> Result<()> {
170 if let Err(error) = fs::DirBuilder::new().mode(0o700).create(path)
172 && error.kind() != io::ErrorKind::AlreadyExists
173 {
174 return Err(error.into());
175 }
176 let metadata = fs::symlink_metadata(path)?;
177 ensure!(
178 metadata.file_type().is_dir(),
179 "VS Code session path is not a directory: {}",
180 path.display()
181 );
182 ensure!(
183 metadata.uid() == uid,
184 "VS Code session path is not owned by the current user: {}",
185 path.display()
186 );
187 ensure!(
188 metadata.permissions().mode() & 0o777 == 0o700,
189 "VS Code session path is not private: {}",
190 path.display()
191 );
192 Ok(())
193}
194
195fn prepare_extension(cache: &Path) -> Result<PathBuf> {
196 let mut hash = Keccak256::new();
197 hash.update(CLIENT);
198 for (name, bytes) in ASSETS {
199 hash.update(name.as_bytes());
200 hash.update(bytes);
201 }
202 let parent = cache.join("extensions");
203 let directory = parent.join(format!("{:x}", hash.finalize()));
204 if directory.is_dir() {
205 return Ok(directory);
206 }
207
208 fs::create_dir_all(&parent)?;
209 let staging = tempfile::Builder::new().prefix(".extract-").tempdir_in(&parent)?;
210 fs::create_dir_all(staging.path().join("out"))?;
211 io::copy(
212 &mut GzDecoder::new(CLIENT),
213 &mut fs::File::create(staging.path().join("out/extension.js"))?,
214 )?;
215 for (name, bytes) in ASSETS {
216 let path = staging.path().join(name);
217 fs::create_dir_all(path.parent().unwrap())?;
218 fs::write(path, bytes)?;
219 }
220 if let Err(error) = fs::rename(staging.path(), &directory)
222 && !directory.is_dir()
223 {
224 return Err(error).wrap_err("Could not cache the bundled VS Code extension");
225 }
226 Ok(directory)
227}
228
229fn default_code_path() -> PathBuf {
230 #[cfg(target_os = "macos")]
231 {
232 if std::env::var_os("PATH").is_some_and(|paths| {
233 std::env::split_paths(&paths).any(|path| path.join("code").is_file())
234 }) {
235 return PathBuf::from("code");
236 }
237 let cli =
239 PathBuf::from("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code");
240 if cli.is_file() {
241 return cli;
242 }
243 }
244 PathBuf::from(if cfg!(windows) { "code.cmd" } else { "code" })
245}
246
247#[cfg(test)]
248mod tests {
249 use std::fs;
250
251 #[cfg(unix)]
252 use std::os::unix::{
253 fs::{PermissionsExt, symlink},
254 net::UnixListener,
255 };
256
257 #[test]
258 fn vscode_session_uses_durable_storage() {
259 let data = tempfile::tempdir().unwrap();
260 let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap();
261 assert_eq!(session, data.path().join("vscode/0123456789abcdef"));
262 assert!(session.is_dir());
263 }
264
265 #[cfg(unix)]
266 #[test]
267 fn vscode_session_rejects_symlink_root() {
268 let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap();
269 let data = tempfile::tempdir().unwrap();
270 let redirected = tempfile::tempdir().unwrap();
271 let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap();
272 let link = super::vscode_session_link(&session, temp.path()).unwrap();
273 let root = link.parent().unwrap();
274 fs::remove_dir_all(root).unwrap();
275 symlink(redirected.path(), root).unwrap();
276
277 assert!(super::vscode_session_link(&session, temp.path()).is_err());
278 assert_eq!(fs::read_dir(redirected.path()).unwrap().count(), 0);
279 }
280
281 #[cfg(unix)]
282 #[test]
283 fn vscode_session_rejects_symlink_session() {
284 let data = tempfile::tempdir().unwrap();
285 let redirected = tempfile::tempdir().unwrap();
286 let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap();
287 fs::remove_dir(&session).unwrap();
288 symlink(redirected.path(), &session).unwrap();
289
290 assert!(super::vscode_session_dir(data.path(), "0123456789abcdef").is_err());
291 assert_eq!(fs::read_dir(redirected.path()).unwrap().count(), 0);
292 }
293
294 #[cfg(unix)]
295 #[test]
296 fn vscode_session_rejects_unexpected_link() {
297 let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap();
298 let data = tempfile::tempdir().unwrap();
299 let redirected = tempfile::tempdir().unwrap();
300 let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap();
301 let link = super::vscode_session_link(&session, temp.path()).unwrap();
302 fs::remove_file(&link).unwrap();
303 symlink(redirected.path(), &link).unwrap();
304 assert!(super::vscode_session_link(&session, temp.path()).is_err());
305 assert_eq!(fs::read_dir(redirected.path()).unwrap().count(), 0);
306
307 fs::remove_file(&link).unwrap();
308 fs::create_dir(&link).unwrap();
309 assert!(super::vscode_session_link(&session, temp.path()).is_err());
310 assert!(link.is_dir());
311 }
312
313 #[cfg(unix)]
314 #[test]
315 fn vscode_session_rejects_shared_permissions() {
316 let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap();
317 let data = tempfile::tempdir().unwrap();
318 let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap();
319 for directory in [&session, session.parent().unwrap()] {
320 fs::set_permissions(directory, fs::Permissions::from_mode(0o755)).unwrap();
321 assert!(super::vscode_session_dir(data.path(), "0123456789abcdef").is_err());
322 fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap();
323 }
324 let link = super::vscode_session_link(&session, temp.path()).unwrap();
325 fs::set_permissions(link.parent().unwrap(), fs::Permissions::from_mode(0o755)).unwrap();
326 assert!(super::vscode_session_link(&session, temp.path()).is_err());
327 }
328
329 #[cfg(unix)]
330 #[test]
331 fn vscode_session_rejects_another_owner() {
332 let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap();
333 let uid = rustix::process::geteuid().as_raw();
334 let error = super::create_private_dir(temp.path(), uid.wrapping_add(1)).unwrap_err();
335 assert!(
336 error.to_string().starts_with("VS Code session path is not owned by the current user:")
337 );
338 }
339
340 #[cfg(unix)]
341 #[test]
342 fn vscode_session_allows_concurrent_launches() {
343 let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap();
344 let data = tempfile::tempdir().unwrap();
345 std::thread::scope(|scope| {
346 let threads = (0..8)
347 .map(|_| {
348 scope.spawn(|| {
349 let session =
350 super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap();
351 super::vscode_session_link(&session, temp.path()).unwrap()
352 })
353 })
354 .collect::<Vec<_>>();
355 let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap();
356 let expected = super::vscode_session_link(&session, temp.path()).unwrap();
357 for thread in threads {
358 assert_eq!(thread.join().unwrap(), expected);
359 }
360 });
361 }
362
363 #[cfg(unix)]
364 #[test]
365 fn vscode_session_path_fits_unix_socket_limit() {
366 let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap();
367 let data = tempfile::tempdir().unwrap();
368 let session = super::vscode_session_dir(
369 &data.path().join("long-home-directory".repeat(8)),
370 "0123456789abcdef",
371 )
372 .unwrap();
373 fs::create_dir(session.join("user-data")).unwrap();
374 assert!(session.as_os_str().len() > 103);
375 let link = super::vscode_session_link(&session, temp.path()).unwrap();
376 let socket = link.join("user-data/1.13-main.sock");
377 let length = socket.as_os_str().len();
378 assert!(length < 103, "{length} bytes");
379 let _listener = UnixListener::bind(socket).unwrap();
380 }
381
382 #[cfg(unix)]
383 #[test]
384 fn vscode_session_uses_private_stable_root() {
385 let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap();
386 let data = tempfile::tempdir().unwrap();
387 let session = super::vscode_session_dir(data.path(), "fedcba9876543210").unwrap();
388 let link = super::vscode_session_link(&session, temp.path()).unwrap();
389 assert_eq!(super::vscode_session_dir(data.path(), "fedcba9876543210").unwrap(), session);
390 assert_eq!(super::vscode_session_link(&session, temp.path()).unwrap(), link);
391 for directory in [&session, session.parent().unwrap(), link.parent().unwrap()] {
392 assert_eq!(fs::metadata(directory).unwrap().permissions().mode() & 0o777, 0o700);
393 }
394 }
395
396 #[cfg(unix)]
397 #[test]
398 fn vscode_session_survives_temp_cleanup() {
399 let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap();
400 let data = tempfile::tempdir().unwrap();
401 let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap();
402 let link = super::vscode_session_link(&session, temp.path()).unwrap();
403 let files =
404 ["user-data/User/settings.json", "user-data/User/History/entry", "extensions/entry"];
405 for file in files {
406 let path = link.join(file);
407 fs::create_dir_all(path.parent().unwrap()).unwrap();
408 fs::write(path, file).unwrap();
409 }
410 fs::remove_dir_all(temp.path()).unwrap();
411 for file in files {
412 assert_eq!(fs::read_to_string(session.join(file)).unwrap(), file);
413 }
414 fs::create_dir(temp.path()).unwrap();
415 assert_eq!(super::vscode_session_link(&session, temp.path()).unwrap(), link);
416 for file in files {
417 assert_eq!(fs::read_to_string(link.join(file)).unwrap(), file);
418 }
419 }
420}