#![cfg(windows)]
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) const fn arch_dir() -> &'static str {
#[cfg(target_arch = "x86_64")]
{
"x64"
}
#[cfg(target_arch = "aarch64")]
{
"arm64"
}
#[cfg(target_arch = "x86")]
{
"x86"
}
#[cfg(target_arch = "arm")]
{
"arm"
}
#[cfg(not(any(
target_arch = "x86_64",
target_arch = "aarch64",
target_arch = "x86",
target_arch = "arm"
)))]
{
compile_error!("Windows builds must target x86_64, aarch64, x86, or arm");
}
}
static CACHED_SIDECAR_DIR: OnceLock<io::Result<PathBuf>> = OnceLock::new();
pub(super) fn ensure_cached_sidecar() -> io::Result<PathBuf> {
let cached = CACHED_SIDECAR_DIR.get_or_init(|| {
let cache_root = resolve_cache_root(std::env::var_os("RUNNING_PROCESS_CONPTY_CACHE"))?;
let dir = cache_root
.join("running-process")
.join("conpty")
.join(env!("CARGO_PKG_VERSION"))
.join(arch_dir());
ensure_in_dir(&dir)?;
Ok(dir)
});
match cached {
Ok(p) => Ok(p.clone()),
Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
}
}
fn resolve_cache_root(override_dir: Option<std::ffi::OsString>) -> io::Result<PathBuf> {
if let Some(p) = override_dir {
if !p.is_empty() {
return Ok(PathBuf::from(p));
}
}
dirs::cache_dir().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"no platform cache directory available",
)
})
}
pub(super) fn ensure_in_dir(cache_dir: &Path) -> io::Result<()> {
let dll = cache_dir.join("conpty.dll");
let exe = cache_dir.join("OpenConsole.exe");
if dll.is_file() && exe.is_file() {
diag(|| format!("ConPTY sidecar cache hit at {}", cache_dir.display()));
return Ok(());
}
if std::env::var_os("RUNNING_PROCESS_CONPTY_OFFLINE").is_some() {
diag(|| "ConPTY sidecar fetch suppressed (RUNNING_PROCESS_CONPTY_OFFLINE)".to_string());
return Err(io::Error::new(
io::ErrorKind::NotFound,
"offline mode: no cached sidecar and fetch disabled",
));
}
fetch_and_extract(cache_dir)
}
fn fetch_and_extract(cache_dir: &Path) -> io::Result<()> {
let url = asset_url();
diag(|| {
format!(
"ConPTY sidecar cache miss; fetching {} → {}",
url,
cache_dir.display()
)
});
let bytes = http_get(&url)
.map_err(|e| io::Error::other(format!("conpty sidecar fetch from {url} failed: {e}")))?;
if let Some(expected) = super::conpty_sidecar_hashes::expected_for_current_arch() {
let actual_hex = sha256_hex(&bytes);
if !ct_eq(&actual_hex, expected.sha256_hex) {
diag(|| {
format!(
"ConPTY sidecar SHA-256 mismatch — discarding fetch; \
expected {} (size {}), got {} (size {}); see #447",
expected.sha256_hex,
expected.size_bytes,
actual_hex,
bytes.len(),
)
});
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"conpty sidecar hash mismatch: expected {}, got {}",
expected.sha256_hex, actual_hex
),
));
}
if expected.size_bytes != 0 && expected.size_bytes != bytes.len() as u64 {
diag(|| {
format!(
"ConPTY sidecar size mismatch (sha matched): expected {} got {}",
expected.size_bytes,
bytes.len()
)
});
}
} else {
diag(|| {
format!(
"no expected sha for arch {}; downloading without verification",
arch_dir()
)
});
}
let parent = cache_dir
.parent()
.ok_or_else(|| io::Error::other("cache dir has no parent"))?;
fs::create_dir_all(parent)?;
let tmp_dir = parent.join(format!(
".tmp-conpty-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = fs::remove_dir_all(&tmp_dir);
fs::create_dir_all(&tmp_dir)?;
extract_tar_zst(&bytes, &tmp_dir).map_err(|e| {
let _ = fs::remove_dir_all(&tmp_dir);
io::Error::other(format!("conpty sidecar archive extraction failed: {e}"))
})?;
if !tmp_dir.join("conpty.dll").is_file() || !tmp_dir.join("OpenConsole.exe").is_file() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"conpty sidecar archive missing conpty.dll or OpenConsole.exe",
));
}
match fs::rename(&tmp_dir, cache_dir) {
Ok(()) => Ok(()),
Err(rename_err) => {
if cache_dir.join("conpty.dll").is_file() && cache_dir.join("OpenConsole.exe").is_file()
{
let _ = fs::remove_dir_all(&tmp_dir);
Ok(())
} else {
let _ = fs::remove_dir_all(&tmp_dir);
Err(rename_err)
}
}
}
}
fn asset_url() -> String {
format!(
"https://github.com/zackees/running-process/releases/download/v{ver}/conpty-sidecar-{arch}.tar.zst",
ver = env!("CARGO_PKG_VERSION"),
arch = arch_dir(),
)
}
fn http_get(url: &str) -> Result<Vec<u8>, String> {
let resp = ureq::get(url)
.timeout(std::time::Duration::from_secs(30))
.call()
.map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(8 * 1024 * 1024);
resp.into_reader()
.take(64 * 1024 * 1024) .read_to_end(&mut out)
.map_err(|e| e.to_string())?;
Ok(out)
}
fn extract_tar_zst(bytes: &[u8], dest: &Path) -> Result<(), String> {
let decoder = zstd::Decoder::new(io::Cursor::new(bytes)).map_err(|e| e.to_string())?;
let mut archive = tar::Archive::new(decoder);
archive.set_overwrite(true);
archive.set_preserve_permissions(false);
for entry in archive.entries().map_err(|e| e.to_string())? {
let mut entry = entry.map_err(|e| e.to_string())?;
let path = entry.path().map_err(|e| e.to_string())?.into_owned();
if path.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir | std::path::Component::RootDir
)
}) {
return Err(format!(
"rejecting unsafe path in archive: {}",
path.display()
));
}
let out_path = dest.join(&path);
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
entry.unpack(&out_path).map_err(|e| e.to_string())?;
}
Ok(())
}
fn diag(f: impl FnOnce() -> String) {
if std::env::var_os("RUNNING_PROCESS_CONPTY_DIAGNOSTICS").is_some() {
eprintln!("running-process: {}", f());
}
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(bytes))
}
fn ct_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.bytes().zip(b.bytes()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
pub(super) fn verify_asset(
bytes: &[u8],
expected: &super::conpty_sidecar_hashes::ExpectedAsset,
) -> Result<(), String> {
let actual = sha256_hex(bytes);
if !ct_eq(&actual, expected.sha256_hex) {
return Err(format!(
"sha mismatch: expected {}, got {}",
expected.sha256_hex, actual
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn stage_fake_sidecar(dir: &Path) {
fs::create_dir_all(dir).expect("mkdir");
for name in ["conpty.dll", "OpenConsole.exe"] {
let mut f = fs::File::create(dir.join(name)).expect("create");
f.write_all(b"fake content for test").expect("write");
}
}
#[test]
fn cache_hit_returns_ok_without_network() {
let tmp = tempfile::tempdir().unwrap();
stage_fake_sidecar(tmp.path());
ensure_in_dir(tmp.path()).expect("cache hit must succeed");
assert!(tmp.path().join("conpty.dll").is_file());
assert!(tmp.path().join("OpenConsole.exe").is_file());
}
#[test]
fn offline_mode_returns_not_found_without_network() {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("RUNNING_PROCESS_CONPTY_OFFLINE", "1");
let result = ensure_in_dir(tmp.path());
std::env::remove_var("RUNNING_PROCESS_CONPTY_OFFLINE");
let err = result.expect_err("offline + empty cache must error");
assert_eq!(err.kind(), io::ErrorKind::NotFound, "got {err}");
}
#[test]
fn cache_root_override_used_when_present() {
let tmp = tempfile::tempdir().unwrap();
let resolved = resolve_cache_root(Some(tmp.path().as_os_str().to_owned())).unwrap();
assert_eq!(resolved, tmp.path());
}
#[test]
fn empty_override_falls_through_to_platform() {
let resolved = resolve_cache_root(Some(std::ffi::OsString::new())).unwrap();
assert_eq!(resolved, dirs::cache_dir().expect("platform cache dir"));
}
#[test]
fn arch_dir_matches_target() {
let s = arch_dir();
#[cfg(target_arch = "x86_64")]
assert_eq!(s, "x64");
#[cfg(target_arch = "aarch64")]
assert_eq!(s, "arm64");
#[cfg(target_arch = "x86")]
assert_eq!(s, "x86");
#[cfg(target_arch = "arm")]
assert_eq!(s, "arm");
}
#[test]
fn asset_url_contains_version_and_arch() {
let url = asset_url();
assert!(url.contains(env!("CARGO_PKG_VERSION")), "got {url}");
assert!(url.contains(arch_dir()), "got {url}");
assert!(url.starts_with("https://github.com/zackees/running-process/releases/download/"));
assert!(url.ends_with(".tar.zst"));
}
#[test]
fn verify_asset_accepts_matching_sha() {
let bytes = b"hello world".to_vec();
let expected = super::super::conpty_sidecar_hashes::ExpectedAsset {
sha256_hex: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
size_bytes: 11,
};
verify_asset(&bytes, &expected).expect("matching sha should verify");
}
#[test]
fn verify_asset_rejects_flipped_byte() {
let bytes = b"hello world".to_vec();
let expected = super::super::conpty_sidecar_hashes::ExpectedAsset {
sha256_hex: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
size_bytes: 11,
};
let mut tampered = bytes.clone();
tampered[0] ^= 0x01;
let err = verify_asset(&tampered, &expected).expect_err("flipped byte should reject");
assert!(err.contains("sha mismatch"), "got: {err}");
}
#[test]
fn ct_eq_matches_and_rejects() {
assert!(ct_eq("abc", "abc"));
assert!(!ct_eq("abc", "abd"));
assert!(!ct_eq("abc", "ab"));
assert!(!ct_eq("", "x"));
assert!(ct_eq("", ""));
}
}