Skip to main content

forge/cmd/lsp/
editor.rs

1//! Launch the bundled VS Code client without requiring a Foundry checkout.
2
3use 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    // Keep launcher paths distinct for dispatchers such as Snap, even when their targets match.
68    // Include the target too so repointing a launcher symlink cannot reuse another editor's state.
69    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    // Portable VS Code appends a Unix socket name to user-data even when XDG_RUNTIME_DIR is set.
74    // A short link keeps sockets below platform limits without storing editor state in /tmp.
75    #[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        // Portable installations otherwise override --user-data-dir during autodetection.
112        .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    // Include the data location so separate homes cannot share a running editor. The prefix also
147    // avoids colliding with older launchers' temporary profile directories.
148    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    // Atomic creation and no-follow metadata reject directories planted by another user.
172    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    // Publish a complete directory atomically; concurrent launches may finish extraction first.
222    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        // The application includes a CLI even when `code` has not been added to PATH.
239        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}