Files
multi-simadmin/packages/operation-registry/test/fixtures/upstream-58e2204/backend/src/esim.rs
T

1538 lines
51 KiB
Rust

//! 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<ConfigManager>,
lpac_lock: Mutex<()>,
}
impl EsimSupervisor {
pub fn new(config_manager: Arc<ConfigManager>) -> 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<WorkModeResponse, String> {
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<EsimLpacStatusResponse, EsimApiError> {
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<EsimLpacRepairResponse, EsimApiError> {
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<EsimCommandResponse, EsimApiError> {
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<EsimEuiccInfo, EsimApiError> {
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<EsimProfilesResponse, EsimApiError> {
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<EsimCommandResponse, EsimApiError> {
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<EsimCommandResponse, EsimApiError> {
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<EsimCommandResponse, EsimApiError> {
self.call_lpac(
"delete",
&["profile", "delete", iccid.as_str()],
ESIM_LONG_TIMEOUT_SECS,
)
.await
}
pub async fn download_profile(
&self,
request: EsimDownloadRequest,
) -> Result<EsimCommandResponse, EsimApiError> {
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<String, String> {
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<String, String> {
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<String> {
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<LpacAssetCandidate> {
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<Vec<LpacAssetCandidate>, 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::<Value>(&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::<Vec<_>>();
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<LpacAssetCandidate>) -> Vec<LpacAssetCandidate> {
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<bool> {
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<u32> {
value
.split('.')
.filter_map(|part| part.parse::<u32>().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<String> {
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<Vec<u8>, 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<u8>, 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<PathBuf> {
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<PathBuf> {
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<EsimCommandResponse, EsimApiError> {
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::<Value>(&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::<Value>(&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::<Value>(&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::<String>()
.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<String> {
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<String> {
keys.iter()
.find_map(|key| value.get(*key).and_then(string_value))
}
fn number_from(value: &Value, keys: &[&str]) -> Option<f64> {
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::<f64>().ok(),
_ => None,
})
})
}
fn memory_kb_from_bytes(value: &Value, keys: &[&str]) -> Option<f64> {
number_from(value, keys).map(|bytes| bytes / 1000.0)
}
fn bool_from(value: &Value, keys: &[&str]) -> Option<bool> {
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<String> {
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<String> {
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<String> {
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<bool> {
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));
}
}