532 lines
18 KiB
Rust
532 lines
18 KiB
Rust
//! Package Management API Handlers
|
|
//!
|
|
//! Implements REST endpoints for package management operations:
|
|
//! - GET /api/v1/packages - List/filter packages
|
|
//! - GET /api/v1/packages/{name} - Get package details
|
|
//! - POST /api/v1/packages - Install package(s) - async
|
|
//! - PUT /api/v1/packages/{name} - Update package - async
|
|
//! - DELETE /api/v1/packages/{name} - Remove package - async
|
|
|
|
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
|
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::{error, info, warn};
|
|
use uuid::Uuid;
|
|
|
|
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
|
|
use crate::packages::{InstallOptions, Package, PackageManagerBackend, PackageSpec};
|
|
|
|
/// Maximum allowed length for package names
|
|
const MAX_PACKAGE_NAME_LENGTH: usize = 256;
|
|
|
|
/// Validate package name: must not be empty and must not exceed max length
|
|
fn validate_package_name(name: &str) -> Result<(), String> {
|
|
if name.is_empty() {
|
|
return Err("Package name cannot be empty".to_string());
|
|
}
|
|
if name.len() > MAX_PACKAGE_NAME_LENGTH {
|
|
return Err(format!(
|
|
"Package name exceeds maximum length of {} characters",
|
|
MAX_PACKAGE_NAME_LENGTH
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate all package names in a request
|
|
fn validate_package_names(packages: &[PackageSpec]) -> Result<(), String> {
|
|
for pkg in packages {
|
|
validate_package_name(&pkg.name)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Standard API response envelope
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ApiResponse<T> {
|
|
pub success: bool,
|
|
pub request_id: String,
|
|
pub timestamp: String,
|
|
pub data: Option<T>,
|
|
pub error: Option<ApiError>,
|
|
}
|
|
|
|
impl<T: Serialize> ApiResponse<T> {
|
|
pub fn success(data: T) -> Self {
|
|
Self {
|
|
success: true,
|
|
request_id: Uuid::new_v4().to_string(),
|
|
timestamp: Utc::now().to_rfc3339(),
|
|
data: Some(data),
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
pub fn error(
|
|
code: &str,
|
|
message: &str,
|
|
details: Option<serde_json::Value>,
|
|
retryable: bool,
|
|
) -> Self {
|
|
Self {
|
|
success: false,
|
|
request_id: Uuid::new_v4().to_string(),
|
|
timestamp: Utc::now().to_rfc3339(),
|
|
data: None,
|
|
error: Some(ApiError {
|
|
code: code.to_string(),
|
|
message: message.to_string(),
|
|
details,
|
|
retryable,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// API error structure
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ApiError {
|
|
pub code: String,
|
|
pub message: String,
|
|
pub details: Option<serde_json::Value>,
|
|
pub retryable: bool,
|
|
}
|
|
|
|
/// Package list response data
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PackageListData {
|
|
pub packages: Vec<Package>,
|
|
pub total: usize,
|
|
}
|
|
|
|
/// Package install request
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct InstallRequest {
|
|
pub packages: Vec<PackageSpec>,
|
|
#[serde(default)]
|
|
pub options: InstallOptions,
|
|
}
|
|
|
|
/// Job response data for async operations
|
|
#[derive(Debug, Serialize)]
|
|
pub struct JobResponseData {
|
|
pub job_id: String,
|
|
pub status: String,
|
|
pub operation: String,
|
|
pub packages: Option<Vec<String>>,
|
|
pub package: Option<String>,
|
|
}
|
|
|
|
/// Query parameters for package listing
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PackageListQuery {
|
|
pub name: Option<String>,
|
|
pub status: Option<String>,
|
|
pub upgradable: Option<bool>,
|
|
pub sort: Option<String>,
|
|
pub order: Option<String>,
|
|
}
|
|
|
|
/// List packages with filtering
|
|
pub async fn list_packages(
|
|
query: web::Query<PackageListQuery>,
|
|
backend: web::Data<Box<dyn PackageManagerBackend>>,
|
|
_req: HttpRequest,
|
|
) -> impl Responder {
|
|
let request_id = Uuid::new_v4().to_string();
|
|
let timestamp = Utc::now().to_rfc3339();
|
|
|
|
info!(request_id = %request_id, "Listing packages");
|
|
|
|
match backend.list_packages(query.name.as_deref()) {
|
|
Ok(mut packages) => {
|
|
// Apply filters
|
|
if let Some(status) = &query.status {
|
|
packages.retain(|p| match status.as_str() {
|
|
"installed" => p.status == crate::packages::PackageStatus::Installed,
|
|
"upgradable" => p.upgradable,
|
|
"available" => p.status == crate::packages::PackageStatus::Available,
|
|
_ => true,
|
|
});
|
|
}
|
|
|
|
if let Some(upgradable) = query.upgradable {
|
|
if upgradable {
|
|
packages.retain(|p| p.upgradable);
|
|
}
|
|
}
|
|
|
|
// Apply sorting
|
|
let sort_field = query.sort.as_deref().unwrap_or("name");
|
|
let ascending = query.order.as_deref().unwrap_or("asc") == "asc";
|
|
|
|
packages.sort_by(|a, b| {
|
|
let cmp = match sort_field {
|
|
"name" => a.name.cmp(&b.name),
|
|
"version" => a.version.cmp(&b.version),
|
|
"status" => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)),
|
|
_ => a.name.cmp(&b.name),
|
|
};
|
|
if ascending {
|
|
cmp
|
|
} else {
|
|
cmp.reverse()
|
|
}
|
|
});
|
|
|
|
let total = packages.len();
|
|
let response = ApiResponse {
|
|
success: true,
|
|
request_id,
|
|
timestamp,
|
|
data: Some(PackageListData { packages, total }),
|
|
error: None,
|
|
};
|
|
|
|
HttpResponse::Ok().json(response)
|
|
}
|
|
Err(e) => {
|
|
error!(request_id = %request_id, error = %e, "Failed to list packages");
|
|
let response = ApiResponse::<()>::error(
|
|
"PKG_MANAGER_ERROR",
|
|
&format!("Failed to list packages: {}", e),
|
|
None,
|
|
true,
|
|
);
|
|
HttpResponse::InternalServerError().json(response)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get package details by name
|
|
pub async fn get_package(
|
|
path: web::Path<String>,
|
|
backend: web::Data<Box<dyn PackageManagerBackend>>,
|
|
_req: HttpRequest,
|
|
) -> impl Responder {
|
|
let request_id = Uuid::new_v4().to_string();
|
|
let _timestamp = Utc::now().to_rfc3339();
|
|
let package_name = path.into_inner();
|
|
|
|
// VULN-001, VULN-003: Validate package name (length and empty string)
|
|
if let Err(e) = validate_package_name(&package_name) {
|
|
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
|
|
return HttpResponse::BadRequest().json(response);
|
|
}
|
|
|
|
info!(request_id = %request_id, package = %package_name, "Getting package details");
|
|
|
|
match backend.get_package(&package_name) {
|
|
Ok(Some(package)) => {
|
|
let response = ApiResponse::success(package);
|
|
HttpResponse::Ok().json(response)
|
|
}
|
|
Ok(None) => {
|
|
warn!(request_id = %request_id, package = %package_name, "Package not found");
|
|
let response = ApiResponse::<()>::error(
|
|
"PKG_NOT_FOUND",
|
|
&format!("Package '{}' not found", package_name),
|
|
None,
|
|
false,
|
|
);
|
|
HttpResponse::NotFound().json(response)
|
|
}
|
|
Err(e) => {
|
|
error!(request_id = %request_id, package = %package_name, error = %e, "Failed to get package");
|
|
let response = ApiResponse::<()>::error(
|
|
"PKG_MANAGER_ERROR",
|
|
&format!("Failed to get package: {}", e),
|
|
None,
|
|
true,
|
|
);
|
|
HttpResponse::InternalServerError().json(response)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Install packages (async operation)
|
|
pub async fn install_packages(
|
|
body: web::Json<InstallRequest>,
|
|
backend: web::Data<Box<dyn PackageManagerBackend>>,
|
|
job_manager: web::Data<JobManager>,
|
|
_req: HttpRequest,
|
|
) -> impl Responder {
|
|
let request_id = Uuid::new_v4().to_string();
|
|
let _timestamp = Utc::now().to_rfc3339();
|
|
let package_names: Vec<String> = body.packages.iter().map(|p| p.name.clone()).collect();
|
|
|
|
// VULN-001, VULN-003: Validate all package names (length and empty string)
|
|
if let Err(e) = validate_package_names(&body.packages) {
|
|
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
|
|
return HttpResponse::BadRequest().json(response);
|
|
}
|
|
|
|
info!(request_id = %request_id, packages = ?package_names, "Installing packages");
|
|
|
|
// Create async job
|
|
match job_manager
|
|
.create_job(JobOperation::Install, package_names.clone())
|
|
.await
|
|
{
|
|
Ok(job_id) => {
|
|
// Spawn background task to execute the installation
|
|
let backend_clone = backend.clone();
|
|
let job_manager_clone = job_manager.clone();
|
|
let options = body.options.clone();
|
|
let packages = body.packages.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let job_id_clone = job_id;
|
|
|
|
// Update job to running
|
|
let _ = job_manager_clone
|
|
.update_job(
|
|
&job_id_clone,
|
|
JobStatus::Running,
|
|
Some(0),
|
|
Some("Starting installation...".to_string()),
|
|
)
|
|
.await;
|
|
let _ = job_manager_clone
|
|
.add_job_log(&job_id_clone, "Job started".to_string())
|
|
.await;
|
|
|
|
// Execute installation
|
|
match backend_clone.install_packages(&packages, &options) {
|
|
Ok(_) => {
|
|
let _ = job_manager_clone.complete_job(&job_id_clone).await;
|
|
info!(job_id = %job_id_clone, "Package installation completed");
|
|
}
|
|
Err(e) => {
|
|
let _ = job_manager_clone
|
|
.fail_job(&job_id_clone, e.to_string())
|
|
.await;
|
|
error!(job_id = %job_id_clone, error = %e, "Package installation failed");
|
|
}
|
|
}
|
|
});
|
|
|
|
let response = ApiResponse::success(JobResponseData {
|
|
job_id: job_id.to_string(),
|
|
status: "pending".to_string(),
|
|
operation: "install".to_string(),
|
|
packages: Some(package_names),
|
|
package: None,
|
|
});
|
|
|
|
HttpResponse::Accepted().json(response)
|
|
}
|
|
Err(e) => {
|
|
error!(request_id = %request_id, error = %e, "Failed to create job");
|
|
let response = ApiResponse::<()>::error(
|
|
"JOB_CREATE_ERROR",
|
|
&format!("Failed to create job: {}", e),
|
|
None,
|
|
true,
|
|
);
|
|
HttpResponse::InternalServerError().json(response)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update a package (async operation)
|
|
pub async fn update_package(
|
|
path: web::Path<String>,
|
|
backend: web::Data<Box<dyn PackageManagerBackend>>,
|
|
job_manager: web::Data<JobManager>,
|
|
_req: HttpRequest,
|
|
) -> impl Responder {
|
|
let request_id = Uuid::new_v4().to_string();
|
|
let _timestamp = Utc::now().to_rfc3339();
|
|
let package_name = path.into_inner();
|
|
|
|
// VULN-001, VULN-003: Validate package name (length and empty string)
|
|
if let Err(e) = validate_package_name(&package_name) {
|
|
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
|
|
return HttpResponse::BadRequest().json(response);
|
|
}
|
|
|
|
info!(request_id = %request_id, package = %package_name, "Updating package");
|
|
|
|
// Create async job
|
|
match job_manager
|
|
.create_job(JobOperation::Update, vec![package_name.clone()])
|
|
.await
|
|
{
|
|
Ok(job_id) => {
|
|
// Spawn background task to execute the update
|
|
let backend_clone = backend.clone();
|
|
let job_manager_clone = job_manager.clone();
|
|
let pkg_name = package_name.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let job_id_clone = job_id;
|
|
|
|
// Update job to running
|
|
let _ = job_manager_clone
|
|
.update_job(
|
|
&job_id_clone,
|
|
JobStatus::Running,
|
|
Some(0),
|
|
Some("Starting update...".to_string()),
|
|
)
|
|
.await;
|
|
let _ = job_manager_clone
|
|
.add_job_log(&job_id_clone, "Job started".to_string())
|
|
.await;
|
|
|
|
// Execute update
|
|
match backend_clone.update_package(&pkg_name) {
|
|
Ok(_) => {
|
|
let _ = job_manager_clone.complete_job(&job_id_clone).await;
|
|
info!(job_id = %job_id_clone, package = %pkg_name, "Package update completed");
|
|
}
|
|
Err(e) => {
|
|
let _ = job_manager_clone
|
|
.fail_job(&job_id_clone, e.to_string())
|
|
.await;
|
|
error!(job_id = %job_id_clone, package = %pkg_name, error = %e, "Package update failed");
|
|
}
|
|
}
|
|
});
|
|
|
|
let response = ApiResponse::success(JobResponseData {
|
|
job_id: job_id.to_string(),
|
|
status: "pending".to_string(),
|
|
operation: "update".to_string(),
|
|
packages: None,
|
|
package: Some(package_name),
|
|
});
|
|
|
|
HttpResponse::Accepted().json(response)
|
|
}
|
|
Err(e) => {
|
|
error!(request_id = %request_id, error = %e, "Failed to create job");
|
|
let response = ApiResponse::<()>::error(
|
|
"JOB_CREATE_ERROR",
|
|
&format!("Failed to create job: {}", e),
|
|
None,
|
|
true,
|
|
);
|
|
HttpResponse::InternalServerError().json(response)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remove a package (async operation)
|
|
pub async fn remove_package(
|
|
path: web::Path<String>,
|
|
backend: web::Data<Box<dyn PackageManagerBackend>>,
|
|
job_manager: web::Data<JobManager>,
|
|
_req: HttpRequest,
|
|
) -> impl Responder {
|
|
let request_id = Uuid::new_v4().to_string();
|
|
let _timestamp = Utc::now().to_rfc3339();
|
|
let package_name = path.into_inner();
|
|
|
|
// VULN-001, VULN-003: Validate package name (length and empty string)
|
|
if let Err(e) = validate_package_name(&package_name) {
|
|
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
|
|
return HttpResponse::BadRequest().json(response);
|
|
}
|
|
|
|
info!(request_id = %request_id, package = %package_name, "Removing package");
|
|
match job_manager
|
|
.create_job(JobOperation::Remove, vec![package_name.clone()])
|
|
.await
|
|
{
|
|
Ok(job_id) => {
|
|
// Spawn background task to execute the removal
|
|
let backend_clone = backend.clone();
|
|
let job_manager_clone = job_manager.clone();
|
|
let pkg_name = package_name.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let job_id_clone = job_id;
|
|
|
|
// Update job to running
|
|
let _ = job_manager_clone
|
|
.update_job(
|
|
&job_id_clone,
|
|
JobStatus::Running,
|
|
Some(0),
|
|
Some("Starting removal...".to_string()),
|
|
)
|
|
.await;
|
|
let _ = job_manager_clone
|
|
.add_job_log(&job_id_clone, "Job started".to_string())
|
|
.await;
|
|
|
|
// Execute removal (purge=false for standard removal)
|
|
match backend_clone.remove_package(&pkg_name, false) {
|
|
Ok(_) => {
|
|
let _ = job_manager_clone.complete_job(&job_id_clone).await;
|
|
info!(job_id = %job_id_clone, package = %pkg_name, "Package removal completed");
|
|
}
|
|
Err(e) => {
|
|
let _ = job_manager_clone
|
|
.fail_job(&job_id_clone, e.to_string())
|
|
.await;
|
|
error!(job_id = %job_id_clone, package = %pkg_name, error = %e, "Package removal failed");
|
|
}
|
|
}
|
|
});
|
|
|
|
let response = ApiResponse::success(JobResponseData {
|
|
job_id: job_id.to_string(),
|
|
status: "pending".to_string(),
|
|
operation: "remove".to_string(),
|
|
packages: None,
|
|
package: Some(package_name),
|
|
});
|
|
|
|
HttpResponse::Accepted().json(response)
|
|
}
|
|
Err(e) => {
|
|
error!(request_id = %request_id, error = %e, "Failed to create job");
|
|
let response = ApiResponse::<()>::error(
|
|
"JOB_CREATE_ERROR",
|
|
&format!("Failed to create job: {}", e),
|
|
None,
|
|
true,
|
|
);
|
|
HttpResponse::InternalServerError().json(response)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Configure routes for package endpoints
|
|
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
|
cfg.service(
|
|
web::scope("/packages")
|
|
.route("", web::get().to(list_packages))
|
|
.route("", web::post().to(install_packages))
|
|
.route("/{name}", web::get().to(get_package))
|
|
.route("/{name}", web::put().to(update_package))
|
|
.route("/{name}", web::delete().to(remove_package)),
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_api_response_success() {
|
|
let response = ApiResponse::success("test data".to_string());
|
|
assert!(response.success);
|
|
assert!(response.request_id.len() > 0);
|
|
assert!(response.data.is_some());
|
|
assert!(response.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_api_response_error() {
|
|
let response: ApiResponse<()> =
|
|
ApiResponse::error("TEST_CODE", "Test message", None, false);
|
|
assert!(!response.success);
|
|
assert!(response.error.is_some());
|
|
assert_eq!(response.error.unwrap().code, "TEST_CODE");
|
|
}
|
|
}
|