Files
openidea/crates/ide-tools/src/process.rs
qiaojianlong ff9f821621 fix(tools): reuse the gh login for GitHub API downloads (#967)
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]>
2026-11-05 09:56:38 +08:00

927 lines
37 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, 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());
}
}
}