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