Files
openidea/crates/ide-tools/src/process.rs
kevinhandl0ng-ai 6a3a712488 fix(tools): skip an unreachable revocation check when curl downloads on Windows (#833)
Schannel curl fails every download with CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers cannot be reached (corporate proxies, firewalls, some regions), although the certificate is valid, so jdtls and other tools could not be installed. On Windows, pass --ssl-revoke-best-effort when `curl --version` reports 7.70+ with Schannel as the active TLS backend: the check is skipped only when the revocation server is offline, and actually revoked certificates are still rejected. Older curl, which rejects the option, is left as is. Fixes #801.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-11-03 23:55:58 +08:00

598 lines
24 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Child processes for installers: streamed output, cancellation, timeouts,
//! and `curl`-based downloads with progress.
use crate::InstallEvent;
use ide_core::error::ErrorKind;
use ide_core::output_encoding::decode_tool_output;
use ide_core::process::{kill_tree, own_group};
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
pub type Emit<'a> = &'a dyn Fn(InstallEvent);
fn describe(cmd: &Command) -> String {
let mut s = Path::new(cmd.get_program()).file_name().unwrap_or_default().to_string_lossy().into_owned();
for a in cmd.get_args() {
s.push(' ');
s.push_str(&a.to_string_lossy());
}
s
}
/// Runs `cmd`, forwarding each stdout/stderr line as [`InstallEvent::Line`].
/// Returns the collected output; fails on non-zero exit or cancellation.
pub fn run_streamed(cmd: Command, cancel: &CancellationToken, emit: Emit) -> IdeResult<String> {
let label = describe(&cmd);
let (ok, output, status) = run_collect(cmd, cancel, emit)?;
if !ok {
let tail: Vec<&str> = output.lines().rev().filter(|l| !l.trim().is_empty()).take(3).collect();
let tail: Vec<&str> = tail.into_iter().rev().collect();
return Err(IdeError::new(
ErrorKind::Io,
format!(
"`{label}` failed ({status}){}",
if tail.is_empty() { String::new() } else { format!(": {}", tail.join(" / ")) }
),
));
}
Ok(output)
}
/// Like [`run_streamed`] but returns `(success, output, status text)` instead of failing on a non-zero exit.
pub fn run_collect(mut cmd: Command, cancel: &CancellationToken, emit: Emit) -> IdeResult<(bool, String, String)> {
emit(InstallEvent::Line { text: format!("$ {}", describe(&cmd)) });
own_group(ide_core::process::prepare(&mut cmd)).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run {}: {e}", describe(&cmd))))?;
let (tx, rx) = mpsc::channel::<String>();
let pipes: [Option<Box<dyn Read + Send>>; 2] = [
child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>),
child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>),
];
for pipe in pipes.into_iter().flatten() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in BufReader::new(pipe).split(b'\n') {
let Ok(line) = line else { break };
let line = decode_tool_output(&line).trim_end_matches('\r').to_owned();
if tx.send(line).is_err() {
break;
}
}
});
}
drop(tx);
let mut output = String::new();
let status = loop {
if cancel.is_cancelled() {
kill_tree(&mut child);
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(line) => {
let line = strip_ansi(&line);
if !line.trim().is_empty() {
emit(InstallEvent::Line { text: line.clone() });
}
output.push_str(&line);
output.push('\n');
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => match child.try_wait()? {
Some(s) => break s,
None => std::thread::sleep(Duration::from_millis(50)),
},
}
};
Ok((status.success(), output, status.to_string()))
}
/// Runs `cmd` to completion within `timeout`; returns (success, stdout + stderr).
pub fn run_quiet(mut cmd: Command, timeout: Duration) -> Option<(bool, String)> {
ide_core::process::prepare(&mut cmd).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().ok()?;
let mut stdout = child.stdout.take()?;
let mut stderr = child.stderr.take()?;
let out_t = std::thread::spawn(move || {
let mut s = Vec::new();
let _ = stdout.read_to_end(&mut s);
decode_tool_output(&s)
});
let err_t = std::thread::spawn(move || {
let mut s = Vec::new();
let _ = stderr.read_to_end(&mut s);
decode_tool_output(&s)
});
let deadline = Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(s)) => break s,
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(15)),
_ => {
kill_tree(&mut child);
return None;
}
}
};
let mut text = out_t.join().unwrap_or_default();
text.push_str(&err_t.join().unwrap_or_default());
Some((status.success(), text))
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
} else if c == '\r' {
out.clear();
} else {
out.push(c);
}
}
out
}
/// Whether `curl` checks certificate revocation with Schannel and takes
/// `--ssl-revoke-best-effort` (7.70+). Schannel fails the whole download with
/// CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers are unreachable (corporate
/// proxies, firewalls); best effort skips only that check, and still rejects
/// certificates that are actually revoked.
fn revoke_best_effort(curl: &Path) -> bool {
static CHECKED: parking_lot::Mutex<Vec<(std::path::PathBuf, bool)>> = parking_lot::Mutex::new(Vec::new());
if let Some((_, ok)) = CHECKED.lock().iter().find(|(path, _)| path == curl) {
return *ok;
}
let mut cmd = Command::new(curl);
cmd.arg("--version");
let ok = run_quiet(cmd, Duration::from_secs(5))
.is_some_and(|(success, out)| success && curl_takes_revoke_best_effort(&out));
CHECKED.lock().push((curl.to_path_buf(), ok));
ok
}
/// Reads `curl --version`: `curl 8.9.1 (Windows) libcurl/8.9.1 Schannel …`.
fn curl_takes_revoke_best_effort(version: &str) -> bool {
let Some(first) = version.lines().find(|l| l.starts_with("curl ")) else { return false };
let mut parts = first.split_whitespace().nth(1).unwrap_or("").split('.').map(|n| n.parse::<u32>().ok());
let (Some(Some(major)), Some(Some(minor))) = (parts.next(), parts.next()) else { return false };
(major, minor) >= (7, 70) && first.split_whitespace().any(|w| w.eq_ignore_ascii_case("schannel"))
}
/// HTTP via the system `curl` (shipped with macOS, Windows 10+ and every
/// mainstream Linux), so the IDE carries no TLS stack of its own.
pub struct Http {
curl: std::path::PathBuf,
https_only: bool,
revoke_best_effort: bool,
}
impl Http {
pub fn new(search_path: &std::ffi::OsStr) -> IdeResult<Self> {
let curl = which::which_in("curl", Some(search_path), ".")
.map_err(|_| IdeError::unavailable("`curl` is required to download tools"))?;
let revoke_best_effort = cfg!(windows) && revoke_best_effort(&curl);
Ok(Self { curl, https_only: false, revoke_best_effort })
}
/// Refuses every protocol but HTTPS, redirects included.
pub fn https_only(mut self) -> Self {
self.https_only = true;
self
}
fn base(&self, url: &str) -> Command {
let mut cmd = Command::new(&self.curl);
own_group(ide_core::process::prepare(&mut cmd));
cmd.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-A", "openidea"]);
// System Settings › HTTP Proxy (a manual proxy comes in through `prepare`'s environment).
cmd.args(ide_core::proxy_settings::proxy().curl_args(url));
if self.https_only {
cmd.args(["--proto", "=https", "--proto-redir", "=https"]);
}
if self.revoke_best_effort {
cmd.arg("--ssl-revoke-best-effort");
}
if url.starts_with("https://api.github.com/")
&& let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN"))
&& !token.is_empty()
{
cmd.arg("-H").arg(format!("Authorization: Bearer {token}"));
}
cmd
}
/// GETs `url` as text.
pub fn get_text(&self, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
self.get_text_limited(url, None, cancel)
}
/// GETs `url` as text, failing when the response is larger than `max_bytes`.
pub fn get_text_limited(&self, url: &str, max_bytes: Option<u64>, cancel: &CancellationToken) -> IdeResult<String> {
self.get_text_response(url, max_bytes, false, cancel)
}
/// Adoptium uses 404 as well as an empty array for absent platform builds.
pub fn get_text_or_empty_on_404(&self, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
self.get_text_response(url, None, true, cancel)
}
fn get_text_response(
&self,
url: &str,
max_bytes: Option<u64>,
empty_on_404: bool,
cancel: &CancellationToken,
) -> IdeResult<String> {
let mut cmd = self.base(url);
if empty_on_404 {
cmd.args(["--write-out", "\n%{http_code}"]);
}
if let Some(max) = max_bytes {
cmd.arg("--max-filesize").arg(max.to_string());
}
cmd.arg(url).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
let stdout = child.stdout.take().expect("piped");
let mut stderr = child.stderr.take().expect("piped");
// Past the limit the pipe closes, which stops curl.
let limit = max_bytes.map_or(u64::MAX, |m| m + 1);
let out_t = std::thread::spawn(move || {
let mut s = Vec::new();
let _ = stdout.take(limit).read_to_end(&mut s);
s
});
let status = loop {
if cancel.is_cancelled() {
kill_tree(&mut child);
return Err(IdeError::cancelled());
}
match child.try_wait()? {
Some(s) => break s,
None => std::thread::sleep(Duration::from_millis(30)),
}
};
let mut body = out_t.join().unwrap_or_default();
if empty_on_404 && let Some(split) = body.iter().rposition(|b| *b == b'\n') {
let code = &body[split + 1..];
if code == b"404" {
return Ok("[]".into());
}
body.truncate(split);
}
if let Some(max) = max_bytes
&& body.len() as u64 > max
{
return Err(IdeError::new(ErrorKind::Io, format!("{url}: response is larger than {max} bytes")));
}
if !status.success() {
let mut err = Vec::new();
let _ = stderr.read_to_end(&mut err);
let err = decode_tool_output(&err);
return Err(IdeError::new(ErrorKind::Io, format!("GET {url} failed: {}", err.trim())));
}
String::from_utf8(body).map_err(|_| IdeError::new(ErrorKind::Protocol, format!("{url}: response is not UTF-8")))
}
/// Downloads `url` to `dest`, reporting progress against `size` when known.
pub fn download(
&self,
url: &str,
dest: &Path,
size: Option<u64>,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<()> {
let mut cmd = self.base(url);
cmd.arg("-o").arg(dest).arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
let mut stderr = child.stderr.take().expect("piped");
let err_t = std::thread::spawn(move || {
let mut s = Vec::new();
let _ = stderr.read_to_end(&mut s);
decode_tool_output(&s)
});
let mut last = Instant::now();
let mut last_bytes = 0;
let status = loop {
if cancel.is_cancelled() {
kill_tree(&mut child);
let _ = std::fs::remove_file(dest);
return Err(IdeError::cancelled());
}
if let Some(s) = child.try_wait()? {
break s;
}
std::thread::sleep(Duration::from_millis(50));
if last.elapsed() >= Duration::from_millis(250) {
last = Instant::now();
let bytes = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
if bytes != last_bytes {
last_bytes = bytes;
emit(progress_event(bytes, size));
}
}
};
if !status.success() {
return Err(IdeError::new(
ErrorKind::Io,
format!("download of {url} failed: {}", err_t.join().unwrap_or_default().trim()),
));
}
let bytes = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
emit(progress_event(bytes, size.or(Some(bytes))));
Ok(())
}
}
pub fn progress_event(bytes: u64, size: Option<u64>) -> InstallEvent {
let mb = |b: u64| b as f64 / 1_048_576.0;
match size.filter(|s| *s > 0) {
Some(total) => InstallEvent::Progress {
fraction: Some((bytes as f64 / total as f64).min(1.0)),
message: format!("Downloaded {:.1} of {:.1} MB", mb(bytes), mb(total)),
},
None => InstallEvent::Progress { fraction: None, message: format!("Downloaded {:.1} MB", mb(bytes)) },
}
}
/// Runs a formatter with separate pipes so diagnostics can never become document text.
pub(crate) fn run_input(
cmd: &mut Command,
input: &str,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<String> {
use std::io::Write;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let mut stdin = child.stdin.take().unwrap();
let input = input.to_owned();
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
enum Pipe {
Written(std::io::Result<()>),
Read(bool, std::io::Result<Vec<u8>>),
}
let (tx, rx) = mpsc::channel();
let writer = tx.clone();
std::thread::spawn(move || {
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
});
for (is_stderr, mut pipe) in [
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
] {
let tx = tx.clone();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
});
}
drop(tx);
let started = Instant::now();
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
let result = loop {
if cancel.is_cancelled() {
break Err(IdeError::cancelled());
}
if started.elapsed() >= timeout {
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
}
if status.is_none() {
match child.try_wait() {
Ok(s) => status = s,
Err(e) => break Err(e.into()),
}
}
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
break Ok(());
}
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(Pipe::Written(result)) => written = Some(result),
Ok(Pipe::Read(true, result)) => errors = Some(result),
Ok(Pipe::Read(false, result)) => output = Some(result),
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
std::thread::sleep(Duration::from_millis(10));
}
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if result.is_err() {
kill_tree(&mut child);
}
result?;
let output = output.unwrap()?;
let errors = errors.unwrap()?;
let status = status.unwrap();
if !status.success() {
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
format!("Formatter exited with {status}")
} else {
diagnostics
}));
}
written.unwrap()?;
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ansi_and_carriage_returns_are_cleaned() {
assert_eq!(strip_ansi("\u{1b}[32mok\u{1b}[0m"), "ok");
assert_eq!(strip_ansi("10%\r50%\r100%"), "100%");
}
#[test]
fn progress_fraction() {
match progress_event(512 * 1024, Some(1024 * 1024)) {
InstallEvent::Progress { fraction: Some(f), message } => {
assert!((f - 0.5).abs() < 1e-9);
assert_eq!(message, "Downloaded 0.5 of 1.0 MB");
}
e => panic!("unexpected {e:?}"),
}
assert!(matches!(progress_event(10, None), InstallEvent::Progress { fraction: None, .. }));
}
#[cfg(unix)]
#[test]
fn streams_lines_and_reports_failure() {
let lines = std::sync::Mutex::new(Vec::new());
let emit = |e: InstallEvent| {
if let InstallEvent::Line { text } = e {
lines.lock().unwrap().push(text);
}
};
let mut cmd = Command::new("sh");
cmd.args(["-c", "echo one; echo two >&2; exit 3"]);
let err = run_streamed(cmd, &CancellationToken::new(), &emit).unwrap_err();
assert!(err.message.contains("failed"), "{}", err.message);
let lines = lines.into_inner().unwrap();
assert!(lines.contains(&"one".to_string()) && lines.contains(&"two".to_string()), "{lines:?}");
}
#[test]
fn revoke_best_effort_needs_schannel_curl_7_70() {
let schannel = "curl 8.9.1 (Windows) libcurl/8.9.1 Schannel zlib/1.3 WinIDN\nRelease-Date: 2024-07-31\n";
assert!(curl_takes_revoke_best_effort(schannel));
assert!(curl_takes_revoke_best_effort("curl 7.70.0 (x86_64-pc-win32) libcurl/7.70.0 Schannel\n"));
// Windows 10 1803 shipped 7.55, which rejects the unknown option.
assert!(!curl_takes_revoke_best_effort("curl 7.55.1 (Windows) libcurl/7.55.1 WinSSL\n"));
// OpenSSL never checks revocation this way; Schannel in parentheses is not the active backend.
assert!(!curl_takes_revoke_best_effort(
"curl 8.10.1 (x86_64-w64-mingw32) libcurl/8.10.1 OpenSSL/3.2.3 (Schannel)\n"
));
assert!(!curl_takes_revoke_best_effort("WARNING: this libcurl is Debug-enabled\n"));
assert!(curl_takes_revoke_best_effort(&format!("WARNING: this libcurl is Debug-enabled\n\n{schannel}")));
}
#[cfg(unix)]
#[test]
fn absent_build_is_empty_but_http_failures_are_not() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let curl = tmp.path().join("curl");
let http = Http { curl: curl.clone(), https_only: false, revoke_best_effort: false };
for (code, exit, expected) in [(404, 22, Some("[]")), (200, 0, Some("payload")), (403, 22, None), (0, 7, None)]
{
std::fs::write(&curl, format!("#!/bin/sh\nprintf 'payload\\n{code:03}'\nexit {exit}\n")).unwrap();
std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o755)).unwrap();
let result = http.get_text_or_empty_on_404("https://example.test/build", &CancellationToken::new());
match expected {
Some(body) => assert_eq!(result.unwrap(), body),
None => assert!(result.is_err()),
}
}
}
#[cfg(unix)]
#[test]
fn cancellation_kills_the_child() {
let cancel = CancellationToken::new();
let c2 = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
c2.cancel();
});
let mut cmd = Command::new("sh");
cmd.args(["-c", "sleep 10"]);
let started = Instant::now();
let err = run_streamed(cmd, &cancel, &|_| {}).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled);
assert!(started.elapsed() < Duration::from_secs(5));
}
/// `npm` / `go` / `pip` run the real work in children of their own: cancelling
/// kills those too, not only the process the installer started.
#[cfg(unix)]
#[test]
fn cancellation_kills_what_the_child_started() {
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid");
let cancel = CancellationToken::new();
let c2 = cancel.clone();
let watch = pid_file.clone();
std::thread::spawn(move || {
while !watch.exists() {
std::thread::sleep(Duration::from_millis(10));
}
std::thread::sleep(Duration::from_millis(50));
c2.cancel();
});
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!("sleep 30 & echo $! > '{}'; wait", pid_file.display()));
let err = run_streamed(cmd, &cancel, &|_| {}).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled);
let pid: i32 = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
// SAFETY: signal 0 only checks that the process exists.
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "the grandchild {pid} survived the cancel");
std::thread::sleep(Duration::from_millis(20));
}
}
#[cfg(unix)]
#[test]
fn quiet_run_times_out() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "sleep 5"]);
assert!(run_quiet(cmd, Duration::from_millis(100)).is_none());
let mut cmd = Command::new("sh");
cmd.args(["-c", "echo v1.2.3"]);
assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into())));
}
}
#[cfg(all(test, unix))]
mod input_tests {
use super::*;
#[test]
fn separate_diagnostics_and_never_return_failed_output() {
let mut command = Command::new("sh");
command.args(["-c", "cat; printf warning >&2"]);
assert_eq!(run_input(&mut command, "a😀\n", Duration::from_secs(2), &Default::default()).unwrap(), "a😀\n");
let mut command = Command::new("sh");
command.args(["-c", "printf broken; printf 'invalid syntax' >&2; exit 1"]);
let e = run_input(&mut command, "", Duration::from_secs(2), &Default::default()).unwrap_err();
assert_eq!(e.message, "invalid syntax");
}
#[test]
fn timeout_kills_formatter_and_cancellation_prevents_start() {
let mut command = Command::new("sh");
command.args(["-c", "sleep 10 &"]);
let start = Instant::now();
assert_eq!(
run_input(&mut command, "", Duration::from_millis(50), &Default::default()).unwrap_err().kind,
ErrorKind::TimedOut
);
assert!(start.elapsed() < Duration::from_secs(2));
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
run_input(&mut Command::new("does-not-exist"), "", Duration::from_secs(2), &cancel).unwrap_err().kind,
ErrorKind::Cancelled
);
}
}