mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Tool downloads from GitHub releases called api.github.com anonymously unless GITHUB_TOKEN or GH_TOKEN was set, so they hit the 60-requests-an-hour limit and failed with 403. When neither variable is set, the request now uses the active github.com account from `gh auth token` (the same login the GitHub features use), asked per request with a 5 s, cancellable lookup. The token goes to curl over stdin (`--header @-`), only for https://api.github.com, with `-q` so a user curlrc can't log or forward it, HTTPS-only redirects, and redacted from error messages. Without gh or a login, downloads stay anonymous. Co-authored-by: qjlqyf <[email protected]>
927 lines
37 KiB
Rust
927 lines
37 KiB
Rust
//! 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, Write};
|
||
use std::path::Path;
|
||
use std::process::{Child, 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
|
||
}
|
||
|
||
const MAX_GITHUB_TOKEN: usize = 4096;
|
||
|
||
fn valid_github_token(value: String) -> Option<String> {
|
||
let value = value.trim();
|
||
(!value.is_empty() && value.len() <= MAX_GITHUB_TOKEN && value.bytes().all(|b| b.is_ascii_graphic()))
|
||
.then(|| value.to_owned())
|
||
}
|
||
|
||
fn environment_github_token(read: impl Fn(&str) -> Option<String>) -> Option<String> {
|
||
["GITHUB_TOKEN", "GH_TOKEN"].into_iter().find_map(|key| read(key).and_then(valid_github_token))
|
||
}
|
||
|
||
/// Ask gh for the active github.com account on each request, so switching or
|
||
/// logging out takes effect without persisting a second copy of the credential.
|
||
fn read_gh_token(gh: &Path, cancel: &CancellationToken, timeout: Duration) -> IdeResult<Option<String>> {
|
||
if cancel.is_cancelled() {
|
||
return Err(IdeError::cancelled());
|
||
}
|
||
let mut cmd = Command::new(gh);
|
||
own_group(ide_core::process::prepare(&mut cmd));
|
||
cmd.args(["auth", "token", "--hostname", "github.com"])
|
||
.env("GH_PROMPT_DISABLED", "1")
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::null());
|
||
let Ok(mut child) = cmd.spawn() else { return Ok(None) };
|
||
let stdout = child.stdout.take().expect("piped");
|
||
let (tx, rx) = mpsc::channel();
|
||
std::thread::spawn(move || {
|
||
let mut bytes = Vec::new();
|
||
let _ = stdout.take((MAX_GITHUB_TOKEN + 2) as u64).read_to_end(&mut bytes);
|
||
let _ = tx.send(bytes);
|
||
});
|
||
let started = Instant::now();
|
||
let mut status = None;
|
||
let mut output = None;
|
||
let bytes = loop {
|
||
if cancel.is_cancelled() || started.elapsed() >= timeout {
|
||
kill_tree(&mut child);
|
||
return if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(None) };
|
||
}
|
||
if status.is_none() {
|
||
match child.try_wait() {
|
||
Ok(value) => status = value,
|
||
Err(_) => {
|
||
kill_tree(&mut child);
|
||
return Ok(None);
|
||
}
|
||
}
|
||
}
|
||
if let Some(status) = status {
|
||
if !status.success() {
|
||
kill_tree(&mut child);
|
||
return Ok(None);
|
||
}
|
||
if let Some(bytes) = output.take() {
|
||
break bytes;
|
||
}
|
||
}
|
||
match rx.recv_timeout(Duration::from_millis(15)) {
|
||
Ok(bytes) => output = Some(bytes),
|
||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||
Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(Duration::from_millis(15)),
|
||
}
|
||
};
|
||
if bytes.len() > MAX_GITHUB_TOKEN + 1 {
|
||
return Ok(None);
|
||
}
|
||
Ok(String::from_utf8(bytes).ok().and_then(valid_github_token))
|
||
}
|
||
|
||
fn send_auth(child: &mut Child, token: Option<&str>) -> IdeResult<()> {
|
||
if let Some(token) = token {
|
||
let result = child
|
||
.stdin
|
||
.take()
|
||
.expect("authenticated request has piped stdin")
|
||
.write_all(format!("Authorization: Bearer {token}\n").as_bytes());
|
||
if let Err(error) = result {
|
||
kill_tree(child);
|
||
return Err(IdeError::unavailable(format!("cannot pass download credentials to curl: {error}")));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn redact_auth(message: &str, token: Option<&str>) -> String {
|
||
match token {
|
||
Some(token) => message.replace(token, "[redacted]"),
|
||
None => message.to_owned(),
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
gh: Option<std::path::PathBuf>,
|
||
explicit_github_token: Option<String>,
|
||
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 gh = which::which_in("gh", Some(search_path), ".").ok();
|
||
let explicit_github_token = environment_github_token(|key| std::env::var(key).ok());
|
||
let revoke_best_effort = cfg!(windows) && revoke_best_effort(&curl);
|
||
Ok(Self { curl, gh, explicit_github_token, 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, cancel: &CancellationToken) -> IdeResult<(Command, Option<String>)> {
|
||
if cancel.is_cancelled() {
|
||
return Err(IdeError::cancelled());
|
||
}
|
||
let github = url.starts_with("https://api.github.com/");
|
||
let token = if github {
|
||
match &self.explicit_github_token {
|
||
Some(token) => Some(token.clone()),
|
||
None => match &self.gh {
|
||
Some(gh) => read_gh_token(gh, cancel, Duration::from_secs(5))?,
|
||
None => None,
|
||
},
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
let mut cmd = Command::new(&self.curl);
|
||
// A user curlrc must not enable verbose token logging or forwarding
|
||
// Authorization across origins on authenticated API requests.
|
||
if token.is_some() {
|
||
cmd.arg("-q");
|
||
}
|
||
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");
|
||
}
|
||
cmd.stdin(Stdio::null());
|
||
if token.is_some() {
|
||
// Keep credentials out of process arguments, inherited environment,
|
||
// and files. curl strips Authorization on cross-origin redirects.
|
||
cmd.args(["--header", "@-", "--proto-redir", "=https"]);
|
||
cmd.env_remove("GH_TOKEN").env_remove("GITHUB_TOKEN").stdin(Stdio::piped());
|
||
}
|
||
Ok((cmd, token))
|
||
}
|
||
|
||
/// 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, token) = self.base(url, cancel)?;
|
||
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).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
|
||
send_auth(&mut child, token.as_deref())?;
|
||
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: {}", redact_auth(err.trim(), token.as_deref())),
|
||
));
|
||
}
|
||
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, token) = self.base(url, cancel)?;
|
||
cmd.arg("-o").arg(dest).arg(url).stdout(Stdio::null()).stderr(Stdio::piped());
|
||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
|
||
send_auth(&mut child, token.as_deref())?;
|
||
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: {}",
|
||
redact_auth(err_t.join().unwrap_or_default().trim(), token.as_deref())
|
||
),
|
||
));
|
||
}
|
||
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(),
|
||
gh: None,
|
||
explicit_github_token: None,
|
||
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
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod github_auth_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn explicit_tokens_keep_precedence_and_reject_header_injection() {
|
||
let read = |key: &str| Some(if key == "GITHUB_TOKEN" { "first" } else { "second" }.to_owned());
|
||
assert_eq!(environment_github_token(read).as_deref(), Some("first"));
|
||
assert_eq!(
|
||
environment_github_token(|key| Some(if key == "GITHUB_TOKEN" { "" } else { "second" }.into())).as_deref(),
|
||
Some("second")
|
||
);
|
||
for bad in ["", "two words", "token\r\nX-Injected: yes", "token\0"] {
|
||
assert!(valid_github_token(bad.into()).is_none());
|
||
}
|
||
assert!(valid_github_token("a".repeat(MAX_GITHUB_TOKEN + 1)).is_none());
|
||
assert_eq!(valid_github_token("gho_example\n".into()).as_deref(), Some("gho_example"));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
mod unix {
|
||
use super::*;
|
||
use std::fs;
|
||
use std::os::unix::fs::PermissionsExt;
|
||
use std::path::PathBuf;
|
||
|
||
fn quote(path: &Path) -> String {
|
||
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
|
||
}
|
||
struct Fixture {
|
||
dir: tempfile::TempDir,
|
||
http: Http,
|
||
}
|
||
impl Fixture {
|
||
fn new() -> Self {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let curl = dir.path().join("curl");
|
||
let gh = dir.path().join("gh");
|
||
let fixture = Self {
|
||
dir,
|
||
http: Http {
|
||
curl,
|
||
gh: Some(gh),
|
||
explicit_github_token: None,
|
||
https_only: false,
|
||
revoke_best_effort: false,
|
||
},
|
||
};
|
||
fixture.script(
|
||
"gh",
|
||
&format!(
|
||
"printf '%s\\n' \"$@\" >> {}\n/bin/cat {}",
|
||
quote(&fixture.path("gh-args")),
|
||
quote(&fixture.path("token"))
|
||
),
|
||
);
|
||
fs::write(fixture.path("token"), "gho_fake_first\n").unwrap();
|
||
fixture.curl("printf '{\"ok\":true}'");
|
||
fixture
|
||
}
|
||
fn path(&self, name: &str) -> PathBuf {
|
||
self.dir.path().join(name)
|
||
}
|
||
fn script(&self, name: &str, body: &str) {
|
||
let path = self.path(name);
|
||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
|
||
}
|
||
fn curl(&self, body: &str) {
|
||
self.script(
|
||
"curl",
|
||
&format!(
|
||
"printf '%s\\n' \"$@\" > {}\n/bin/cat > {}\n{body}",
|
||
quote(&self.path("curl-args")),
|
||
quote(&self.path("headers"))
|
||
),
|
||
);
|
||
}
|
||
fn get(&self, url: &str) -> IdeResult<String> {
|
||
self.http.get_text(url, &CancellationToken::new())
|
||
}
|
||
}
|
||
const API: &str = "https://api.github.com/repos/example/project/releases/latest";
|
||
|
||
#[test]
|
||
fn active_gh_account_is_read_per_request_and_token_is_not_an_argument() {
|
||
let f = Fixture::new();
|
||
assert_eq!(f.get(API).unwrap(), "{\"ok\":true}");
|
||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_first\n");
|
||
let args = fs::read_to_string(f.path("curl-args")).unwrap();
|
||
assert!(!args.contains("gho_fake"));
|
||
assert!(args.starts_with("-q\n"));
|
||
assert!(args.contains("--header\n@-\n--proto-redir\n=https\n"));
|
||
assert!(!args.contains("--location-trusted"));
|
||
assert_eq!(fs::read_to_string(f.path("gh-args")).unwrap(), "auth\ntoken\n--hostname\ngithub.com\n");
|
||
fs::write(f.path("token"), "gho_fake_second\n").unwrap();
|
||
f.get(API).unwrap();
|
||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_second\n");
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_token_avoids_gh_and_non_github_urls_receive_no_credentials() {
|
||
let mut f = Fixture::new();
|
||
f.http.explicit_github_token = Some("explicit_fake".into());
|
||
f.get(API).unwrap();
|
||
assert!(!f.path("gh-args").exists());
|
||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer explicit_fake\n");
|
||
f.http.explicit_github_token = None;
|
||
for url in [
|
||
"https://example.com/file",
|
||
"http://api.github.com/repos/x",
|
||
"https://api.github.com.evil.example/file",
|
||
"https://[email protected]/file",
|
||
"https://github.com/example/project/releases/download/v1/file",
|
||
] {
|
||
f.get(url).unwrap();
|
||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||
assert!(!f.path("gh-args").exists());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn missing_logged_out_or_malformed_gh_falls_back_to_anonymous() {
|
||
let mut f = Fixture::new();
|
||
for body in ["exit 1", "printf 'fake\\nX-Injected: yes\\n'", "printf ''", "printf 'fake'; exit 1"] {
|
||
f.script("gh", body);
|
||
f.get(API).unwrap();
|
||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||
}
|
||
f.http.gh = None;
|
||
f.get(API).unwrap();
|
||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn download_auth_and_errors_do_not_expose_the_token() {
|
||
let f = Fixture::new();
|
||
let dest = f.path("download");
|
||
f.curl(&format!("printf downloaded > {}", quote(&dest)));
|
||
f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap();
|
||
assert_eq!(fs::read_to_string(&dest).unwrap(), "downloaded");
|
||
assert!(fs::read_to_string(f.path("headers")).unwrap().contains("gho_fake_first"));
|
||
f.curl(&format!("/bin/cat {} >&2; exit 22", quote(&f.path("headers"))));
|
||
let error = f.get(API).unwrap_err().to_string();
|
||
assert!(error.contains("[redacted]"));
|
||
assert!(!error.contains("gho_fake_first"));
|
||
let error = f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap_err().to_string();
|
||
assert!(!error.contains("gho_fake_first"));
|
||
}
|
||
|
||
#[test]
|
||
fn credential_lookup_is_bounded_and_cancellable_even_with_inherited_stdout() {
|
||
let f = Fixture::new();
|
||
// The parent exits but a child keeps stdout open: joining a reader
|
||
// without checking the deadline would hang here.
|
||
f.script("gh", "/bin/sleep 5 &\nexit 0");
|
||
let started = Instant::now();
|
||
assert!(
|
||
read_gh_token(f.http.gh.as_ref().unwrap(), &CancellationToken::new(), Duration::from_millis(60))
|
||
.unwrap()
|
||
.is_none()
|
||
);
|
||
assert!(started.elapsed() < Duration::from_secs(3));
|
||
f.script("gh", "/bin/sleep 5");
|
||
let cancel = CancellationToken::new();
|
||
let trigger = cancel.clone();
|
||
let thread = std::thread::spawn(move || {
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
trigger.cancel();
|
||
});
|
||
assert_eq!(f.http.get_text(API, &cancel).unwrap_err().kind, ErrorKind::Cancelled);
|
||
thread.join().unwrap();
|
||
assert!(!f.path("curl-args").exists());
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires an active gh github.com login and network access"]
|
||
fn live_github_release_uses_authenticated_quota() {
|
||
let http = Http::new(&std::env::var_os("PATH").unwrap()).unwrap();
|
||
assert!(http.gh.is_some());
|
||
let cancel = CancellationToken::new();
|
||
let body = http.get_text_limited("https://api.github.com/rate_limit", Some(64 * 1024), &cancel).unwrap();
|
||
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||
assert!(response["resources"]["core"]["limit"].as_u64().unwrap() > 60);
|
||
let body = http
|
||
.get_text_limited(
|
||
"https://api.github.com/repos/redhat-developer/vscode-xml/releases/latest",
|
||
Some(1024 * 1024),
|
||
&cancel,
|
||
)
|
||
.unwrap();
|
||
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||
assert!(response["tag_name"].is_string());
|
||
}
|
||
}
|
||
}
|