diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index f87b5799c..f8f05b1ff 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -100,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec { /// Java versions `vendor` publishes; cancellable with `cancel_request(request)`. pub async fn jdk_download_releases( host: &Host, - window: &str, + _window: &str, vendor: String, request: RequestId, ) -> IdeResult { - let tools = tools(host, window)?; + let tools = Tools::new(&host.data_dir); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); host.end_request(request); @@ -123,14 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult, request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = tools(host, window)?; + let tools = Tools::new(&host.data_dir); let location = location.filter(|p| !p.as_os_str().is_empty()); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 2f883b7b9..96c7c9740 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -175,6 +175,30 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { matches!( cmd, "app_info" + | "app_build_info" + | "app_log_location" + | "app_check_update" + | "app_startup_update_check" + | "app_install_update" + | "caches_schedule_invalidation" + | "clear_caches" + | "clipboard_file_list" + | "clipboard_has_files" + | "clipboard_set_file_list" + | "jdk_download_vendors" + | "jdk_download_releases" + | "jdk_download_location" + | "jdk_download" + | "run_system_env" + | "run_gradle_jvm_args_script" + | "proxy_check_connection" + | "docker_settings" + | "docker_save_settings" + | "docker_set_registry_password" + | "docker_run_env" + | "db_console_path" + | "db_export_file" + | "vcs_write_patch" | "app_metrics" | "app_quit" | "app_cancel_quit" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index bf6a0a057..4a1d0250a 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -20,6 +20,94 @@ fn host(base: &Path, environment: bool) -> Arc { })) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn application_commands_work_in_restricted_projects_and_without_a_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let host = host(&tmp.path().join("app"), false); + assert!(host.project("welcome").is_err()); + assert!(ide_host::ops::project::roots(&host, "welcome").is_err()); + assert!(!host.open_project("main", &root).unwrap().is_trusted()); + for command in [ + "app_info", + "app_build_info", + "app_log_location", + "app_check_update", + "app_startup_update_check", + "app_install_update", + "caches_schedule_invalidation", + "clear_caches", + "clipboard_file_list", + "clipboard_has_files", + "clipboard_set_file_list", + "jdk_download_vendors", + "jdk_download_releases", + "jdk_download_location", + "jdk_download", + "run_system_env", + "run_gradle_jvm_args_script", + "proxy_check_connection", + "docker_settings", + "docker_save_settings", + "docker_set_registry_password", + "docker_run_env", + "db_console_path", + "db_export_file", + "vcs_write_patch", + ] { + assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); + for window in ["main", "welcome"] { + assert!(host.authorize_command(window, command).is_ok(), "{window}: {command}"); + } + } + for command in [ + "run_start", + "build_sync", + "debug_start", + "sdk_detect", + "tools_list", + "tools_install", + "tools_uninstall", + "plugin_install_dir", + "plugin_reload", + "vcs_git_version", + "docker_compose_op", + "db_execute", + "unknown_command", + ] { + assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); + for window in ["main", "welcome"] { + assert!(host.authorize_command(window, command).is_err(), "{window}: {command}"); + } + } + // These requests validate the vendor before any network access, independently of project trust. + for window in ["main", "welcome"] { + let error = ide_host::ops::tools::jdk_download_releases( + &host, + window, + "unknown-vendor".into(), + ide_core::RequestId(901), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + let error = ide_host::ops::tools::jdk_download( + &host, + window, + "unknown-vendor".into(), + 25, + None, + ide_core::RequestId(902), + ide_host::ops::Channel::new(|_| {}), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + } + host.shutdown().await; +} + #[test] fn persists_user_decisions_locations_revocation_and_one_time_migration() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/features/app/__tests__/host.test.ts b/src/features/app/__tests__/host.test.ts new file mode 100644 index 000000000..27f7d38ee --- /dev/null +++ b/src/features/app/__tests__/host.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { BuildInfo } from "@/platform/ipc"; + +const { appBuildInfo, isTauri } = vi.hoisted(() => ({ appBuildInfo: vi.fn(), isTauri: vi.fn() })); +vi.mock("@/platform/ipc", () => ({ commands: { appBuildInfo }, isTauri })); + +const build: BuildInfo = { + version: "0.1.32", + build: "dev", + profile: "debug", + os: "windows", + arch: "x86_64", + tauri: "2.11.3", + updates: false, +}; + +beforeEach(() => { + vi.resetModules(); + appBuildInfo.mockReset(); + isTauri.mockReturnValue(true); +}); + +it("retries after a rejected request and caches the successful build info", async () => { + appBuildInfo.mockRejectedValueOnce(new Error("Project is not trusted")).mockResolvedValueOnce(build); + const { buildInfo, hostOs } = await import("../host"); + + const first = buildInfo(); + expect(buildInfo()).toBe(first); + await expect(first).resolves.toBeNull(); + await expect(hostOs()).resolves.toBe("windows"); + await expect(buildInfo()).resolves.toBe(build); + expect(appBuildInfo).toHaveBeenCalledTimes(2); +}); + +it("shares an in-flight successful request", async () => { + let resolve!: (value: BuildInfo) => void; + appBuildInfo.mockReturnValue(new Promise((done) => (resolve = done))); + const { buildInfo } = await import("../host"); + const first = buildInfo(); + expect(buildInfo()).toBe(first); + expect(appBuildInfo).toHaveBeenCalledTimes(1); + resolve(build); + await expect(first).resolves.toBe(build); +}); + +it("does not invoke the backend outside Tauri", async () => { + isTauri.mockReturnValue(false); + const { buildInfo } = await import("../host"); + await expect(buildInfo()).resolves.toBeNull(); + expect(appBuildInfo).not.toHaveBeenCalled(); +}); diff --git a/src/features/app/host.ts b/src/features/app/host.ts index 649365e98..4e1fdacdb 100644 --- a/src/features/app/host.ts +++ b/src/features/app/host.ts @@ -13,7 +13,12 @@ function normalize(os: string): HostOs { let info: Promise | null = null; export function buildInfo(): Promise { - info ??= isTauri() ? commands.appBuildInfo().catch(() => null) : Promise.resolve(null); + info ??= isTauri() + ? commands.appBuildInfo().catch(() => { + info = null; + return null; + }) + : Promise.resolve(null); return info; }