use crate::client::UgosClient;
use crate::error::{Result, UgosError};
use crate::types::common::ResultWrapper;
use crate::types::kvm::{
HostInfo, ImageInfo, LogPage, NetworkDetail, NetworkSummary, Overview, Snapshot, StorageInfo,
StorageUsage, UsbDevice, VmDetail, VmSummary, VncLink,
};
#[allow(clippy::module_name_repetitions)]
pub trait KvmApi {
fn vm_list(&self) -> impl Future<Output = Result<Vec<VmSummary>>> + Send;
fn vm_show(&self, name: &str) -> impl Future<Output = Result<VmDetail>> + Send;
fn vm_start(&self, name: &str) -> impl Future<Output = Result<()>> + Send;
fn vm_stop(&self, name: &str, force: bool) -> impl Future<Output = Result<()>> + Send;
fn vm_reboot(&self, name: &str, force: bool) -> impl Future<Output = Result<()>> + Send;
fn vm_delete(&self, name: &str) -> impl Future<Output = Result<()>> + Send;
fn vm_create(&self, spec: &VmDetail) -> impl Future<Output = Result<String>> + Send;
fn vm_update(&self, spec: &VmDetail) -> impl Future<Output = Result<()>> + Send;
fn host_info(&self) -> impl Future<Output = Result<HostInfo>> + Send;
fn overview(&self) -> impl Future<Output = Result<Overview>> + Send;
fn vm_name_taken(&self, display_name: &str) -> impl Future<Output = Result<bool>> + Send;
fn check_memory(&self, bytes: i64) -> impl Future<Output = Result<MemoryStatus>> + Send;
fn snapshot_list(&self, vm: &str) -> impl Future<Output = Result<Vec<Snapshot>>> + Send;
fn snapshot_create(&self, vm: &str) -> impl Future<Output = Result<String>> + Send;
fn snapshot_delete(&self, vm: &str, name: &str) -> impl Future<Output = Result<()>> + Send;
fn snapshot_revert(
&self,
vm: &str,
name: &str,
snapshot_first: bool,
) -> impl Future<Output = Result<()>> + Send;
fn snapshot_describe(
&self,
vm: &str,
name: &str,
description: &str,
) -> impl Future<Output = Result<()>> + Send;
fn network_list(&self) -> impl Future<Output = Result<Vec<NetworkSummary>>> + Send;
fn network_show(&self, name: &str) -> impl Future<Output = Result<NetworkDetail>> + Send;
fn network_create(&self, network: &NetworkDetail) -> impl Future<Output = Result<()>> + Send;
fn network_update(&self, network: &NetworkDetail) -> impl Future<Output = Result<()>> + Send;
fn network_delete(&self, name: &str) -> impl Future<Output = Result<()>> + Send;
fn network_name_taken(&self, name: &str) -> impl Future<Output = Result<bool>> + Send;
fn network_check_usage(&self, name: &str) -> impl Future<Output = Result<Vec<String>>> + Send;
fn storage_list(&self) -> impl Future<Output = Result<Vec<StorageInfo>>> + Send;
fn storage_check_usage(
&self,
name: &str,
uuid: &str,
) -> impl Future<Output = Result<Vec<String>>> + Send;
fn storage_add(&self, name: &str, uuid: &str) -> impl Future<Output = Result<()>> + Send;
fn storage_delete(&self, name: &str, uuid: &str) -> impl Future<Output = Result<()>> + Send;
fn storage_usage_list(&self) -> impl Future<Output = Result<Vec<StorageUsage>>> + Send;
fn image_list(&self) -> impl Future<Output = Result<Vec<ImageInfo>>> + Send;
fn image_delete(
&self,
file_name: &str,
image_name: &str,
) -> impl Future<Output = Result<()>> + Send;
fn image_check_usage(&self, name: &str) -> impl Future<Output = Result<Vec<String>>> + Send;
fn image_check_name(&self, name: &str) -> impl Future<Output = Result<bool>> + Send;
fn image_upload(
&self,
path: &std::path::Path,
iso_name: &str,
progress: &(dyn Fn(usize, usize) + Send + Sync),
) -> impl Future<Output = Result<String>> + Send;
fn image_upload_url(
&self,
url: &str,
iso_name: &str,
progress: &(dyn Fn(usize, usize) + Send + Sync),
) -> impl Future<Output = Result<String>> + Send;
fn image_register(
&self,
path: &str,
image_name: &str,
file_name: &str,
) -> impl Future<Output = Result<()>> + Send;
fn usb_list(&self, vm: &str) -> impl Future<Output = Result<Vec<UsbDevice>>> + Send;
fn passthrough_devices(&self) -> impl Future<Output = Result<Vec<serde_json::Value>>> + Send;
fn vnc_list(&self, vm: &str) -> impl Future<Output = Result<Vec<VncLink>>> + Send;
fn vnc_generate(
&self,
vm: &str,
source_url: &str,
) -> impl Future<Output = Result<String>> + Send;
fn log_search(&self, page: u32, page_size: u32)
-> impl Future<Output = Result<LogPage>> + Send;
fn log_operators(&self) -> impl Future<Output = Result<Vec<String>>> + Send;
fn heartbeat(&self) -> impl Future<Output = Result<()>> + Send;
fn ova_export(
&self,
vm: &str,
storage_name: &str,
storage_uuid: &str,
ova_path: &str,
) -> impl Future<Output = Result<()>> + Send;
fn ova_parse(&self, ova_path: &str) -> impl Future<Output = Result<VmDetail>> + Send;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryStatus {
Fits,
Tight,
TooMuch,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct MemoryCheck {
#[serde(default)]
memory_status: i64,
}
async fn explain_create_failure(
client: &UgosClient,
spec: &VmDetail,
original: UgosError,
) -> UgosError {
let name = &spec.virtual_machine_display_name;
if client.vm_name_taken(name).await.unwrap_or(false) {
return UgosError::OperationFailed(format!("a VM named '{name}' already exists"));
}
if matches!(
client.check_memory(spec.memory.value * 1024).await,
Ok(MemoryStatus::TooMuch)
) {
return UgosError::OperationFailed(format!(
"{} MiB of memory is more than this NAS has",
spec.memory.value / 1024
));
}
original
}
async fn explain_start_failure(client: &UgosClient, vm: &str, original: UgosError) -> UgosError {
let Ok(detail) = client.vm_show(vm).await else {
return original;
};
match client.check_memory(detail.memory.value * 1024).await {
Ok(MemoryStatus::TooMuch) => UgosError::OperationFailed(format!(
"{} needs {} MiB, more than this NAS has",
vm,
detail.memory.value / 1024
)),
Ok(MemoryStatus::Tight) => UgosError::OperationFailed(format!(
"{original}. {} needs {} MiB, more than is free right now",
vm,
detail.memory.value / 1024
)),
_ => original,
}
}
const CHUNK_SIZE: u64 = 10 * 1024 * 1024;
async fn unique_file_name(client: &UgosClient, wanted: &str) -> Result<String> {
let images: ResultWrapper<Vec<ImageInfo>> = client.get("kvm/image/ShowImageList").await?;
let taken: Vec<&str> = images.result.iter().map(|i| i.file_name.as_str()).collect();
if !taken.contains(&wanted) {
return Ok(wanted.to_owned());
}
let (stem, ext) = wanted.rsplit_once('.').unwrap_or((wanted, ""));
for n in 2..1000 {
let candidate = if ext.is_empty() {
format!("{stem}-{n}")
} else {
format!("{stem}-{n}.{ext}")
};
if !taken.contains(&candidate.as_str()) {
return Ok(candidate);
}
}
Err(UgosError::Encryption(format!(
"no free file name for '{wanted}'"
)))
}
fn ensure_nonempty(size: u64) -> Result<()> {
if size == 0 {
return Err(UgosError::Encryption("file is empty".to_owned()));
}
Ok(())
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SnapshotCreated {
#[serde(default)]
snapshot_display_name: String,
}
async fn snapshot_display_name(client: &UgosClient, vm: &str, name: &str) -> Result<String> {
let (uuid, _) = resolve_vm(client, vm).await?;
let snaps: ResultWrapper<Vec<Snapshot>> = client
.get_with_params("kvm/manager/ShowListSnapshot", &[("name", uuid.as_str())])
.await?;
snaps
.result
.iter()
.find(|s| s.name == name || s.display_name == name)
.map(|s| s.display_name.clone())
.ok_or_else(|| UgosError::NotFound {
kind: "snapshot",
name: name.to_owned(),
})
}
async fn resolve_storage_uuid(client: &UgosClient, spec: &mut VmDetail) -> Result<()> {
if !spec.storage_uuid.is_empty() {
return Ok(());
}
let volumes: ResultWrapper<Vec<StorageInfo>> =
client.get("kvm/storage/ShowStorageList").await?;
let volume = volumes
.result
.iter()
.find(|v| v.name == spec.storage_name || v.label == spec.storage_name)
.ok_or_else(|| UgosError::NotFound {
kind: "storage volume",
name: spec.storage_name.clone(),
})?;
spec.storage_uuid.clone_from(&volume.uuid);
volume.name.clone_into(&mut spec.storage_name);
Ok(())
}
async fn resolve_vm(client: &UgosClient, name: &str) -> Result<(String, String)> {
let vms: ResultWrapper<Vec<VmSummary>> = client.get("kvm/manager/ShowLocalVirtualList").await?;
let vm = vms
.result
.iter()
.find(|v| v.vir_name == name || v.vir_display_name.eq_ignore_ascii_case(name))
.ok_or_else(|| UgosError::NotFound {
kind: "VM",
name: name.to_owned(),
})?;
Ok((vm.vir_name.clone(), vm.vir_display_name.clone()))
}
impl KvmApi for UgosClient {
async fn vm_list(&self) -> Result<Vec<VmSummary>> {
let resp: ResultWrapper<Vec<VmSummary>> =
self.get("kvm/manager/ShowLocalVirtualList").await?;
Ok(resp.result)
}
async fn vm_show(&self, name: &str) -> Result<VmDetail> {
let (uuid, _) = resolve_vm(self, name).await?;
self.get_with_params("kvm/manager/ShowLocalVirtualMachine", &[("name", &uuid)])
.await
}
async fn vm_start(&self, name: &str) -> Result<()> {
let (uuid, display) = resolve_vm(self, name).await?;
let started: Result<ResultWrapper<String>> = self
.get_with_params(
"kvm/manager/PowerOn",
&[
("name", uuid.as_str()),
("virtualMachineDisplayName", display.as_str()),
],
)
.await;
if let Err(e) = started {
return Err(explain_start_failure(self, name, e).await);
}
Ok(())
}
async fn vm_stop(&self, name: &str, force: bool) -> Result<()> {
let (uuid, display) = resolve_vm(self, name).await?;
let endpoint = if force {
"kvm/manager/ForcedShutdown"
} else {
"kvm/manager/Shutdown"
};
let _: ResultWrapper<String> = self
.get_with_params(
endpoint,
&[
("name", uuid.as_str()),
("virtualMachineDisplayName", display.as_str()),
],
)
.await?;
Ok(())
}
async fn vm_reboot(&self, name: &str, force: bool) -> Result<()> {
let (uuid, display) = resolve_vm(self, name).await?;
if force {
let _: ResultWrapper<String> = self
.get_with_params(
"kvm/manager/ForcedRestart",
&[
("name", uuid.as_str()),
("virtualMachineDisplayName", display.as_str()),
],
)
.await?;
} else {
let _: ResultWrapper<String> = self
.get_with_params("kvm/manager/Reboot", &[("name", uuid.as_str())])
.await?;
}
Ok(())
}
async fn vm_delete(&self, name: &str) -> Result<()> {
let (uuid, display) = resolve_vm(self, name).await?;
let _: ResultWrapper<String> = self
.get_with_params(
"kvm/manager/DeleteVirtualMachine",
&[
("name", uuid.as_str()),
("virtualMachineDisplayName", display.as_str()),
],
)
.await?;
Ok(())
}
async fn vm_create(&self, spec: &VmDetail) -> Result<String> {
let mut spec = spec.clone();
resolve_storage_uuid(self, &mut spec).await?;
let created: Result<ResultWrapper<String>> =
self.post("kvm/manager/CreateVirtualMachine", &spec).await;
if let Err(e) = created {
return Err(explain_create_failure(self, &spec, e).await);
}
let vms: ResultWrapper<Vec<VmSummary>> =
self.get("kvm/manager/ShowLocalVirtualList").await?;
Ok(vms
.result
.iter()
.filter(|v| v.vir_display_name == spec.virtual_machine_display_name)
.max_by_key(|v| v.create_time)
.map(|v| v.vir_name.clone())
.unwrap_or_default())
}
async fn vm_update(&self, spec: &VmDetail) -> Result<()> {
let mut spec = spec.clone();
resolve_storage_uuid(self, &mut spec).await?;
let _: ResultWrapper<String> = self.post("kvm/manager/UpdateVirtualMachine", &spec).await?;
Ok(())
}
async fn host_info(&self) -> Result<HostInfo> {
self.get("kvm/manager/ShowNativeInfo").await
}
async fn overview(&self) -> Result<Overview> {
self.get("kvm/manager/ShowOverview").await
}
async fn vm_name_taken(&self, display_name: &str) -> Result<bool> {
let body = serde_json::json!({"name": "", "virtualMachineDisplayName": display_name});
let resp: ResultWrapper<bool> = self.post("kvm/manager/CheckVirName", &body).await?;
Ok(resp.result)
}
async fn check_memory(&self, bytes: i64) -> Result<MemoryStatus> {
let resp: MemoryCheck = self
.get_with_params(
"kvm/manager/CheckResource",
&[("memory", &bytes.to_string())],
)
.await?;
Ok(match resp.memory_status {
0 => MemoryStatus::Fits,
1 => MemoryStatus::Tight,
_ => MemoryStatus::TooMuch,
})
}
async fn snapshot_list(&self, vm: &str) -> Result<Vec<Snapshot>> {
let (uuid, _) = resolve_vm(self, vm).await?;
let resp: ResultWrapper<Vec<Snapshot>> = self
.get_with_params("kvm/manager/ShowListSnapshot", &[("name", uuid.as_str())])
.await?;
Ok(resp.result)
}
async fn snapshot_create(&self, vm: &str) -> Result<String> {
let (uuid, display) = resolve_vm(self, vm).await?;
let resp: SnapshotCreated = self
.get_with_params(
"kvm/manager/GenerateSnapshot",
&[
("name", uuid.as_str()),
("virtualMachineDisplayName", display.as_str()),
],
)
.await?;
Ok(resp.snapshot_display_name)
}
async fn snapshot_delete(&self, vm: &str, name: &str) -> Result<()> {
let display = snapshot_display_name(self, vm, name).await?;
let _: ResultWrapper<String> = self
.get_with_params(
"kvm/manager/DeleteSnapshot",
&[
("name", name),
("virtualMachineDisplayName", display.as_str()),
],
)
.await?;
Ok(())
}
async fn snapshot_revert(&self, vm: &str, name: &str, snapshot_first: bool) -> Result<()> {
let (uuid, _) = resolve_vm(self, vm).await?;
let _: ResultWrapper<String> = self
.get_with_params(
"kvm/manager/RevertSnapshot",
&[
("virtualMachineName", uuid.as_str()),
("snapshotName", name),
(
"createSnapshot",
if snapshot_first { "true" } else { "false" },
),
],
)
.await?;
Ok(())
}
async fn snapshot_describe(&self, vm: &str, name: &str, description: &str) -> Result<()> {
let _ = snapshot_display_name(self, vm, name).await?;
let body = serde_json::json!({"name": name, "description": description});
let _: ResultWrapper<String> = self.post("kvm/manager/EditSnapshot", &body).await?;
Ok(())
}
async fn network_list(&self) -> Result<Vec<NetworkSummary>> {
let resp: ResultWrapper<Vec<NetworkSummary>> =
self.get("kvm/network/ShowNetworkList").await?;
Ok(resp.result)
}
async fn network_show(&self, name: &str) -> Result<NetworkDetail> {
let resp: ResultWrapper<NetworkDetail> = self
.get_with_params("kvm/network/GetNetworkByName", &[("name", name)])
.await?;
Ok(resp.result)
}
async fn network_create(&self, network: &NetworkDetail) -> Result<()> {
let created: Result<ResultWrapper<String>> =
self.post("kvm/network/CreateNetwork", network).await;
if let Err(e) = created {
if self
.network_name_taken(&network.network_name)
.await
.unwrap_or(false)
{
return Err(UgosError::OperationFailed(format!(
"a network named '{}' already exists",
network.network_name
)));
}
return Err(e);
}
Ok(())
}
async fn network_update(&self, network: &NetworkDetail) -> Result<()> {
let _: ResultWrapper<String> = self.post("kvm/network/UpdateNetwork", network).await?;
Ok(())
}
async fn network_name_taken(&self, name: &str) -> Result<bool> {
let body = serde_json::json!({"name": name});
let resp: ResultWrapper<bool> = self.post("kvm/network/CheckName", &body).await?;
Ok(resp.result)
}
async fn network_check_usage(&self, name: &str) -> Result<Vec<String>> {
let resp: ResultWrapper<Vec<String>> = self
.get_with_params("kvm/network/CheckNetwork", &[("name", name)])
.await?;
Ok(resp.result)
}
async fn network_delete(&self, name: &str) -> Result<()> {
let deleted: Result<ResultWrapper<String>> = self
.get_with_params("kvm/network/DeleteNetwork", &[("name", name)])
.await;
if let Err(e) = deleted {
if let Ok(vms) = self.network_check_usage(name).await
&& !vms.is_empty()
{
return Err(UgosError::OperationFailed(format!(
"network '{name}' is still attached to: {}",
vms.join(", ")
)));
}
return Err(e);
}
Ok(())
}
async fn storage_list(&self) -> Result<Vec<StorageInfo>> {
let resp: ResultWrapper<Vec<StorageInfo>> =
self.get("kvm/storage/ShowLocalStorageList").await?;
Ok(resp.result)
}
async fn storage_check_usage(&self, name: &str, uuid: &str) -> Result<Vec<String>> {
let resp: ResultWrapper<Vec<String>> = self
.get_with_params(
"kvm/storage/CheckStorage",
&[("name", name), ("uuid", uuid)],
)
.await?;
Ok(resp.result)
}
async fn storage_add(&self, name: &str, uuid: &str) -> Result<()> {
let body = serde_json::json!({"storageName": name, "storageUUID": uuid});
let _: ResultWrapper<String> = self.post("kvm/storage/AddStorage", &body).await?;
Ok(())
}
async fn storage_delete(&self, name: &str, uuid: &str) -> Result<()> {
let _: ResultWrapper<String> = self
.get_with_params(
"kvm/storage/DeleteStorage",
&[("name", name), ("uuid", uuid)],
)
.await?;
Ok(())
}
async fn storage_usage_list(&self) -> Result<Vec<StorageUsage>> {
let resp: ResultWrapper<Vec<StorageUsage>> =
self.get("kvm/storage/ShowLocalStorageUsageList").await?;
Ok(resp.result)
}
async fn image_list(&self) -> Result<Vec<ImageInfo>> {
let resp: ResultWrapper<Vec<ImageInfo>> = self.get("kvm/image/ShowImageList").await?;
Ok(resp.result)
}
async fn image_delete(&self, file_name: &str, image_name: &str) -> Result<()> {
let _: ResultWrapper<String> = self
.get_with_params(
"kvm/image/DeleteImage",
&[("fileName", file_name), ("imageName", image_name)],
)
.await?;
Ok(())
}
async fn image_check_usage(&self, name: &str) -> Result<Vec<String>> {
let resp: ResultWrapper<Vec<String>> = self
.get_with_params("kvm/image/CheckImageUsage", &[("name", name)])
.await?;
Ok(resp.result)
}
async fn image_check_name(&self, name: &str) -> Result<bool> {
let resp: ResultWrapper<bool> = self
.get_with_params("kvm/image/CheckImageName", &[("name", name)])
.await?;
Ok(resp.result)
}
async fn image_upload(
&self,
path: &std::path::Path,
iso_name: &str,
progress: &(dyn Fn(usize, usize) + Send + Sync),
) -> Result<String> {
use std::io::Read;
let wanted = path.file_name().map_or_else(
|| format!("{iso_name}.iso"),
|n| n.to_string_lossy().into_owned(),
);
let file_name = unique_file_name(self, &wanted).await?;
let mut file = std::fs::File::open(path)
.map_err(|e| UgosError::io(format!("opening '{}'", path.display()), e))?;
let size = file
.metadata()
.map_err(|e| UgosError::io("reading file size", e))?
.len();
ensure_nonempty(size)?;
let total = usize::try_from(size.div_ceil(CHUNK_SIZE))
.map_err(|_| UgosError::Encryption("file too large to chunk".to_owned()))?;
let chunk_len = usize::try_from(CHUNK_SIZE)
.map_err(|_| UgosError::Encryption("chunk size unsupported here".to_owned()))?;
let mut buf = vec![0u8; chunk_len];
for index in 0..total {
let mut filled = 0;
while filled < buf.len() {
let n = file
.read(&mut buf[filled..])
.map_err(|e| UgosError::io(format!("reading chunk {index}"), e))?;
if n == 0 {
break;
}
filled += n;
}
let part = reqwest::multipart::Part::bytes(buf[..filled].to_vec())
.file_name("blob")
.mime_str("application/octet-stream")
.map_err(|e| UgosError::Encryption(format!("building chunk: {e}")))?;
let form = reqwest::multipart::Form::new()
.text("isoName", iso_name.to_owned())
.text("fileName", file_name.clone())
.text("size", size.to_string())
.text("chunks", total.to_string())
.text("chunk", index.to_string())
.part("file", part);
let _: ResultWrapper<String> = self.post_multipart("kvm/image/UploadUpk", form).await?;
progress(index + 1, total);
}
Ok(file_name)
}
async fn image_upload_url(
&self,
url: &str,
iso_name: &str,
progress: &(dyn Fn(usize, usize) + Send + Sync),
) -> Result<String> {
use std::io::Write;
let file_name = safe_download_name(url, iso_name);
let (scratch, temp) = create_scratch_file(&file_name)?;
let plain = reqwest::Client::builder()
.build()
.map_err(|e| UgosError::Encryption(format!("HTTP client build: {e}")))?;
let mut resp = plain.get(url).send().await?.error_for_status()?;
let mut out = std::fs::File::create(&temp)
.map_err(|e| UgosError::io(format!("creating '{}'", temp.display()), e))?;
let mut downloaded = 0usize;
while let Some(chunk) = resp.chunk().await? {
out.write_all(&chunk)
.map_err(|e| UgosError::io("writing download", e))?;
downloaded += chunk.len();
progress(downloaded, 0);
}
drop(out);
let result = self.image_upload(&temp, iso_name, progress).await;
let _ = std::fs::remove_file(&temp);
let _ = std::fs::remove_dir(&scratch);
result
}
async fn image_register(&self, path: &str, image_name: &str, file_name: &str) -> Result<()> {
let body = serde_json::json!({
"path": path,
"imageName": image_name,
"fileName": file_name,
});
let _: ResultWrapper<String> = self.post("kvm/image/UploadPath", &body).await?;
Ok(())
}
async fn passthrough_devices(&self) -> Result<Vec<serde_json::Value>> {
let resp: ResultWrapper<Vec<serde_json::Value>> =
self.get("kvm/passthrough/devices").await?;
Ok(resp.result)
}
async fn usb_list(&self, vm: &str) -> Result<Vec<UsbDevice>> {
let (uuid, _) = resolve_vm(self, vm).await?;
let resp: ResultWrapper<Vec<UsbDevice>> = self
.get_with_params("kvm/usb/USBList", &[("vmName", uuid.as_str())])
.await?;
Ok(resp.result)
}
async fn vnc_list(&self, vm: &str) -> Result<Vec<VncLink>> {
let (uuid, _) = resolve_vm(self, vm).await?;
let resp: ResultWrapper<Vec<VncLink>> = self
.get_with_params("kvm/vnc/ListAllLink", &[("virName", uuid.as_str())])
.await?;
Ok(resp.result)
}
async fn vnc_generate(&self, vm: &str, source_url: &str) -> Result<String> {
let (uuid, _) = resolve_vm(self, vm).await?;
let body = serde_json::json!({"virName": uuid, "type": 0, "sourceUrl": source_url});
let resp: ResultWrapper<String> = self.post("kvm/vnc/GenerateNoVNClink", &body).await?;
Ok(resp.result)
}
async fn log_search(&self, page: u32, page_size: u32) -> Result<LogPage> {
let body = serde_json::json!({
"pageNum": page,
"pageSize": page_size,
"operator": "",
"startTime": "",
"endTime": "",
"createTimeSort": "desc",
"operatorSort": ""
});
self.post("kvm/logs/PageSearchLogs", &body).await
}
async fn log_operators(&self) -> Result<Vec<String>> {
let resp: ResultWrapper<Vec<String>> = self.get("kvm/logs/GetAllOperator").await?;
Ok(resp.result)
}
async fn heartbeat(&self) -> Result<()> {
let _: serde_json::Value = self.get("verify/heartbeat").await?;
Ok(())
}
async fn ova_export(
&self,
vm: &str,
storage_name: &str,
storage_uuid: &str,
ova_path: &str,
) -> Result<()> {
let (uuid, _) = resolve_vm(self, vm).await?;
let body = serde_json::json!({
"virtualName": uuid,
"storageName": storage_name,
"storageUUID": storage_uuid,
"ovaPath": ova_path,
});
let _: ResultWrapper<String> = self.post("kvm/manager/ExportOVA", &body).await?;
Ok(())
}
async fn ova_parse(&self, ova_path: &str) -> Result<VmDetail> {
let body = serde_json::json!({"ovaPath": ova_path});
self.post("kvm/manager/ParseOvaFile", &body).await
}
}
fn safe_download_name(url: &str, iso_name: &str) -> String {
let fallback = || format!("{iso_name}.iso");
let candidate = url
.rsplit('/')
.next()
.map(|n| n.split(['?', '#']).next().unwrap_or(n))
.unwrap_or_default();
let rejected = candidate.is_empty()
|| candidate == "."
|| candidate == ".."
|| candidate.contains('/')
|| candidate.contains('\\')
|| candidate.contains('\0');
if rejected {
return fallback();
}
if candidate.starts_with('.') {
return fallback();
}
candidate.to_owned()
}
fn new_private_dir(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new().mode(0o700).create(path)
}
#[cfg(not(unix))]
{
std::fs::DirBuilder::new().create(path)
}
}
fn create_scratch_file(file_name: &str) -> Result<(std::path::PathBuf, std::path::PathBuf)> {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let mut last = None;
let mut dir = std::path::PathBuf::new();
for _ in 0..16 {
let seq = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let candidate =
std::env::temp_dir().join(format!("ugos-cli-{}-{stamp}-{seq}", std::process::id()));
match new_private_dir(&candidate) {
Ok(()) => {
dir = candidate;
last = None;
break;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
last = Some(e);
}
Err(e) => {
return Err(UgosError::Encryption(format!(
"creating '{}': {e}",
candidate.display()
)));
}
}
}
if let Some(e) = last {
return Err(UgosError::Encryption(format!(
"no free scratch directory under '{}': {e}",
std::env::temp_dir().display()
)));
}
let path = dir.join(file_name);
let probe = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|e| UgosError::io(format!("creating '{}'", path.display()), e))?;
drop(probe);
Ok((dir, path))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod scratch_tests {
use super::create_scratch_file;
#[test]
fn creates_the_file_and_hands_back_both_paths() {
let (dir, file) = create_scratch_file("debian.iso").unwrap();
assert!(dir.is_dir(), "directory missing");
assert!(file.is_file(), "file missing");
assert_eq!(
file.file_name().and_then(|n| n.to_str()),
Some("debian.iso")
);
assert_eq!(file.parent(), Some(dir.as_path()));
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn two_calls_never_share_a_directory() {
let (d1, f1) = create_scratch_file("same.iso").unwrap();
let (d2, f2) = create_scratch_file("same.iso").unwrap();
assert_ne!(d1, d2, "scratch directories collided");
assert_ne!(f1, f2);
for (d, f) in [(d1, f1), (d2, f2)] {
let _ = std::fs::remove_file(&f);
let _ = std::fs::remove_dir(&d);
}
}
#[cfg(unix)]
#[test]
fn the_directory_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let (dir, file) = create_scratch_file("x.iso").unwrap();
let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "group or other can reach the download");
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn refuses_a_path_that_already_exists() {
let (dir, file) = create_scratch_file("taken.iso").unwrap();
let second = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&file);
assert!(second.is_err(), "an existing path was reopened");
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
}
#[cfg(test)]
mod download_name_tests {
use super::safe_download_name;
#[test]
fn takes_the_last_path_segment() {
assert_eq!(
safe_download_name("https://example.org/iso/debian.iso", "fallback"),
"debian.iso"
);
}
#[test]
fn strips_a_query_string() {
assert_eq!(
safe_download_name("https://example.org/debian.iso?token=x", "fallback"),
"debian.iso"
);
}
#[test]
fn rejects_traversal_segments() {
assert_eq!(safe_download_name("https://example.org/..", "fb"), "fb.iso");
assert_eq!(safe_download_name("https://example.org/.", "fb"), "fb.iso");
}
#[test]
fn rejects_an_empty_segment() {
assert_eq!(safe_download_name("https://example.org/", "fb"), "fb.iso");
}
#[test]
fn rejects_hidden_names() {
assert_eq!(
safe_download_name("https://example.org/.bashrc", "fb"),
"fb.iso"
);
}
#[test]
fn rejects_embedded_separators() {
assert_eq!(
safe_download_name("https://example.org/a\\b.iso", "fb"),
"fb.iso"
);
}
}