//! eUICC profile management for removable eSIM cards. //! //! In this project "eSIM mode" is a feature gate for managing profiles stored //! on a physical eUICC SIM card inserted in the device. It does not switch //! board-level SIM hardware and does not start background workers. use std::env; use std::fs; use std::io::{self, Cursor}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde_json::Value; use tokio::sync::Mutex; use crate::config::ConfigManager; use crate::models::{ EsimCommandResponse, EsimDownloadRequest, EsimEuiccInfo, EsimLpacRepairRequest, EsimLpacRepairResponse, EsimLpacStatusResponse, EsimProfile, EsimProfilesResponse, WorkMode, WorkModeResponse, }; const ESIM_SHORT_TIMEOUT_SECS: u64 = 20; const ESIM_LONG_TIMEOUT_SECS: u64 = 60; const LPAC_REPAIR_TIMEOUT_SECS: u64 = 120; const LPAC_PROBE_TIMEOUT_SECS: u64 = 3; const MAX_LPAC_DOWNLOAD_BYTES: usize = 25 * 1024 * 1024; const LPAC_OFFICIAL_RELEASE_BASE_URL: &str = "https://github.com/estkme-group/lpac/releases/latest/download"; const LPAC_COMPAT_RELEASE_BASE_URL: &str = "https://github.com/3899/SimAdmin/releases/download/lpac"; const LPAC_COMPAT_MANIFEST_NAME: &str = "lpac.json"; const PRIVATE_LPAC_DIR: &str = "/opt/simadmin/lpac"; const PRIVATE_LPAC_PATH: &str = "/opt/simadmin/lpac/lpac"; #[derive(Debug, Clone)] struct LpacAssetCandidate { name: String, url: String, } #[derive(Debug)] pub enum EsimApiError { Disabled, Unavailable(String), Command(String), } impl EsimApiError { pub fn message(&self) -> String { match self { Self::Disabled => "eSIM module is disabled in current work mode".to_string(), Self::Unavailable(message) | Self::Command(message) => message.clone(), } } } pub struct EsimSupervisor { config_manager: Arc, lpac_lock: Mutex<()>, } impl EsimSupervisor { pub fn new(config_manager: Arc) -> Self { Self { config_manager, lpac_lock: Mutex::new(()), } } pub async fn worker_running(&self) -> bool { self.config_manager.get_work_mode() == WorkMode::Esim } pub async fn switch_mode(&self, target: WorkMode) -> Result { self.config_manager.set_work_mode(target)?; let mode = self.config_manager.get_work_mode(); Ok(WorkModeResponse { mode, // Kept for API compatibility with v1.0.5 clients. There is no // worker after the simplification; true means eSIM APIs are enabled. worker_running: mode == WorkMode::Esim, }) } pub async fn get_lpac_status(&self) -> Result { if self.config_manager.get_work_mode() != WorkMode::Esim { return Err(EsimApiError::Disabled); } let _guard = self.lpac_lock.lock().await; let raw_arch = detect_machine_arch() .await .unwrap_or_else(|err| format!("unknown ({err})")); let arch = normalize_lpac_arch(&raw_arch).unwrap_or("").to_string(); let glibc_version = detect_glibc_version().await.unwrap_or_default(); let asset_name = if arch.is_empty() { String::new() } else { recommended_lpac_asset_name(&arch, &glibc_version) }; let command_path = resolve_lpac_path(&self.config_manager.get_esim_config().lpac_path); let probe = probe_lpac_binary(&command_path).await; let message = if arch.is_empty() && !probe.usable { format!("unsupported device architecture: {raw_arch}") } else { probe.message }; Ok(EsimLpacStatusResponse { installed: probe.installed, usable: probe.usable, path: command_path.to_string_lossy().to_string(), arch, glibc_version, asset_name, message, source: read_lpac_source(), }) } pub async fn repair_lpac( &self, request: EsimLpacRepairRequest, ) -> Result { if self.config_manager.get_work_mode() != WorkMode::Esim { return Err(EsimApiError::Disabled); } let _guard = self.lpac_lock.lock().await; let raw_arch = detect_machine_arch() .await .map_err(|err| EsimApiError::Command(format!("Failed to detect device arch: {err}")))?; let arch = normalize_lpac_arch(&raw_arch).ok_or_else(|| { EsimApiError::Command(format!("unsupported device architecture: {raw_arch}")) })?; let glibc_version = detect_glibc_version().await.unwrap_or_default(); let requested_asset_url = request .asset_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); let proxy_prefix = crate::ota::normalize_proxy_prefix(request.proxy_prefix); let candidates = match requested_asset_url { Some(asset_url) => vec![LpacAssetCandidate { name: asset_url .rsplit('/') .next() .filter(|value| !value.is_empty()) .unwrap_or("custom-lpac.zip") .to_string(), url: asset_url, }], None => resolve_lpac_asset_candidates(arch, &glibc_version, &proxy_prefix).await, }; let mut errors = Vec::new(); for candidate in candidates { let result = async { let bytes = download_lpac_asset(&candidate.url, &proxy_prefix).await?; install_lpac_asset(&bytes, &candidate.url).await?; let probe = probe_lpac_binary(Path::new(PRIVATE_LPAC_PATH)).await; if !probe.usable { return Err(EsimApiError::Command(format!( "lpac was installed but is not usable: {}", probe.message ))); } Ok::<(), EsimApiError>(()) } .await; match result { Ok(()) => { return Ok(EsimLpacRepairResponse { installed: true, path: PRIVATE_LPAC_PATH.to_string(), arch: arch.to_string(), asset_name: candidate.name, asset_url: candidate.url, message: "lpac installed and verified".to_string(), }); } Err(err) => errors.push(format!("{}: {}", candidate.name, err.message())), } } Err(EsimApiError::Command(format!( "No compatible lpac asset worked for arch={arch}, glibc={}. Tried: {}", if glibc_version.is_empty() { "unknown" } else { glibc_version.as_str() }, errors.join(" | ") ))) } async fn call_lpac( &self, action: &str, args: &[&str], timeout_seconds: u64, ) -> Result { if self.config_manager.get_work_mode() != WorkMode::Esim { return Err(EsimApiError::Disabled); } let _guard = self.lpac_lock.lock().await; run_lpac_command( &self.config_manager.get_esim_config().lpac_path, action, args, timeout_seconds, ) .await } pub async fn get_euicc_info(&self) -> Result { let response = self .call_lpac("info", &["chip", "info"], ESIM_SHORT_TIMEOUT_SECS) .await?; if !command_succeeded(&response) { return Err(EsimApiError::Command(response.msg)); } let mut info = normalize_euicc_info(response); if info.memory_total_kb.is_none() { info.memory_total_customizable = Some(true); let esim_config = self.config_manager.get_esim_config(); if let Some(total_kb) = esim_config.custom_memory_total_kb { info.memory_total_kb = Some(total_kb as f64); } } else { info.memory_total_customizable = Some(false); } Ok(info) } pub async fn get_profiles(&self) -> Result { let response = self .call_lpac("profiles", &["profile", "list"], ESIM_SHORT_TIMEOUT_SECS) .await?; if !command_succeeded(&response) { return Err(EsimApiError::Command(response.msg)); } Ok(normalize_profiles(response)) } pub async fn enable_profile(&self, iccid: String) -> Result { self.call_lpac( "enable", &["profile", "enable", iccid.as_str(), "1"], ESIM_LONG_TIMEOUT_SECS, ) .await } pub async fn rename_profile( &self, iccid: String, name: String, ) -> Result { self.call_lpac( "rename", &["profile", "nickname", iccid.as_str(), name.as_str()], ESIM_LONG_TIMEOUT_SECS, ) .await } pub async fn delete_profile(&self, iccid: String) -> Result { self.call_lpac( "delete", &["profile", "delete", iccid.as_str()], ESIM_LONG_TIMEOUT_SECS, ) .await } pub async fn download_profile( &self, request: EsimDownloadRequest, ) -> Result { let mut args = vec![ "profile", "download", "-s", request.smdp.as_str(), "-m", request.matching_id.as_str(), ]; let cc = request.confirmation_code.as_deref().unwrap_or("").trim(); if !cc.is_empty() { args.push("-c"); args.push(cc); } let imei = request.imei.as_deref().unwrap_or("").trim(); if !imei.is_empty() { args.push("-i"); args.push(imei); } // download can take up to 120 seconds self.call_lpac("download", &args, 120).await } } fn command_succeeded(response: &EsimCommandResponse) -> bool { response.code == 0 && (response.status.is_empty() || response.status.eq_ignore_ascii_case("success") || response.status.eq_ignore_ascii_case("ok")) } struct LpacProbe { installed: bool, usable: bool, message: String, } async fn detect_machine_arch() -> Result { let output = tokio::time::timeout( Duration::from_secs(LPAC_PROBE_TIMEOUT_SECS), tokio::process::Command::new("uname").arg("-m").output(), ) .await .map_err(|_| "uname -m timed out".to_string())? .map_err(|err| format!("failed to run uname -m: {err}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(if stderr.is_empty() { format!("uname -m exited with {}", output.status) } else { stderr }); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn normalize_lpac_arch(raw: &str) -> Option<&'static str> { match raw.trim() { "aarch64" | "arm64" => Some("aarch64"), "x86_64" | "amd64" => Some("x86_64"), _ => None, } } async fn detect_glibc_version() -> Result { if let Ok(output) = tokio::time::timeout( Duration::from_secs(LPAC_PROBE_TIMEOUT_SECS), tokio::process::Command::new("getconf") .arg("GNU_LIBC_VERSION") .output(), ) .await { if let Ok(output) = output { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); if let Some(version) = stdout.split_whitespace().last() { if !version.trim().is_empty() { return Ok(version.trim().to_string()); } } } } } let output = tokio::time::timeout( Duration::from_secs(LPAC_PROBE_TIMEOUT_SECS), tokio::process::Command::new("ldd") .arg("--version") .output(), ) .await .map_err(|_| "ldd --version timed out".to_string())? .map_err(|err| format!("failed to run ldd --version: {err}"))?; let text = String::from_utf8_lossy(&output.stdout); find_version_token(&text).ok_or_else(|| "failed to parse glibc version".to_string()) } fn find_version_token(text: &str) -> Option { text.split_whitespace().find_map(|token| { let token = token.trim_matches(|ch: char| !ch.is_ascii_digit() && ch != '.'); let has_dot = token.contains('.'); let numeric = token.chars().all(|ch| ch.is_ascii_digit() || ch == '.'); (has_dot && numeric).then(|| token.to_string()) }) } fn recommended_lpac_asset_name(arch: &str, glibc_version: &str) -> String { if arch == "aarch64" && version_le("2.31", glibc_version).unwrap_or(false) { return "lpac-linux-aarch64-glibc2.31.zip".to_string(); } format!("lpac-linux-{arch}.zip") } async fn resolve_lpac_asset_candidates( arch: &str, glibc_version: &str, proxy_prefix: &str, ) -> Vec { let mut candidates = Vec::new(); if let Ok(mut manifest_candidates) = fetch_compatible_lpac_candidates(arch, glibc_version, proxy_prefix).await { candidates.append(&mut manifest_candidates); } for name in [ format!("lpac-linux-{arch}.zip"), format!("lpac-linux-{arch}-with-qmi.zip"), format!("lpac-linux-{arch}-without-lto.zip"), ] { candidates.push(LpacAssetCandidate { url: format!("{LPAC_OFFICIAL_RELEASE_BASE_URL}/{name}"), name, }); } dedupe_lpac_candidates(candidates) } async fn fetch_compatible_lpac_candidates( arch: &str, glibc_version: &str, proxy_prefix: &str, ) -> Result, EsimApiError> { let manifest_url = format!("{LPAC_COMPAT_RELEASE_BASE_URL}/{LPAC_COMPAT_MANIFEST_NAME}"); let bytes = download_lpac_asset(&manifest_url, proxy_prefix).await?; let manifest = serde_json::from_slice::(&bytes) .map_err(|err| EsimApiError::Command(format!("Invalid lpac asset manifest: {err}")))?; let Some(assets) = manifest.get("assets").and_then(Value::as_array) else { return Ok(Vec::new()); }; let mut items = assets .iter() .filter_map(|asset| { let name = asset.get("name").and_then(Value::as_str)?; let asset_arch = asset.get("arch").and_then(Value::as_str)?; if asset_arch != arch { return None; } let asset_glibc = asset.get("glibc").and_then(Value::as_str).unwrap_or(""); if !asset_glibc.is_empty() && !glibc_version.is_empty() && !version_le(asset_glibc, glibc_version).unwrap_or(false) { return None; } Some((asset_glibc.to_string(), name.to_string())) }) .collect::>(); items.sort_by(|left, right| compare_version(&right.0, &left.0).then(left.1.cmp(&right.1))); Ok(items .into_iter() .map(|(_, name)| LpacAssetCandidate { url: format!("{LPAC_COMPAT_RELEASE_BASE_URL}/{name}"), name, }) .collect()) } fn dedupe_lpac_candidates(candidates: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); candidates .into_iter() .filter(|candidate| seen.insert(candidate.url.clone())) .collect() } fn version_le(left: &str, right: &str) -> Option { Some(compare_version(left, right) != std::cmp::Ordering::Greater) } fn compare_version(left: &str, right: &str) -> std::cmp::Ordering { let left = parse_version_parts(left); let right = parse_version_parts(right); let len = left.len().max(right.len()); for index in 0..len { let a = *left.get(index).unwrap_or(&0); let b = *right.get(index).unwrap_or(&0); match a.cmp(&b) { std::cmp::Ordering::Equal => continue, ordering => return ordering, } } std::cmp::Ordering::Equal } fn parse_version_parts(value: &str) -> Vec { value .split('.') .filter_map(|part| part.parse::().ok()) .collect() } async fn probe_lpac_binary(command_path: &Path) -> LpacProbe { let mut command = tokio::process::Command::new(command_path); configure_lpac_environment(&mut command, command_path); let output = match tokio::time::timeout( Duration::from_secs(LPAC_PROBE_TIMEOUT_SECS), command.output(), ) .await { Ok(Ok(output)) => output, Ok(Err(err)) if err.kind() == std::io::ErrorKind::NotFound => { return LpacProbe { installed: false, usable: false, message: "lpac is not installed or not found in PATH".to_string(), }; } Ok(Err(err)) => { return LpacProbe { installed: command_path.exists(), usable: false, message: format!("Failed to run lpac: {err}"), }; } Err(_) => { return LpacProbe { installed: command_path.exists(), usable: false, message: "lpac probe timed out".to_string(), }; } }; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let combined = format!("{stdout}\n{stderr}"); if combined.contains("GLIBC_") || combined.contains("No such file or directory") || combined.contains("Permission denied") { return LpacProbe { installed: true, usable: false, message: if stderr.is_empty() { stdout } else { stderr }, }; } LpacProbe { installed: true, usable: true, message: "lpac is available".to_string(), } } fn read_lpac_source() -> Option { fs::read_to_string(Path::new(PRIVATE_LPAC_DIR).join("SOURCE.txt")) .ok() .map(|content| content.trim().to_string()) .filter(|content| !content.is_empty()) } async fn download_lpac_asset(asset_url: &str, proxy_prefix: &str) -> Result, EsimApiError> { let client = reqwest::Client::builder() .user_agent("SimAdmin lpac repair") .timeout(Duration::from_secs(LPAC_REPAIR_TIMEOUT_SECS)) .build() .map_err(|err| EsimApiError::Command(format!("Failed to create HTTP client: {err}")))?; let mut urls = Vec::new(); if !proxy_prefix.is_empty() { urls.push(format!("{proxy_prefix}{asset_url}")); } urls.push(asset_url.to_string()); let mut last_error = String::new(); for url in urls { match client.get(&url).send().await { Ok(response) => { let status = response.status(); if !status.is_success() { last_error = format!("lpac download failed: HTTP {status}"); continue; } if let Some(size) = response.content_length() { if size > MAX_LPAC_DOWNLOAD_BYTES as u64 { return Err(EsimApiError::Command(format!( "lpac asset is too large: {size} bytes" ))); } } let bytes = response.bytes().await.map_err(|err| { EsimApiError::Command(format!("Failed to read lpac asset: {err}")) })?; if bytes.len() > MAX_LPAC_DOWNLOAD_BYTES { return Err(EsimApiError::Command(format!( "lpac asset is too large: {} bytes", bytes.len() ))); } return Ok(bytes.to_vec()); } Err(err) => { last_error = format!("Failed to download lpac asset: {err}"); } } } Err(EsimApiError::Command(last_error)) } async fn install_lpac_asset(bytes: &[u8], asset_url: &str) -> Result<(), EsimApiError> { let stamp = current_millis(); let temp_dir = env::temp_dir().join(format!("simadmin-lpac-repair-{stamp}")); let extract_dir = temp_dir.join("extract"); let install_parent = Path::new(PRIVATE_LPAC_DIR) .parent() .ok_or_else(|| EsimApiError::Command("Invalid lpac install path".to_string()))? .to_path_buf(); let new_dir = install_parent.join(format!(".lpac-new-{stamp}")); let backup_dir = install_parent.join(format!(".lpac-old-{stamp}")); let result = async { fs::create_dir_all(&extract_dir) .map_err(|err| EsimApiError::Command(format!("Failed to create temp dir: {err}")))?; extract_lpac_archive(bytes, &extract_dir).await?; let bundle_root = find_lpac_root(&extract_dir).ok_or_else(|| { EsimApiError::Command("downloaded lpac asset does not contain lpac executable".to_string()) })?; if new_dir.exists() { fs::remove_dir_all(&new_dir).map_err(|err| { EsimApiError::Command(format!("Failed to clean pending lpac dir: {err}")) })?; } fs::create_dir_all(&new_dir) .map_err(|err| EsimApiError::Command(format!("Failed to create lpac dir: {err}")))?; copy_dir_recursive(&bundle_root, &new_dir)?; copy_optional_lpac_libs(&extract_dir, &new_dir)?; fs::write( new_dir.join("SOURCE.txt"), format!( "lpac is installed from:\n{asset_url}\n\nProject:\nhttps://github.com/estkme-group/lpac\n" ), ) .map_err(|err| EsimApiError::Command(format!("Failed to write lpac source: {err}")))?; chmod_lpac_tree(&new_dir).await; activate_lpac_tree(&new_dir, &backup_dir).await } .await; let _ = fs::remove_dir_all(&temp_dir); if result.is_err() { let _ = fs::remove_dir_all(&new_dir); } result } async fn extract_lpac_archive(bytes: &[u8], target_dir: &Path) -> Result<(), EsimApiError> { let bytes = bytes.to_vec(); let target_dir = target_dir.to_path_buf(); tokio::task::spawn_blocking(move || extract_zip_archive(bytes, &target_dir)) .await .map_err(|err| EsimApiError::Command(format!("lpac extraction task failed: {err}")))? } fn extract_zip_archive(bytes: Vec, target_dir: &Path) -> Result<(), EsimApiError> { let reader = Cursor::new(bytes); let mut archive = zip::ZipArchive::new(reader) .map_err(|err| EsimApiError::Command(format!("Invalid lpac zip archive: {err}")))?; for index in 0..archive.len() { let mut file = archive.by_index(index).map_err(|err| { EsimApiError::Command(format!("Failed to read lpac zip entry: {err}")) })?; let Some(path) = file.enclosed_name().map(|path| target_dir.join(path)) else { continue; }; if file.is_dir() { fs::create_dir_all(&path).map_err(|err| { EsimApiError::Command(format!("Failed to create extracted directory: {err}")) })?; continue; } if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| { EsimApiError::Command(format!("Failed to create extracted parent dir: {err}")) })?; } let mut output = fs::File::create(&path).map_err(|err| { EsimApiError::Command(format!("Failed to create extracted file: {err}")) })?; io::copy(&mut file, &mut output) .map_err(|err| EsimApiError::Command(format!("Failed to extract lpac file: {err}")))?; } Ok(()) } fn find_lpac_root(root: &Path) -> Option { let direct = root.join("lpac"); if direct.is_file() { return Some(root.to_path_buf()); } let executables = root.join("executables").join("lpac"); if executables.is_file() { return executables.parent().map(Path::to_path_buf); } find_file_named(root, "lpac").and_then(|path| path.parent().map(Path::to_path_buf)) } fn find_file_named(root: &Path, name: &str) -> Option { let entries = fs::read_dir(root).ok()?; for entry in entries.flatten() { let path = entry.path(); if path.file_name().and_then(|value| value.to_str()) == Some(name) && path.is_file() { return Some(path); } if path.is_dir() { if let Some(found) = find_file_named(&path, name) { return Some(found); } } } None } fn copy_optional_lpac_libs(extract_dir: &Path, target_dir: &Path) -> Result<(), EsimApiError> { let target_lib = target_dir.join("lib"); if target_lib.exists() { return Ok(()); } for name in ["lib", "libraries"] { let source = extract_dir.join(name); if source.is_dir() { fs::create_dir_all(&target_lib).map_err(|err| { EsimApiError::Command(format!("Failed to create lpac lib dir: {err}")) })?; copy_dir_recursive(&source, &target_lib)?; return Ok(()); } } Ok(()) } fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), EsimApiError> { fs::create_dir_all(target) .map_err(|err| EsimApiError::Command(format!("Failed to create directory: {err}")))?; for entry in fs::read_dir(source) .map_err(|err| EsimApiError::Command(format!("Failed to read directory: {err}")))? { let entry = entry.map_err(|err| EsimApiError::Command(format!("Failed to read entry: {err}")))?; let source_path = entry.path(); let target_path = target.join(entry.file_name()); let file_type = entry .file_type() .map_err(|err| EsimApiError::Command(format!("Failed to read file type: {err}")))?; if file_type.is_dir() { copy_dir_recursive(&source_path, &target_path)?; } else if file_type.is_file() { fs::copy(&source_path, &target_path).map_err(|err| { EsimApiError::Command(format!("Failed to copy {}: {err}", source_path.display())) })?; } } Ok(()) } async fn chmod_lpac_tree(target_dir: &Path) { let _ = tokio::process::Command::new("chmod") .arg("-R") .arg("a+rX") .arg(target_dir) .output() .await; let _ = tokio::process::Command::new("chmod") .arg("0755") .arg(target_dir.join("lpac")) .output() .await; } async fn activate_lpac_tree(new_dir: &Path, backup_dir: &Path) -> Result<(), EsimApiError> { let target_dir = Path::new(PRIVATE_LPAC_DIR); if let Some(parent) = target_dir.parent() { fs::create_dir_all(parent) .map_err(|err| EsimApiError::Command(format!("Failed to create install dir: {err}")))?; } if backup_dir.exists() { fs::remove_dir_all(backup_dir) .map_err(|err| EsimApiError::Command(format!("Failed to clean backup dir: {err}")))?; } let had_existing = target_dir.exists(); if had_existing { fs::rename(target_dir, backup_dir).map_err(|err| { EsimApiError::Command(format!("Failed to backup existing lpac: {err}")) })?; } let install_result = fs::rename(new_dir, target_dir) .map_err(|err| EsimApiError::Command(format!("Failed to install lpac: {err}"))); if let Err(err) = install_result { if had_existing { let _ = fs::rename(backup_dir, target_dir); } return Err(err); } let probe = probe_lpac_binary(Path::new(PRIVATE_LPAC_PATH)).await; if !probe.usable { let _ = fs::remove_dir_all(target_dir); if had_existing { let _ = fs::rename(backup_dir, target_dir); } return Err(EsimApiError::Command(format!( "Installed lpac is not usable: {}", probe.message ))); } if had_existing { let _ = fs::remove_dir_all(backup_dir); } Ok(()) } fn current_millis() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() } async fn run_lpac_command( lpac_path: &str, action: &str, args: &[&str], timeout_seconds: u64, ) -> Result { let command_path = resolve_lpac_path(lpac_path); let mut command = tokio::process::Command::new(&command_path); command.args(args); configure_lpac_environment(&mut command, &command_path); let output = tokio::time::timeout(Duration::from_secs(timeout_seconds), command.output()) .await .map_err(|_| { EsimApiError::Command(format!("lpac {action} timed out after {timeout_seconds}s")) })? .map_err(|err| { if err.kind() == std::io::ErrorKind::NotFound { EsimApiError::Unavailable( "lpac is unavailable; use the eSIM Manager repair action, run \ install_latest.sh, or set esim.lpac_path" .to_string(), ) } else { EsimApiError::Command(format!("Failed to spawn lpac: {err}")) } })?; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if stdout.is_empty() { if output.status.success() { return Ok(EsimCommandResponse { code: 0, status: "ok".to_string(), action: action.to_string(), msg: "ok".to_string(), data: None, }); } return Err(EsimApiError::Command(if stderr.is_empty() { format!("lpac {action} exited with status {}", output.status) } else { stderr })); } // Since lpac stdout can contain multiple JSON objects separated by whitespace/newlines (e.g. progress updates followed by the final lpa object), // we search from the end of the stdout to find the last valid JSON block that starts with {"type" or simply {. let mut parsed_value = None; let mut search_pos = stdout.len(); while let Some(pos) = stdout[..search_pos].rfind(r#"{"type"#) { if let Ok(val) = serde_json::from_str::(&stdout[pos..]) { parsed_value = Some(val); break; } if pos == 0 { break; } search_pos = pos; } if parsed_value.is_none() { let mut search_pos = stdout.len(); while let Some(pos) = stdout[..search_pos].rfind('{') { if let Ok(val) = serde_json::from_str::(&stdout[pos..]) { parsed_value = Some(val); break; } if pos == 0 { break; } search_pos = pos; } } let value = match parsed_value { Some(val) => val, None => serde_json::from_str::(&stdout).map_err(|err| { EsimApiError::Command(format!( "Invalid JSON from lpac {action}: {err}; stdout: {stdout}" )) })?, }; Ok(normalize_lpac_response( action, value, stderr, output.status.success(), )) } fn resolve_lpac_path(lpac_path: &str) -> PathBuf { let configured = lpac_path.trim(); if configured.is_empty() || configured == "lpac" || configured == PRIVATE_LPAC_PATH { let private_lpac = Path::new(PRIVATE_LPAC_PATH); if private_lpac.exists() { return private_lpac.to_path_buf(); } return PathBuf::from("lpac"); } PathBuf::from(configured) } fn configure_lpac_environment(command: &mut tokio::process::Command, command_path: &Path) { set_env_default(command, "LPAC_APDU", "qmi"); set_env_default(command, "LPAC_HTTP", "curl"); set_env_default(command, "LPAC_APDU_QMI_DEVICE", "/dev/wwan0qmi0"); set_env_default(command, "LPAC_APDU_QMI_UIM_SLOT", "1"); set_env_default(command, "LPAC_APDU_AT_DEVICE", "/dev/wwan0at0"); if let Some(parent) = command_path .parent() .filter(|path| !path.as_os_str().is_empty()) { let lib_dir = parent.join("lib"); if lib_dir.is_dir() { let mut ld_library_path = lib_dir.to_string_lossy().to_string(); if let Some(existing) = env::var_os("LD_LIBRARY_PATH") { ld_library_path.push(':'); ld_library_path.push_str(&existing.to_string_lossy()); } command.env("LD_LIBRARY_PATH", ld_library_path); } } } fn set_env_default(command: &mut tokio::process::Command, key: &str, value: &str) { if env::var_os(key).is_none() { command.env(key, value); } } fn normalize_lpac_response( action: &str, value: Value, stderr: String, process_success: bool, ) -> EsimCommandResponse { if value.get("code").is_some() && value.get("status").is_some() { let mut response: EsimCommandResponse = serde_json::from_value(value).unwrap_or_else(|_| EsimCommandResponse { code: if process_success { 0 } else { 1 }, status: if process_success { "ok" } else { "error" }.to_string(), action: action.to_string(), msg: stderr.clone(), data: None, }); if response.action.is_empty() { response.action = action.to_string(); } if response.msg.is_empty() && !stderr.is_empty() { response.msg = stderr; } return response; } let payload = value.get("payload").unwrap_or(&value); let code = payload .get("code") .and_then(|item| item.as_i64()) .unwrap_or(if process_success { 0 } else { 1 }) as i32; let msg = string_from(payload, &["message", "msg", "error"]) .or_else(|| (!stderr.is_empty()).then_some(stderr)) .unwrap_or_else(|| { if code == 0 { "success".to_string() } else { "lpac command failed".to_string() } }); let data = payload .get("data") .cloned() .or_else(|| value.get("data").cloned()); EsimCommandResponse { code, status: if code == 0 { "ok" } else { "error" }.to_string(), action: action.to_string(), msg, data, } } fn eum_from_eid(eid: &str) -> Option<&'static str> { let normalized = eid .chars() .filter(|ch| !ch.is_whitespace()) .collect::() .to_ascii_uppercase(); if normalized.len() < 8 { return None; } match &normalized[..8] { "89033023" => Some("Thales"), "89033024" | "89039011" => Some("Idemia"), "89044011" | "89044020" => Some("Giesecke+Devrient"), "89049032" | "89041030" => Some("STMicroelectronics"), "89043051" => Some("NXP Semiconductors"), "89034011" => Some("Valid"), "8904C012" => Some("Workz"), "89014022" | "89014052" => Some("Kigen"), "89046031" => Some("Infineon Technologies"), "89086030" => Some("EastcomPeace"), "89086016" => Some("HED"), "89086011" => Some("China Mobile IoT"), "89086002" => Some("Tongxin Micro"), "89086026" | "89086027" => Some("Tianyu"), "89086014" => Some("Hengbao"), "89086012" => Some("Datang Micro"), "89086004" => Some("ZTE ICT"), _ => None, } } fn normalize_euicc_info(response: EsimCommandResponse) -> EsimEuiccInfo { let data = response.data.unwrap_or(Value::Null); let root = data .get("euicc") .or_else(|| data.get("euiccInfo")) .unwrap_or(&data); let euicc_info2 = object_from(root, &["EUICCInfo2", "euiccInfo2", "euicc_info2"]); let ext_resource = euicc_info2 .and_then(|info| object_from(info, &["extCardResource", "ExtCardResource"])) .or_else(|| object_from(root, &["extCardResource", "ExtCardResource"])); let eid = string_from(root, &["eid", "EID", "eidValue", "eid_value"]) .or_else(|| string_from(&data, &["eid", "EID", "eidValue", "eid_value"])) .unwrap_or_default(); let raw_manufacturer = string_from(root, &["manufacturer", "vendor", "maker"]).or_else(|| { euicc_info2.and_then(|info| string_from(info, &["manufacturer", "vendor", "maker"])) }); EsimEuiccInfo { eid: eid.clone(), status: string_from(root, &["status", "state"]).unwrap_or_else(|| "ready".to_string()), manufacturer: eum_from_eid(&eid) .map(ToString::to_string) .or(raw_manufacturer) .unwrap_or_default(), memory_total_kb: number_from( root, &[ "memory_total_kb", "total_kb", "total", "totalNonVolatileMemoryKb", ], ) .or_else(|| { ext_resource.and_then(|resource| { memory_kb_from_bytes( resource, &[ "totalNonVolatileMemory", "total_non_volatile_memory", "nonVolatileMemory", ], ) }) }), memory_available_kb: number_from( root, &[ "memory_available_kb", "available_kb", "free_kb", "available", "freeNonVolatileMemoryKb", ], ) .or_else(|| { ext_resource.and_then(|resource| { memory_kb_from_bytes( resource, &[ "freeNonVolatileMemory", "free_non_volatile_memory", "availableNonVolatileMemory", ], ) }) }), memory_total_customizable: None, updated_at: None, raw: data, } } fn normalize_profiles(response: EsimCommandResponse) -> EsimProfilesResponse { let data = response.data.unwrap_or(Value::Null); let null = Value::Null; let profiles_value = if let Some(profiles) = data.get("profiles") { profiles } else if let Some(profile_info) = data.get("profileInfo") { profile_info } else if let Some(profile_info) = data.get("profile_info") { profile_info } else if data.is_array() { &data } else { &null }; let profiles = profiles_value .as_array() .map(|items| items.iter().map(normalize_profile).collect()) .unwrap_or_default(); EsimProfilesResponse { profiles } } pub fn normalize_profile(value: &Value) -> EsimProfile { let null = Value::Null; let ppr = value.get("ppr").unwrap_or(&null); let operator = value .get("originalOperator") .or_else(|| value.get("original_operator")) .or_else(|| value.get("operator")) .unwrap_or(&null); let imsi = string_from(value, &["imsi", "IMSI", "profileImsi", "profile_imsi"]); let mut mcc = string_from(value, &["mcc", "MCC"]) .or_else(|| string_from(operator, &["mcc", "MCC"])) .or_else(|| mccmnc_part(value, 0)) .or_else(|| mccmnc_part(operator, 0)); let mut mnc = string_from(value, &["mnc", "MNC"]) .or_else(|| string_from(operator, &["mnc", "MNC"])) .or_else(|| mccmnc_part(value, 1)) .or_else(|| mccmnc_part(operator, 1)); if (mcc.is_none() || mnc.is_none()) && imsi.as_deref().is_some() { if let Some((imsi_mcc, imsi_mnc)) = split_mcc_mnc_from_imsi(imsi.as_deref().unwrap_or("")) { if mcc.is_none() { mcc = Some(imsi_mcc); } if mnc.is_none() { mnc = Some(imsi_mnc); } } } EsimProfile { iccid: { let raw_iccid = string_from(value, &["iccid", "ICCID", "id"]).unwrap_or_default(); raw_iccid.chars().filter(|c| c.is_ascii_digit()).collect() }, name: string_from( value, &[ "profileNickname", "profile_nickname", "nickname", "name", "profileName", "profile_name", "serviceProviderName", ], ) .unwrap_or_default(), provider: string_from( value, &[ "serviceProviderName", "service_provider_name", "provider", "service_provider", "spn", "carrier", "operatorName", "profileOwner", "profileOwer", ], ) .or_else(|| string_from(operator, &["name", "operatorName", "displayName"])) .or_else(|| string_value(operator)) .or_else(|| string_from(value, &["profileName", "profile_name"])) .unwrap_or_default(), state: profile_state_from(value), profile_class: profile_class_from(value).unwrap_or_default(), imsi, msisdn: string_from( value, &[ "msisdn", "MSISDN", "phone_number", "phoneNumber", "phone", "ownNumber", "own_number", "number", ], ), smsc: string_from( value, &[ "smsc", "SMSC", "sms_center", "smsCenter", "smscAddress", "smsc_address", ], ), smdp: string_from( value, &[ "smdp", "smdp_address", "smdpAddress", "smdp+", "smdpServer", "smdp_server", "dpAddress", "defaultDpAddress", ], ), matching_id: None, isdp_aid: string_from(value, &["isdpAid", "isdp_aid", "aid"]), mcc, mnc, disable_allowed: bool_from(value, &["disable_allowed", "disableAllowed"]) .or_else(|| bool_from(ppr, &["disableAllowed", "disable_allowed"])) .or_else(|| policy_allows(value, &["disable", "disabling"])) .or(Some(true)), delete_allowed: bool_from(value, &["delete_allowed", "deleteAllowed"]) .or_else(|| bool_from(ppr, &["deleteAllowed", "delete_allowed"])) .or_else(|| policy_allows(value, &["delete", "deletion"])) .or(Some(true)), updated_at: None, raw: value.clone(), } } fn object_from<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> { keys.iter() .find_map(|key| value.get(*key).filter(|item| item.is_object())) } fn string_value(value: &Value) -> Option { match value { Value::String(text) => { let trimmed = text.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) } Value::Number(number) => Some(number.to_string()), Value::Bool(flag) => Some(flag.to_string()), _ => None, } } fn string_from(value: &Value, keys: &[&str]) -> Option { keys.iter() .find_map(|key| value.get(*key).and_then(string_value)) } fn number_from(value: &Value, keys: &[&str]) -> Option { keys.iter().find_map(|key| { value.get(*key).and_then(|item| match item { Value::Number(number) => number.as_f64(), Value::String(text) => text.trim().parse::().ok(), _ => None, }) }) } fn memory_kb_from_bytes(value: &Value, keys: &[&str]) -> Option { number_from(value, keys).map(|bytes| bytes / 1000.0) } fn bool_from(value: &Value, keys: &[&str]) -> Option { keys.iter().find_map(|key| { value.get(*key).and_then(|item| match item { Value::Bool(flag) => Some(*flag), Value::Number(number) => number.as_i64().map(|n| n != 0), Value::String(text) if matches_bool(text, true) => Some(true), Value::String(text) if matches_bool(text, false) => Some(false), _ => None, }) }) } fn matches_bool(value: &str, expected: bool) -> bool { let normalized = value.trim().to_ascii_lowercase(); let truthy = ["1", "true", "yes", "on", "enabled", "active", "allowed"]; let falsy = ["0", "false", "no", "off", "disabled", "inactive", "denied"]; if expected { truthy.contains(&normalized.as_str()) } else { falsy.contains(&normalized.as_str()) } } fn profile_state_from(value: &Value) -> String { for key in ["state", "status", "profileState", "profile_state"] { if let Some(raw) = value.get(key) { if let Some(state) = normalize_profile_state(raw) { return state; } } } bool_from(value, &["enabled", "active", "is_enabled", "is_active"]) .map(|enabled| { if enabled { "enabled".to_string() } else { "disabled".to_string() } }) .unwrap_or_else(|| "unknown".to_string()) } fn normalize_profile_state(value: &Value) -> Option { match value { Value::Number(number) => number.as_i64().map(|state| { if state == 1 { "enabled".to_string() } else if state == 0 { "disabled".to_string() } else { state.to_string() } }), Value::Bool(enabled) => Some(if *enabled { "enabled".to_string() } else { "disabled".to_string() }), Value::String(text) => { let normalized = text.trim().to_ascii_lowercase(); if normalized.is_empty() { None } else if matches!(normalized.as_str(), "1" | "enabled" | "active") { Some("enabled".to_string()) } else if matches!(normalized.as_str(), "0" | "disabled" | "inactive") { Some("disabled".to_string()) } else { Some(normalized) } } _ => None, } } fn profile_class_from(value: &Value) -> Option { for key in ["class", "profile_class", "profileClass"] { let Some(raw) = value.get(key) else { continue; }; match raw { Value::Number(number) => { return number.as_i64().map(|class| match class { 0 => "test".to_string(), 1 => "provisioning".to_string(), 2 => "operational".to_string(), _ => class.to_string(), }); } _ => { if let Some(text) = string_value(raw) { return Some(text); } } } } None } fn mccmnc_part(value: &Value, part: usize) -> Option { let mccmnc = string_from( value, &[ "mccmnc", "mcc_mnc", "mccMnc", "plmn", "operatorCode", "operator_code", "operatorIdentifier", "operator_identifier", ], )?; let mccmnc = mccmnc.trim(); if part == 0 { (mccmnc.len() >= 3).then(|| mccmnc[..3].to_string()) } else { (mccmnc.len() > 3).then(|| mccmnc[3..].to_string()) } } fn split_mcc_mnc_from_imsi(imsi: &str) -> Option<(String, String)> { let digits = imsi.trim(); if digits.len() < 5 || !digits.chars().all(|ch| ch.is_ascii_digit()) { return None; } let mcc = digits[..3].to_string(); let mnc_len = if mcc == "460" { 2 } else { 3.min(digits.len() - 3) }; if digits.len() < 3 + mnc_len { return None; } Some((mcc, digits[3..3 + mnc_len].to_string())) } fn policy_allows(value: &Value, deny_markers: &[&str]) -> Option { let rules = value .get("profilePolicyRules") .or_else(|| value.get("profile_policy_rules")) .or_else(|| value.get("policyRules")) .or_else(|| value.get("rules"))?; let mut saw_rule = false; if let Some(items) = rules.as_array() { for item in items { let text = string_value(item).unwrap_or_else(|| item.to_string()); let text = text.to_ascii_lowercase(); if deny_markers.iter().any(|marker| text.contains(marker)) { return Some(false); } saw_rule = true; } } else { let text = string_value(rules).unwrap_or_else(|| rules.to_string()); let text = text.to_ascii_lowercase(); if deny_markers.iter().any(|marker| text.contains(marker)) { return Some(false); } saw_rule = !text.trim().is_empty(); } saw_rule.then_some(true) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn parses_lpac_chip_info_aliases() { let response = EsimCommandResponse { data: Some(json!({ "eidValue": "89086030EXAMPLE", "EUICCInfo2": { "extCardResource": { "freeNonVolatileMemory": 405123, "totalNonVolatileMemory": 478900 } } })), ..Default::default() }; let info = normalize_euicc_info(response); assert_eq!(info.eid, "89086030EXAMPLE"); assert_eq!(info.manufacturer, "EastcomPeace"); assert_eq!(info.memory_available_kb, Some(405.123)); assert_eq!(info.memory_total_kb, Some(478.9)); } #[test] fn parses_lpac_profile_aliases() { let profile = normalize_profile(&json!({ "iccid": "89812000EXAMPLEICCID00", "isdpAid": "TEST_ISDP_AID", "profileState": 1, "profileNickname": "主卡", "serviceProviderName": "BillionConnect", "profileName": "BillionConnect", "profileClass": 2, "imsi": "001010" })); assert_eq!(profile.name, "主卡"); assert_eq!(profile.provider, "BillionConnect"); assert_eq!(profile.state, "enabled"); assert_eq!(profile.profile_class, "operational"); assert_eq!(profile.mcc.as_deref(), Some("001")); assert_eq!(profile.mnc.as_deref(), Some("010")); assert_eq!(profile.disable_allowed, Some(true)); assert_eq!(profile.delete_allowed, Some(true)); } }