3756 lines
148 KiB
Rust
3756 lines
148 KiB
Rust
use std::hash::{Hash, Hasher};
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use futures::{stream, StreamExt};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::agent_catalog;
|
|
use crate::agent_manager::{
|
|
AgentDriverInfo, AgentManager, AgentRegistry, ArtifactFormat, InstalledDriver, JavaRuntimeMode, DEFAULT_JRE_KEY,
|
|
};
|
|
use crate::DownloadSource;
|
|
|
|
/// Number of attempts to delete a JRE directory before giving up (Windows
|
|
/// experiences transient `ERROR_ACCESS_DENIED` when java.exe is still mapped
|
|
/// or anti-virus is scanning the archive). POSIX returns 1 — `unlink` of an
|
|
/// in-use file always succeeds.
|
|
const JRE_REMOVE_ATTEMPTS: usize = if cfg!(windows) { 6 } else { 1 };
|
|
|
|
/// Exponential-ish backoff between retries. Total wait ≈ 1.55s on Windows.
|
|
const JRE_REMOVE_BACKOFF_MS: &[u64] = &[50, 100, 200, 400, 400, 400];
|
|
|
|
/// Keep batch updates concurrent without allowing a large registry to exhaust
|
|
/// the download server, local disk, or the application's file descriptors.
|
|
const MAX_CONCURRENT_AGENT_UPDATES: usize = 4;
|
|
|
|
/// Delete an old JRE directory, retrying on Windows to cover the daemon-exit
|
|
/// and AV-scan release window. Returns the original `std::io::Error` when all
|
|
/// retries fail so callers can decide whether to fall back to rename-stash.
|
|
fn remove_jre_dir_with_retry(path: &Path) -> std::io::Result<()> {
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
let mut last_err: Option<std::io::Error> = None;
|
|
for i in 0..JRE_REMOVE_ATTEMPTS {
|
|
match std::fs::remove_dir_all(path) {
|
|
Ok(()) => return Ok(()),
|
|
Err(err) => {
|
|
log::warn!(
|
|
"remove_dir_all({}) attempt {}/{} failed: {err}",
|
|
path.display(),
|
|
i + 1,
|
|
JRE_REMOVE_ATTEMPTS
|
|
);
|
|
last_err = Some(err);
|
|
if i + 1 < JRE_REMOVE_ATTEMPTS {
|
|
let delay_ms = JRE_REMOVE_BACKOFF_MS.get(i).copied().unwrap_or(400);
|
|
std::thread::sleep(Duration::from_millis(delay_ms));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(last_err.unwrap_or_else(|| std::io::Error::other("remove_dir_all failed without an error")))
|
|
}
|
|
|
|
/// Render a friendly error message when the old JRE directory cannot be
|
|
/// replaced. On Windows, lists likely culprits (process holding java.exe,
|
|
/// AV scanning) and suggests restarting dbx; on POSIX returns a concise
|
|
/// message. The original OS error is appended in parentheses for support.
|
|
fn format_jre_dir_remove_error(path: &Path, os_err: &std::io::Error) -> String {
|
|
if cfg!(windows) {
|
|
format!(
|
|
"Failed to remove the old JRE directory: {}\n\
|
|
Possible causes:\n \
|
|
- a dbx Agent / java process still holds the directory\n \
|
|
- antivirus software is scanning it\n\
|
|
Close any process that may hold the directory, or restart dbx and try again.\n\
|
|
(original error: {os_err})",
|
|
path.display()
|
|
)
|
|
} else {
|
|
format!("Failed to remove the old JRE directory: {} (original error: {os_err})", path.display())
|
|
}
|
|
}
|
|
|
|
/// Windows-only: rename the old JRE dir to a unique sibling so the install
|
|
/// can continue even when files inside are still mapped. Returns the stash
|
|
/// path so the caller can record it for later cleanup. On POSIX this is
|
|
/// unreachable (callers gate on `cfg(windows)` after a failed remove).
|
|
#[cfg(windows)]
|
|
fn stash_old_jre_dir(path: &Path) -> std::io::Result<PathBuf> {
|
|
use std::time::SystemTime;
|
|
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.ok_or_else(|| std::io::Error::other("JRE directory has no file name"))?;
|
|
let ts = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
|
// uuid::Uuid::new_v4() is already a workspace dependency — use its short
|
|
// form for a unique suffix without pulling in `rand`.
|
|
let rand = uuid::Uuid::new_v4().simple().to_string();
|
|
let stash = path.with_file_name(format!("{file_name}.old-{ts}-{rand}"));
|
|
std::fs::rename(path, &stash)?;
|
|
Ok(stash)
|
|
}
|
|
|
|
/// Replace an old JRE directory in-place: try retried `remove_dir_all` first;
|
|
/// on Windows fall back to rename-stash if removal fails. Returns the stash
|
|
/// path (Some) if the rename fallback was used so the caller can persist it
|
|
/// for startup cleanup, or None if the directory was deleted outright (or
|
|
/// did not exist).
|
|
fn replace_old_jre_dir(path: &Path) -> Result<Option<PathBuf>, String> {
|
|
match remove_jre_dir_with_retry(path) {
|
|
Ok(()) => Ok(None),
|
|
Err(remove_err) => {
|
|
#[cfg(windows)]
|
|
{
|
|
match stash_old_jre_dir(path) {
|
|
Ok(stash) => {
|
|
log::warn!("remove_dir_all failed, stashed old JRE at {} ({remove_err})", stash.display());
|
|
// The caller will persist this stash under
|
|
// state_lock after extraction succeeds.
|
|
Ok(Some(stash))
|
|
}
|
|
Err(rename_err) => {
|
|
log::warn!(
|
|
"remove_dir_all and rename both failed for {}: remove={remove_err}, rename={rename_err}",
|
|
path.display()
|
|
);
|
|
Err(format_jre_dir_remove_error(path, &remove_err))
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
Err(format_jre_dir_remove_error(path, &remove_err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const REGISTRY_PATH: &str = "https://github.com/t8y2/dbx/releases/download/agents-latest/agent-registry.json";
|
|
const REGISTRY_R2_PATH: &str = "agents/agent-registry.json";
|
|
|
|
static REGISTRY_CACHE: std::sync::LazyLock<
|
|
tokio::sync::Mutex<std::collections::HashMap<DownloadSource, (std::time::Instant, AgentRegistry)>>,
|
|
> = std::sync::LazyLock::new(|| tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
pub struct AgentProgressEvent {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub operation_id: Option<String>,
|
|
pub step: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub downloaded: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub total: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub db_type: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub current: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub total_drivers: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
pub struct AgentDriverUpdateIssue {
|
|
pub db_type: String,
|
|
pub error: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
|
|
pub struct UpgradeAllAgentDriversResult {
|
|
pub upgraded: u32,
|
|
pub failed: Vec<AgentDriverUpdateIssue>,
|
|
}
|
|
|
|
impl AgentProgressEvent {
|
|
pub fn step(step: impl Into<String>) -> Self {
|
|
Self {
|
|
operation_id: None,
|
|
step: step.into(),
|
|
downloaded: None,
|
|
total: None,
|
|
db_type: None,
|
|
current: None,
|
|
total_drivers: None,
|
|
}
|
|
}
|
|
|
|
pub fn transfer(step: impl Into<String>, downloaded: u64, total: u64) -> Self {
|
|
Self { downloaded: Some(downloaded), total: Some(total), ..Self::step(step) }
|
|
}
|
|
|
|
pub fn with_batch(mut self, db_type: Option<&str>, current: Option<u32>, total_drivers: Option<u32>) -> Self {
|
|
self.db_type = db_type.map(ToString::to_string);
|
|
self.current = current;
|
|
self.total_drivers = total_drivers;
|
|
self
|
|
}
|
|
|
|
pub fn with_operation_id(mut self, operation_id: &str) -> Self {
|
|
self.operation_id = Some(operation_id.to_string());
|
|
self
|
|
}
|
|
}
|
|
|
|
pub fn build_agent_list(am: &AgentManager, registry: Option<&AgentRegistry>) -> Vec<AgentDriverInfo> {
|
|
let local_state = am.load_state();
|
|
let use_managed_jre = local_state.java_runtime.mode == JavaRuntimeMode::Managed;
|
|
agent_catalog::driver_store_entries()
|
|
.map(|(key, label)| {
|
|
let jar_valid = am.is_driver_jar_valid(key);
|
|
let native_installed = am.driver_native_path(key).exists();
|
|
let launch_config_installed = am.driver_launch_config_path(key).exists();
|
|
let installed = jar_valid || native_installed || launch_config_installed;
|
|
let local = local_state.installed_drivers.get(key);
|
|
let remote = registry.and_then(|r| agent_registry_driver(r, key));
|
|
let remote_requires_java_runtime = remote.is_some_and(remote_driver_requires_java_runtime);
|
|
let requires_java_runtime = if installed {
|
|
jar_valid && !native_installed && !launch_config_installed
|
|
} else {
|
|
remote_requires_java_runtime
|
|
};
|
|
let jre_key = remote
|
|
.map(|r| r.jre.clone())
|
|
.or_else(|| local.map(|l| l.jre.clone()))
|
|
.unwrap_or_else(|| DEFAULT_JRE_KEY.to_string());
|
|
let remote_jre_version = registry.and_then(|r| r.resolve_jre(&jre_key)).map(|j| &j.version);
|
|
let local_jre_version = installed_jre_version(&local_state, &jre_key);
|
|
let jre_update_available = installed
|
|
&& requires_java_runtime
|
|
&& use_managed_jre
|
|
&& (!am.is_jre_installed(&jre_key)
|
|
|| remote_jre_version.is_some_and(|version| local_jre_version != Some(version)));
|
|
AgentDriverInfo {
|
|
db_type: key.to_string(),
|
|
label: label.to_string(),
|
|
version: remote.map(|r| r.version.clone()).unwrap_or_default(),
|
|
size: remote.and_then(driver_download_artifact).map(|artifact| artifact.size).unwrap_or(0),
|
|
installed,
|
|
installed_version: local.map(|l| l.version.clone()),
|
|
update_available: match (local, remote) {
|
|
(Some(l), Some(r)) => l.version != r.version || jre_update_available,
|
|
_ => false,
|
|
},
|
|
requires_java_runtime,
|
|
jre: jre_key.clone(),
|
|
jre_installed: !requires_java_runtime || am.is_jre_installed(&jre_key),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn driver_download_artifact(driver: &crate::agent_manager::DriverInfo) -> Option<&crate::agent_manager::ArtifactInfo> {
|
|
driver.native.get(AgentManager::current_platform()).or(driver.jar.as_ref())
|
|
}
|
|
|
|
fn remote_driver_requires_java_runtime(driver: &crate::agent_manager::DriverInfo) -> bool {
|
|
driver.jar.is_some() && !driver.native.contains_key(AgentManager::current_platform())
|
|
}
|
|
|
|
fn installed_jre_version<'a>(state: &'a crate::agent_manager::AgentState, jre_key: &str) -> Option<&'a String> {
|
|
state
|
|
.jre_versions
|
|
.get(jre_key)
|
|
.or_else(|| (jre_key == DEFAULT_JRE_KEY).then_some(state.jre_version.as_ref()).flatten())
|
|
}
|
|
|
|
fn mark_executable(path: &Path) -> Result<(), String> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut permissions = std::fs::metadata(path).map_err(|err| err.to_string())?.permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(path, permissions).map_err(|err| err.to_string())?;
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = path;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn jre_needs_install(am: &AgentManager, registry: &AgentRegistry, jre_key: &str) -> bool {
|
|
let state = am.load_state();
|
|
if state.java_runtime.mode != JavaRuntimeMode::Managed {
|
|
return false;
|
|
}
|
|
if !am.is_jre_installed(jre_key) {
|
|
return true;
|
|
}
|
|
registry.resolve_jre(jre_key).is_some_and(|jre| state.jre_versions.get(jre_key) != Some(&jre.version))
|
|
}
|
|
|
|
pub fn local_agent_jar_candidates(db_type: &str) -> Vec<PathBuf> {
|
|
let jar_name = format!("dbx-agent-{db_type}.jar");
|
|
let mut candidates = Vec::new();
|
|
|
|
for agents_dir in local_agents_dir_candidates() {
|
|
candidates.push(agent_driver_jar_path(&agents_dir, db_type, &jar_name));
|
|
candidates.push(agent_legacy_jar_path(&agents_dir, db_type, &jar_name));
|
|
}
|
|
|
|
candidates
|
|
}
|
|
|
|
fn local_agents_dir_candidates() -> Vec<PathBuf> {
|
|
let mut candidates = vec![PathBuf::from("agents"), PathBuf::from("..").join("agents")];
|
|
if let Some(workspace_root) = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().and_then(|path| path.parent()) {
|
|
candidates.push(workspace_root.join("agents"));
|
|
}
|
|
candidates.push(PathBuf::from("..").join("dbx-agents"));
|
|
candidates.push(PathBuf::from("dbx-agents"));
|
|
candidates
|
|
}
|
|
|
|
fn agent_driver_jar_path(agents_dir: &Path, db_type: &str, jar_name: &str) -> PathBuf {
|
|
agents_dir.join("drivers").join(db_type).join("build").join("libs").join(jar_name)
|
|
}
|
|
|
|
fn agent_legacy_jar_path(agents_dir: &Path, db_type: &str, jar_name: &str) -> PathBuf {
|
|
agents_dir.join(db_type).join("build").join("libs").join(jar_name)
|
|
}
|
|
|
|
pub fn find_local_agent_jar(db_type: &str) -> Option<PathBuf> {
|
|
local_agent_jar_candidates(db_type).into_iter().find(|path| path.exists())
|
|
}
|
|
|
|
pub fn install_local_agent(am: &AgentManager, db_type: &str, source: PathBuf) -> Result<(), String> {
|
|
install_local_agent_file(am, db_type, &source)?;
|
|
am.mutate_state(|state| record_local_agent_install(state, db_type, DEFAULT_JRE_KEY))
|
|
}
|
|
|
|
fn install_local_agent_file(am: &AgentManager, db_type: &str, source: &Path) -> Result<(), String> {
|
|
let jar_path = am.driver_jar_path(db_type);
|
|
let parent = jar_path.parent().ok_or_else(|| format!("Invalid driver path: {}", jar_path.display()))?;
|
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
let staging_path = parent.join(format!(".agent-jar-import-{}", uuid::Uuid::new_v4()));
|
|
std::fs::copy(source, &staging_path).map_err(|e| format!("Failed to copy local agent jar: {e}"))?;
|
|
if !is_valid_agent_jar(&staging_path) {
|
|
std::fs::remove_file(&staging_path).ok();
|
|
return Err(format!("Local agent jar is invalid or corrupt: {}", source.display()));
|
|
}
|
|
replace_imported_agent_file(&staging_path, &jar_path)
|
|
}
|
|
|
|
fn record_local_agent_install(state: &mut crate::agent_manager::AgentState, db_type: &str, jre_key: &str) {
|
|
state.installed_drivers.insert(
|
|
db_type.to_string(),
|
|
InstalledDriver {
|
|
version: "0.1.0-local".to_string(),
|
|
installed_at: chrono::Utc::now().to_rfc3339(),
|
|
jre: jre_key.to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
fn is_valid_agent_jar(path: &Path) -> bool {
|
|
let Ok(file) = std::fs::File::open(path) else {
|
|
return false;
|
|
};
|
|
let Ok(mut archive) = zip::ZipArchive::new(file) else {
|
|
return false;
|
|
};
|
|
let Ok(mut manifest) = archive.by_name("META-INF/MANIFEST.MF") else {
|
|
return false;
|
|
};
|
|
let mut manifest_text = String::new();
|
|
manifest.read_to_string(&mut manifest_text).is_ok() && manifest_text.contains("Main-Class:")
|
|
}
|
|
|
|
pub async fn fetch_registry() -> Result<AgentRegistry, String> {
|
|
fetch_registry_from(DownloadSource::Official).await
|
|
}
|
|
|
|
pub async fn fetch_registry_from(source: DownloadSource) -> Result<AgentRegistry, String> {
|
|
{
|
|
let cache = REGISTRY_CACHE.lock().await;
|
|
if let Some((ts, registry)) = cache.get(&source) {
|
|
if ts.elapsed() < std::time::Duration::from_secs(300) {
|
|
return Ok(registry.clone());
|
|
}
|
|
}
|
|
}
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|err| format!("Failed to create HTTP client: {err}"))?;
|
|
let resp = open_download_response(&client, source, REGISTRY_PATH, REGISTRY_R2_PATH, "dbx-agent-manager")
|
|
.await
|
|
.map_err(|err| format!("Failed to fetch agent registry: {err}"))?;
|
|
let registry: AgentRegistry = resp.json().await.map_err(|err| format!("Failed to parse registry: {err}"))?;
|
|
REGISTRY_CACHE.lock().await.insert(source, (std::time::Instant::now(), registry.clone()));
|
|
Ok(registry)
|
|
}
|
|
|
|
async fn open_download_response(
|
|
client: &reqwest::Client,
|
|
source: DownloadSource,
|
|
github_url: &str,
|
|
r2_path: &str,
|
|
user_agent: &str,
|
|
) -> Result<reqwest::Response, String> {
|
|
let mut errors = Vec::new();
|
|
for url in source.download_candidate_urls(github_url, r2_path)? {
|
|
match client
|
|
.get(&url)
|
|
.header(reqwest::header::USER_AGENT, user_agent)
|
|
.header(reqwest::header::ACCEPT_ENCODING, "identity")
|
|
.send()
|
|
.await
|
|
.and_then(|response| response.error_for_status())
|
|
{
|
|
Ok(response) => return Ok(response),
|
|
Err(error) => errors.push(format!("{url}: {error}")),
|
|
}
|
|
}
|
|
Err(errors.join("; "))
|
|
}
|
|
|
|
pub async fn invalidate_registry_cache() {
|
|
REGISTRY_CACHE.lock().await.clear();
|
|
}
|
|
|
|
pub async fn install_agent_driver(
|
|
am: &AgentManager,
|
|
db_type: &str,
|
|
progress: impl Fn(AgentProgressEvent),
|
|
) -> Result<(), String> {
|
|
install_agent_driver_from(am, db_type, DownloadSource::Official, progress).await
|
|
}
|
|
|
|
pub async fn install_agent_driver_from(
|
|
am: &AgentManager,
|
|
db_type: &str,
|
|
source: DownloadSource,
|
|
progress: impl Fn(AgentProgressEvent),
|
|
) -> Result<(), String> {
|
|
install_agent_driver_with_batch(am, db_type, source, &progress, None, None).await
|
|
}
|
|
|
|
/// Ensure an already-selected fallback Agent can be launched, installing only
|
|
/// missing or invalid runtime artifacts. A usable installed driver is never
|
|
/// upgraded implicitly.
|
|
pub async fn ensure_agent_driver_ready(am: &AgentManager, db_type: &str) -> Result<(), String> {
|
|
ensure_agent_driver_ready_from(am, db_type, DownloadSource::Official).await
|
|
}
|
|
|
|
async fn ensure_agent_driver_ready_from(
|
|
am: &AgentManager,
|
|
db_type: &str,
|
|
source: DownloadSource,
|
|
) -> Result<(), String> {
|
|
if agent_driver_runtime_readiness(am, db_type).is_ok() {
|
|
return Ok(());
|
|
}
|
|
|
|
let _installation_guard = am.installation_operation_lock.read().await;
|
|
let driver_lock = driver_operation_lock(am, db_type).await;
|
|
let _driver_guard = driver_lock.lock().await;
|
|
|
|
// Another fallback may have completed installation while this task waited.
|
|
if agent_driver_runtime_readiness(am, db_type).is_ok() {
|
|
return Ok(());
|
|
}
|
|
|
|
let registry = fetch_registry_from(source).await?;
|
|
let progress = |_| {};
|
|
if !am.is_driver_installed(db_type) {
|
|
install_agent_driver_from_registry(am, ®istry, source, db_type, &progress, None, None).await?;
|
|
} else if am.driver_requires_java_runtime(db_type) {
|
|
let state = am.load_state();
|
|
let jre_key = state
|
|
.installed_drivers
|
|
.get(db_type)
|
|
.map(|driver| driver.jre.as_str())
|
|
.or_else(|| agent_registry_driver(®istry, db_type).map(|driver| driver.jre.as_str()))
|
|
.unwrap_or(DEFAULT_JRE_KEY);
|
|
ensure_jre_from_registry(am, ®istry, source, jre_key, db_type, &progress, None, None).await?;
|
|
}
|
|
|
|
agent_driver_runtime_readiness(am, db_type)
|
|
}
|
|
|
|
fn agent_driver_runtime_readiness(am: &AgentManager, db_type: &str) -> Result<(), String> {
|
|
let state = am.load_state();
|
|
let jre_key = state.installed_drivers.get(db_type).map(|driver| driver.jre.as_str()).unwrap_or(DEFAULT_JRE_KEY);
|
|
am.resolve_agent_launch_spec(&state, db_type, jre_key).map(|_| ())
|
|
}
|
|
|
|
pub async fn upgrade_all_agent_drivers(
|
|
am: &AgentManager,
|
|
progress: impl Fn(AgentProgressEvent),
|
|
) -> Result<UpgradeAllAgentDriversResult, String> {
|
|
upgrade_all_agent_drivers_from(am, DownloadSource::Official, progress).await
|
|
}
|
|
|
|
pub async fn upgrade_all_agent_drivers_from(
|
|
am: &AgentManager,
|
|
source: DownloadSource,
|
|
progress: impl Fn(AgentProgressEvent),
|
|
) -> Result<UpgradeAllAgentDriversResult, String> {
|
|
let registry = fetch_registry_from(source).await?;
|
|
upgrade_all_agent_drivers_with_registry(am, ®istry, source, &progress).await
|
|
}
|
|
|
|
async fn upgrade_all_agent_drivers_with_registry(
|
|
am: &AgentManager,
|
|
registry: &AgentRegistry,
|
|
source: DownloadSource,
|
|
progress: &impl Fn(AgentProgressEvent),
|
|
) -> Result<UpgradeAllAgentDriversResult, String> {
|
|
let agents = build_agent_list(am, Some(registry));
|
|
let updatable: Vec<String> =
|
|
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
|
|
let total = updatable.len() as u32;
|
|
|
|
// Run independent driver installs concurrently, with a fixed upper bound
|
|
// so a large registry cannot saturate download and file-system resources.
|
|
let installs = updatable.into_iter().enumerate().map(|(index, db_type)| async move {
|
|
let result = install_agent_driver_from_registry_locked(
|
|
am,
|
|
registry,
|
|
source,
|
|
&db_type,
|
|
progress,
|
|
Some((index + 1) as u32),
|
|
Some(total),
|
|
)
|
|
.await;
|
|
(db_type, result)
|
|
});
|
|
|
|
let outcomes = stream::iter(installs).buffer_unordered(MAX_CONCURRENT_AGENT_UPDATES).collect::<Vec<_>>().await;
|
|
|
|
let mut result = UpgradeAllAgentDriversResult::default();
|
|
for (db_type, outcome) in outcomes {
|
|
match outcome {
|
|
Ok(()) => result.upgraded += 1,
|
|
Err(error) => {
|
|
log::warn!("Failed to update {} agent driver: {}", db_type, error);
|
|
result.failed.push(AgentDriverUpdateIssue { db_type, error });
|
|
}
|
|
}
|
|
}
|
|
|
|
progress(AgentProgressEvent::step("all-done"));
|
|
Ok(result)
|
|
}
|
|
|
|
async fn driver_operation_lock(am: &AgentManager, db_type: &str) -> Arc<tokio::sync::Mutex<()>> {
|
|
let mut locks = am.driver_operation_locks.lock().await;
|
|
locks.entry(db_type.to_string()).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))).clone()
|
|
}
|
|
|
|
async fn jre_operation_lock(am: &AgentManager, jre_key: &str) -> Arc<tokio::sync::Mutex<()>> {
|
|
let mut locks = am.jre_install_locks.lock().await;
|
|
locks.entry(jre_key.to_string()).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))).clone()
|
|
}
|
|
|
|
pub async fn uninstall_agent_driver(am: &AgentManager, db_type: &str) -> Result<(), String> {
|
|
let _installation_guard = am.installation_operation_lock.read().await;
|
|
let driver_lock = driver_operation_lock(am, db_type).await;
|
|
let _driver_guard = driver_lock.lock().await;
|
|
prune_driver_download_cache(am, db_type)?;
|
|
let jar_path = am.driver_jar_path(db_type);
|
|
if jar_path.exists() {
|
|
std::fs::remove_file(&jar_path).map_err(|err| err.to_string())?;
|
|
}
|
|
if let Some(driver_dir) = jar_path.parent() {
|
|
if driver_dir.exists() {
|
|
std::fs::remove_dir_all(driver_dir).map_err(|err| err.to_string())?;
|
|
}
|
|
}
|
|
am.mutate_state(|state| state.installed_drivers.remove(db_type))?;
|
|
am.stop_daemon_by_key(db_type).await;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn clear_agent_download_cache(am: &AgentManager) -> Result<(), String> {
|
|
remove_download_cache_entries(am, |_| true, "download cache")
|
|
}
|
|
|
|
pub async fn uninstall_agent_jre(am: &AgentManager, jre_key: &str) -> Result<(), String> {
|
|
// Keep the dependency check and removal atomic with respect to driver
|
|
// installs/uninstalls that may add or remove a dependency on this JRE.
|
|
let _installation_guard = am.installation_operation_lock.write().await;
|
|
let jre_lock = jre_operation_lock(am, jre_key).await;
|
|
let _jre_guard = jre_lock.lock().await;
|
|
let local_state = am.load_state();
|
|
let dependents: Vec<&str> = local_state
|
|
.installed_drivers
|
|
.keys()
|
|
.filter(|db_type| am.installed_driver_jre_dependency(&local_state, db_type) == Some(jre_key))
|
|
.map(|k| k.as_str())
|
|
.collect();
|
|
if !dependents.is_empty() {
|
|
return Err(format!("JRE {jre_key} is in use by drivers: {}. Uninstall them first.", dependents.join(", ")));
|
|
}
|
|
// Stop daemons first so any java.exe holding the JRE files exits before
|
|
// we try to remove the directory (Windows ERROR_ACCESS_DENIED otherwise).
|
|
am.stop_daemons().await;
|
|
let jre_dir = am.jre_dir(jre_key);
|
|
if let Err(err) = remove_jre_dir_with_retry(&jre_dir) {
|
|
return Err(format_jre_dir_remove_error(&jre_dir, &err));
|
|
}
|
|
am.mutate_state(|state| state.jre_versions.remove(jre_key))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reinstall_agent_jre(
|
|
am: &AgentManager,
|
|
jre_key: &str,
|
|
progress: impl Fn(AgentProgressEvent),
|
|
) -> Result<(), String> {
|
|
reinstall_agent_jre_from(am, jre_key, DownloadSource::Official, progress).await
|
|
}
|
|
|
|
pub async fn reinstall_agent_jre_from(
|
|
am: &AgentManager,
|
|
jre_key: &str,
|
|
source: DownloadSource,
|
|
progress: impl Fn(AgentProgressEvent),
|
|
) -> Result<(), String> {
|
|
// Replacing a JRE must not race a driver operation that is using or about
|
|
// to persist a dependency on the same runtime.
|
|
let _installation_guard = am.installation_operation_lock.write().await;
|
|
let jre_lock = jre_operation_lock(am, jre_key).await;
|
|
let _jre_guard = jre_lock.lock().await;
|
|
let registry = fetch_registry_from(source).await?;
|
|
let jre_info = registry.resolve_jre(jre_key).ok_or_else(|| format!("No JRE definition for version: {jre_key}"))?;
|
|
let platform = AgentManager::current_platform();
|
|
let platform_jre = jre_info
|
|
.platforms
|
|
.get(platform)
|
|
.ok_or_else(|| format!("No JRE {jre_key} available for platform: {platform}"))?;
|
|
let jre_archive = jre_archive_download_path(am, jre_key, platform_jre.format);
|
|
download_with_progress(
|
|
am,
|
|
&progress,
|
|
"jre",
|
|
source,
|
|
&platform_jre.url,
|
|
&r2_path_with_cache_buster(&github_url_to_r2_path(&platform_jre.url, "jre"), &jre_info.version),
|
|
&jre_archive,
|
|
platform_jre.size,
|
|
platform_jre.sha256.as_deref(),
|
|
Some(CacheIdentity::Jre { key: jre_key, version: &jre_info.version }),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await?;
|
|
let jre_dir = am.jre_dir(jre_key);
|
|
// Stop daemons before deleting so java.exe processes release file
|
|
// handles on Windows (Issue #1100). Falls back to a rename-stash if the
|
|
// directory still cannot be removed.
|
|
am.stop_daemons().await;
|
|
let stash = replace_old_jre_dir(&jre_dir)?;
|
|
persist_pending_jre_cleanup(am, stash.as_ref()).await?;
|
|
extract_jre_archive(&jre_archive, &jre_dir, platform_jre.format)?;
|
|
std::fs::remove_file(&jre_archive).ok();
|
|
am.mutate_state(|state| state.jre_versions.insert(jre_key.to_string(), jre_info.version.clone()))?;
|
|
cleanup_jre_download_cache_after_success(am, jre_key);
|
|
progress(AgentProgressEvent::step("done"));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn import_agents_from_zip(
|
|
am: &AgentManager,
|
|
zip_path: &Path,
|
|
progress: impl Fn(AgentProgressEvent),
|
|
) -> Result<OfflineImportResult, String> {
|
|
import_offline_zip(am, zip_path, |p| {
|
|
progress(AgentProgressEvent {
|
|
operation_id: None,
|
|
step: p.step,
|
|
downloaded: Some(p.current as u64),
|
|
total: Some(p.total as u64),
|
|
db_type: p.db_type,
|
|
current: Some(p.current),
|
|
total_drivers: Some(p.total),
|
|
});
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub fn inspect_offline_package(package_path: &Path) -> Result<OfflineImportPlan, String> {
|
|
if is_tar_zstd_package(package_path) {
|
|
inspect_tar_zstd_driver_package(package_path)
|
|
} else {
|
|
inspect_offline_zip(package_path)
|
|
}
|
|
}
|
|
|
|
pub async fn import_agents_from_package(
|
|
am: &AgentManager,
|
|
package_path: &Path,
|
|
progress: impl Fn(AgentProgressEvent),
|
|
) -> Result<OfflineImportResult, String> {
|
|
if is_tar_zstd_package(package_path) {
|
|
import_tar_zstd_driver_package(am, package_path, |event| {
|
|
progress(AgentProgressEvent {
|
|
operation_id: None,
|
|
step: event.step,
|
|
downloaded: Some(event.current as u64),
|
|
total: Some(event.total as u64),
|
|
db_type: event.db_type,
|
|
current: Some(event.current),
|
|
total_drivers: Some(event.total),
|
|
});
|
|
})
|
|
.await
|
|
} else {
|
|
import_agents_from_zip(am, package_path, progress).await
|
|
}
|
|
}
|
|
|
|
fn is_tar_zstd_package(path: &Path) -> bool {
|
|
path.file_name().and_then(|name| name.to_str()).is_some_and(|name| name.to_ascii_lowercase().ends_with(".tar.zst"))
|
|
}
|
|
|
|
async fn install_agent_driver_with_batch(
|
|
am: &AgentManager,
|
|
db_type: &str,
|
|
source: DownloadSource,
|
|
progress: &impl Fn(AgentProgressEvent),
|
|
current: Option<u32>,
|
|
total_drivers: Option<u32>,
|
|
) -> Result<(), String> {
|
|
let _installation_guard = am.installation_operation_lock.read().await;
|
|
let driver_lock = driver_operation_lock(am, db_type).await;
|
|
let _driver_guard = driver_lock.lock().await;
|
|
install_agent_driver_with_batch_unlocked(am, db_type, source, progress, current, total_drivers).await
|
|
}
|
|
|
|
async fn install_agent_driver_from_registry_locked(
|
|
am: &AgentManager,
|
|
registry: &AgentRegistry,
|
|
source: DownloadSource,
|
|
db_type: &str,
|
|
progress: &impl Fn(AgentProgressEvent),
|
|
current: Option<u32>,
|
|
total_drivers: Option<u32>,
|
|
) -> Result<(), String> {
|
|
let _installation_guard = am.installation_operation_lock.read().await;
|
|
let driver_lock = driver_operation_lock(am, db_type).await;
|
|
let _driver_guard = driver_lock.lock().await;
|
|
install_agent_driver_from_registry(am, registry, source, db_type, progress, current, total_drivers).await
|
|
}
|
|
|
|
async fn install_agent_driver_with_batch_unlocked(
|
|
am: &AgentManager,
|
|
db_type: &str,
|
|
source: DownloadSource,
|
|
progress: &impl Fn(AgentProgressEvent),
|
|
current: Option<u32>,
|
|
total_drivers: Option<u32>,
|
|
) -> Result<(), String> {
|
|
match fetch_registry_from(source).await {
|
|
Ok(registry) => {
|
|
match install_agent_driver_from_registry(am, ®istry, source, db_type, progress, current, total_drivers)
|
|
.await
|
|
{
|
|
Ok(()) => Ok(()),
|
|
Err(registry_err) => {
|
|
if let Some(local_jar) = find_local_agent_jar(db_type) {
|
|
install_local_agent_with_registry_jre(
|
|
am,
|
|
®istry,
|
|
source,
|
|
db_type,
|
|
local_jar,
|
|
progress,
|
|
current,
|
|
total_drivers,
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
Err(registry_err)
|
|
}
|
|
}
|
|
}
|
|
Err(registry_err) => {
|
|
if let Some(local_jar) = find_local_agent_jar(db_type) {
|
|
install_local_agent(am, db_type, local_jar)?;
|
|
am.stop_daemon_by_key(db_type).await;
|
|
progress(AgentProgressEvent::step("done").with_batch(Some(db_type), current, total_drivers));
|
|
return Ok(());
|
|
}
|
|
Err(registry_err)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn ensure_jre_from_registry(
|
|
am: &AgentManager,
|
|
registry: &AgentRegistry,
|
|
source: DownloadSource,
|
|
jre_key: &str,
|
|
db_type: &str,
|
|
progress: &impl Fn(AgentProgressEvent),
|
|
current: Option<u32>,
|
|
total_drivers: Option<u32>,
|
|
) -> Result<(), String> {
|
|
// Fast path: already installed — return immediately without acquiring the
|
|
// per-JRE lock. The lock is only needed when a download + extract may be
|
|
// required.
|
|
if !jre_needs_install(am, registry, jre_key) {
|
|
return Ok(());
|
|
}
|
|
|
|
// Acquire (or create) the per-JRE-key mutex so that concurrent driver
|
|
// installs sharing the same JRE download it exactly once.
|
|
let lock = jre_operation_lock(am, jre_key).await;
|
|
let _jre_guard = lock.lock().await;
|
|
|
|
// Double-check: the previous lock holder may have already installed.
|
|
if !jre_needs_install(am, registry, jre_key) {
|
|
return Ok(());
|
|
}
|
|
|
|
let jre_info = registry.resolve_jre(jre_key).ok_or_else(|| format!("No JRE definition for version: {jre_key}"))?;
|
|
let platform = AgentManager::current_platform();
|
|
let platform_jre = jre_info
|
|
.platforms
|
|
.get(platform)
|
|
.ok_or_else(|| format!("No JRE {jre_key} available for platform: {platform}"))?;
|
|
let jre_archive = jre_archive_download_path(am, jre_key, platform_jre.format);
|
|
progress(AgentProgressEvent::transfer("jre", 0, platform_jre.size).with_batch(
|
|
Some(db_type),
|
|
current,
|
|
total_drivers,
|
|
));
|
|
download_with_progress(
|
|
am,
|
|
progress,
|
|
"jre",
|
|
source,
|
|
&platform_jre.url,
|
|
&r2_path_with_cache_buster(&github_url_to_r2_path(&platform_jre.url, "jre"), &jre_info.version),
|
|
&jre_archive,
|
|
platform_jre.size,
|
|
platform_jre.sha256.as_deref(),
|
|
Some(CacheIdentity::Jre { key: jre_key, version: &jre_info.version }),
|
|
Some(db_type),
|
|
current,
|
|
total_drivers,
|
|
)
|
|
.await?;
|
|
progress(AgentProgressEvent::transfer("jre-extract", 0, 0).with_batch(Some(db_type), current, total_drivers));
|
|
let jre_dir = am.jre_dir(jre_key);
|
|
// Stop only daemons that use this JRE before replacing its directory
|
|
// (Windows ERROR_ACCESS_DENIED, Issue #1100). In a concurrent
|
|
// upgrade-all this avoids killing unrelated daemons mid-install.
|
|
stop_daemons_using_jre(am, jre_key).await;
|
|
let stash = replace_old_jre_dir(&jre_dir)?;
|
|
|
|
// Persist the stash path *before* extraction so that a crash during
|
|
// archive extraction (or a process kill) doesn't leave the renamed-stash
|
|
// directory as an orphan that never gets cleaned up.
|
|
persist_pending_jre_cleanup(am, stash.as_ref()).await?;
|
|
|
|
extract_jre_archive(&jre_archive, &jre_dir, platform_jre.format)?;
|
|
std::fs::remove_file(&jre_archive).ok();
|
|
cleanup_jre_download_cache_after_success(am, jre_key);
|
|
|
|
// Persist the JRE version after extraction succeeds, while still holding
|
|
// the per-JRE lock. This guarantees the DCL in another task's
|
|
// jre_needs_install() sees the installed version and skips download.
|
|
am.mutate_state(|state| state.jre_versions.insert(jre_key.to_string(), jre_info.version.clone()))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop daemons whose installed driver actually runs on `jre_key`.
|
|
async fn stop_daemons_using_jre(am: &AgentManager, jre_key: &str) {
|
|
let state = am.load_state();
|
|
let keys: Vec<String> = state
|
|
.installed_drivers
|
|
.keys()
|
|
.filter(|db_type| am.installed_driver_jre_dependency(&state, db_type) == Some(jre_key))
|
|
.cloned()
|
|
.collect();
|
|
for db_type in keys {
|
|
am.stop_daemon_by_key(&db_type).await;
|
|
}
|
|
}
|
|
|
|
/// Record a rename-stashed JRE before extraction so startup can clean it up
|
|
/// even if the process exits mid-install.
|
|
async fn persist_pending_jre_cleanup(am: &AgentManager, stash: Option<&PathBuf>) -> Result<(), String> {
|
|
let Some(stash_path) = stash else {
|
|
return Ok(());
|
|
};
|
|
|
|
am.mutate_state(|state| {
|
|
if !state.pending_jre_cleanup.contains(stash_path) {
|
|
state.pending_jre_cleanup.push(stash_path.clone());
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn persist_local_agent_install_state(
|
|
am: &AgentManager,
|
|
db_type: &str,
|
|
jre_key: &str,
|
|
jre_version: Option<&str>,
|
|
) -> Result<(), String> {
|
|
am.mutate_state(|state| {
|
|
if let Some(version) = jre_version {
|
|
state.jre_versions.insert(jre_key.to_string(), version.to_string());
|
|
}
|
|
record_local_agent_install(state, db_type, jre_key);
|
|
})
|
|
}
|
|
|
|
async fn install_local_agent_with_registry_jre(
|
|
am: &AgentManager,
|
|
registry: &AgentRegistry,
|
|
source: DownloadSource,
|
|
db_type: &str,
|
|
local_jar: PathBuf,
|
|
progress: &impl Fn(AgentProgressEvent),
|
|
current: Option<u32>,
|
|
total_drivers: Option<u32>,
|
|
) -> Result<(), String> {
|
|
let jre_key = DEFAULT_JRE_KEY;
|
|
if jre_needs_install(am, registry, jre_key) {
|
|
ensure_jre_from_registry(am, registry, source, jre_key, db_type, progress, current, total_drivers).await?;
|
|
}
|
|
install_local_agent_file(am, db_type, &local_jar)?;
|
|
// This fallback can run for several drivers during upgrade-all. Keep its
|
|
// driver and JRE updates in one state_lock-protected transaction.
|
|
persist_local_agent_install_state(
|
|
am,
|
|
db_type,
|
|
jre_key,
|
|
registry.resolve_jre(jre_key).map(|jre| jre.version.as_str()),
|
|
)
|
|
.await?;
|
|
am.stop_daemon_by_key(db_type).await;
|
|
progress(AgentProgressEvent::step("done").with_batch(Some(db_type), current, total_drivers));
|
|
Ok(())
|
|
}
|
|
|
|
async fn install_agent_driver_from_registry(
|
|
am: &AgentManager,
|
|
registry: &AgentRegistry,
|
|
source: DownloadSource,
|
|
db_type: &str,
|
|
progress: &impl Fn(AgentProgressEvent),
|
|
current: Option<u32>,
|
|
total_drivers: Option<u32>,
|
|
) -> Result<(), String> {
|
|
let Some(driver) = agent_registry_driver(registry, db_type) else {
|
|
if let Some(local_jar) = find_local_agent_jar(db_type) {
|
|
install_local_agent_with_registry_jre(
|
|
am,
|
|
registry,
|
|
source,
|
|
db_type,
|
|
local_jar,
|
|
progress,
|
|
current,
|
|
total_drivers,
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
return Err(format!("Unknown driver type: {db_type}"));
|
|
};
|
|
let jre_key = &driver.jre;
|
|
let native_artifact = driver.native.get(AgentManager::current_platform());
|
|
let jar_artifact = driver.jar.as_ref();
|
|
let requires_java_runtime = native_artifact.is_none();
|
|
let needs_jre = requires_java_runtime && jre_needs_install(am, registry, jre_key);
|
|
|
|
if needs_jre {
|
|
ensure_jre_from_registry(am, registry, source, jre_key, db_type, progress, current, total_drivers).await?;
|
|
}
|
|
|
|
let (artifact, target_path, is_native_artifact) = if let Some(native) = native_artifact {
|
|
(native, am.driver_native_path(db_type), true)
|
|
} else if let Some(jar) = jar_artifact {
|
|
(jar, am.driver_jar_path(db_type), false)
|
|
} else {
|
|
return Err(format!("No driver artifact available for {db_type}"));
|
|
};
|
|
if let Some(parent) = target_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|err| format!("Failed to create driver directory: {err}"))?;
|
|
}
|
|
let artifact_kind = if is_native_artifact { DriverArtifactKind::Native } else { DriverArtifactKind::Jar };
|
|
let download_path = driver_artifact_download_path(&target_path, artifact.format);
|
|
progress(AgentProgressEvent::transfer("driver", 0, artifact.size).with_batch(
|
|
Some(db_type),
|
|
current,
|
|
total_drivers,
|
|
));
|
|
download_with_progress(
|
|
am,
|
|
progress,
|
|
"driver",
|
|
source,
|
|
&artifact.url,
|
|
&r2_path_with_cache_buster(&github_url_to_r2_path(&artifact.url, "driver"), &driver.version),
|
|
&download_path,
|
|
artifact.size,
|
|
artifact.sha256.as_deref(),
|
|
Some(CacheIdentity::Driver { db_type, version: &driver.version }),
|
|
Some(db_type),
|
|
current,
|
|
total_drivers,
|
|
)
|
|
.await?;
|
|
install_downloaded_driver_artifact(
|
|
&download_path,
|
|
&target_path,
|
|
artifact.format,
|
|
artifact_kind,
|
|
db_type,
|
|
&driver.version,
|
|
)?;
|
|
// Some drivers publish both a native agent and a legacy JAR fallback. Only
|
|
// validate the artifact type that was actually installed.
|
|
if is_native_artifact {
|
|
mark_executable(&target_path)?;
|
|
std::fs::remove_file(am.driver_jar_path(db_type)).ok();
|
|
} else {
|
|
if !am.is_driver_jar_valid(db_type) {
|
|
std::fs::remove_file(&target_path).ok();
|
|
return Err(format!("Downloaded driver jar is invalid or corrupt: {}", target_path.display()));
|
|
}
|
|
std::fs::remove_file(am.driver_native_path(db_type)).ok();
|
|
}
|
|
|
|
am.mutate_state(|state| {
|
|
if requires_java_runtime {
|
|
if let Some(jre_info) = registry.resolve_jre(jre_key) {
|
|
state.jre_versions.insert(jre_key.clone(), jre_info.version.clone());
|
|
}
|
|
}
|
|
state.installed_drivers.insert(
|
|
db_type.to_string(),
|
|
InstalledDriver {
|
|
version: driver.version.clone(),
|
|
installed_at: chrono::Utc::now().to_rfc3339(),
|
|
jre: jre_key.clone(),
|
|
},
|
|
);
|
|
})?;
|
|
am.stop_daemon_by_key(db_type).await;
|
|
cleanup_driver_download_cache_after_success(am, db_type);
|
|
progress(AgentProgressEvent::step("done").with_batch(Some(db_type), current, total_drivers));
|
|
Ok(())
|
|
}
|
|
|
|
fn driver_artifact_download_path(target_path: &Path, format: Option<ArtifactFormat>) -> PathBuf {
|
|
match format {
|
|
Some(ArtifactFormat::TarZstd) => {
|
|
let file_name = target_path.file_name().and_then(|name| name.to_str()).unwrap_or("agent");
|
|
target_path.with_file_name(format!(".{file_name}.tar.zst"))
|
|
}
|
|
None => target_path.to_path_buf(),
|
|
}
|
|
}
|
|
|
|
fn install_downloaded_driver_artifact(
|
|
download_path: &Path,
|
|
target_path: &Path,
|
|
format: Option<ArtifactFormat>,
|
|
artifact_kind: DriverArtifactKind,
|
|
db_type: &str,
|
|
expected_version: &str,
|
|
) -> Result<(), String> {
|
|
let Some(format) = format else {
|
|
return Ok(());
|
|
};
|
|
let result = match format {
|
|
ArtifactFormat::TarZstd => {
|
|
install_driver_from_tar_zstd_package(download_path, target_path, artifact_kind, db_type, expected_version)
|
|
}
|
|
};
|
|
if result.is_ok() {
|
|
std::fs::remove_file(download_path).ok();
|
|
}
|
|
result
|
|
}
|
|
|
|
fn install_driver_from_tar_zstd_package(
|
|
package_path: &Path,
|
|
target_path: &Path,
|
|
expected_kind: DriverArtifactKind,
|
|
db_type: &str,
|
|
expected_version: &str,
|
|
) -> Result<(), String> {
|
|
let info = tar_zstd_driver_package_info(package_path)?;
|
|
if info.db_type != db_type {
|
|
return Err(format!("Driver package contains {}, expected {db_type}", info.db_type));
|
|
}
|
|
if info.version != expected_version {
|
|
return Err(format!(
|
|
"Driver package version mismatch for {db_type}: expected {expected_version}, got {}",
|
|
info.version
|
|
));
|
|
}
|
|
if info.kind != expected_kind {
|
|
return Err(format!(
|
|
"Driver package artifact type mismatch for {db_type}: expected {}, got {}",
|
|
expected_kind.label(),
|
|
info.kind.label()
|
|
));
|
|
}
|
|
let parent = target_path.parent().ok_or_else(|| format!("Invalid driver path: {}", target_path.display()))?;
|
|
std::fs::create_dir_all(parent).map_err(|error| format!("Failed to create driver directory: {error}"))?;
|
|
let staging_path = parent.join(format!(".agent-package-{}", uuid::Uuid::new_v4()));
|
|
let result = extract_tar_zstd_file(package_path, &info.entry_name, &staging_path, info.size).and_then(|_| {
|
|
match info.kind {
|
|
DriverArtifactKind::Jar if !is_valid_agent_jar(&staging_path) => {
|
|
return Err(format!("Packaged driver jar is invalid or corrupt: {}", info.entry_name));
|
|
}
|
|
DriverArtifactKind::Native => {
|
|
validate_native_agent_binary(&staging_path)?;
|
|
mark_executable(&staging_path)?;
|
|
}
|
|
DriverArtifactKind::Jar => {}
|
|
}
|
|
replace_download(&staging_path, target_path)
|
|
});
|
|
if result.is_err() {
|
|
std::fs::remove_file(&staging_path).ok();
|
|
}
|
|
result
|
|
}
|
|
|
|
fn read_registry_from_tar_zstd(package_path: &Path) -> Result<AgentRegistry, String> {
|
|
const MAX_REGISTRY_BYTES: u64 = 1024 * 1024;
|
|
|
|
let file = std::fs::File::open(package_path).map_err(|error| format!("Failed to open driver package: {error}"))?;
|
|
let decoder = zstd::stream::read::Decoder::new(file)
|
|
.map_err(|error| format!("Failed to open zstd driver package: {error}"))?;
|
|
let mut archive = tar::Archive::new(decoder);
|
|
let entries = archive.entries().map_err(|error| format!("Invalid tar.zst driver package: {error}"))?;
|
|
for entry in entries {
|
|
let mut entry = entry.map_err(|error| format!("Invalid tar.zst driver package entry: {error}"))?;
|
|
let path = entry.path().map_err(|error| format!("Invalid driver package path: {error}"))?;
|
|
let name = safe_archive_entry_name(&path)?;
|
|
if name != "agent-registry.json" {
|
|
continue;
|
|
}
|
|
if !entry.header().entry_type().is_file() {
|
|
return Err("Driver package registry is not a regular file".to_string());
|
|
}
|
|
if entry.size() > MAX_REGISTRY_BYTES {
|
|
return Err("Driver package registry is too large".to_string());
|
|
}
|
|
let mut json = String::new();
|
|
entry.read_to_string(&mut json).map_err(|error| format!("Failed to read driver package registry: {error}"))?;
|
|
return serde_json::from_str(&json).map_err(|error| format!("Invalid driver package registry: {error}"));
|
|
}
|
|
Err("agent-registry.json not found in the driver package".to_string())
|
|
}
|
|
|
|
fn extract_tar_zstd_file(
|
|
package_path: &Path,
|
|
expected_entry: &str,
|
|
destination: &Path,
|
|
expected_size: u64,
|
|
) -> Result<(), String> {
|
|
let file = std::fs::File::open(package_path).map_err(|error| format!("Failed to open driver package: {error}"))?;
|
|
let decoder = zstd::stream::read::Decoder::new(file)
|
|
.map_err(|error| format!("Failed to open zstd driver package: {error}"))?;
|
|
let mut archive = tar::Archive::new(decoder);
|
|
let entries = archive.entries().map_err(|error| format!("Invalid tar.zst driver package: {error}"))?;
|
|
let mut extracted = false;
|
|
for entry in entries {
|
|
let mut entry = entry.map_err(|error| format!("Invalid tar.zst driver package entry: {error}"))?;
|
|
let path = entry.path().map_err(|error| format!("Invalid driver package path: {error}"))?;
|
|
let name = safe_archive_entry_name(&path)?;
|
|
if name == "agent-registry.json" || entry.header().entry_type().is_dir() {
|
|
continue;
|
|
}
|
|
if name != expected_entry {
|
|
return Err(format!("Unexpected file in driver package: {name}"));
|
|
}
|
|
if extracted || !entry.header().entry_type().is_file() {
|
|
return Err(format!("Invalid driver package entry: {name}"));
|
|
}
|
|
let mut output =
|
|
std::fs::File::create(destination).map_err(|error| format!("Failed to create staged driver: {error}"))?;
|
|
let copied = std::io::copy(&mut entry, &mut output)
|
|
.map_err(|error| format!("Failed to extract driver package: {error}"))?;
|
|
std::io::Write::flush(&mut output).map_err(|error| format!("Failed to flush staged driver: {error}"))?;
|
|
if expected_size > 0 && copied != expected_size {
|
|
return Err(format!("Packaged driver size mismatch: expected {expected_size} bytes, got {copied} bytes"));
|
|
}
|
|
extracted = true;
|
|
}
|
|
if extracted {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("Driver package entry not found: {expected_entry}"))
|
|
}
|
|
}
|
|
|
|
fn safe_archive_entry_name(path: &Path) -> Result<String, String> {
|
|
let mut parts = Vec::new();
|
|
for component in path.components() {
|
|
match component {
|
|
std::path::Component::Normal(value) => parts.push(value.to_string_lossy().into_owned()),
|
|
std::path::Component::CurDir => {}
|
|
_ => return Err(format!("Unsafe driver package path: {}", path.display())),
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
return Err("Driver package contains an empty path".to_string());
|
|
}
|
|
Ok(parts.join("/"))
|
|
}
|
|
|
|
fn agent_registry_driver<'a>(
|
|
registry: &'a AgentRegistry,
|
|
db_type: &str,
|
|
) -> Option<&'a crate::agent_manager::DriverInfo> {
|
|
registry.drivers.get(db_type)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn download_with_progress(
|
|
am: &AgentManager,
|
|
progress: &impl Fn(AgentProgressEvent),
|
|
step: &str,
|
|
source: DownloadSource,
|
|
url: &str,
|
|
r2_path: &str,
|
|
dest: &std::path::Path,
|
|
total_size: u64,
|
|
expected_sha256: Option<&str>,
|
|
cache_identity: Option<CacheIdentity<'_>>,
|
|
db_type: Option<&str>,
|
|
current: Option<u32>,
|
|
total_drivers: Option<u32>,
|
|
) -> Result<(), String> {
|
|
const DOWNLOAD_ATTEMPTS: usize = 4;
|
|
let expected_sha256 = normalized_sha256(expected_sha256)?;
|
|
|
|
if let Some(parent) = dest.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
|
|
}
|
|
let tmp = download_temp_path(dest);
|
|
let tmp_source = download_source_path(&tmp);
|
|
let cache_path = cached_download_path(am, url, total_size, expected_sha256, cache_identity, dest);
|
|
prune_download_cache(am).ok();
|
|
if cached_download_is_valid(am, &cache_path, total_size, expected_sha256) {
|
|
std::fs::copy(&cache_path, &tmp).map_err(|err| format!("Failed to copy cached download: {err}"))?;
|
|
progress(AgentProgressEvent::transfer(step, total_size, total_size).with_batch(
|
|
db_type,
|
|
current,
|
|
total_drivers,
|
|
));
|
|
return replace_download(&tmp, dest);
|
|
}
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(300))
|
|
.build()
|
|
.map_err(|err| format!("Failed to create HTTP client: {err}"))?;
|
|
let mut last_err = None;
|
|
let mut completed = false;
|
|
let mut rejected_sources = std::collections::HashSet::new();
|
|
for attempt in 1..=DOWNLOAD_ATTEMPTS {
|
|
let mut resume_from = std::fs::metadata(&tmp).map(|meta| meta.len()).unwrap_or(0);
|
|
let resume_source = std::fs::read_to_string(&tmp_source).ok().map(|value| value.trim().to_string());
|
|
if resume_from > 0 && resume_source.is_none() {
|
|
std::fs::remove_file(&tmp).ok();
|
|
resume_from = 0;
|
|
}
|
|
if total_size > 0 && resume_from > total_size {
|
|
std::fs::remove_file(&tmp).ok();
|
|
std::fs::remove_file(&tmp_source).ok();
|
|
resume_from = 0;
|
|
}
|
|
if total_size > 0 && resume_from == total_size {
|
|
match validate_artifact_integrity(&tmp, total_size, expected_sha256) {
|
|
Ok(()) => {
|
|
progress(AgentProgressEvent::transfer(step, total_size, total_size).with_batch(
|
|
db_type,
|
|
current,
|
|
total_drivers,
|
|
));
|
|
completed = true;
|
|
break;
|
|
}
|
|
Err(err) => {
|
|
if let Some(source_url) = resume_source {
|
|
rejected_sources.insert(source_url);
|
|
}
|
|
std::fs::remove_file(&tmp).ok();
|
|
std::fs::remove_file(&tmp_source).ok();
|
|
last_err = Some(err);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
let (mut resp, resumed, source_url) = match open_agent_download_response(
|
|
&client,
|
|
source,
|
|
url,
|
|
r2_path,
|
|
"dbx-agent-manager",
|
|
resume_from,
|
|
total_size,
|
|
resume_source.as_deref(),
|
|
&rejected_sources,
|
|
)
|
|
.await
|
|
{
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
if resume_from > 0 {
|
|
std::fs::remove_file(&tmp).ok();
|
|
std::fs::remove_file(&tmp_source).ok();
|
|
}
|
|
last_err = Some(err);
|
|
continue;
|
|
}
|
|
};
|
|
let starting_size = if resumed { resume_from } else { 0 };
|
|
let content_length = total_size.max(starting_size + resp.content_length().unwrap_or(0));
|
|
let mut file = if resumed {
|
|
std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(&tmp)
|
|
.map_err(|err| format!("Failed to open temp file for resume: {err}"))?
|
|
} else {
|
|
std::fs::File::create(&tmp).map_err(|err| format!("Failed to create temp file: {err}"))?
|
|
};
|
|
std::fs::write(&tmp_source, &source_url).map_err(|err| format!("Failed to write download source: {err}"))?;
|
|
let mut downloaded = starting_size;
|
|
let transfer_result = async {
|
|
while let Some(chunk) = resp.chunk().await.map_err(|err| format!("Download stream error: {err}"))? {
|
|
std::io::Write::write_all(&mut file, &chunk).map_err(|err| format!("Failed to write chunk: {err}"))?;
|
|
downloaded += chunk.len() as u64;
|
|
progress(AgentProgressEvent::transfer(step, downloaded, content_length).with_batch(
|
|
db_type,
|
|
current,
|
|
total_drivers,
|
|
));
|
|
}
|
|
std::io::Write::flush(&mut file).map_err(|err| format!("Failed to flush temp file: {err}"))
|
|
}
|
|
.await;
|
|
drop(file);
|
|
|
|
if let Err(err) = transfer_result {
|
|
last_err = Some(format!("{err} (attempt {attempt}/{DOWNLOAD_ATTEMPTS}, source {source_url})"));
|
|
continue;
|
|
}
|
|
|
|
let actual_size = std::fs::metadata(&tmp).map(|meta| meta.len()).unwrap_or(0);
|
|
if total_size == 0 || actual_size == total_size {
|
|
match validate_artifact_integrity(&tmp, total_size, expected_sha256) {
|
|
Ok(()) => {
|
|
completed = true;
|
|
break;
|
|
}
|
|
Err(err) => {
|
|
rejected_sources.insert(source_url.clone());
|
|
std::fs::remove_file(&tmp).ok();
|
|
std::fs::remove_file(&tmp_source).ok();
|
|
last_err = Some(format!("{err} (attempt {attempt}/{DOWNLOAD_ATTEMPTS}, source {source_url})"));
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
if actual_size > total_size {
|
|
std::fs::remove_file(&tmp).ok();
|
|
std::fs::remove_file(&tmp_source).ok();
|
|
}
|
|
last_err = Some(format!(
|
|
"Downloaded {step} is incomplete: expected {total_size} bytes, got {actual_size} bytes (attempt {attempt}/{DOWNLOAD_ATTEMPTS}, source {source_url})"
|
|
));
|
|
}
|
|
if !completed {
|
|
let actual_size = std::fs::metadata(&tmp).map(|meta| meta.len()).unwrap_or(0);
|
|
return Err(last_err.unwrap_or_else(|| {
|
|
format!("Downloaded {step} is incomplete: expected {total_size} bytes, got {actual_size} bytes")
|
|
}));
|
|
}
|
|
std::fs::remove_file(&tmp_source).ok();
|
|
if let Some(parent) = cache_path.parent() {
|
|
if let Err(err) = std::fs::create_dir_all(parent) {
|
|
log::warn!("Failed to create agent download cache directory: {err}");
|
|
} else if let Err(err) = std::fs::copy(&tmp, &cache_path) {
|
|
log::warn!("Failed to cache agent download: {err}");
|
|
}
|
|
}
|
|
replace_download(&tmp, dest)
|
|
}
|
|
|
|
async fn open_agent_download_response(
|
|
client: &reqwest::Client,
|
|
source: DownloadSource,
|
|
github_url: &str,
|
|
r2_path: &str,
|
|
user_agent: &str,
|
|
resume_from: u64,
|
|
expected_size: u64,
|
|
resume_source: Option<&str>,
|
|
rejected_sources: &std::collections::HashSet<String>,
|
|
) -> Result<(reqwest::Response, bool, String), String> {
|
|
let mut errors = Vec::new();
|
|
for candidate_url in source.download_candidate_urls(github_url, r2_path)? {
|
|
if rejected_sources.contains(&candidate_url) {
|
|
errors.push(format!("{candidate_url}: skipped after SHA-256 mismatch"));
|
|
continue;
|
|
}
|
|
if resume_from > 0 && resume_source.is_some_and(|source| source != candidate_url) {
|
|
continue;
|
|
}
|
|
let mut request = client
|
|
.get(&candidate_url)
|
|
.header(reqwest::header::USER_AGENT, user_agent)
|
|
.header(reqwest::header::ACCEPT_ENCODING, "identity");
|
|
if resume_from > 0 {
|
|
request = request.header(reqwest::header::RANGE, format!("bytes={resume_from}-"));
|
|
}
|
|
let resp = match request.send().await {
|
|
Ok(resp) => resp,
|
|
Err(err) => {
|
|
errors.push(format!("{candidate_url}: {err}"));
|
|
continue;
|
|
}
|
|
};
|
|
let status = resp.status();
|
|
if expected_size > 0 {
|
|
let response_size = response_total_size(&resp, resume_from);
|
|
if response_size != Some(expected_size) {
|
|
let found = response_size.map_or_else(|| "unknown".to_string(), |size| size.to_string());
|
|
errors.push(format!(
|
|
"{candidate_url}: artifact size mismatch, expected {expected_size} bytes, got {found} bytes"
|
|
));
|
|
continue;
|
|
}
|
|
}
|
|
if resume_from > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT {
|
|
return Ok((resp, true, candidate_url));
|
|
}
|
|
if status.is_success() {
|
|
return match resp.error_for_status() {
|
|
Ok(resp) => Ok((resp, false, candidate_url)),
|
|
Err(err) => Err(format!("{candidate_url}: {err}")),
|
|
};
|
|
}
|
|
errors.push(format!("{candidate_url}: HTTP {status}"));
|
|
}
|
|
Err(format!("Failed to download artifact: {}", errors.join("; ")))
|
|
}
|
|
|
|
fn response_total_size(resp: &reqwest::Response, resume_from: u64) -> Option<u64> {
|
|
if resp.status() == reqwest::StatusCode::PARTIAL_CONTENT {
|
|
return resp
|
|
.headers()
|
|
.get(reqwest::header::CONTENT_RANGE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(content_range_total_size);
|
|
}
|
|
resp.content_length().map(|size| size + resume_from)
|
|
}
|
|
|
|
fn content_range_total_size(value: &str) -> Option<u64> {
|
|
value.rsplit('/').next()?.parse().ok()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum CacheIdentity<'a> {
|
|
Driver { db_type: &'a str, version: &'a str },
|
|
Jre { key: &'a str, version: &'a str },
|
|
}
|
|
|
|
impl CacheIdentity<'_> {
|
|
fn hash_key(self) -> String {
|
|
match self {
|
|
Self::Driver { db_type, version } => format!("driver:{db_type}:{version}"),
|
|
Self::Jre { key, version } => format!("jre:{key}:{version}"),
|
|
}
|
|
}
|
|
|
|
fn file_prefix(self) -> String {
|
|
match self {
|
|
Self::Driver { db_type, version } => {
|
|
format!("driver-{}-{}", cache_file_token(db_type), cache_file_token(version))
|
|
}
|
|
Self::Jre { key, version } => format!("jre-{}-{}", cache_file_token(key), cache_file_token(version)),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cached_download_path(
|
|
am: &AgentManager,
|
|
url: &str,
|
|
total_size: u64,
|
|
expected_sha256: Option<&str>,
|
|
cache_identity: Option<CacheIdentity<'_>>,
|
|
dest: &std::path::Path,
|
|
) -> std::path::PathBuf {
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
url.hash(&mut hasher);
|
|
total_size.hash(&mut hasher);
|
|
expected_sha256.hash(&mut hasher);
|
|
let identity_hash_key = cache_identity.map(CacheIdentity::hash_key);
|
|
identity_hash_key.hash(&mut hasher);
|
|
let hash = hasher.finish();
|
|
let file_name = dest.file_name().and_then(|name| name.to_str()).unwrap_or("download");
|
|
let prefix = cache_identity.map(CacheIdentity::file_prefix).unwrap_or_else(|| "download".to_string());
|
|
am.download_cache_dir().join(format!("{prefix}-{hash:016x}-{file_name}"))
|
|
}
|
|
|
|
fn cached_download_is_valid(
|
|
am: &AgentManager,
|
|
path: &std::path::Path,
|
|
expected_size: u64,
|
|
expected_sha256: Option<&str>,
|
|
) -> bool {
|
|
let Ok(meta) = std::fs::metadata(path) else {
|
|
return false;
|
|
};
|
|
if !meta.is_file() {
|
|
return false;
|
|
}
|
|
if expected_size > 0 && meta.len() != expected_size {
|
|
let _ = std::fs::remove_file(path);
|
|
return false;
|
|
}
|
|
let max_age = std::time::Duration::from_secs(am.download_cache_max_age_days() * 24 * 60 * 60);
|
|
if meta.modified().ok().and_then(|modified| modified.elapsed().ok()).is_some_and(|age| age > max_age) {
|
|
let _ = std::fs::remove_file(path);
|
|
return false;
|
|
}
|
|
if validate_artifact_integrity(path, expected_size, expected_sha256).is_err() {
|
|
let _ = std::fs::remove_file(path);
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
fn normalized_sha256(expected_sha256: Option<&str>) -> Result<Option<&str>, String> {
|
|
let Some(expected_sha256) = expected_sha256.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
if expected_sha256.len() != 64 || !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
|
return Err("Invalid SHA-256 metadata for agent artifact".to_string());
|
|
}
|
|
Ok(Some(expected_sha256))
|
|
}
|
|
|
|
fn validate_artifact_integrity(path: &Path, expected_size: u64, expected_sha256: Option<&str>) -> Result<(), String> {
|
|
let metadata = std::fs::metadata(path).map_err(|err| format!("Failed to inspect downloaded artifact: {err}"))?;
|
|
if expected_size > 0 && metadata.len() != expected_size {
|
|
return Err(format!(
|
|
"Downloaded artifact size mismatch: expected {expected_size} bytes, got {} bytes",
|
|
metadata.len()
|
|
));
|
|
}
|
|
let Some(expected_sha256) = expected_sha256 else {
|
|
return Ok(());
|
|
};
|
|
let actual_sha256 = file_sha256(path)?;
|
|
if actual_sha256.eq_ignore_ascii_case(expected_sha256) {
|
|
return Ok(());
|
|
}
|
|
Err(format!("Downloaded artifact SHA-256 mismatch: expected {expected_sha256}, got {actual_sha256}"))
|
|
}
|
|
|
|
fn file_sha256(path: &Path) -> Result<String, String> {
|
|
let mut file = std::fs::File::open(path).map_err(|err| format!("Failed to hash downloaded artifact: {err}"))?;
|
|
let mut hasher = Sha256::new();
|
|
let mut buffer = [0_u8; 64 * 1024];
|
|
loop {
|
|
let read = file.read(&mut buffer).map_err(|err| format!("Failed to hash downloaded artifact: {err}"))?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
hasher.update(&buffer[..read]);
|
|
}
|
|
Ok(format!("{:x}", hasher.finalize()))
|
|
}
|
|
|
|
fn prune_download_cache(am: &AgentManager) -> Result<(), String> {
|
|
let cache_dir = am.download_cache_dir();
|
|
let max_age = std::time::Duration::from_secs(am.download_cache_max_age_days() * 24 * 60 * 60);
|
|
let Ok(entries) = std::fs::read_dir(&cache_dir) else {
|
|
return Ok(());
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let Ok(meta) = entry.metadata() else {
|
|
continue;
|
|
};
|
|
if meta.modified().ok().and_then(|modified| modified.elapsed().ok()).is_some_and(|age| age > max_age) {
|
|
let _ = if meta.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) };
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn prune_driver_download_cache(am: &AgentManager, db_type: &str) -> Result<(), String> {
|
|
let prefix = format!("driver-{}-", cache_file_token(db_type));
|
|
remove_download_cache_entries(am, |name| name.starts_with(&prefix), "cached driver download")
|
|
}
|
|
|
|
fn prune_jre_download_cache(am: &AgentManager, jre_key: &str) -> Result<(), String> {
|
|
let prefix = format!("jre-{}-", cache_file_token(jre_key));
|
|
remove_download_cache_entries(am, |name| name.starts_with(&prefix), "cached JRE download")
|
|
}
|
|
|
|
fn cleanup_driver_download_cache_after_success(am: &AgentManager, db_type: &str) {
|
|
if let Err(err) = prune_driver_download_cache(am, db_type) {
|
|
log::warn!("Failed to clean cached download for {db_type}: {err}");
|
|
}
|
|
}
|
|
|
|
fn cleanup_jre_download_cache_after_success(am: &AgentManager, jre_key: &str) {
|
|
if let Err(err) = prune_jre_download_cache(am, jre_key) {
|
|
log::warn!("Failed to clean cached JRE download for {jre_key}: {err}");
|
|
}
|
|
}
|
|
|
|
fn remove_download_cache_entries(
|
|
am: &AgentManager,
|
|
should_remove: impl Fn(&str) -> bool,
|
|
context: &str,
|
|
) -> Result<(), String> {
|
|
let cache_dir = am.download_cache_dir();
|
|
let Ok(entries) = std::fs::read_dir(&cache_dir) else {
|
|
return Ok(());
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
|
continue;
|
|
};
|
|
if !should_remove(name) {
|
|
continue;
|
|
}
|
|
let meta = match entry.metadata() {
|
|
Ok(meta) => meta,
|
|
Err(_) => continue,
|
|
};
|
|
if meta.is_dir() {
|
|
std::fs::remove_dir_all(&path).map_err(|err| format!("Failed to remove {context}: {err}"))?;
|
|
} else {
|
|
std::fs::remove_file(&path).map_err(|err| format!("Failed to remove {context}: {err}"))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cache_file_token(value: &str) -> String {
|
|
let token = value
|
|
.chars()
|
|
.map(|ch| if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { ch } else { '-' })
|
|
.collect::<String>()
|
|
.trim_matches('-')
|
|
.to_string();
|
|
if token.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
token
|
|
}
|
|
}
|
|
|
|
fn r2_path_with_cache_buster(r2_path: &str, version: &str) -> String {
|
|
let separator = if r2_path.contains('?') { '&' } else { '?' };
|
|
format!("{r2_path}{separator}v={}", cache_file_token(version))
|
|
}
|
|
|
|
pub fn github_url_to_r2_path(github_url: &str, category: &str) -> String {
|
|
let filename = github_url.rsplit('/').next().unwrap_or(github_url);
|
|
match category {
|
|
"jre" => format!("agents/jre/{filename}"),
|
|
"driver" => format!("agents/drivers/{filename}"),
|
|
_ => format!("agents/{filename}"),
|
|
}
|
|
}
|
|
|
|
pub fn ensure_driver_app_version(
|
|
db_type: &str,
|
|
driver: &crate::agent_manager::DriverInfo,
|
|
current_version: &str,
|
|
) -> Result<(), String> {
|
|
if is_app_version_compatible(&driver.min_app_version, current_version) {
|
|
return Ok(());
|
|
}
|
|
Err(format!(
|
|
"{db_type} driver {} requires DBX {} or newer. Current DBX version is {}.",
|
|
driver.version, driver.min_app_version, current_version
|
|
))
|
|
}
|
|
|
|
pub fn is_app_version_compatible(min_app_version: &str, current_version: &str) -> bool {
|
|
!crate::update::is_newer_version(min_app_version, current_version)
|
|
}
|
|
|
|
pub fn download_temp_path(dest: &std::path::Path) -> std::path::PathBuf {
|
|
let file_name = dest.file_name().and_then(|name| name.to_str()).unwrap_or("download");
|
|
dest.with_file_name(format!("{file_name}.download"))
|
|
}
|
|
|
|
fn download_source_path(tmp: &std::path::Path) -> std::path::PathBuf {
|
|
tmp.with_extension(format!(
|
|
"{}source",
|
|
tmp.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(|extension| format!("{extension}."))
|
|
.unwrap_or_default()
|
|
))
|
|
}
|
|
|
|
pub fn replace_download(tmp: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {
|
|
if let Some(parent) = dest.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
if dest.exists() {
|
|
let backup = backup_path(dest);
|
|
std::fs::rename(dest, &backup).map_err(|e| format!("Failed to back up existing file: {e}"))?;
|
|
match std::fs::rename(tmp, dest) {
|
|
Ok(()) => {
|
|
std::fs::remove_file(&backup).ok();
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
let _ = std::fs::rename(&backup, dest);
|
|
Err(format!("Failed to replace downloaded file: {err}"))
|
|
}
|
|
}
|
|
} else {
|
|
std::fs::rename(tmp, dest).map_err(|e| format!("Failed to move downloaded file into place: {e}"))
|
|
}
|
|
}
|
|
|
|
fn backup_path(dest: &std::path::Path) -> std::path::PathBuf {
|
|
let file_name = dest.file_name().and_then(|name| name.to_str()).unwrap_or("download");
|
|
dest.with_file_name(format!("{file_name}.backup-{}", uuid::Uuid::new_v4()))
|
|
}
|
|
|
|
// ──────────── Offline import ────────────
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct OfflineImportProgress {
|
|
pub step: String,
|
|
pub current: u32,
|
|
pub total: u32,
|
|
/// Display label for the current item (e.g. "MySQL", "JRE 21.0.12").
|
|
pub label: String,
|
|
/// The real database-type key (e.g. "mysql"), used by the frontend for
|
|
/// per-driver progress routing. `None` for JRE-only steps.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub db_type: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OfflineImportResult {
|
|
pub jre_installed: Vec<String>,
|
|
pub drivers_installed: Vec<String>,
|
|
pub drivers_skipped: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OfflineImportPlan {
|
|
pub driver_keys: Vec<String>,
|
|
pub includes_jre: bool,
|
|
}
|
|
|
|
type OfflineJreEntry = (String, String, Option<ArtifactFormat>);
|
|
type OfflineDriverEntry = (String, String, bool);
|
|
type OfflineArchiveEntries = (Vec<OfflineJreEntry>, Vec<OfflineDriverEntry>);
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DriverArtifactKind {
|
|
Jar,
|
|
Native,
|
|
}
|
|
|
|
impl DriverArtifactKind {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Jar => "jar",
|
|
Self::Native => "native",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TarZstdDriverPackageInfo {
|
|
db_type: String,
|
|
version: String,
|
|
jre: String,
|
|
kind: DriverArtifactKind,
|
|
entry_name: String,
|
|
size: u64,
|
|
}
|
|
|
|
fn inspect_tar_zstd_driver_package(package_path: &Path) -> Result<OfflineImportPlan, String> {
|
|
let info = tar_zstd_driver_package_info(package_path)?;
|
|
Ok(OfflineImportPlan { driver_keys: vec![info.db_type], includes_jre: false })
|
|
}
|
|
|
|
async fn import_tar_zstd_driver_package(
|
|
am: &AgentManager,
|
|
package_path: &Path,
|
|
progress: impl Fn(OfflineImportProgress),
|
|
) -> Result<OfflineImportResult, String> {
|
|
let _installation_guard = am.installation_operation_lock.write().await;
|
|
let info = tar_zstd_driver_package_info(package_path)?;
|
|
let mut result =
|
|
OfflineImportResult { jre_installed: Vec::new(), drivers_installed: Vec::new(), drivers_skipped: Vec::new() };
|
|
if let Some(installed) = am.load_state().installed_drivers.get(&info.db_type) {
|
|
if installed.version != "0.1.0-local"
|
|
&& installed.version != "local"
|
|
&& !crate::update::is_newer_version(&info.version, &installed.version)
|
|
{
|
|
result.drivers_skipped.push(info.db_type);
|
|
return Ok(result);
|
|
}
|
|
}
|
|
|
|
progress(OfflineImportProgress {
|
|
step: "driver".to_string(),
|
|
current: 1,
|
|
total: 1,
|
|
label: agent_catalog::label_for_key(&info.db_type).unwrap_or(&info.db_type).to_string(),
|
|
db_type: Some(info.db_type.clone()),
|
|
});
|
|
let target_path = match info.kind {
|
|
DriverArtifactKind::Jar => am.driver_jar_path(&info.db_type),
|
|
DriverArtifactKind::Native => am.driver_native_path(&info.db_type),
|
|
};
|
|
install_driver_from_tar_zstd_package(package_path, &target_path, info.kind, &info.db_type, &info.version)?;
|
|
match info.kind {
|
|
DriverArtifactKind::Jar => {
|
|
std::fs::remove_file(am.driver_native_path(&info.db_type)).ok();
|
|
}
|
|
DriverArtifactKind::Native => {
|
|
std::fs::remove_file(am.driver_jar_path(&info.db_type)).ok();
|
|
}
|
|
}
|
|
am.mutate_state(|state| {
|
|
state.installed_drivers.insert(
|
|
info.db_type.clone(),
|
|
InstalledDriver {
|
|
version: info.version.clone(),
|
|
installed_at: chrono::Utc::now().to_rfc3339(),
|
|
jre: info.jre.clone(),
|
|
},
|
|
);
|
|
})?;
|
|
am.stop_daemon_by_key(&info.db_type).await;
|
|
result.drivers_installed.push(info.db_type);
|
|
Ok(result)
|
|
}
|
|
|
|
fn tar_zstd_driver_package_info(package_path: &Path) -> Result<TarZstdDriverPackageInfo, String> {
|
|
let registry = read_registry_from_tar_zstd(package_path)?;
|
|
if registry.drivers.len() != 1 {
|
|
return Err("A tar.zst driver package must contain exactly one driver".to_string());
|
|
}
|
|
let (db_type, driver) = registry.drivers.iter().next().expect("checked one driver");
|
|
validate_offline_driver_key(db_type)?;
|
|
let platform = AgentManager::current_platform();
|
|
let native_artifact = driver.native.get(platform);
|
|
let jar_artifact = driver.jar.as_ref();
|
|
let (kind, artifact) = match (native_artifact, jar_artifact) {
|
|
(Some(_), Some(_)) => {
|
|
return Err("A tar.zst driver package must contain exactly one driver artifact".to_string());
|
|
}
|
|
(Some(artifact), None) => (DriverArtifactKind::Native, artifact),
|
|
(None, Some(artifact)) => (DriverArtifactKind::Jar, artifact),
|
|
(None, None) if !driver.native.is_empty() => {
|
|
return Err(format!("Driver package does not support platform: {platform}"));
|
|
}
|
|
(None, None) => return Err("A tar.zst driver package contains no driver artifact".to_string()),
|
|
};
|
|
if artifact.format.is_some() {
|
|
return Err("Nested driver packages are not supported".to_string());
|
|
}
|
|
let artifact_filename = artifact
|
|
.url
|
|
.rsplit('/')
|
|
.next()
|
|
.filter(|name| !name.is_empty())
|
|
.ok_or_else(|| format!("Invalid packaged driver URL: {}", artifact.url))?;
|
|
let entry_name = format!("drivers/{artifact_filename}");
|
|
validate_tar_zstd_package_entries(package_path, &entry_name, artifact.size)?;
|
|
Ok(TarZstdDriverPackageInfo {
|
|
db_type: db_type.clone(),
|
|
version: driver.version.clone(),
|
|
jre: driver.jre.clone(),
|
|
kind,
|
|
entry_name,
|
|
size: artifact.size,
|
|
})
|
|
}
|
|
|
|
fn validate_tar_zstd_package_entries(
|
|
package_path: &Path,
|
|
expected_entry: &str,
|
|
expected_size: u64,
|
|
) -> Result<(), String> {
|
|
let file = std::fs::File::open(package_path).map_err(|error| format!("Failed to open driver package: {error}"))?;
|
|
let decoder = zstd::stream::read::Decoder::new(file)
|
|
.map_err(|error| format!("Failed to open zstd driver package: {error}"))?;
|
|
let mut archive = tar::Archive::new(decoder);
|
|
let entries = archive.entries().map_err(|error| format!("Invalid tar.zst driver package: {error}"))?;
|
|
let mut registry_seen = false;
|
|
let mut driver_seen = false;
|
|
for entry in entries {
|
|
let entry = entry.map_err(|error| format!("Invalid tar.zst driver package entry: {error}"))?;
|
|
let path = entry.path().map_err(|error| format!("Invalid driver package path: {error}"))?;
|
|
let name = safe_archive_entry_name(&path)?;
|
|
if entry.header().entry_type().is_dir() {
|
|
continue;
|
|
}
|
|
if !entry.header().entry_type().is_file() {
|
|
return Err(format!("Driver package contains a non-regular entry: {name}"));
|
|
}
|
|
match name.as_str() {
|
|
"agent-registry.json" if !registry_seen => registry_seen = true,
|
|
value if value == expected_entry && !driver_seen => {
|
|
if expected_size > 0 && entry.size() != expected_size {
|
|
return Err(format!(
|
|
"Packaged driver size mismatch: expected {expected_size} bytes, got {} bytes",
|
|
entry.size()
|
|
));
|
|
}
|
|
driver_seen = true;
|
|
}
|
|
_ => return Err(format!("Unexpected file in driver package: {name}")),
|
|
}
|
|
}
|
|
if !registry_seen {
|
|
return Err("agent-registry.json not found in the driver package".to_string());
|
|
}
|
|
if !driver_seen {
|
|
return Err(format!("Driver package entry not found: {expected_entry}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn inspect_offline_zip(zip_path: &Path) -> Result<OfflineImportPlan, String> {
|
|
let file = std::fs::File::open(zip_path).map_err(|e| format!("Failed to open ZIP file: {e}"))?;
|
|
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP file: {e}"))?;
|
|
let registry = read_registry_from_zip(&mut archive)?;
|
|
let (jre_entries, driver_entries) = collect_offline_entries(&mut archive, ®istry)?;
|
|
Ok(OfflineImportPlan {
|
|
driver_keys: driver_entries.into_iter().map(|(db_type, _, _)| db_type).collect(),
|
|
includes_jre: !jre_entries.is_empty(),
|
|
})
|
|
}
|
|
|
|
pub async fn import_offline_zip(
|
|
am: &AgentManager,
|
|
zip_path: &Path,
|
|
progress: impl Fn(OfflineImportProgress),
|
|
) -> Result<OfflineImportResult, String> {
|
|
// Offline import can touch both JRE and driver directories — hold an
|
|
// exclusive installation-operation lock so that concurrent driver installs,
|
|
// JRE installs, Upgrade All, and uninstall operations are serialised.
|
|
let _installation_guard = am.installation_operation_lock.write().await;
|
|
|
|
let file = std::fs::File::open(zip_path).map_err(|e| format!("Failed to open ZIP file: {e}"))?;
|
|
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP file: {e}"))?;
|
|
|
|
let registry = read_registry_from_zip(&mut archive)?;
|
|
|
|
let platform = AgentManager::current_platform();
|
|
std::fs::create_dir_all(am.base_dir()).map_err(|e| format!("Failed to create agent directory: {e}"))?;
|
|
let mut local_state = am.load_state();
|
|
let mut result =
|
|
OfflineImportResult { jre_installed: Vec::new(), drivers_installed: Vec::new(), drivers_skipped: Vec::new() };
|
|
|
|
let (jre_entries, driver_entries) = collect_offline_entries(&mut archive, ®istry)?;
|
|
|
|
let total = (jre_entries.len() + driver_entries.len()) as u32;
|
|
if total == 0 {
|
|
return Err(format!("Offline package contains no drivers compatible with platform: {platform}"));
|
|
}
|
|
validate_offline_driver_entries(am, &mut archive, &driver_entries)?;
|
|
let mut current: u32 = 0;
|
|
|
|
for (jre_key, entry_name, format) in &jre_entries {
|
|
current += 1;
|
|
let jre_version = registry.resolve_jre(jre_key).map(|j| j.version.clone());
|
|
let existing_version = local_state.jre_versions.get(jre_key);
|
|
if am.is_jre_installed(jre_key) && existing_version == jre_version.as_ref() {
|
|
continue;
|
|
}
|
|
|
|
progress(OfflineImportProgress {
|
|
step: "jre-extract".into(),
|
|
current,
|
|
total,
|
|
label: format!("JRE {jre_key}"),
|
|
db_type: None,
|
|
});
|
|
|
|
let mut entry = archive.by_name(entry_name).map_err(|e| format!("Failed to read {entry_name}: {e}"))?;
|
|
let tmp_archive = am.base_dir().join(format!("jre-offline-{jre_key}{}", jre_archive_suffix(*format)));
|
|
{
|
|
let mut out =
|
|
std::fs::File::create(&tmp_archive).map_err(|e| format!("Failed to create temp file: {e}"))?;
|
|
std::io::copy(&mut entry, &mut out).map_err(|e| format!("Failed to extract JRE archive: {e}"))?;
|
|
}
|
|
|
|
let jre_dir = am.jre_dir(jre_key);
|
|
let staging_dir = am.base_dir().join(format!(".jre-offline-import-{}", uuid::Uuid::new_v4()));
|
|
if let Err(error) = extract_jre_archive(&tmp_archive, &staging_dir, *format) {
|
|
std::fs::remove_dir_all(&staging_dir).ok();
|
|
std::fs::remove_file(&tmp_archive).ok();
|
|
return Err(error);
|
|
}
|
|
if !jre_dir_contains_java(&staging_dir) {
|
|
std::fs::remove_dir_all(&staging_dir).ok();
|
|
std::fs::remove_file(&tmp_archive).ok();
|
|
return Err(format!("Offline JRE archive does not contain a Java executable: {entry_name}"));
|
|
}
|
|
let pending_cleanup = replace_imported_jre_dir(&staging_dir, &jre_dir)?;
|
|
std::fs::remove_file(&tmp_archive).ok();
|
|
if let Some(path) = pending_cleanup {
|
|
local_state.pending_jre_cleanup.push(path);
|
|
}
|
|
|
|
if let Some(ver) = jre_version {
|
|
local_state.jre_versions.insert(jre_key.clone(), ver);
|
|
}
|
|
result.jre_installed.push(jre_key.clone());
|
|
}
|
|
|
|
for (db_type, entry_name, is_native) in &driver_entries {
|
|
current += 1;
|
|
|
|
if let Some(remote_driver) = registry.drivers.get(db_type) {
|
|
if let Some(installed) = local_state.installed_drivers.get(db_type) {
|
|
if installed.version != "0.1.0-local"
|
|
&& installed.version != "local"
|
|
&& !crate::update::is_newer_version(&remote_driver.version, &installed.version)
|
|
{
|
|
result.drivers_skipped.push(db_type.clone());
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
progress(OfflineImportProgress {
|
|
step: "driver".into(),
|
|
current,
|
|
total,
|
|
label: agent_catalog::label_for_key(db_type).unwrap_or(db_type).to_string(),
|
|
db_type: Some(db_type.clone()),
|
|
});
|
|
|
|
let driver_path = if *is_native { am.driver_native_path(db_type) } else { am.driver_jar_path(db_type) };
|
|
if let Some(parent) = driver_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
let mut entry = archive.by_name(entry_name).map_err(|e| format!("Failed to read {entry_name}: {e}"))?;
|
|
let parent = driver_path.parent().ok_or_else(|| format!("Invalid driver path: {}", driver_path.display()))?;
|
|
let staging_path = parent.join(format!(".offline-agent-import-{}", uuid::Uuid::new_v4()));
|
|
let mut out = std::fs::File::create(&staging_path).map_err(|e| format!("Failed to write driver: {e}"))?;
|
|
std::io::copy(&mut entry, &mut out).map_err(|e| format!("Failed to copy driver: {e}"))?;
|
|
drop(out);
|
|
if *is_native {
|
|
if let Err(error) = validate_native_agent_binary(&staging_path) {
|
|
std::fs::remove_file(&staging_path).ok();
|
|
return Err(error);
|
|
}
|
|
mark_executable(&staging_path)?;
|
|
} else {
|
|
// Validate the staged JAR before replacing a working driver so a
|
|
// corrupt offline package cannot destroy the previous installation.
|
|
if !is_valid_agent_jar(&staging_path) {
|
|
std::fs::remove_file(&staging_path).ok();
|
|
return Err(format!("Offline agent jar is invalid or corrupt: {entry_name}"));
|
|
}
|
|
}
|
|
replace_imported_agent_file(&staging_path, &driver_path)?;
|
|
if *is_native {
|
|
std::fs::remove_file(am.driver_jar_path(db_type)).ok();
|
|
} else {
|
|
std::fs::remove_file(am.driver_native_path(db_type)).ok();
|
|
}
|
|
|
|
let version = registry.drivers.get(db_type).map(|d| d.version.clone()).unwrap_or_else(|| "local".to_string());
|
|
let jre_key =
|
|
registry.drivers.get(db_type).map(|d| d.jre.clone()).unwrap_or_else(|| DEFAULT_JRE_KEY.to_string());
|
|
|
|
local_state.installed_drivers.insert(
|
|
db_type.clone(),
|
|
InstalledDriver { version, installed_at: chrono::Utc::now().to_rfc3339(), jre: jre_key },
|
|
);
|
|
result.drivers_installed.push(db_type.clone());
|
|
}
|
|
|
|
am.mutate_state(|state| {
|
|
for jre_key in &result.jre_installed {
|
|
if let Some(version) = local_state.jre_versions.get(jre_key) {
|
|
state.jre_versions.insert(jre_key.clone(), version.clone());
|
|
}
|
|
}
|
|
for path in &local_state.pending_jre_cleanup {
|
|
if !state.pending_jre_cleanup.contains(path) {
|
|
state.pending_jre_cleanup.push(path.clone());
|
|
}
|
|
}
|
|
for db_type in &result.drivers_installed {
|
|
if let Some(driver) = local_state.installed_drivers.get(db_type) {
|
|
state.installed_drivers.insert(db_type.clone(), driver.clone());
|
|
}
|
|
}
|
|
})?;
|
|
Ok(result)
|
|
}
|
|
|
|
fn collect_offline_entries(
|
|
archive: &mut zip::ZipArchive<std::fs::File>,
|
|
registry: &AgentRegistry,
|
|
) -> Result<OfflineArchiveEntries, String> {
|
|
let platform = AgentManager::current_platform();
|
|
let mut jres = std::collections::BTreeMap::<String, (String, Option<ArtifactFormat>)>::new();
|
|
let mut drivers = std::collections::BTreeMap::<String, (String, bool)>::new();
|
|
|
|
for index in 0..archive.len() {
|
|
let entry = archive.by_index(index).map_err(|e| format!("Failed to inspect ZIP entry: {e}"))?;
|
|
let Some(path) = entry.enclosed_name() else {
|
|
return Err(format!("Offline package contains an unsafe path: {}", entry.name()));
|
|
};
|
|
let name = path.to_string_lossy().replace('\\', "/");
|
|
if name.starts_with("jre/") && name.contains(platform) {
|
|
let jre_format = if name.ends_with(".tar.zst") {
|
|
Some(ArtifactFormat::TarZstd)
|
|
} else if name.ends_with(".tar.gz") {
|
|
None
|
|
} else {
|
|
continue;
|
|
};
|
|
let jre_key = extract_jre_key_from_filename(&name)
|
|
.ok_or_else(|| format!("Invalid JRE filename in offline package: {name}"))?;
|
|
validate_offline_identifier(&jre_key, "JRE")?;
|
|
let replace = !jres.contains_key(&jre_key) || jre_format == Some(ArtifactFormat::TarZstd);
|
|
if replace {
|
|
jres.insert(jre_key, (name, jre_format));
|
|
}
|
|
} else if name.starts_with("drivers/") && name.ends_with(".jar") {
|
|
let db_type = db_type_for_jar_offline_entry(registry, &name)
|
|
.or_else(|| extract_db_type_from_filename(&name))
|
|
.ok_or_else(|| format!("Unable to identify offline driver: {name}"))?;
|
|
validate_offline_driver_key(&db_type)?;
|
|
drivers.entry(db_type).or_insert((name, false));
|
|
} else if name.starts_with("drivers/") {
|
|
if let Some(db_type) = db_type_for_native_offline_entry(registry, platform, &name) {
|
|
validate_offline_driver_key(&db_type)?;
|
|
// Prefer the native artifact when a package contains both the
|
|
// platform executable and a Java fallback for the same driver.
|
|
drivers.insert(db_type, (name, true));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok((
|
|
jres.into_iter().map(|(jre_key, (name, format))| (jre_key, name, format)).collect(),
|
|
drivers.into_iter().map(|(db_type, (name, is_native))| (db_type, name, is_native)).collect(),
|
|
))
|
|
}
|
|
|
|
fn validate_offline_driver_entries(
|
|
am: &AgentManager,
|
|
archive: &mut zip::ZipArchive<std::fs::File>,
|
|
driver_entries: &[OfflineDriverEntry],
|
|
) -> Result<(), String> {
|
|
for (_, entry_name, is_native) in driver_entries {
|
|
let staging_path = am.base_dir().join(format!(".offline-agent-validation-{}", uuid::Uuid::new_v4()));
|
|
let result = (|| {
|
|
let mut entry = archive.by_name(entry_name).map_err(|e| format!("Failed to read {entry_name}: {e}"))?;
|
|
let mut out = std::fs::File::create(&staging_path).map_err(|e| format!("Failed to write driver: {e}"))?;
|
|
std::io::copy(&mut entry, &mut out).map_err(|e| format!("Failed to copy driver: {e}"))?;
|
|
drop(out);
|
|
if *is_native {
|
|
validate_native_agent_binary(&staging_path)
|
|
} else if is_valid_agent_jar(&staging_path) {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("Offline agent jar is invalid or corrupt: {entry_name}"))
|
|
}
|
|
})();
|
|
std::fs::remove_file(&staging_path).ok();
|
|
result?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_offline_driver_key(db_type: &str) -> Result<(), String> {
|
|
validate_offline_identifier(db_type, "driver")?;
|
|
if agent_catalog::label_for_key(db_type).is_none() {
|
|
return Err(format!("Offline package contains an unknown driver type: {db_type}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_offline_identifier(value: &str, kind: &str) -> Result<(), String> {
|
|
if value.is_empty()
|
|
|| matches!(value, "." | "..")
|
|
|| !value.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_'))
|
|
{
|
|
return Err(format!("Offline package contains an invalid {kind} identifier: {value}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_registry_from_zip(archive: &mut zip::ZipArchive<std::fs::File>) -> Result<AgentRegistry, String> {
|
|
let mut entry = archive
|
|
.by_name("agent-registry.json")
|
|
.map_err(|_| "agent-registry.json not found in the ZIP; not a valid offline driver package.".to_string())?;
|
|
let mut buf = String::new();
|
|
entry.read_to_string(&mut buf).map_err(|e| format!("Failed to read agent-registry.json: {e}"))?;
|
|
serde_json::from_str(&buf).map_err(|e| format!("Invalid agent-registry.json: {e}"))
|
|
}
|
|
|
|
fn extract_jre_key_from_filename(name: &str) -> Option<String> {
|
|
let filename = name.rsplit('/').next()?;
|
|
let rest = filename.strip_prefix("dbx-jre-").or_else(|| filename.strip_prefix("jre-"))?;
|
|
let key = rest.split('-').next()?;
|
|
if key.is_empty() {
|
|
return None;
|
|
}
|
|
Some(key.to_string())
|
|
}
|
|
|
|
fn extract_db_type_from_filename(name: &str) -> Option<String> {
|
|
let filename = name.rsplit('/').next()?;
|
|
let rest = filename.strip_prefix("dbx-agent-")?;
|
|
let db_type = rest.strip_suffix(".jar")?;
|
|
if db_type.is_empty() {
|
|
return None;
|
|
}
|
|
Some(db_type.to_string())
|
|
}
|
|
|
|
fn db_type_for_native_offline_entry(registry: &AgentRegistry, platform: &str, name: &str) -> Option<String> {
|
|
let filename = name.rsplit('/').next()?;
|
|
registry.drivers.iter().find_map(|(db_type, driver)| {
|
|
let artifact = driver.native.get(platform)?;
|
|
let artifact_filename = artifact.url.rsplit('/').next()?;
|
|
(artifact_filename == filename).then(|| db_type.clone())
|
|
})
|
|
}
|
|
|
|
fn db_type_for_jar_offline_entry(registry: &AgentRegistry, name: &str) -> Option<String> {
|
|
let filename = name.rsplit('/').next()?;
|
|
registry.drivers.iter().find_map(|(db_type, driver)| {
|
|
let artifact = driver.jar.as_ref()?;
|
|
let artifact_filename = artifact.url.rsplit('/').next()?;
|
|
(artifact_filename == filename).then(|| db_type.clone())
|
|
})
|
|
}
|
|
|
|
fn jre_archive_suffix(format: Option<ArtifactFormat>) -> &'static str {
|
|
match format {
|
|
Some(ArtifactFormat::TarZstd) => ".tar.zst",
|
|
None => ".tar.gz",
|
|
}
|
|
}
|
|
|
|
fn jre_archive_download_path(am: &AgentManager, jre_key: &str, format: Option<ArtifactFormat>) -> PathBuf {
|
|
am.base_dir().join(format!("jre-{jre_key}-download{}", jre_archive_suffix(format)))
|
|
}
|
|
|
|
fn extract_jre_archive(archive: &Path, dest: &Path, format: Option<ArtifactFormat>) -> Result<(), String> {
|
|
match format {
|
|
Some(ArtifactFormat::TarZstd) => {
|
|
let file = std::fs::File::open(archive).map_err(|e| format!("Failed to open JRE archive: {e}"))?;
|
|
let decoder =
|
|
zstd::stream::read::Decoder::new(file).map_err(|e| format!("Failed to open zstd JRE archive: {e}"))?;
|
|
extract_jre_tar(tar::Archive::new(decoder), dest)
|
|
}
|
|
None => extract_tar_gz(archive, dest),
|
|
}
|
|
}
|
|
|
|
fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<(), String> {
|
|
let file = std::fs::File::open(archive).map_err(|e| format!("Failed to open JRE archive: {e}"))?;
|
|
let decoder = flate2::read::GzDecoder::new(file);
|
|
extract_jre_tar(tar::Archive::new(decoder), dest)
|
|
}
|
|
|
|
fn extract_jre_tar<R: Read>(mut archive: tar::Archive<R>, dest: &Path) -> Result<(), String> {
|
|
let parent = dest.parent().ok_or_else(|| format!("Invalid JRE destination: {}", dest.display()))?;
|
|
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create JRE directory: {e}"))?;
|
|
|
|
let staging = tempfile::Builder::new()
|
|
.prefix(".jre-extract-")
|
|
.tempdir_in(parent)
|
|
.map_err(|e| format!("Failed to create JRE extraction directory: {e}"))?;
|
|
archive.unpack(staging.path()).map_err(|e| format!("Failed to extract JRE archive: {e}"))?;
|
|
|
|
let mut roots = std::fs::read_dir(staging.path())
|
|
.map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))?;
|
|
if roots.len() != 1 {
|
|
return Err("Invalid JRE archive: expected a single top-level directory".to_string());
|
|
}
|
|
|
|
let root = roots.pop().expect("root count checked above");
|
|
if !root.file_type().map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))?.is_dir() {
|
|
return Err("Invalid JRE archive: expected a top-level directory".to_string());
|
|
}
|
|
|
|
std::fs::create_dir_all(dest).map_err(|e| format!("Failed to create JRE directory: {e}"))?;
|
|
for entry in std::fs::read_dir(root.path()).map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))? {
|
|
let entry = entry.map_err(|e| format!("Failed to inspect extracted JRE archive: {e}"))?;
|
|
std::fs::rename(entry.path(), dest.join(entry.file_name()))
|
|
.map_err(|e| format!("Failed to install extracted JRE: {e}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod jre_archive_tests {
|
|
use super::*;
|
|
use std::io::Cursor;
|
|
|
|
fn append_file(
|
|
builder: &mut tar::Builder<flate2::write::GzEncoder<std::fs::File>>,
|
|
path: &str,
|
|
data: &[u8],
|
|
mode: u32,
|
|
) {
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_size(data.len() as u64);
|
|
header.set_mode(mode);
|
|
header.set_cksum();
|
|
builder.append_data(&mut header, path, Cursor::new(data)).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_jre_archive_without_system_tools_and_strips_top_level_directory() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let archive_path = temp.path().join("jre.tar.gz");
|
|
let encoder = flate2::write::GzEncoder::new(
|
|
std::fs::File::create(&archive_path).unwrap(),
|
|
flate2::Compression::default(),
|
|
);
|
|
let mut builder = tar::Builder::new(encoder);
|
|
append_file(&mut builder, "jdk-21/bin/java", b"java", 0o755);
|
|
append_file(&mut builder, "jdk-21/conf/release", b"JAVA_VERSION=21", 0o644);
|
|
builder.into_inner().unwrap().finish().unwrap();
|
|
|
|
let dest = temp.path().join("managed-jre");
|
|
extract_tar_gz(&archive_path, &dest).unwrap();
|
|
|
|
assert_eq!(std::fs::read(dest.join("bin/java")).unwrap(), b"java");
|
|
assert_eq!(std::fs::read(dest.join("conf/release")).unwrap(), b"JAVA_VERSION=21");
|
|
assert!(!dest.join("jdk-21").exists());
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
assert_eq!(std::fs::metadata(dest.join("bin/java")).unwrap().permissions().mode() & 0o777, 0o755);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_jre_archive_without_a_single_top_level_directory() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let archive_path = temp.path().join("jre.tar.gz");
|
|
let encoder = flate2::write::GzEncoder::new(
|
|
std::fs::File::create(&archive_path).unwrap(),
|
|
flate2::Compression::default(),
|
|
);
|
|
let mut builder = tar::Builder::new(encoder);
|
|
append_file(&mut builder, "jdk-21/bin/java", b"java", 0o755);
|
|
append_file(&mut builder, "unexpected/readme.txt", b"unexpected", 0o644);
|
|
builder.into_inner().unwrap().finish().unwrap();
|
|
|
|
let error = extract_tar_gz(&archive_path, &temp.path().join("managed-jre")).unwrap_err();
|
|
assert!(error.contains("single top-level directory"), "unexpected error: {error}");
|
|
}
|
|
}
|
|
|
|
pub async fn import_agent_driver(am: &AgentManager, db_type: &str, source_path: &Path) -> Result<(), String> {
|
|
// Manual imports replace the same artifact paths as downloads. Reuse the
|
|
// install operation and per-driver locks so an import cannot race an
|
|
// install, Upgrade All, or uninstall for this driver.
|
|
let _installation_guard = am.installation_operation_lock.read().await;
|
|
let driver_lock = driver_operation_lock(am, db_type).await;
|
|
let _driver_guard = driver_lock.lock().await;
|
|
|
|
if !source_path.is_file() {
|
|
return Err(format!("File not found: {}", source_path.display()));
|
|
}
|
|
|
|
if source_path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("jar")) {
|
|
install_local_agent(am, db_type, source_path.to_path_buf())?;
|
|
std::fs::remove_file(am.driver_native_path(db_type)).ok();
|
|
return Ok(());
|
|
}
|
|
|
|
validate_native_agent_binary(source_path)?;
|
|
let native_path = am.driver_native_path(db_type);
|
|
let parent = native_path.parent().ok_or_else(|| format!("Invalid driver path: {}", native_path.display()))?;
|
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
let staging_path = parent.join(format!(".agent-import-{}", uuid::Uuid::new_v4()));
|
|
std::fs::copy(source_path, &staging_path).map_err(|e| format!("Failed to copy native agent: {e}"))?;
|
|
mark_executable(&staging_path)?;
|
|
replace_imported_agent_file(&staging_path, &native_path)?;
|
|
std::fs::remove_file(am.driver_jar_path(db_type)).ok();
|
|
|
|
am.mutate_state(|state| {
|
|
state.installed_drivers.insert(
|
|
db_type.to_string(),
|
|
InstalledDriver {
|
|
version: "0.1.0-local".to_string(),
|
|
installed_at: chrono::Utc::now().to_rfc3339(),
|
|
jre: DEFAULT_JRE_KEY.to_string(),
|
|
},
|
|
);
|
|
})
|
|
}
|
|
|
|
pub async fn import_agent_jar(am: &AgentManager, db_type: &str, jar_path: &Path) -> Result<(), String> {
|
|
import_agent_driver(am, db_type, jar_path).await
|
|
}
|
|
|
|
fn replace_imported_agent_file(staging_path: &Path, target_path: &Path) -> Result<(), String> {
|
|
let backup_path = target_path.with_file_name(format!(
|
|
".{}-backup-{}",
|
|
target_path.file_name().and_then(|name| name.to_str()).unwrap_or("agent"),
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let had_existing = target_path.exists();
|
|
if had_existing {
|
|
std::fs::rename(target_path, &backup_path).map_err(|e| format!("Failed to replace existing agent: {e}"))?;
|
|
}
|
|
if let Err(error) = std::fs::rename(staging_path, target_path) {
|
|
if had_existing {
|
|
let _ = std::fs::rename(&backup_path, target_path);
|
|
}
|
|
let _ = std::fs::remove_file(staging_path);
|
|
return Err(format!("Failed to install agent: {error}"));
|
|
}
|
|
if had_existing {
|
|
std::fs::remove_file(backup_path).ok();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn replace_imported_jre_dir(staging_dir: &Path, target_dir: &Path) -> Result<Option<PathBuf>, String> {
|
|
let backup_dir = target_dir.with_file_name(format!(
|
|
".{}-backup-{}",
|
|
target_dir.file_name().and_then(|name| name.to_str()).unwrap_or("jre"),
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let had_existing = target_dir.exists();
|
|
if had_existing {
|
|
std::fs::rename(target_dir, &backup_dir).map_err(|error| {
|
|
let _ = std::fs::remove_dir_all(staging_dir);
|
|
format!("Failed to replace existing JRE: {error}")
|
|
})?;
|
|
}
|
|
if let Err(error) = std::fs::rename(staging_dir, target_dir) {
|
|
if had_existing {
|
|
let _ = std::fs::rename(&backup_dir, target_dir);
|
|
}
|
|
let _ = std::fs::remove_dir_all(staging_dir);
|
|
return Err(format!("Failed to install JRE: {error}"));
|
|
}
|
|
if had_existing && remove_jre_dir_with_retry(&backup_dir).is_err() {
|
|
// The new runtime is already installed. Keep the old directory for
|
|
// startup cleanup rather than turning a successful import into an error.
|
|
return Ok(Some(backup_dir));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn jre_dir_contains_java(path: &Path) -> bool {
|
|
let java_name = if cfg!(windows) { "java.exe" } else { "java" };
|
|
path.join("bin").join(java_name).is_file()
|
|
|| path.join("Contents").join("Home").join("bin").join(java_name).is_file()
|
|
}
|
|
|
|
fn validate_native_agent_binary(path: &Path) -> Result<(), String> {
|
|
let mut file = std::fs::File::open(path).map_err(|e| format!("Failed to read native agent: {e}"))?;
|
|
let mut magic = [0_u8; 4];
|
|
file.read_exact(&mut magic).map_err(|e| format!("Failed to read native agent header: {e}"))?;
|
|
let valid = if cfg!(target_os = "windows") {
|
|
is_windows_binary_for_current_arch(&mut file, &magic)
|
|
} else if cfg!(target_os = "linux") {
|
|
is_elf_binary_for_current_arch(&mut file, &magic)
|
|
} else if cfg!(target_os = "macos") {
|
|
is_macho_binary_for_current_arch(&mut file, &magic)
|
|
} else {
|
|
false
|
|
};
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("The selected file is not a {} native agent for this platform", AgentManager::current_platform()))
|
|
}
|
|
}
|
|
|
|
fn is_elf_binary_for_current_arch(file: &mut std::fs::File, magic: &[u8; 4]) -> bool {
|
|
if magic != b"\x7fELF" || file.seek(SeekFrom::Start(4)).is_err() {
|
|
return false;
|
|
}
|
|
let mut header = [0_u8; 16];
|
|
if file.read_exact(&mut header).is_err() || header[0] != 2 {
|
|
return false;
|
|
}
|
|
let machine = match header[1] {
|
|
1 => u16::from_le_bytes([header[14], header[15]]),
|
|
2 => u16::from_be_bytes([header[14], header[15]]),
|
|
_ => return false,
|
|
};
|
|
(cfg!(target_arch = "x86_64") && machine == 62) || (cfg!(target_arch = "aarch64") && machine == 183)
|
|
}
|
|
|
|
fn is_macho_binary_for_current_arch(file: &mut std::fs::File, magic: &[u8; 4]) -> bool {
|
|
const CPU_TYPE_X86_64: u32 = 0x0100_0007;
|
|
const CPU_TYPE_ARM64: u32 = 0x0100_000c;
|
|
let expected = if cfg!(target_arch = "aarch64") { CPU_TYPE_ARM64 } else { CPU_TYPE_X86_64 };
|
|
|
|
let thin_endian = match magic {
|
|
[0xce, 0xfa, 0xed, 0xfe] | [0xcf, 0xfa, 0xed, 0xfe] => Some(true),
|
|
[0xfe, 0xed, 0xfa, 0xce] | [0xfe, 0xed, 0xfa, 0xcf] => Some(false),
|
|
_ => None,
|
|
};
|
|
if let Some(little_endian) = thin_endian {
|
|
if file.seek(SeekFrom::Start(4)).is_err() {
|
|
return false;
|
|
}
|
|
let mut cpu_type = [0_u8; 4];
|
|
if file.read_exact(&mut cpu_type).is_err() {
|
|
return false;
|
|
}
|
|
let cpu_type = if little_endian { u32::from_le_bytes(cpu_type) } else { u32::from_be_bytes(cpu_type) };
|
|
return cpu_type == expected;
|
|
}
|
|
|
|
let (little_endian, arch_size) = match magic {
|
|
[0xca, 0xfe, 0xba, 0xbe] => (false, 20_u64),
|
|
[0xbe, 0xba, 0xfe, 0xca] => (true, 20_u64),
|
|
[0xca, 0xfe, 0xba, 0xbf] => (false, 32_u64),
|
|
[0xbf, 0xba, 0xfe, 0xca] => (true, 32_u64),
|
|
_ => return false,
|
|
};
|
|
if file.seek(SeekFrom::Start(4)).is_err() {
|
|
return false;
|
|
}
|
|
let mut count = [0_u8; 4];
|
|
if file.read_exact(&mut count).is_err() {
|
|
return false;
|
|
}
|
|
let count = if little_endian { u32::from_le_bytes(count) } else { u32::from_be_bytes(count) };
|
|
// A real universal binary has only a handful of slices; cap the count so
|
|
// a malformed header cannot trigger unbounded seeks during import.
|
|
if count == 0 || count > 64 {
|
|
return false;
|
|
}
|
|
for index in 0..count {
|
|
if file.seek(SeekFrom::Start(8 + u64::from(index) * arch_size)).is_err() {
|
|
return false;
|
|
}
|
|
let mut cpu_type = [0_u8; 4];
|
|
if file.read_exact(&mut cpu_type).is_err() {
|
|
return false;
|
|
}
|
|
let cpu_type = if little_endian { u32::from_le_bytes(cpu_type) } else { u32::from_be_bytes(cpu_type) };
|
|
if cpu_type == expected {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn is_windows_binary_for_current_arch(file: &mut std::fs::File, magic: &[u8; 4]) -> bool {
|
|
if &magic[..2] != b"MZ" || file.seek(SeekFrom::Start(0x3c)).is_err() {
|
|
return false;
|
|
}
|
|
let mut pe_offset = [0_u8; 4];
|
|
if file.read_exact(&mut pe_offset).is_err()
|
|
|| file.seek(SeekFrom::Start(u32::from_le_bytes(pe_offset) as u64)).is_err()
|
|
{
|
|
return false;
|
|
}
|
|
let mut pe_header = [0_u8; 6];
|
|
if file.read_exact(&mut pe_header).is_err() || &pe_header[..4] != b"PE\0\0" {
|
|
return false;
|
|
}
|
|
let machine = u16::from_le_bytes([pe_header[4], pe_header[5]]);
|
|
(cfg!(target_arch = "x86_64") && machine == 0x8664) || (cfg!(target_arch = "aarch64") && machine == 0xaa64)
|
|
}
|
|
|
|
// ──────────── Tests ────────────
|
|
|
|
#[cfg(test)]
|
|
mod agent_download_url_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn r2_cache_buster_uses_version_query() {
|
|
assert_eq!(
|
|
r2_path_with_cache_buster("agents/jre/dbx-jre-21-macos-x64.tar.gz", "21.0.11+7"),
|
|
"agents/jre/dbx-jre-21-macos-x64.tar.gz?v=21.0.11-7"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn r2_cache_buster_preserves_existing_query() {
|
|
assert_eq!(
|
|
r2_path_with_cache_buster("agents/drivers/dbx-agent-h2.jar?mirror=r2", "0.5.33"),
|
|
"agents/drivers/dbx-agent-h2.jar?mirror=r2&v=0.5.33"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn offline_jre_filename_parser_accepts_release_and_legacy_names() {
|
|
assert_eq!(extract_jre_key_from_filename("jre/dbx-jre-21-macos-aarch64.tar.gz").as_deref(), Some("21"));
|
|
assert_eq!(extract_jre_key_from_filename("jre/jre-21-macos-aarch64.tar.gz").as_deref(), Some("21"));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_native_header_validator_checks_cpu_architecture() {
|
|
let path = std::env::temp_dir().join(format!("dbx-agent-pe-test-{}", uuid::Uuid::new_v4()));
|
|
let expected_machine = if cfg!(target_arch = "aarch64") { 0xaa64_u16 } else { 0x8664_u16 };
|
|
let wrong_machine = if expected_machine == 0xaa64 { 0x8664_u16 } else { 0xaa64_u16 };
|
|
|
|
std::fs::write(&path, test_pe_binary(expected_machine)).unwrap();
|
|
let mut file = std::fs::File::open(&path).unwrap();
|
|
assert!(is_windows_binary_for_current_arch(&mut file, b"MZ\0\0"));
|
|
|
|
std::fs::write(&path, test_pe_binary(wrong_machine)).unwrap();
|
|
let mut file = std::fs::File::open(&path).unwrap();
|
|
assert!(!is_windows_binary_for_current_arch(&mut file, b"MZ\0\0"));
|
|
std::fs::remove_file(path).ok();
|
|
}
|
|
|
|
fn test_pe_binary(machine: u16) -> Vec<u8> {
|
|
let mut bytes = vec![0_u8; 0x48];
|
|
bytes[..2].copy_from_slice(b"MZ");
|
|
bytes[0x3c..0x40].copy_from_slice(&(0x40_u32).to_le_bytes());
|
|
bytes[0x40..0x44].copy_from_slice(b"PE\0\0");
|
|
bytes[0x44..0x46].copy_from_slice(&machine.to_le_bytes());
|
|
bytes
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod agent_registry_install_tests {
|
|
use super::*;
|
|
use crate::agent_manager::{ArtifactFormat, ArtifactInfo, DriverInfo, InstalledDriver, JavaRuntimeConfig, JreInfo};
|
|
|
|
static ENSURE_AGENT_TEST_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
|
|
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
|
|
|
|
fn test_manager(name: &str) -> AgentManager {
|
|
let dir = std::env::temp_dir().join(format!("dbx-agent-registry-install-{name}-{}", uuid::Uuid::new_v4()));
|
|
AgentManager::new_with_base_dir(dir)
|
|
}
|
|
|
|
fn write_test_agent_jar(path: &Path) {
|
|
use std::io::Write;
|
|
|
|
let file = std::fs::File::create(path).unwrap();
|
|
let mut archive = zip::ZipWriter::new(file);
|
|
archive.start_file("META-INF/MANIFEST.MF", zip::write::SimpleFileOptions::default()).unwrap();
|
|
archive.write_all(b"Manifest-Version: 1.0\nMain-Class: com.dbx.Agent\n").unwrap();
|
|
archive.finish().unwrap();
|
|
}
|
|
|
|
fn registry_with_native_and_legacy_jar(
|
|
db_type: &str,
|
|
version: &str,
|
|
native_url: &str,
|
|
native_size: u64,
|
|
) -> AgentRegistry {
|
|
let mut drivers = std::collections::HashMap::new();
|
|
drivers.insert(
|
|
db_type.to_string(),
|
|
DriverInfo {
|
|
version: version.to_string(),
|
|
label: db_type.to_string(),
|
|
min_app_version: "0.1.0".to_string(),
|
|
jre: DEFAULT_JRE_KEY.to_string(),
|
|
jar: Some(ArtifactInfo {
|
|
url: format!("https://example.com/dbx-agent-{db_type}-legacy-placeholder.jar"),
|
|
sha256: None,
|
|
size: 0,
|
|
format: None,
|
|
}),
|
|
native: [(
|
|
AgentManager::current_platform().to_string(),
|
|
ArtifactInfo { url: native_url.to_string(), sha256: None, size: native_size, format: None },
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
},
|
|
);
|
|
AgentRegistry { jre: None, jres: std::collections::HashMap::new(), drivers }
|
|
}
|
|
|
|
fn registry_with_jar(db_type: &str, version: &str, url: &str, size: u64) -> AgentRegistry {
|
|
let mut drivers = std::collections::HashMap::new();
|
|
drivers.insert(
|
|
db_type.to_string(),
|
|
DriverInfo {
|
|
version: version.to_string(),
|
|
label: db_type.to_string(),
|
|
min_app_version: "0.1.0".to_string(),
|
|
jre: DEFAULT_JRE_KEY.to_string(),
|
|
jar: Some(ArtifactInfo { url: url.to_string(), sha256: None, size, format: None }),
|
|
native: std::collections::HashMap::new(),
|
|
},
|
|
);
|
|
AgentRegistry { jre: None, jres: std::collections::HashMap::new(), drivers }
|
|
}
|
|
|
|
fn registry_with_jre_version(version: &str) -> AgentRegistry {
|
|
AgentRegistry {
|
|
jre: None,
|
|
jres: [(
|
|
DEFAULT_JRE_KEY.to_string(),
|
|
JreInfo { version: version.to_string(), platforms: std::collections::HashMap::new() },
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
drivers: std::collections::HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn write_cached_driver_download(
|
|
am: &AgentManager,
|
|
db_type: &str,
|
|
version: &str,
|
|
url: &str,
|
|
dest: &Path,
|
|
bytes: &[u8],
|
|
) -> PathBuf {
|
|
let cache_path = cached_download_path(
|
|
am,
|
|
url,
|
|
bytes.len() as u64,
|
|
None,
|
|
Some(CacheIdentity::Driver { db_type, version }),
|
|
dest,
|
|
);
|
|
std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&cache_path, bytes).unwrap();
|
|
cache_path
|
|
}
|
|
|
|
fn current_platform_native_binary() -> Vec<u8> {
|
|
if cfg!(windows) {
|
|
let mut bytes = vec![0_u8; 0x48];
|
|
bytes[..2].copy_from_slice(b"MZ");
|
|
bytes[0x3c..0x40].copy_from_slice(&(0x40_u32).to_le_bytes());
|
|
bytes[0x40..0x44].copy_from_slice(b"PE\0\0");
|
|
let machine = if cfg!(target_arch = "aarch64") { 0xaa64_u16 } else { 0x8664_u16 };
|
|
bytes[0x44..0x46].copy_from_slice(&machine.to_le_bytes());
|
|
bytes
|
|
} else if cfg!(target_os = "linux") {
|
|
let mut bytes = vec![0_u8; 20];
|
|
bytes[..4].copy_from_slice(b"\x7fELF");
|
|
bytes[4] = 2;
|
|
bytes[5] = 1;
|
|
let machine = if cfg!(target_arch = "aarch64") { 183_u16 } else { 62_u16 };
|
|
bytes[18..20].copy_from_slice(&machine.to_le_bytes());
|
|
bytes
|
|
} else if cfg!(target_os = "macos") {
|
|
let mut bytes = vec![0xcf, 0xfa, 0xed, 0xfe];
|
|
let cpu_type = if cfg!(target_arch = "aarch64") { 0x0100_000c_u32 } else { 0x0100_0007_u32 };
|
|
bytes.extend_from_slice(&cpu_type.to_le_bytes());
|
|
bytes
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
fn test_agent_jar() -> Vec<u8> {
|
|
let mut bytes = std::io::Cursor::new(Vec::new());
|
|
{
|
|
use std::io::Write;
|
|
let mut archive = zip::ZipWriter::new(&mut bytes);
|
|
archive.start_file("META-INF/MANIFEST.MF", zip::write::SimpleFileOptions::default()).unwrap();
|
|
archive.write_all(b"Manifest-Version: 1.0\nMain-Class: com.dbx.Agent\n").unwrap();
|
|
archive.finish().unwrap();
|
|
}
|
|
bytes.into_inner()
|
|
}
|
|
|
|
fn build_tar_zstd_driver_package(
|
|
db_type: &str,
|
|
version: &str,
|
|
kind: DriverArtifactKind,
|
|
driver_bytes: &[u8],
|
|
) -> Vec<u8> {
|
|
let platform = AgentManager::current_platform();
|
|
let (filename, artifact) = match kind {
|
|
DriverArtifactKind::Jar => (
|
|
format!("dbx-agent-{db_type}-{version}.jar"),
|
|
serde_json::json!({
|
|
"jar": {
|
|
"url": format!("dbx-agent-{db_type}-{version}.jar"),
|
|
"size": driver_bytes.len()
|
|
}
|
|
}),
|
|
),
|
|
DriverArtifactKind::Native => {
|
|
let extension = if platform.starts_with("windows-") { ".exe" } else { "" };
|
|
let filename = format!("dbx-agent-{db_type}-{version}-{platform}{extension}");
|
|
(
|
|
filename.clone(),
|
|
serde_json::json!({
|
|
"native": {
|
|
platform: {
|
|
"url": filename,
|
|
"size": driver_bytes.len()
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
};
|
|
let mut driver = serde_json::json!({
|
|
"version": version,
|
|
"label": db_type,
|
|
"min_app_version": "0.6.0",
|
|
"jre": DEFAULT_JRE_KEY
|
|
});
|
|
driver.as_object_mut().unwrap().extend(artifact.as_object().unwrap().clone());
|
|
let registry = serde_json::json!({
|
|
"jres": {},
|
|
"drivers": {
|
|
db_type: driver
|
|
}
|
|
});
|
|
let registry_bytes = registry.to_string().into_bytes();
|
|
let encoder = zstd::stream::write::Encoder::new(Vec::new(), 3).unwrap();
|
|
let mut archive = tar::Builder::new(encoder);
|
|
|
|
let mut registry_header = tar::Header::new_gnu();
|
|
registry_header.set_size(registry_bytes.len() as u64);
|
|
registry_header.set_mode(0o644);
|
|
registry_header.set_cksum();
|
|
archive.append_data(&mut registry_header, "agent-registry.json", registry_bytes.as_slice()).unwrap();
|
|
|
|
let mut driver_header = tar::Header::new_gnu();
|
|
driver_header.set_size(driver_bytes.len() as u64);
|
|
driver_header.set_mode(if kind == DriverArtifactKind::Native { 0o755 } else { 0o644 });
|
|
driver_header.set_cksum();
|
|
archive.append_data(&mut driver_header, format!("drivers/{filename}"), driver_bytes).unwrap();
|
|
|
|
archive.into_inner().unwrap().finish().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn managed_jre_content_revision_triggers_install() {
|
|
let manager = test_manager("jre-content-revision");
|
|
let java_path = manager.jre_java_path(DEFAULT_JRE_KEY);
|
|
std::fs::create_dir_all(java_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&java_path, b"java").unwrap();
|
|
|
|
let mut state = crate::agent_manager::AgentState::default();
|
|
state.jre_versions.insert(DEFAULT_JRE_KEY.to_string(), "21.0.12+kerberos.1".to_string());
|
|
manager.save_state(&state).unwrap();
|
|
|
|
let registry = registry_with_jre_version("21.0.12+kerberos.ec.2");
|
|
assert!(jre_needs_install(&manager, ®istry, DEFAULT_JRE_KEY));
|
|
|
|
state.jre_versions.insert(DEFAULT_JRE_KEY.to_string(), "21.0.12+kerberos.ec.2".to_string());
|
|
manager.save_state(&state).unwrap();
|
|
assert!(!jre_needs_install(&manager, ®istry, DEFAULT_JRE_KEY));
|
|
}
|
|
|
|
fn install_jre(manager: &AgentManager) {
|
|
let java_path = manager.jre_java_path(DEFAULT_JRE_KEY);
|
|
std::fs::create_dir_all(java_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&java_path, b"java").unwrap();
|
|
let mut state = manager.load_state();
|
|
state.jre_versions.insert(DEFAULT_JRE_KEY.to_string(), "21.0.0".to_string());
|
|
manager.save_state(&state).unwrap();
|
|
}
|
|
|
|
fn record_driver(manager: &AgentManager, db_type: &str) {
|
|
let mut state = manager.load_state();
|
|
state.installed_drivers.insert(
|
|
db_type.to_string(),
|
|
InstalledDriver {
|
|
version: "1.0.0".to_string(),
|
|
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
|
jre: DEFAULT_JRE_KEY.to_string(),
|
|
},
|
|
);
|
|
manager.save_state(&state).unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uninstall_jre_ignores_native_driver_dependents() {
|
|
// A native (non-Java) driver still records the JRE key in its state
|
|
// entry, but must not block uninstalling the JRE it never uses.
|
|
let manager = test_manager("jre-uninstall-native-dependent");
|
|
install_jre(&manager);
|
|
std::fs::create_dir_all(manager.driver_dir("kafka")).unwrap();
|
|
std::fs::write(manager.driver_native_path("kafka"), b"native-binary").unwrap();
|
|
record_driver(&manager, "kafka");
|
|
|
|
uninstall_agent_jre(&manager, DEFAULT_JRE_KEY).await.expect("native driver must not block JRE uninstall");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uninstall_jre_blocked_by_jar_driver_dependent() {
|
|
// A JAR (Java) driver genuinely depends on the JRE and must block the
|
|
// uninstall so the driver keeps a runtime.
|
|
let manager = test_manager("jre-uninstall-jar-dependent");
|
|
install_jre(&manager);
|
|
std::fs::create_dir_all(manager.driver_dir("mysql")).unwrap();
|
|
std::fs::write(manager.driver_jar_path("mysql"), test_agent_jar()).unwrap();
|
|
record_driver(&manager, "mysql");
|
|
|
|
let err = uninstall_agent_jre(&manager, DEFAULT_JRE_KEY).await.expect_err("jar driver must block uninstall");
|
|
assert!(err.contains("is in use by drivers"), "unexpected error: {err}");
|
|
assert!(err.contains("mysql"), "expected dependent driver in error: {err}");
|
|
}
|
|
|
|
fn registry_with_jre(jre_key: &str, version: &str, url: &str, size: u64) -> AgentRegistry {
|
|
AgentRegistry {
|
|
jre: None,
|
|
jres: [(
|
|
jre_key.to_string(),
|
|
JreInfo {
|
|
version: version.to_string(),
|
|
platforms: [(
|
|
AgentManager::current_platform().to_string(),
|
|
ArtifactInfo { url: url.to_string(), sha256: None, size, format: None },
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
},
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
drivers: std::collections::HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn build_jre_archive(am: &AgentManager, jre_key: &str) -> Vec<u8> {
|
|
let archive_root = am.base_dir().join("jre-test-archive");
|
|
let payload = archive_root.join("payload");
|
|
let java_path = am.jre_java_path(jre_key);
|
|
let relative_java_path = java_path.strip_prefix(am.jre_dir(jre_key)).unwrap();
|
|
let java_path = payload.join(relative_java_path);
|
|
std::fs::create_dir_all(java_path.parent().unwrap()).unwrap();
|
|
std::fs::write(java_path, b"java").unwrap();
|
|
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
|
let mut builder = tar::Builder::new(encoder);
|
|
builder.append_dir_all("payload", &payload).unwrap();
|
|
builder.into_inner().unwrap().finish().unwrap()
|
|
}
|
|
|
|
fn build_zstd_jre_archive(am: &AgentManager, jre_key: &str) -> Vec<u8> {
|
|
let archive_root = am.base_dir().join("jre-zstd-test-archive");
|
|
let payload = archive_root.join("payload");
|
|
let java_path = am.jre_java_path(jre_key);
|
|
let relative_java_path = java_path.strip_prefix(am.jre_dir(jre_key)).unwrap();
|
|
let java_path = payload.join(relative_java_path);
|
|
std::fs::create_dir_all(java_path.parent().unwrap()).unwrap();
|
|
std::fs::write(java_path, b"java").unwrap();
|
|
let encoder = zstd::stream::write::Encoder::new(Vec::new(), 3).unwrap();
|
|
let mut builder = tar::Builder::new(encoder);
|
|
builder.append_dir_all("payload", &payload).unwrap();
|
|
builder.into_inner().unwrap().finish().unwrap()
|
|
}
|
|
|
|
fn write_cached_jre_download(
|
|
am: &AgentManager,
|
|
jre_key: &str,
|
|
version: &str,
|
|
url: &str,
|
|
format: Option<ArtifactFormat>,
|
|
expected_sha256: Option<&str>,
|
|
archive: &[u8],
|
|
) {
|
|
let dest = jre_archive_download_path(am, jre_key, format);
|
|
let cache_path = cached_download_path(
|
|
am,
|
|
url,
|
|
archive.len() as u64,
|
|
expected_sha256,
|
|
Some(CacheIdentity::Jre { key: jre_key, version }),
|
|
&dest,
|
|
);
|
|
std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
|
|
std::fs::write(cache_path, archive).unwrap();
|
|
}
|
|
|
|
async fn cache_test_registry(registry: AgentRegistry) {
|
|
REGISTRY_CACHE.lock().await.insert(DownloadSource::Cnb, (std::time::Instant::now(), registry));
|
|
}
|
|
|
|
fn mongodb_registry_with_jre(
|
|
driver_version: &str,
|
|
driver_url: &str,
|
|
driver_size: u64,
|
|
jre_version: &str,
|
|
jre_url: &str,
|
|
jre_size: u64,
|
|
) -> AgentRegistry {
|
|
let mut registry = registry_with_jar("mongodb", driver_version, driver_url, driver_size);
|
|
registry.jres = registry_with_jre(DEFAULT_JRE_KEY, jre_version, jre_url, jre_size).jres;
|
|
registry
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_agent_runtime_installs_missing_driver_and_jre() {
|
|
let _test_guard = ENSURE_AGENT_TEST_LOCK.lock().await;
|
|
let manager = test_manager("ensure-missing-driver-and-jre");
|
|
let driver_version = "0.1.47";
|
|
let driver_url = "https://example.com/dbx-agent-mongodb.jar";
|
|
let driver_bytes = test_agent_jar();
|
|
let jre_version = "21.0.12";
|
|
let jre_url = "https://example.com/dbx-jre.tar.gz";
|
|
let jre_archive = build_jre_archive(&manager, DEFAULT_JRE_KEY);
|
|
let registry = mongodb_registry_with_jre(
|
|
driver_version,
|
|
driver_url,
|
|
driver_bytes.len() as u64,
|
|
jre_version,
|
|
jre_url,
|
|
jre_archive.len() as u64,
|
|
);
|
|
write_cached_driver_download(
|
|
&manager,
|
|
"mongodb",
|
|
driver_version,
|
|
driver_url,
|
|
&manager.driver_jar_path("mongodb"),
|
|
&driver_bytes,
|
|
);
|
|
write_cached_jre_download(&manager, DEFAULT_JRE_KEY, jre_version, jre_url, None, None, &jre_archive);
|
|
cache_test_registry(registry).await;
|
|
|
|
ensure_agent_driver_ready_from(&manager, "mongodb", DownloadSource::Cnb).await.unwrap();
|
|
|
|
assert!(manager.is_driver_jar_valid("mongodb"));
|
|
assert!(manager.is_jre_installed(DEFAULT_JRE_KEY));
|
|
let state = manager.load_state();
|
|
assert_eq!(state.installed_drivers["mongodb"].version, driver_version);
|
|
assert_eq!(state.jre_versions[DEFAULT_JRE_KEY], jre_version);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_agent_runtime_ready_fast_path_preserves_valid_old_driver() {
|
|
let _test_guard = ENSURE_AGENT_TEST_LOCK.lock().await;
|
|
let manager = test_manager("ensure-ready-old-driver");
|
|
let driver_path = manager.driver_jar_path("mongodb");
|
|
std::fs::create_dir_all(driver_path.parent().unwrap()).unwrap();
|
|
let old_driver_bytes = test_agent_jar();
|
|
std::fs::write(&driver_path, &old_driver_bytes).unwrap();
|
|
install_jre(&manager);
|
|
record_driver(&manager, "mongodb");
|
|
|
|
let latest_version = "9.9.9";
|
|
let latest_url = "https://example.com/dbx-agent-mongodb-latest.jar";
|
|
let latest_driver_bytes = test_agent_jar();
|
|
let cache_path = write_cached_driver_download(
|
|
&manager,
|
|
"mongodb",
|
|
latest_version,
|
|
latest_url,
|
|
&driver_path,
|
|
&latest_driver_bytes,
|
|
);
|
|
let registry_guard = REGISTRY_CACHE.lock().await;
|
|
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_millis(100),
|
|
ensure_agent_driver_ready_from(&manager, "mongodb", DownloadSource::Cnb),
|
|
)
|
|
.await
|
|
.expect("ready Agent path must not wait for the registry cache")
|
|
.unwrap();
|
|
drop(registry_guard);
|
|
|
|
assert_eq!(std::fs::read(&driver_path).unwrap(), old_driver_bytes);
|
|
assert_eq!(manager.load_state().installed_drivers["mongodb"].version, "1.0.0");
|
|
assert!(cache_path.exists(), "ready fast path must not consume a pending driver download");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_agent_runtime_repairs_damaged_jre_without_upgrading_valid_driver() {
|
|
let _test_guard = ENSURE_AGENT_TEST_LOCK.lock().await;
|
|
let manager = test_manager("ensure-damaged-jre");
|
|
let driver_path = manager.driver_jar_path("mongodb");
|
|
std::fs::create_dir_all(driver_path.parent().unwrap()).unwrap();
|
|
let old_driver_bytes = test_agent_jar();
|
|
std::fs::write(&driver_path, &old_driver_bytes).unwrap();
|
|
record_driver(&manager, "mongodb");
|
|
let damaged_java = manager.jre_java_path(DEFAULT_JRE_KEY);
|
|
std::fs::create_dir_all(&damaged_java).unwrap();
|
|
|
|
let jre_version = "21.0.12";
|
|
let jre_url = "https://example.com/dbx-jre.tar.gz";
|
|
let jre_archive = build_jre_archive(&manager, DEFAULT_JRE_KEY);
|
|
let registry = mongodb_registry_with_jre(
|
|
"9.9.9",
|
|
"https://example.com/dbx-agent-mongodb-latest.jar",
|
|
test_agent_jar().len() as u64,
|
|
jre_version,
|
|
jre_url,
|
|
jre_archive.len() as u64,
|
|
);
|
|
write_cached_jre_download(&manager, DEFAULT_JRE_KEY, jre_version, jre_url, None, None, &jre_archive);
|
|
cache_test_registry(registry).await;
|
|
|
|
ensure_agent_driver_ready_from(&manager, "mongodb", DownloadSource::Cnb).await.unwrap();
|
|
|
|
assert!(manager.is_jre_installed(DEFAULT_JRE_KEY));
|
|
assert_eq!(std::fs::read(&driver_path).unwrap(), old_driver_bytes);
|
|
assert_eq!(manager.load_state().installed_drivers["mongodb"].version, "1.0.0");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_ensure_agent_runtime_installs_once() {
|
|
let _test_guard = ENSURE_AGENT_TEST_LOCK.lock().await;
|
|
let manager = Arc::new(test_manager("ensure-concurrent-single-install"));
|
|
let driver_version = "0.1.47";
|
|
let driver_url = "https://example.com/dbx-agent-mongodb.jar";
|
|
let driver_bytes = test_agent_jar();
|
|
let jre_version = "21.0.12";
|
|
let jre_url = "https://example.com/dbx-jre.tar.gz";
|
|
let jre_archive = build_jre_archive(&manager, DEFAULT_JRE_KEY);
|
|
let registry = mongodb_registry_with_jre(
|
|
driver_version,
|
|
driver_url,
|
|
driver_bytes.len() as u64,
|
|
jre_version,
|
|
jre_url,
|
|
jre_archive.len() as u64,
|
|
);
|
|
let driver_cache_path = write_cached_driver_download(
|
|
&manager,
|
|
"mongodb",
|
|
driver_version,
|
|
driver_url,
|
|
&manager.driver_jar_path("mongodb"),
|
|
&driver_bytes,
|
|
);
|
|
write_cached_jre_download(&manager, DEFAULT_JRE_KEY, jre_version, jre_url, None, None, &jre_archive);
|
|
cache_test_registry(registry).await;
|
|
|
|
let first = ensure_agent_driver_ready_from(&manager, "mongodb", DownloadSource::Cnb);
|
|
let second = ensure_agent_driver_ready_from(&manager, "mongodb", DownloadSource::Cnb);
|
|
let (first, second) = tokio::join!(first, second);
|
|
|
|
first.unwrap();
|
|
second.unwrap();
|
|
assert!(manager.is_driver_jar_valid("mongodb"));
|
|
assert!(manager.is_jre_installed(DEFAULT_JRE_KEY));
|
|
assert!(!driver_cache_path.exists(), "the single successful install should consume the cached artifact");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_agent_runtime_propagates_corrupt_install_error() {
|
|
let _test_guard = ENSURE_AGENT_TEST_LOCK.lock().await;
|
|
let manager = test_manager("ensure-corrupt-install-error");
|
|
manager
|
|
.mutate_state(|state| {
|
|
state.java_runtime = JavaRuntimeConfig { mode: JavaRuntimeMode::System, custom_java_path: None };
|
|
})
|
|
.unwrap();
|
|
let driver_version = "0.1.47";
|
|
let driver_url = "https://example.com/dbx-agent-mongodb.jar";
|
|
let corrupt_driver = b"not-a-jar";
|
|
let registry = registry_with_jar("mongodb", driver_version, driver_url, corrupt_driver.len() as u64);
|
|
write_cached_driver_download(
|
|
&manager,
|
|
"mongodb",
|
|
driver_version,
|
|
driver_url,
|
|
&manager.driver_jar_path("mongodb"),
|
|
corrupt_driver,
|
|
);
|
|
cache_test_registry(registry).await;
|
|
|
|
let error = ensure_agent_driver_ready_from(&manager, "mongodb", DownloadSource::Cnb)
|
|
.await
|
|
.expect_err("corrupt driver install must fail");
|
|
|
|
assert!(error.contains("invalid or corrupt"), "unexpected error: {error}");
|
|
assert!(!manager.load_state().installed_drivers.contains_key("mongodb"));
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_info_deserializes_sha256_metadata() {
|
|
let expected_sha256 = "a".repeat(64);
|
|
let artifact: ArtifactInfo = serde_json::from_value(serde_json::json!({
|
|
"url": "https://example.com/artifact.tar.zst",
|
|
"sha256": expected_sha256,
|
|
"size": 4,
|
|
"format": "tar_zstd"
|
|
}))
|
|
.unwrap();
|
|
|
|
assert_eq!(artifact.sha256.as_deref(), Some(expected_sha256.as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn cached_download_rejects_same_size_sha256_mismatch() {
|
|
let manager = test_manager("cache-sha256-mismatch");
|
|
let cache_path = manager.download_cache_dir().join("artifact.bin");
|
|
std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&cache_path, b"bad!").unwrap();
|
|
let expected_sha256 = format!("{:x}", Sha256::digest(b"good"));
|
|
|
|
assert!(!cached_download_is_valid(&manager, &cache_path, 4, Some(&expected_sha256)));
|
|
assert!(!cache_path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn cached_download_accepts_matching_sha256() {
|
|
let manager = test_manager("cache-sha256-match");
|
|
let cache_path = manager.download_cache_dir().join("artifact.bin");
|
|
std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&cache_path, b"good").unwrap();
|
|
let expected_sha256 = format!("{:x}", Sha256::digest(b"good"));
|
|
|
|
assert!(cached_download_is_valid(&manager, &cache_path, 4, Some(&expected_sha256)));
|
|
assert!(cache_path.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_install_replaces_hive_legacy_jar_with_native_driver() {
|
|
let manager = test_manager("hive-native-replaces-legacy-jar");
|
|
let db_type = "hive";
|
|
let version = "0.1.31";
|
|
let native_url = "https://example.com/dbx-agent-hive";
|
|
let native_bytes = b"native-agent";
|
|
let registry = registry_with_native_and_legacy_jar(db_type, version, native_url, native_bytes.len() as u64);
|
|
let native_path = manager.driver_native_path(db_type);
|
|
std::fs::create_dir_all(manager.driver_dir(db_type)).unwrap();
|
|
write_test_agent_jar(&manager.driver_jar_path(db_type));
|
|
let cache_path =
|
|
write_cached_driver_download(&manager, db_type, version, native_url, &native_path, native_bytes);
|
|
let events = std::sync::Mutex::new(Vec::new());
|
|
let progress = |event| events.lock().unwrap().push(event);
|
|
|
|
install_agent_driver_from_registry(
|
|
&manager,
|
|
®istry,
|
|
DownloadSource::Official,
|
|
db_type,
|
|
&progress,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(std::fs::read(&native_path).unwrap(), native_bytes);
|
|
assert!(!cache_path.exists());
|
|
assert!(!manager.driver_jar_path(db_type).exists());
|
|
assert_eq!(manager.load_state().installed_drivers.get(db_type).unwrap().version, version);
|
|
assert!(events
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|event| event.step == "done" && event.db_type.as_deref() == Some(db_type)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_install_extracts_tar_zstd_native_driver_package() {
|
|
let manager = test_manager("tar-zstd-native-package");
|
|
let db_type = "duckdb";
|
|
let version = "0.1.0";
|
|
let package_url = "https://example.com/dbx-agent-duckdb.tar.zst";
|
|
let native_bytes = current_platform_native_binary();
|
|
let package_bytes = build_tar_zstd_driver_package(db_type, version, DriverArtifactKind::Native, &native_bytes);
|
|
let mut registry =
|
|
registry_with_native_and_legacy_jar(db_type, version, package_url, package_bytes.len() as u64);
|
|
registry.drivers.get_mut(db_type).unwrap().native.get_mut(AgentManager::current_platform()).unwrap().format =
|
|
Some(ArtifactFormat::TarZstd);
|
|
let native_path = manager.driver_native_path(db_type);
|
|
let package_path = driver_artifact_download_path(&native_path, Some(ArtifactFormat::TarZstd));
|
|
let cache_path =
|
|
write_cached_driver_download(&manager, db_type, version, package_url, &package_path, &package_bytes);
|
|
let progress = |_| {};
|
|
|
|
install_agent_driver_from_registry(
|
|
&manager,
|
|
®istry,
|
|
DownloadSource::Official,
|
|
db_type,
|
|
&progress,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(std::fs::read(&native_path).unwrap(), native_bytes);
|
|
assert!(!package_path.exists());
|
|
assert!(!cache_path.exists());
|
|
assert_eq!(manager.load_state().installed_drivers[db_type].version, version);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_install_extracts_tar_zstd_java_driver_package() {
|
|
let manager = test_manager("tar-zstd-java-package");
|
|
let db_type = "dameng";
|
|
let version = "0.2.0";
|
|
let package_url = "https://example.com/dbx-agent-dameng.tar.zst";
|
|
let jar_bytes = test_agent_jar();
|
|
let package_bytes = build_tar_zstd_driver_package(db_type, version, DriverArtifactKind::Jar, &jar_bytes);
|
|
let mut registry = registry_with_jar(db_type, version, package_url, package_bytes.len() as u64);
|
|
registry.drivers.get_mut(db_type).unwrap().jar.as_mut().unwrap().format = Some(ArtifactFormat::TarZstd);
|
|
manager
|
|
.mutate_state(|state| {
|
|
state.java_runtime = JavaRuntimeConfig { mode: JavaRuntimeMode::System, custom_java_path: None };
|
|
})
|
|
.unwrap();
|
|
let jar_path = manager.driver_jar_path(db_type);
|
|
let package_path = driver_artifact_download_path(&jar_path, Some(ArtifactFormat::TarZstd));
|
|
let cache_path =
|
|
write_cached_driver_download(&manager, db_type, version, package_url, &package_path, &package_bytes);
|
|
let progress = |_| {};
|
|
|
|
install_agent_driver_from_registry(
|
|
&manager,
|
|
®istry,
|
|
DownloadSource::Official,
|
|
db_type,
|
|
&progress,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(std::fs::read(&jar_path).unwrap(), jar_bytes);
|
|
assert!(!package_path.exists());
|
|
assert!(!cache_path.exists());
|
|
assert_eq!(manager.load_state().installed_drivers[db_type].version, version);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batch_upgrade_preserves_successful_state_and_reports_independent_failure() {
|
|
let manager = test_manager("batch-upgrade");
|
|
let oracle_url = "https://example.com/dbx-agent-oracle";
|
|
let dameng_url = "https://example.com/dbx-agent-dameng";
|
|
let kingbase_url = "https://example.com/dbx-agent-kingbase.jar";
|
|
let oracle_bytes = b"oracle-native-agent";
|
|
let dameng_bytes = b"dameng-native-agent";
|
|
let corrupt_jar = b"not-a-jar";
|
|
|
|
let mut registry =
|
|
registry_with_native_and_legacy_jar("oracle", "2.0.0", oracle_url, oracle_bytes.len() as u64);
|
|
registry.drivers.extend(
|
|
registry_with_native_and_legacy_jar("dameng", "2.0.0", dameng_url, dameng_bytes.len() as u64).drivers,
|
|
);
|
|
registry.drivers.extend(registry_with_jar("kingbase", "2.0.0", kingbase_url, corrupt_jar.len() as u64).drivers);
|
|
|
|
let mut state = manager.load_state();
|
|
state.java_runtime = JavaRuntimeConfig { mode: JavaRuntimeMode::System, custom_java_path: None };
|
|
for (db_type, version) in [("oracle", "1.0.0"), ("dameng", "1.0.0"), ("kingbase", "1.0.0")] {
|
|
state.installed_drivers.insert(
|
|
db_type.to_string(),
|
|
InstalledDriver {
|
|
version: version.to_string(),
|
|
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
|
jre: DEFAULT_JRE_KEY.to_string(),
|
|
},
|
|
);
|
|
}
|
|
manager.save_state(&state).unwrap();
|
|
|
|
write_cached_driver_download(
|
|
&manager,
|
|
"oracle",
|
|
"2.0.0",
|
|
oracle_url,
|
|
&manager.driver_native_path("oracle"),
|
|
oracle_bytes,
|
|
);
|
|
write_cached_driver_download(
|
|
&manager,
|
|
"dameng",
|
|
"2.0.0",
|
|
dameng_url,
|
|
&manager.driver_native_path("dameng"),
|
|
dameng_bytes,
|
|
);
|
|
write_cached_driver_download(
|
|
&manager,
|
|
"kingbase",
|
|
"2.0.0",
|
|
kingbase_url,
|
|
&manager.driver_jar_path("kingbase"),
|
|
corrupt_jar,
|
|
);
|
|
let events = std::sync::Mutex::new(Vec::new());
|
|
let progress = |event| events.lock().unwrap().push(event);
|
|
|
|
let result = upgrade_all_agent_drivers_with_registry(&manager, ®istry, DownloadSource::Official, &progress)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.upgraded, 2);
|
|
assert_eq!(result.failed.len(), 1);
|
|
assert_eq!(result.failed[0].db_type, "kingbase");
|
|
let state = manager.load_state();
|
|
assert_eq!(state.installed_drivers["oracle"].version, "2.0.0");
|
|
assert_eq!(state.installed_drivers["dameng"].version, "2.0.0");
|
|
assert_eq!(state.installed_drivers["kingbase"].version, "1.0.0");
|
|
assert_eq!(events.lock().unwrap().iter().filter(|event| event.step == "done").count(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shared_jre_is_not_downloaded_again_after_the_first_install_persists_its_version() {
|
|
let manager = test_manager("shared-jre-deduplication");
|
|
let jre_key = DEFAULT_JRE_KEY;
|
|
let version = "21.0.12";
|
|
let url = "https://example.com/dbx-jre.tar.gz";
|
|
let archive = build_jre_archive(&manager, jre_key);
|
|
let registry = registry_with_jre(jre_key, version, url, archive.len() as u64);
|
|
let events = std::sync::Mutex::new(Vec::new());
|
|
let progress = |event| events.lock().unwrap().push(event);
|
|
|
|
write_cached_jre_download(&manager, jre_key, version, url, None, None, &archive);
|
|
ensure_jre_from_registry(
|
|
&manager,
|
|
®istry,
|
|
DownloadSource::Official,
|
|
jre_key,
|
|
"oracle",
|
|
&progress,
|
|
Some(1),
|
|
Some(2),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// The successful install cleans its cache. Re-add it so the old
|
|
// implementation fails deterministically by consuming it again.
|
|
write_cached_jre_download(&manager, jre_key, version, url, None, None, &archive);
|
|
ensure_jre_from_registry(
|
|
&manager,
|
|
®istry,
|
|
DownloadSource::Official,
|
|
jre_key,
|
|
"dameng",
|
|
&progress,
|
|
Some(2),
|
|
Some(2),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(manager.load_state().jre_versions[jre_key], version);
|
|
assert!(events
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|event| event.step == "jre" && event.db_type.as_deref() == Some("oracle")));
|
|
assert!(!events
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|event| event.step == "jre" && event.db_type.as_deref() == Some("dameng")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn managed_jre_install_extracts_tar_zstd_archive() {
|
|
let manager = test_manager("managed-zstd-jre");
|
|
let jre_key = DEFAULT_JRE_KEY;
|
|
let version = "21.0.12";
|
|
let url = "https://example.com/dbx-jre.tar.zst";
|
|
let archive = build_zstd_jre_archive(&manager, jre_key);
|
|
let expected_sha256 = format!("{:x}", Sha256::digest(&archive));
|
|
let mut registry = registry_with_jre(jre_key, version, url, archive.len() as u64);
|
|
let artifact =
|
|
registry.jres.get_mut(jre_key).unwrap().platforms.get_mut(AgentManager::current_platform()).unwrap();
|
|
artifact.format = Some(ArtifactFormat::TarZstd);
|
|
artifact.sha256 = Some(expected_sha256.clone());
|
|
write_cached_jre_download(
|
|
&manager,
|
|
jre_key,
|
|
version,
|
|
url,
|
|
Some(ArtifactFormat::TarZstd),
|
|
Some(&expected_sha256),
|
|
&archive,
|
|
);
|
|
|
|
ensure_jre_from_registry(&manager, ®istry, DownloadSource::Official, jre_key, "dameng", &|_| {}, None, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(manager.is_jre_installed(jre_key));
|
|
assert_eq!(manager.load_state().jre_versions[jre_key], version);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stash_is_recorded_before_jre_extraction() {
|
|
let manager = test_manager("stash-before-extract");
|
|
let stash = manager.base_dir().join("jre-21.old-test");
|
|
|
|
persist_pending_jre_cleanup(&manager, Some(&stash)).await.unwrap();
|
|
|
|
assert_eq!(manager.load_state().pending_jre_cleanup, vec![stash]);
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_local_agent_state_updates_preserve_each_driver() {
|
|
let manager = test_manager("concurrent-local-agent-state");
|
|
let start = std::sync::Arc::new(std::sync::Barrier::new(3));
|
|
std::thread::scope(|scope| {
|
|
for db_type in ["oracle", "dameng"] {
|
|
let start = start.clone();
|
|
let manager = &manager;
|
|
scope.spawn(move || {
|
|
start.wait();
|
|
manager
|
|
.mutate_state(|state| {
|
|
state.jre_versions.insert(DEFAULT_JRE_KEY.to_string(), "21.0.12".to_string());
|
|
record_local_agent_install(state, db_type, DEFAULT_JRE_KEY);
|
|
})
|
|
.unwrap();
|
|
});
|
|
}
|
|
start.wait();
|
|
});
|
|
|
|
let state = manager.load_state();
|
|
assert!(state.installed_drivers.contains_key("oracle"));
|
|
assert!(state.installed_drivers.contains_key("dameng"));
|
|
assert_eq!(state.jre_versions[DEFAULT_JRE_KEY], "21.0.12");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batch_registry_install_waits_for_an_existing_driver_operation() {
|
|
let manager = test_manager("batch-driver-operation-lock");
|
|
let db_type = "oracle";
|
|
let version = "0.1.31";
|
|
let native_url = "https://example.com/dbx-agent-oracle";
|
|
let native_bytes = b"native-agent";
|
|
let registry = registry_with_native_and_legacy_jar(db_type, version, native_url, native_bytes.len() as u64);
|
|
write_cached_driver_download(
|
|
&manager,
|
|
db_type,
|
|
version,
|
|
native_url,
|
|
&manager.driver_native_path(db_type),
|
|
native_bytes,
|
|
);
|
|
let first_lock = driver_operation_lock(&manager, "oracle").await;
|
|
let first_guard = first_lock.lock().await;
|
|
let progress = |_| {};
|
|
|
|
let blocked = tokio::time::timeout(std::time::Duration::from_millis(50), async {
|
|
install_agent_driver_from_registry_locked(
|
|
&manager,
|
|
®istry,
|
|
DownloadSource::Official,
|
|
db_type,
|
|
&progress,
|
|
Some(1),
|
|
Some(1),
|
|
)
|
|
.await
|
|
})
|
|
.await;
|
|
assert!(blocked.is_err(), "batch install entered while another operation owned the driver files");
|
|
|
|
drop(first_guard);
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(1),
|
|
install_agent_driver_from_registry_locked(
|
|
&manager,
|
|
®istry,
|
|
DownloadSource::Official,
|
|
db_type,
|
|
&progress,
|
|
Some(1),
|
|
Some(1),
|
|
),
|
|
)
|
|
.await
|
|
.expect("batch install did not resume after the driver lock was released")
|
|
.unwrap();
|
|
assert_eq!(std::fs::read(manager.driver_native_path(db_type)).unwrap(), native_bytes);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manual_import_waits_for_an_existing_driver_operation() {
|
|
let manager = test_manager("manual-import-driver-operation-lock");
|
|
let db_type = "h2";
|
|
let source = manager.base_dir().join("dbx-agent-h2.jar");
|
|
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
|
|
write_test_agent_jar(&source);
|
|
let lock = driver_operation_lock(&manager, db_type).await;
|
|
let first_guard = lock.lock().await;
|
|
|
|
let blocked =
|
|
tokio::time::timeout(std::time::Duration::from_millis(50), import_agent_driver(&manager, db_type, &source))
|
|
.await;
|
|
assert!(blocked.is_err(), "manual import entered while another operation owned the driver files");
|
|
|
|
drop(first_guard);
|
|
tokio::time::timeout(std::time::Duration::from_secs(1), import_agent_driver(&manager, db_type, &source))
|
|
.await
|
|
.expect("manual import did not resume after the driver lock was released")
|
|
.unwrap();
|
|
assert_eq!(std::fs::read(manager.driver_jar_path(db_type)).unwrap(), std::fs::read(source).unwrap());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn jre_exclusive_operation_waits_for_in_flight_driver_operation() {
|
|
let manager = test_manager("jre-exclusive-operation-lock");
|
|
let driver_guard = manager.installation_operation_lock.read().await;
|
|
|
|
let blocked =
|
|
tokio::time::timeout(std::time::Duration::from_millis(50), manager.installation_operation_lock.write())
|
|
.await;
|
|
assert!(blocked.is_err(), "JRE replacement entered before an in-flight driver operation completed");
|
|
|
|
drop(driver_guard);
|
|
let _jre_guard =
|
|
tokio::time::timeout(std::time::Duration::from_secs(1), manager.installation_operation_lock.write())
|
|
.await
|
|
.expect("JRE replacement did not resume after driver operations completed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registry_install_rejects_corrupt_downloaded_jar() {
|
|
let manager = test_manager("corrupt-jar");
|
|
let db_type = "h2";
|
|
let version = "0.2.0";
|
|
let jar_url = "https://example.com/dbx-agent-h2.jar";
|
|
let jar_bytes = b"jar";
|
|
let registry = registry_with_jar(db_type, version, jar_url, jar_bytes.len() as u64);
|
|
let jar_path = manager.driver_jar_path(db_type);
|
|
let cache_path = write_cached_driver_download(&manager, db_type, version, jar_url, &jar_path, jar_bytes);
|
|
manager
|
|
.save_state(&crate::agent_manager::AgentState {
|
|
java_runtime: JavaRuntimeConfig { mode: JavaRuntimeMode::System, custom_java_path: None },
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
let progress = |_| {};
|
|
|
|
let err = install_agent_driver_from_registry(
|
|
&manager,
|
|
®istry,
|
|
DownloadSource::Official,
|
|
db_type,
|
|
&progress,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.contains("invalid or corrupt"));
|
|
assert!(cache_path.exists());
|
|
assert!(!jar_path.exists());
|
|
assert!(!manager.load_state().installed_drivers.contains_key(db_type));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn offline_import_exclusive_lock_waits_for_in_flight_driver_operation() {
|
|
let manager = test_manager("offline-import-lock");
|
|
// Simulate an in-flight driver operation holding a read lock.
|
|
let driver_guard = manager.installation_operation_lock.read().await;
|
|
// import_offline_zip acquires the write lock — it must wait.
|
|
let blocked =
|
|
tokio::time::timeout(std::time::Duration::from_millis(50), manager.installation_operation_lock.write())
|
|
.await;
|
|
assert!(blocked.is_err(), "offline import entered before an in-flight driver operation completed");
|
|
|
|
drop(driver_guard);
|
|
let _offline_guard =
|
|
tokio::time::timeout(std::time::Duration::from_secs(1), manager.installation_operation_lock.write())
|
|
.await
|
|
.expect("offline import did not resume after driver operations completed");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod jre_dir_remove_tests {
|
|
use super::*;
|
|
|
|
fn unique_tmp(name: &str) -> PathBuf {
|
|
std::env::temp_dir().join(format!("dbx-jre-remove-{name}-{}", uuid::Uuid::new_v4()))
|
|
}
|
|
|
|
#[test]
|
|
fn remove_returns_ok_when_path_missing() {
|
|
let path = unique_tmp("missing");
|
|
assert!(!path.exists());
|
|
assert!(remove_jre_dir_with_retry(&path).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn remove_deletes_existing_dir() {
|
|
let dir = unique_tmp("happy");
|
|
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
|
std::fs::write(dir.join("bin").join("java"), b"x").unwrap();
|
|
assert!(dir.exists());
|
|
remove_jre_dir_with_retry(&dir).expect("happy path delete");
|
|
assert!(!dir.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn windows_error_message_lists_root_causes_and_path() {
|
|
let path = PathBuf::from("/tmp/dbx-jre-test");
|
|
let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "拒绝访问。 (os error 5)");
|
|
let rendered = format_jre_dir_remove_error(&path, &err);
|
|
assert!(rendered.contains(&path.display().to_string()), "missing path: {rendered}");
|
|
assert!(rendered.contains("(original error:"), "missing original error wrapper: {rendered}");
|
|
assert!(rendered.contains("拒绝访问"), "missing original error text: {rendered}");
|
|
if cfg!(windows) {
|
|
assert!(rendered.starts_with("Failed to remove the old JRE directory:"), "wrong prefix: {rendered}");
|
|
assert!(rendered.contains("java process still holds the directory"), "missing process advice: {rendered}");
|
|
assert!(rendered.contains("restart dbx and try again"), "missing restart advice: {rendered}");
|
|
} else {
|
|
// POSIX path: short form, no Windows-specific advice.
|
|
assert!(rendered.contains("Failed to remove the old JRE directory"));
|
|
assert!(!rendered.contains("antivirus"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(windows)]
|
|
fn stash_old_jre_dir_renames_and_is_unique() {
|
|
let base = unique_tmp("stash-unique");
|
|
std::fs::create_dir_all(&base).unwrap();
|
|
let jre_a = base.join("jre-21");
|
|
std::fs::create_dir_all(&jre_a).unwrap();
|
|
let stash_a = stash_old_jre_dir(&jre_a).expect("first stash");
|
|
assert!(stash_a.exists(), "stash dir should exist after rename");
|
|
assert!(!jre_a.exists(), "original dir should be gone after rename");
|
|
|
|
// Recreate original and stash again — name must differ.
|
|
std::fs::create_dir_all(&jre_a).unwrap();
|
|
let stash_b = stash_old_jre_dir(&jre_a).expect("second stash");
|
|
assert_ne!(stash_a, stash_b, "stash names must be unique across calls");
|
|
|
|
// Cleanup.
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
}
|