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