дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Refs #1083 (step B). A local project with a devcontainer.json can be reopened in its dev container (File › Reopen in Container, Find Action): the Dev Containers CLI brings the container up and the window connects to openidea-server inside it, as for WSL, so terminal, language servers, run, debug and git work in the container. The project's identity stays the local folder, so reopening from Recent Projects finds the same container. Missing CLI gives install guidance; trust of the local project carries over. Pure CLI output parsing lives in ide-remote; a failed connection no longer closes the current backend early, saved locations keep the container configuration, and Docker context precedence follows the CLI. Co-authored-by: andreabasile <[email protected]>
448 рядки
19 KiB
Rust
448 рядки
19 KiB
Rust
use ide_core::NullSink;
|
|
#[cfg(unix)]
|
|
use ide_host::ops;
|
|
use ide_host::{Host, HostConfig, trust::TrustStore};
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
#[cfg(unix)]
|
|
use std::time::{Duration, Instant};
|
|
|
|
fn host(base: &Path, environment: bool) -> Arc<Host> {
|
|
Arc::new(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(NullSink)),
|
|
owner: |w| w,
|
|
runtime: tokio::runtime::Handle::current(),
|
|
project_environment: environment,
|
|
}))
|
|
}
|
|
|
|
#[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",
|
|
"remote_bootstrap_list",
|
|
"deploy_test_connection",
|
|
"plugin_market",
|
|
"plugin_market_url",
|
|
"plugin_set_market_url",
|
|
"plugin_market_download",
|
|
"plugin_preview_install",
|
|
] {
|
|
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",
|
|
"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}");
|
|
for window in ["main", "welcome"] {
|
|
assert!(host.authorize_command(window, command).is_err(), "{window}: {command}");
|
|
}
|
|
}
|
|
// Plugin installs run plugin code: from the welcome screen, but not in an untrusted project.
|
|
for command in ["plugin_install_dir", "plugin_uninstall", "plugin_set_enabled", "plugin_reload"] {
|
|
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
|
|
assert!(host.authorize_command("main", command).is_err(), "{command}");
|
|
assert!(host.authorize_command("welcome", command).is_ok(), "{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;
|
|
}
|
|
|
|
#[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();
|
|
let config = tmp.path().join("config");
|
|
let data = tmp.path().join("data");
|
|
let a = tmp.path().join("projects/a");
|
|
let b = tmp.path().join("projects/b");
|
|
std::fs::create_dir_all(a.join(".openidea")).unwrap();
|
|
std::fs::create_dir_all(b.join(".openidea")).unwrap();
|
|
std::fs::write(a.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
|
|
std::fs::write(b.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
|
|
let recent = ide_workspace::RecentProjects::load(&config);
|
|
recent.touch(&a, None).unwrap();
|
|
let trust = TrustStore::load(&config, &data);
|
|
assert!(trust.trusted(&a).unwrap());
|
|
assert!(!trust.status(&b).unwrap().decided);
|
|
recent.touch(&b, None).unwrap();
|
|
let trust = TrustStore::load(&config, &data);
|
|
assert!(!trust.trusted(&b).unwrap(), "migration is not repeated");
|
|
trust.decide(&a, true, true).unwrap();
|
|
assert!(trust.trusted(&b).unwrap());
|
|
trust.decide(&b, false, false).unwrap();
|
|
assert!(!trust.trusted(&b).unwrap(), "revocation overrides a trusted location");
|
|
let trust = TrustStore::load(&config, &data);
|
|
assert!(!trust.trusted(&b).unwrap());
|
|
trust.remove_location(&dunce::canonicalize(a.parent().unwrap()).unwrap()).unwrap();
|
|
assert!(trust.trusted(&a).unwrap());
|
|
trust.decide(&a, false, false).unwrap();
|
|
assert!(!TrustStore::load(&config, &data).trusted(&a).unwrap());
|
|
std::fs::write(data.join("project-trust.json"), "broken").unwrap();
|
|
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
|
|
}
|
|
|
|
#[test]
|
|
fn an_inherited_folder_is_trusted_for_this_process_only() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
|
|
let root = tmp.path().join("workspaces/app");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let trust = TrustStore::load(&config, &data);
|
|
assert!(!trust.trusted(&root).unwrap());
|
|
trust.inherit(&root).unwrap();
|
|
assert!(trust.trusted(&root).unwrap());
|
|
assert!(!trust.trusted(root.parent().unwrap()).unwrap(), "only that folder");
|
|
assert!(trust.status(&root).unwrap().decided, "the trust prompt doesn't ask again");
|
|
assert!(trust.decisions().unwrap().projects.is_empty(), "no decision is recorded");
|
|
assert!(!TrustStore::load(&config, &data).trusted(&root).unwrap(), "not persisted");
|
|
trust.decide(&root, false, false).unwrap();
|
|
assert!(!trust.trusted(&root).unwrap(), "a decision to restrict it still wins");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn script(root: &Path, name: &str, marker: &str) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let path = root.join(name);
|
|
std::fs::write(&path, format!("#!/bin/sh\nprintf executed > '{marker}'\n")).unwrap();
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = dunce::canonicalize(tmp.path()).unwrap().join("malicious");
|
|
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
|
script(&root, "hook", "hook.marker");
|
|
script(&root, "server", "lsp.marker");
|
|
script(&root, "gradlew", "build.marker");
|
|
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
|
std::fs::write(root.join("main.py"), "print('safe')\n").unwrap();
|
|
std::fs::write(root.join(".devcontainer.json"), r#"{"postStartCommand":["./hook"]}"#).unwrap();
|
|
std::fs::write(root.join(".openidea/settings.toml"), format!(
|
|
"[languageServers.trust-witness]\nlanguages = ['python']\ncommand = {:?}\n[languageServers.pyright]\nenabled = false\n", root.join("server").to_str().unwrap()
|
|
)).unwrap();
|
|
let host = host(&tmp.path().join("app"), true);
|
|
let project = host.open_project("main", &root).unwrap();
|
|
assert!(!project.is_trusted());
|
|
let doc = ops::doc::open(&host, "main", root.join("main.py")).await.unwrap();
|
|
ops::doc::save(&host, "main", doc.id).await.unwrap();
|
|
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
|
assert!(project.index.status().files > 0);
|
|
let sync = || {
|
|
ops::build::build_sync(
|
|
&host,
|
|
"main",
|
|
ide_build::BuildTool::Gradle,
|
|
ide_core::RequestId(472),
|
|
false,
|
|
vec![],
|
|
None,
|
|
ops::Channel::new(|_| {}),
|
|
)
|
|
};
|
|
assert!(sync().await.is_err(), "restricted build marker: {}", root.join("build.marker").exists());
|
|
assert!(
|
|
ops::sdk::sdk_locate_tools(&host, "missing-window", vec!["server".into()], Some(root.clone())).await.is_err()
|
|
);
|
|
for command in [
|
|
"run_start",
|
|
"debug_start",
|
|
"notebook_execute",
|
|
"coverage_collect",
|
|
"profiler_collect",
|
|
"docker_compose_op",
|
|
"agent_prompt",
|
|
"sdk_detect",
|
|
"build_sync",
|
|
] {
|
|
assert!(host.authorize_command("main", command).is_err(), "{command}");
|
|
assert!(host.authorize_command("missing-window", command).is_err(), "{command}");
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
for marker in ["hook.marker", "lsp.marker", "build.marker"] {
|
|
assert!(!root.join(marker).exists(), "{marker}");
|
|
}
|
|
host.set_project_trust(&root, true, false).await.unwrap();
|
|
assert!(Arc::ptr_eq(&project, &host.project("main").unwrap()));
|
|
assert_eq!(project.documents.snapshot(doc.id).unwrap().text, doc.text);
|
|
sync().await.unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
while !root.join("hook.marker").exists() || !root.join("lsp.marker").exists() {
|
|
assert!(Instant::now() < deadline, "trusted services must start");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
assert!(root.join("build.marker").exists());
|
|
host.set_project_trust(&root, false, false).await.unwrap();
|
|
assert!(!project.is_trusted());
|
|
assert!(sync().await.is_err());
|
|
ops::doc::save(&host, "main", doc.id).await.unwrap();
|
|
host.shutdown().await;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn restricted_terminal_keeps_user_input_but_ignores_project_startup_commands() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let host = host(&tmp.path().join("app"), false);
|
|
host.global_settings
|
|
.set(
|
|
ide_workspace::SettingsScope::Global,
|
|
"terminal.env",
|
|
serde_json::json!({"ZDOTDIR": root, "ENV": "/dev/null", "BASH_ENV": "/dev/null"}),
|
|
)
|
|
.unwrap();
|
|
host.open_project("main", &root).unwrap();
|
|
let terminal = ops::term::spawn(
|
|
&host,
|
|
"main",
|
|
ide_terminal::TerminalOptions {
|
|
cwd: root.clone().into(),
|
|
shell: Some("/bin/sh".into()),
|
|
args: vec!["-c".into(), "touch automatic.marker".into()],
|
|
env: Default::default(),
|
|
cols: 80,
|
|
rows: 24,
|
|
shell_integration: false,
|
|
},
|
|
Arc::new(|_| {}),
|
|
)
|
|
.unwrap();
|
|
host.terminals.write(terminal.id, "printf user > terminal.marker; exit\n").unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
while !root.join("terminal.marker").exists() {
|
|
assert!(Instant::now() < deadline, "user terminal input must run");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
assert!(!root.join("automatic.marker").exists());
|
|
host.shutdown().await;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn trusted_locations_use_canonical_directory_boundaries() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join("trusted/project");
|
|
let other = tmp.path().join("trusted-other/project");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::create_dir_all(&other).unwrap();
|
|
std::os::unix::fs::symlink(other.parent().unwrap(), root.parent().unwrap().join("escape")).unwrap();
|
|
let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
|
trust.decide(&root, true, true).unwrap();
|
|
assert!(!trust.trusted(&other).unwrap());
|
|
let alias = root.parent().unwrap().join("escape/project");
|
|
assert!(!trust.trusted(&alias).unwrap());
|
|
assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn add_location_trusts_every_project_under_it() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let location = tmp.path().join("projects");
|
|
let inside = location.join("nested/deep/app");
|
|
let outside = tmp.path().join("projects-other/app");
|
|
std::fs::create_dir_all(&inside).unwrap();
|
|
std::fs::create_dir_all(&outside).unwrap();
|
|
let file = tmp.path().join("not-a-dir");
|
|
std::fs::write(&file, "x").unwrap();
|
|
let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
|
trust.add_location(&location).unwrap();
|
|
assert!(trust.trusted(&inside).unwrap());
|
|
assert!(!trust.trusted(&outside).unwrap());
|
|
assert_eq!(trust.decisions().unwrap().locations.len(), 1);
|
|
trust.add_location(&location).unwrap();
|
|
assert_eq!(trust.decisions().unwrap().locations.len(), 1, "re-adding is a no-op");
|
|
assert!(trust.add_location(&file).is_err(), "a file is not a location");
|
|
assert!(trust.add_location(&tmp.path().join("missing")).is_err(), "a missing path is not a location");
|
|
let reloaded = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
|
assert!(reloaded.trusted(&inside).unwrap());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
|
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
|
let git = |args: &[&str]| {
|
|
let output = std::process::Command::new("git").current_dir(&root).args(args).output().unwrap();
|
|
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
|
};
|
|
git(&["init", "-q"]);
|
|
git(&["config", "user.name", "Test"]);
|
|
git(&["config", "user.email", "[email protected]"]);
|
|
git(&["config", "commit.gpgsign", "false"]);
|
|
git(&["config", "core.hooksPath", ".git/hooks"]);
|
|
let file = root.join("safe.txt");
|
|
std::fs::write(&file, "safe\n").unwrap();
|
|
script(&root, ".git/hooks/pre-commit", "git-hook.marker");
|
|
let executable = root.join(".openidea/git-witness");
|
|
std::fs::write(&executable, "#!/bin/sh\nprintf executed > git-executable.marker\nexec git \"$@\"\n").unwrap();
|
|
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
std::fs::write(
|
|
root.join(".openidea/settings.toml"),
|
|
format!("[vcs.git]\nexecutable = {:?}\n", executable.to_str().unwrap()),
|
|
)
|
|
.unwrap();
|
|
let host = host(&tmp.path().join("app"), false);
|
|
let project = host.open_project("main", &root).unwrap();
|
|
assert!(!project.with_repo(|repo| repo.status()).unwrap().is_empty());
|
|
assert!(ops::vcs::vcs_stage(&host, "main", vec![file.clone()]).await.is_err());
|
|
assert!(!root.join("git-hook.marker").exists());
|
|
assert!(!root.join("git-executable.marker").exists());
|
|
host.set_project_trust(&root, true, false).await.unwrap();
|
|
ops::vcs::vcs_stage(&host, "main", vec![file]).await.unwrap();
|
|
project.with_repo(|repo| repo.commit("trusted", false, false)).unwrap();
|
|
assert!(root.join("git-executable.marker").exists());
|
|
assert!(root.join("git-hook.marker").exists());
|
|
ide_vcs::set_git_executable(None);
|
|
host.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn revocation_still_applies_when_a_project_folder_was_removed() {
|
|
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);
|
|
host.trust.decide(&root, true, true).unwrap();
|
|
let project = host.open_project("main", &root).unwrap();
|
|
std::fs::remove_dir_all(&root).unwrap();
|
|
host.set_project_trust(&project.root, false, false).await.unwrap();
|
|
assert!(!project.is_trusted());
|
|
host.shutdown().await;
|
|
}
|