дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(remote): browse remote directories without an open project (#691)
Browse in the Remote Development dialog failed with "no project is open in this window" on the welcome screen, and in untrusted project windows: remote_bootstrap_list was not classified in the restricted-mode allowlist, so IPC authorization required a trusted project before the command reached SSH. The listing itself already used the selected SSH configuration and never depended on a project. remote_bootstrap_list and deploy_test_connection are now allowed alongside the other application-level remote commands; both only connect or list remote paths and run no project code. Every other command used by the dialog and the welcome screen's remote entries was checked and is already classified. Fixes #672
Цей коміт міститься в:
1 джерело
245fe3cdf4
коміт
b3abc8edd1
5 files changed
+182
No files matched your search
@@ -51,6 +51,7 @@ uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
||||
@@ -91,6 +91,7 @@ mod tests {
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
|
||||
@@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "index_status"
|
||||
| "jvm_file_presentations"
|
||||
| "deploy_coverage"
|
||||
| "deploy_test_connection"
|
||||
| "find_files"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
@@ -243,6 +244,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_bootstrap_list"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
|
||||
@@ -56,6 +56,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"db_console_path",
|
||||
"db_export_file",
|
||||
"vcs_write_patch",
|
||||
"remote_bootstrap_list",
|
||||
"deploy_test_connection",
|
||||
] {
|
||||
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
for window in ["main", "welcome"] {
|
||||
@@ -75,6 +77,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"vcs_git_version",
|
||||
"docker_compose_op",
|
||||
"db_execute",
|
||||
"remote_delete",
|
||||
"remote_mkdir",
|
||||
"remote_rename",
|
||||
"remote_import_local",
|
||||
"remote_bootstrap_exec",
|
||||
"deploy_upload",
|
||||
"deploy_download",
|
||||
"deploy_delete_remote",
|
||||
"deploy_sync",
|
||||
"unknown_command",
|
||||
] {
|
||||
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
@@ -109,6 +120,55 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
|
||||
use ide_core::remote_settings::{SshAuth, SshConfiguration};
|
||||
use ide_ssh::{FsConnection, SshFiles, SshManager};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let server_root = tmp.path().join("server");
|
||||
std::fs::create_dir_all(server_root.join("projects/demo")).unwrap();
|
||||
std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap();
|
||||
let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await;
|
||||
let ssh = Arc::new(SshManager::with_files(
|
||||
tmp.path().join("secrets.json"),
|
||||
SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None },
|
||||
));
|
||||
ssh.set_configs(vec![SshConfiguration {
|
||||
id: "selected-host".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(port),
|
||||
user: "dev".into(),
|
||||
auth: SshAuth::Password,
|
||||
..Default::default()
|
||||
}]);
|
||||
ssh.set_secret("selected-host", Some("secret".into()), false);
|
||||
assert!(ssh.session("selected-host").await.is_err());
|
||||
ssh.trust_host("selected-host").await.unwrap();
|
||||
|
||||
let mut host = host(&tmp.path().join("app"), false);
|
||||
let state = Arc::get_mut(&mut host).unwrap();
|
||||
state.ssh = ssh;
|
||||
state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache")));
|
||||
assert!(host.project("welcome").is_err());
|
||||
host.authorize_command("welcome", "remote_bootstrap_list").unwrap();
|
||||
let connection = FsConnection::Sftp { ssh: "selected-host".into() };
|
||||
let entries = host.remote.list(&connection, "/projects").await.unwrap();
|
||||
assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir));
|
||||
assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir));
|
||||
assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty());
|
||||
host.authorize_command("welcome", "deploy_test_connection").unwrap();
|
||||
host.remote.test_connection(&connection, "/projects").await.unwrap();
|
||||
let local = tmp.path().join("local-deployment");
|
||||
std::fs::create_dir(&local).unwrap();
|
||||
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
|
||||
assert!(host.project("welcome").is_err());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { RemoteEntry, Settings } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemoteProgress } from "@/platform/remote";
|
||||
import { ConnectHost } from "./ConnectDialog";
|
||||
import { closeConnectDialog, openConnectDialog, useConnectDialog } from "./model";
|
||||
|
||||
const { list, openRemote, executeAction } = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
openRemote: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return { ...real, commands: { ...real.commands, remoteBootstrapList: list } };
|
||||
});
|
||||
vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote }));
|
||||
vi.mock("@/platform/actions", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/platform/actions")>()),
|
||||
executeAction,
|
||||
}));
|
||||
|
||||
const entry = (path: string, isDir = true): RemoteEntry => ({
|
||||
name: path.slice(path.lastIndexOf("/") + 1),
|
||||
path,
|
||||
isDir,
|
||||
isSymlink: false,
|
||||
size: 0,
|
||||
mtime: null,
|
||||
permissions: "rwxr-xr-x",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
useProject.setState({
|
||||
project: null,
|
||||
opening: false,
|
||||
settings: {
|
||||
ssh: {
|
||||
configs: [
|
||||
{ id: "first", name: "First host", host: "first.example", user: "dev" },
|
||||
{ id: "selected", name: "Selected host", host: "selected.example", user: "dev" },
|
||||
],
|
||||
},
|
||||
} as Settings,
|
||||
});
|
||||
useRemoteProgress.setState({ progress: null, location: null });
|
||||
openConnectDialog();
|
||||
});
|
||||
|
||||
afterEach(() => closeConnectDialog());
|
||||
|
||||
it("browses the selected SSH host, navigates folders and opens one without a current project", async () => {
|
||||
let finish!: (result: { status: "ok"; data: RemoteEntry[] }) => void;
|
||||
list.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve)));
|
||||
openRemote.mockResolvedValue(true);
|
||||
render(<ConnectHost />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "SSH configuration" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: /Selected host/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse" }));
|
||||
expect(list).toHaveBeenCalledWith("selected", "~");
|
||||
expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Open" })).toBeDisabled();
|
||||
finish({
|
||||
status: "ok",
|
||||
data: [entry("/home/dev/.cache"), entry("/home/dev/projects"), entry("/home/dev/README.md", false)],
|
||||
});
|
||||
const folders = await screen.findByRole("list", { name: "Remote folders" });
|
||||
expect(
|
||||
within(folders)
|
||||
.getAllByRole("button")
|
||||
.map((button) => button.textContent),
|
||||
).toEqual(["..", "projects", ".cache"]);
|
||||
expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev");
|
||||
list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects/demo")] });
|
||||
fireEvent.click(within(folders).getByRole("button", { name: "projects" }));
|
||||
await screen.findByRole("button", { name: "demo" });
|
||||
expect(list).toHaveBeenLastCalledWith("selected", "/home/dev/projects");
|
||||
list.mockResolvedValueOnce({ status: "ok", data: [entry("/home/dev/projects")] });
|
||||
fireEvent.click(within(folders).getByRole("button", { name: ".." }));
|
||||
await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev"));
|
||||
expect(list).toHaveBeenLastCalledWith("selected", "/home/dev");
|
||||
list.mockResolvedValueOnce({ status: "ok", data: [] });
|
||||
fireEvent.click(within(folders).getByRole("button", { name: "projects" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev/projects"));
|
||||
expect(within(folders).getAllByRole("button")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
await waitFor(() => expect(openRemote).toHaveBeenCalledWith("selected", "/home/dev/projects"));
|
||||
expect(useConnectDialog.getState().open).toBe(false);
|
||||
expect(useProject.getState().project).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a connection error and lets the welcome window retry Browse", async () => {
|
||||
list.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
render(<ConnectHost />);
|
||||
fireEvent.change(screen.getByLabelText("Project directory:"), { target: { value: " /srv/projects " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Connection refused");
|
||||
expect(list).toHaveBeenCalledWith("first", "/srv/projects");
|
||||
expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled();
|
||||
list.mockResolvedValueOnce({ status: "ok", data: [] });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse" }));
|
||||
await screen.findByRole("list", { name: "Remote folders" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(openRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers SSH configuration from the welcome window and disables Browse until one exists", () => {
|
||||
useProject.setState({ settings: { ssh: { configs: [] } } as unknown as Settings });
|
||||
render(<ConnectHost />);
|
||||
expect(screen.getByRole("button", { name: "Browse" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Open" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure…" }));
|
||||
expect(executeAction).toHaveBeenCalledWith("file.settings", "tools.sshConfigurations");
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача