use ggen_utils::error::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use zip::ZipArchive;
use super::fmea_validator::FmeaValidator;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MarketplaceConfig {
#[serde(default)]
pub fmea_validation: bool,
#[serde(default)]
pub require_fmea: bool,
#[serde(default = "default_critical_threshold")]
pub critical_threshold: u16,
}
fn default_critical_threshold() -> u16 {
200
}
#[derive(Debug, Clone, Default, Deserialize)]
struct GgenConfigSubset {
marketplace: Option<MarketplaceConfig>,
}
fn validate_package_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(ggen_utils::error::Error::new(
"Package name cannot be empty",
));
}
if name.len() > 100 {
return Err(ggen_utils::error::Error::new(
"Package name too long (max 100 chars)",
));
}
if name.contains("..") || name.contains("/") || name.contains("\\") {
return Err(ggen_utils::error::Error::new(
"Package name contains invalid characters (no path separators or traversal)",
));
}
if name.chars().any(|c| c.is_control()) {
return Err(ggen_utils::error::Error::new(
"Package name contains control characters",
));
}
Ok(())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstallInput {
pub package: String,
pub target: Option<String>,
pub force: bool,
pub no_dependencies: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstallOptions {
pub package_name: String,
pub version: Option<String>,
pub target_path: Option<PathBuf>,
pub force: bool,
pub with_dependencies: bool,
pub dry_run: bool,
}
impl InstallOptions {
pub fn new(package_name: impl Into<String>) -> Self {
Self {
package_name: package_name.into(),
with_dependencies: true,
..Default::default()
}
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn with_target(mut self, path: PathBuf) -> Self {
self.target_path = Some(path);
self
}
pub fn force(mut self) -> Self {
self.force = true;
self
}
pub fn dry_run(mut self) -> Self {
self.dry_run = true;
self
}
pub fn no_dependencies(mut self) -> Self {
self.with_dependencies = false;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallResult {
pub package_name: String,
pub version: String,
pub install_path: PathBuf,
pub dependencies_installed: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageManifest {
pub name: String,
pub version: String,
pub title: String,
pub description: String,
#[serde(default)]
pub dependencies: HashMap<String, String>,
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockfileEntry {
pub name: String,
pub version: String,
pub resolved: String,
pub integrity: Option<String>,
#[serde(default)]
pub dependencies: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Lockfile {
pub version: String,
pub packages: HashMap<String, LockfileEntry>,
}
#[derive(Debug)]
#[allow(dead_code)]
struct DependencyGraph {
nodes: HashMap<String, PackageNode>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct PackageNode {
name: String,
version: String,
dependencies: HashMap<String, String>,
}
#[allow(dead_code)]
impl DependencyGraph {
fn new() -> Self {
Self {
nodes: HashMap::new(),
}
}
fn add_package(&mut self, manifest: PackageManifest) {
let key = format!("{}@{}", manifest.name, manifest.version);
self.nodes.insert(
key,
PackageNode {
name: manifest.name.clone(),
version: manifest.version.clone(),
dependencies: manifest.dependencies.clone(),
},
);
}
fn detect_circular(&self) -> Result<()> {
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
for key in self.nodes.keys() {
if !visited.contains(key) {
self.dfs_cycle_check(key, &mut visited, &mut rec_stack)?;
}
}
self.validate_all_dependencies_exist()?;
Ok(())
}
fn dfs_cycle_check(
&self, node: &str, visited: &mut HashSet<String>, rec_stack: &mut HashSet<String>,
) -> Result<()> {
visited.insert(node.to_string());
rec_stack.insert(node.to_string());
if let Some(pkg) = self.nodes.get(node) {
for (dep_name, dep_version) in &pkg.dependencies {
let dep_key = format!("{}@{}", dep_name, dep_version);
if !visited.contains(&dep_key) {
self.dfs_cycle_check(&dep_key, visited, rec_stack)?;
} else if rec_stack.contains(&dep_key) {
return Err(ggen_utils::error::Error::new(&format!(
"Circular dependency detected: {} -> {}. This is a blocking issue that prevents installation.",
node, dep_key
)));
}
}
}
rec_stack.remove(node);
Ok(())
}
fn validate_all_dependencies_exist(&self) -> Result<()> {
let mut missing_deps = Vec::new();
for (pkg_key, pkg) in &self.nodes {
for (dep_name, dep_version) in &pkg.dependencies {
let dep_key = format!("{}@{}", dep_name, dep_version);
if !self.nodes.contains_key(&dep_key) {
missing_deps.push((pkg_key.clone(), dep_key));
}
}
}
if !missing_deps.is_empty() {
let mut error_msg = String::from("⚠️ Unresolved dependencies detected:\n");
for (pkg, dep) in missing_deps {
error_msg.push_str(&format!(
" - {} depends on {} (not in dependency graph)\n",
pkg, dep
));
}
error_msg.push_str("These missing dependencies may cause installation to fail. Ensure all dependencies are available in the marketplace.");
tracing::warn!("{}", error_msg);
}
Ok(())
}
fn topological_sort(&self) -> Result<Vec<String>> {
let mut in_degree: HashMap<String, usize> = HashMap::new();
let mut adj_list: HashMap<String, Vec<String>> = HashMap::new();
for key in self.nodes.keys() {
in_degree.insert(key.clone(), 0);
adj_list.insert(key.clone(), Vec::new());
}
for (key, node) in &self.nodes {
for (dep_name, dep_version) in &node.dependencies {
let dep_key = format!("{}@{}", dep_name, dep_version);
if self.nodes.contains_key(&dep_key) {
if let Some(adj_list_entry) = adj_list.get_mut(&dep_key) {
adj_list_entry.push(key.clone());
} else {
return Err(ggen_utils::error::Error::new(
"Internal error: adjacency list entry not found",
));
}
if let Some(degree) = in_degree.get_mut(key) {
*degree += 1;
} else {
return Err(ggen_utils::error::Error::new(
"Internal error: in-degree entry not found",
));
}
}
}
}
let mut queue: VecDeque<String> = in_degree
.iter()
.filter(|(_, °ree)| degree == 0)
.map(|(k, _)| k.clone())
.collect();
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.clone());
if let Some(neighbors) = adj_list.get(&node) {
for neighbor in neighbors {
if let Some(degree) = in_degree.get_mut(neighbor) {
*degree -= 1;
if *degree == 0 {
queue.push_back(neighbor.clone());
}
} else {
return Err(ggen_utils::error::Error::new(
"Internal error: in-degree entry not found for neighbor",
));
}
}
}
}
if result.len() != self.nodes.len() {
return Err(ggen_utils::error::Error::new(
"Dependency cycle detected during topological sort",
));
}
Ok(result)
}
}
fn parse_package_spec(spec: &str) -> (String, String) {
match spec.rsplit_once('@') {
Some((name, version)) => (name.to_string(), version.to_string()),
None => (spec.to_string(), "latest".to_string()),
}
}
#[allow(dead_code)]
fn resolve_version(package_name: &str, version_spec: &str, registry_path: &Path) -> Result<String> {
if version_spec == "latest" {
return get_latest_version(package_name, registry_path);
}
if !version_spec.starts_with('^')
&& !version_spec.starts_with('~')
&& !version_spec.starts_with('>')
&& !version_spec.starts_with('<')
&& !version_spec.starts_with('=')
{
return Ok(version_spec.to_string());
}
let available_versions = get_available_versions(package_name, registry_path)?;
if version_spec.starts_with('^') {
let base_version = version_spec.trim_start_matches('^');
resolve_caret_range(base_version, &available_versions)
} else if version_spec.starts_with('~') {
let base_version = version_spec.trim_start_matches('~');
resolve_tilde_range(base_version, &available_versions)
} else if version_spec.starts_with(">=") {
let base_version = version_spec.trim_start_matches(">=");
resolve_gte_range(base_version, &available_versions)
} else {
Ok(version_spec.to_string())
}
}
#[allow(dead_code)]
fn get_latest_version(package_name: &str, registry_path: &Path) -> Result<String> {
let versions = get_available_versions(package_name, registry_path)?;
versions
.last()
.ok_or_else(|| {
ggen_utils::error::Error::new(&format!("No versions found for {}", package_name))
})
.map(|v| v.to_string())
}
#[allow(dead_code)]
fn get_available_versions(package_name: &str, registry_path: &Path) -> Result<Vec<String>> {
let index_path = registry_path.join("index.json");
if !index_path.exists() {
return Err(ggen_utils::error::Error::new(&format!(
"Package {} not found in registry",
package_name
)));
}
let content = std::fs::read_to_string(&index_path)?;
let index: serde_json::Value = serde_json::from_str(&content)?;
let versions: Vec<String> = index
.get("packages")
.and_then(|p| p.get(package_name))
.and_then(|v| v.as_array())
.ok_or_else(|| {
ggen_utils::error::Error::new(&format!(
"Package {} not found in registry",
package_name
))
})?
.iter()
.filter_map(|v| v.get("version").and_then(|ver| ver.as_str()))
.map(String::from)
.collect();
if versions.is_empty() {
return Err(ggen_utils::error::Error::new(&format!(
"No versions found for package {}",
package_name
)));
}
Ok(versions)
}
#[allow(dead_code)]
fn resolve_caret_range(base: &str, versions: &[String]) -> Result<String> {
let parts: Vec<&str> = base.split('.').collect();
if parts.is_empty() {
return Err(ggen_utils::error::Error::new("Invalid version format"));
}
let major: u32 = parts[0]
.parse()
.map_err(|_| ggen_utils::error::Error::new("Invalid major version"))?;
versions
.iter()
.rev()
.find(|v| {
let v_parts: Vec<&str> = v.split('.').collect();
if v_parts.is_empty() {
return false;
}
let v_major: u32 = v_parts[0].parse().unwrap_or(0);
v_major == major && v.as_str() >= base
})
.ok_or_else(|| ggen_utils::error::Error::new(&format!("No matching version for ^{}", base)))
.map(|v| v.to_string())
}
fn resolve_tilde_range(base: &str, versions: &[String]) -> Result<String> {
let parts: Vec<&str> = base.split('.').collect();
if parts.len() < 2 {
return Err(ggen_utils::error::Error::new("Invalid version format"));
}
let major: u32 = parts[0]
.parse()
.map_err(|_| ggen_utils::error::Error::new("Invalid major version"))?;
let minor: u32 = parts[1]
.parse()
.map_err(|_| ggen_utils::error::Error::new("Invalid minor version"))?;
versions
.iter()
.rev()
.find(|v| {
let v_parts: Vec<&str> = v.split('.').collect();
if v_parts.len() < 2 {
return false;
}
let v_major: u32 = v_parts[0].parse().unwrap_or(0);
let v_minor: u32 = v_parts[1].parse().unwrap_or(0);
v_major == major && v_minor == minor && v.as_str() >= base
})
.ok_or_else(|| ggen_utils::error::Error::new(&format!("No matching version for ~{}", base)))
.map(|v| v.to_string())
}
fn resolve_gte_range(base: &str, versions: &[String]) -> Result<String> {
versions
.iter()
.rev()
.find(|v| v.as_str() >= base)
.ok_or_else(|| {
ggen_utils::error::Error::new(&format!("No matching version for >={}", base))
})
.map(|v| v.to_string())
}
#[allow(dead_code)]
async fn load_package_manifest(
package_name: &str, version: &str, registry_path: &Path,
) -> Result<PackageManifest> {
let package_dir = registry_path.join(package_name).join(version);
let manifest_path = package_dir.join("package.json");
if !manifest_path.exists() {
return Err(ggen_utils::error::Error::new(&format!(
"Package {}@{} not found in registry",
package_name, version
)));
}
let content = tokio::fs::read_to_string(&manifest_path).await?;
let manifest: PackageManifest = serde_json::from_str(&content)?;
Ok(manifest)
}
#[allow(dead_code)]
async fn extract_tarball(tarball_path: &Path, target_dir: &Path) -> Result<()> {
if !tarball_path.exists() {
return Err(ggen_utils::error::Error::new(&format!(
"Tarball not found: {}",
tarball_path.display()
)));
}
tokio::fs::create_dir_all(target_dir).await?;
let file = fs::File::open(tarball_path)?;
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(target_dir)
.map_err(|e| ggen_utils::error::Error::new(&format!("Failed to extract tarball: {}", e)))?;
Ok(())
}
fn calculate_sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn verify_checksum(bytes: &[u8], expected: &str) -> Result<()> {
let actual = calculate_sha256(bytes);
if actual != expected {
return Err(ggen_utils::error::Error::new(&format!(
"Checksum mismatch: expected {}, got {}",
expected, actual
)));
}
Ok(())
}
async fn download_with_retry(url: &str, max_retries: u32) -> Result<Vec<u8>> {
use reqwest::Client;
let client = Client::builder()
.timeout(std::time::Duration::from_secs(120)) .user_agent(format!("ggen/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to create HTTP client: {}", e))
})?;
let mut last_error = None;
let mut is_rate_limited = false;
for attempt in 1..=max_retries {
match client.get(url).send().await {
Ok(response) => {
let status = response.status();
if status.is_success() {
match response.bytes().await {
Ok(bytes) => {
tracing::info!(
"Downloaded {} bytes from {} on attempt {}",
bytes.len(),
url,
attempt
);
return Ok(bytes.to_vec());
}
Err(e) => {
last_error = Some(ggen_utils::error::Error::new(&format!(
"Failed to read response body: {} (attempt {}/{})",
e, attempt, max_retries
)));
}
}
} else if status.is_client_error()
&& status != reqwest::StatusCode::TOO_MANY_REQUESTS
{
return Err(ggen_utils::error::Error::new(&format!(
"HTTP {} error (permanent): {} (attempt {}/{})",
status, url, attempt, max_retries
)));
} else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
is_rate_limited = true;
tracing::warn!(
"Rate limited (HTTP 429) from {} - backing off (attempt {}/{})",
url,
attempt,
max_retries
);
last_error = Some(ggen_utils::error::Error::new(&format!(
"HTTP 429 Too Many Requests from {} (attempt {}/{})",
url, attempt, max_retries
)));
} else {
tracing::warn!(
"HTTP {} error from {} - retrying (attempt {}/{})",
status,
url,
attempt,
max_retries
);
last_error = Some(ggen_utils::error::Error::new(&format!(
"HTTP {} error (transient): {} (attempt {}/{})",
status, url, attempt, max_retries
)));
}
}
Err(e) => {
let error_msg = e.to_string();
let is_timeout = error_msg.contains("timeout") || error_msg.contains("timed out");
let is_connection =
error_msg.contains("connect") || error_msg.contains("connection");
if is_timeout {
tracing::warn!(
"Download timeout from {} - retrying (attempt {}/{})",
url,
attempt,
max_retries
);
} else if is_connection {
tracing::warn!(
"Connection error from {} - retrying (attempt {}/{})",
url,
attempt,
max_retries
);
} else {
tracing::warn!(
"Network error from {}: {} - retrying (attempt {}/{})",
url,
error_msg,
attempt,
max_retries
);
}
last_error = Some(ggen_utils::error::Error::new(&format!(
"Network error downloading from {}: {} (attempt {}/{}). Check your internet connection.",
url, error_msg, attempt, max_retries
)));
}
}
if attempt < max_retries {
let base_delay_secs = if is_rate_limited {
30 << attempt.saturating_sub(1)
} else {
1 << (attempt - 1)
};
let jitter_ms = fastrand::u64(0..100);
let delay = std::time::Duration::from_secs(base_delay_secs)
+ std::time::Duration::from_millis(jitter_ms);
tracing::info!("Waiting {:?} before retry attempt {}", delay, attempt + 1);
tokio::time::sleep(delay).await;
}
}
Err(last_error.unwrap_or_else(|| {
ggen_utils::error::Error::new(&format!(
"Failed to download from {} after {} attempts. {}",
url,
max_retries,
if is_rate_limited {
"Server returned rate limit (429). Please try again later."
} else {
"Check your network connection and try again."
}
))
}))
}
fn get_cache_path(package_name: &str, version: &str) -> Result<PathBuf> {
let cache_base = dirs::home_dir()
.ok_or_else(|| {
ggen_utils::error::Error::new(
"❌ Home directory not found. Cannot determine cache location for downloaded packages."
)
})?
.join(".ggen")
.join("cache");
Ok(cache_base.join("downloads").join(format!(
"{}-{}.zip",
package_name.replace('/', "-"),
version
)))
}
async fn verify_cache(cache_path: &Path, expected_checksum: Option<&str>) -> Result<bool> {
if !cache_path.exists() {
return Ok(false);
}
let bytes = match tokio::fs::read(cache_path).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(
"Cache file {} appears corrupted (read error: {}). Will re-download.",
cache_path.display(),
e
);
let _ = tokio::fs::remove_file(cache_path).await;
return Ok(false);
}
};
if let Some(expected) = expected_checksum {
let actual = calculate_sha256(&bytes);
if actual != expected {
tracing::warn!(
"Cache file {} checksum mismatch (expected {}, got {}). Cache corrupted, will re-download.",
cache_path.display(),
expected,
actual
);
let _ = tokio::fs::remove_file(cache_path).await;
return Ok(false);
}
Ok(true)
} else {
Ok(true)
}
}
async fn install_from_local_filesystem(source_path: &Path, target_path: &Path) -> Result<String> {
if !source_path.exists() {
return Err(ggen_utils::error::Error::new(&format!(
"Local package path does not exist: {}",
source_path.display()
)));
}
if !source_path.is_dir() {
return Err(ggen_utils::error::Error::new(&format!(
"Local package path is not a directory: {}",
source_path.display()
)));
}
tokio::fs::create_dir_all(target_path).await?;
tokio::task::spawn_blocking({
let source = source_path.to_path_buf();
let target = target_path.to_path_buf();
move || copy_dir_all(&source, &target)
})
.await
.map_err(|e| ggen_utils::error::Error::new(&format!("Task join error: {}", e)))??;
let checksum = tokio::task::spawn_blocking({
let target = target_path.to_path_buf();
move || calculate_directory_checksum(&target)
})
.await
.map_err(|e| ggen_utils::error::Error::new(&format!("Task join error: {}", e)))?;
Ok(checksum)
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
if !src.is_dir() {
return Err(ggen_utils::error::Error::new(&format!(
"Source is not a directory: {}",
src.display()
)));
}
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name();
let dst_path = dst.join(&file_name);
if path.is_dir() {
copy_dir_all(&path, &dst_path)?;
} else {
if file_name.to_string_lossy().starts_with('.') {
continue;
}
fs::copy(&path, &dst_path)?;
}
}
Ok(())
}
fn calculate_directory_checksum(dir: &Path) -> String {
use sha2::{Digest, Sha256};
use std::fs;
let mut hasher = Sha256::new();
fn walk_dir(dir: &Path, hasher: &mut Sha256) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_dir(&path, hasher);
} else {
if let Ok(rel_path) = path.strip_prefix(dir) {
hasher.update(rel_path.to_string_lossy().as_bytes());
}
if let Ok(content) = fs::read(&path) {
hasher.update(&content);
}
}
}
}
}
walk_dir(dir, &mut hasher);
format!("{:x}", hasher.finalize())
}
async fn extract_package_from_zip(
zip_bytes: &[u8], package_path: &str, target_dir: &Path,
) -> Result<()> {
use std::io::Cursor;
let zip_bytes = zip_bytes.to_vec();
let package_path = package_path.to_string();
let target_dir_clone = target_dir.to_path_buf();
tokio::task::spawn_blocking(move || {
const MAX_ZIP_SIZE: usize = 100 * 1024 * 1024; if zip_bytes.len() > MAX_ZIP_SIZE {
return Err(ggen_utils::error::Error::new(&format!(
"Security: ZIP file too large ({} bytes), possible zip bomb",
zip_bytes.len()
)));
}
let cursor = Cursor::new(zip_bytes);
let mut archive = ZipArchive::new(cursor)
.map_err(|e| ggen_utils::error::Error::new(&format!("Failed to open ZIP: {}", e)))?;
const MAX_FILES: usize = 10000;
if archive.len() > MAX_FILES {
return Err(ggen_utils::error::Error::new(&format!(
"Security: ZIP contains too many files ({}), possible zip bomb",
archive.len()
)));
}
let archive_prefix = format!("ggen-master/{}", package_path);
std::fs::create_dir_all(&target_dir_clone).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to create target dir: {}", e))
})?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to read file {}: {}", i, e))
})?;
let file_path = file.name();
if !file_path.starts_with(&archive_prefix) {
continue;
}
let relative_path = file_path.strip_prefix(&archive_prefix).ok_or_else(|| {
ggen_utils::error::Error::new(&format!("Invalid path: {}", file_path))
})?;
if relative_path.is_empty() || relative_path.ends_with('/') {
continue;
}
if relative_path.contains("..") || relative_path.starts_with('/') || relative_path.starts_with('\\') {
return Err(ggen_utils::error::Error::new(&format!(
"Security: Path traversal detected in ZIP file: {}",
relative_path
)));
}
let normalized_path = PathBuf::from(relative_path);
if normalized_path.is_absolute() {
return Err(ggen_utils::error::Error::new(&format!(
"Security: Absolute path detected in ZIP file: {}",
relative_path
)));
}
let out_path = target_dir_clone.join(&normalized_path);
if !out_path.starts_with(&target_dir_clone) {
return Err(ggen_utils::error::Error::new(&format!(
"Security: Path traversal attempt detected: {} resolves outside target directory",
relative_path
)));
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to create parent dir: {}", e))
})?;
}
let mut out_file = std::fs::File::create(&out_path).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to create file: {}", e))
})?;
std::io::copy(&mut file, &mut out_file).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to extract file: {}", e))
})?;
}
Ok(())
})
.await
.map_err(|e| {
let target_dir_for_cleanup = target_dir.to_path_buf();
if target_dir_for_cleanup.exists() {
let cleanup_dir = target_dir_for_cleanup.clone();
tokio::spawn(async move {
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&cleanup_dir).await {
tracing::warn!(
"Failed to cleanup partial installation at {}: {}",
cleanup_dir.display(),
cleanup_err
);
}
});
}
ggen_utils::error::Error::new(&format!("Task join error: {}", e))
})?
.inspect_err(|_e| {
let target_dir_for_cleanup = target_dir.to_path_buf();
if target_dir_for_cleanup.exists() {
let cleanup_dir = target_dir_for_cleanup.clone();
tokio::spawn(async move {
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&cleanup_dir).await {
tracing::warn!(
"Failed to cleanup partial installation at {}: {}",
cleanup_dir.display(),
cleanup_err
);
}
});
}
})
}
async fn download_and_install_package(
package_name: &str, package_path: &str, download_url: Option<&str>,
expected_checksum: Option<&str>, install_path: &Path,
) -> Result<String> {
let url = download_url
.ok_or_else(|| {
ggen_utils::error::Error::new(&format!(
"❌ Missing download URL for package {}. Package metadata is incomplete. Run 'ggen marketplace sync' to refresh the registry.",
package_name
))
})?;
let cache_path = get_cache_path(package_name, "latest")?;
let cache_dir = cache_path
.parent()
.ok_or_else(|| ggen_utils::error::Error::new("Invalid cache path"))?;
tokio::fs::create_dir_all(cache_dir).await?;
let zip_bytes = if verify_cache(&cache_path, expected_checksum).await? {
tracing::info!("Using cached download: {}", cache_path.display());
tokio::fs::read(&cache_path).await?
} else {
tracing::info!("Downloading from: {}", url);
let bytes = download_with_retry(url, 3).await?;
let expected = expected_checksum
.ok_or_else(|| {
ggen_utils::error::Error::new(&format!(
"❌ Missing checksum for package {}. Package metadata is incomplete. Run 'ggen marketplace sync' to refresh the registry.",
package_name
))
})?;
verify_checksum(&bytes, expected)?;
tokio::fs::write(&cache_path, &bytes)
.await
.map_err(|e| ggen_utils::error::Error::new(&format!("Failed to save cache: {}", e)))?;
bytes
};
extract_package_from_zip(&zip_bytes, package_path, install_path).await?;
let checksum = calculate_sha256(&zip_bytes);
Ok(checksum)
}
async fn load_package_info_from_registry(
package_name: &str,
) -> Result<(String, Option<String>, Option<String>)> {
let registry_url = std::env::var("GGEN_REGISTRY_URL").unwrap_or_else(|_| {
"https://seanchatmangpt.github.io/ggen/marketplace/registry/index.json".to_string()
});
let index_bytes = if registry_url.starts_with("http://") || registry_url.starts_with("https://")
{
download_with_retry(®istry_url, 3).await?
} else {
tokio::fs::read(®istry_url).await?
};
let index: serde_json::Value = serde_json::from_slice(&index_bytes)?;
let packages = index
.get("packages")
.and_then(|p| p.as_array())
.ok_or_else(|| ggen_utils::error::Error::new("Invalid registry format"))?;
let package = packages
.iter()
.find(|p| p.get("name").and_then(|n| n.as_str()) == Some(package_name))
.ok_or_else(|| {
ggen_utils::error::Error::new(&format!(
"Package {} not found in registry",
package_name
))
})?;
let package_path = package
.get("path")
.and_then(|p| p.as_str())
.map(String::from)
.unwrap_or_else(|| format!("marketplace/packages/{}", package_name));
let download_url = package
.get("download_url")
.and_then(|u| u.as_str())
.map(String::from);
let checksum = package
.get("checksum")
.and_then(|c| c.as_str())
.map(String::from);
Ok((package_path, download_url, checksum))
}
async fn load_lockfile(packages_dir: &Path) -> Result<Lockfile> {
let lockfile_path = packages_dir.join("ggen.lock");
if !lockfile_path.exists() {
return Ok(Lockfile {
version: "1.0".to_string(),
packages: HashMap::new(),
});
}
let backup_path = lockfile_path.with_extension("lock.backup");
if let Err(e) = tokio::fs::copy(&lockfile_path, &backup_path).await {
tracing::warn!("Failed to create lockfile backup: {}", e);
}
let content = tokio::fs::read_to_string(&lockfile_path)
.await
.map_err(|e| {
ggen_utils::error::Error::new(&format!(
"Failed to read lockfile from {}: {}. Lockfile may be corrupted.",
lockfile_path.display(),
e
))
})?;
let lockfile: Lockfile = match serde_json::from_str(&content) {
Ok(lockfile) => lockfile,
Err(e) => {
tracing::error!(
"Lockfile corruption detected at {}: {}. Attempting recovery from backup.",
lockfile_path.display(),
e
);
if backup_path.exists() {
let backup_content = tokio::fs::read_to_string(&backup_path).await.map_err(|e| {
ggen_utils::error::Error::new(&format!(
"Lockfile corrupted and backup recovery failed: {}. Please manually restore or delete lockfile.",
e
))
})?;
match serde_json::from_str::<Lockfile>(&backup_content) {
Ok(backup_lockfile) => {
tracing::info!("Successfully recovered lockfile from backup");
if let Err(restore_err) =
tokio::fs::copy(&backup_path, &lockfile_path).await
{
tracing::warn!(
"Failed to restore backup to main lockfile: {}",
restore_err
);
}
backup_lockfile
}
Err(backup_err) => {
return Err(ggen_utils::error::Error::new(&format!(
"Lockfile corrupted at {} and backup also corrupted: {}. Please manually restore or delete lockfile.",
lockfile_path.display(),
backup_err
)));
}
}
} else {
return Err(ggen_utils::error::Error::new(&format!(
"Lockfile corrupted at {} and no backup available. Please manually restore or delete lockfile.",
lockfile_path.display()
)));
}
}
};
Ok(lockfile)
}
async fn save_lockfile(lockfile: &Lockfile, packages_dir: &Path) -> Result<()> {
let lockfile_path = packages_dir.join("ggen.lock");
let lock_path = lockfile_path.with_extension("lock.lock"); let content = serde_json::to_string_pretty(lockfile)?;
let lock_file = tokio::task::spawn_blocking({
let lock_path = lock_path.clone();
move || {
use std::fs::OpenOptions;
use std::io::Write;
let mut lock_file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&lock_path)
.map_err(|e| {
ggen_utils::error::Error::new(&format!(
"Failed to create lockfile lock at {}: {}. Another process may be writing.",
lock_path.display(),
e
))
})?;
let pid = std::process::id();
writeln!(lock_file, "{}", pid).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to write to lockfile lock: {}", e))
})?;
lock_file.sync_all().map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to sync lockfile lock: {}", e))
})?;
Ok::<std::fs::File, ggen_utils::error::Error>(lock_file)
}
})
.await
.map_err(|e| {
ggen_utils::error::Error::new(&format!("Task join error acquiring lock: {}", e))
})??;
let _lock_guard = lock_file;
if lockfile_path.exists() {
let backup_path = lockfile_path.with_extension("lock.backup");
if let Err(e) = tokio::fs::copy(&lockfile_path, &backup_path).await {
tracing::warn!("Failed to create lockfile backup before save: {}", e);
}
}
let temp_path = lockfile_path.with_extension("lock.tmp");
tokio::fs::write(&temp_path, content).await.map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to write lockfile temp file: {}", e))
})?;
tokio::fs::rename(&temp_path, &lockfile_path)
.await
.map_err(|e| {
std::mem::drop(tokio::fs::remove_file(&temp_path));
ggen_utils::error::Error::new(&format!("Failed to atomically update lockfile: {}", e))
})?;
Ok(())
}
async fn validate_package_fmea(install_path: &Path, package_name: &str) -> Result<()> {
let config = load_marketplace_config().await;
if !config.fmea_validation {
tracing::debug!(
"FMEA validation skipped for {} (not enabled in ggen.toml)",
package_name
);
return Ok(());
}
tracing::info!("Running FMEA validation for package: {}", package_name);
let validator = if config.require_fmea {
FmeaValidator::fortune_500()
} else {
FmeaValidator::new()
};
let report = validator.validate_package(install_path).map_err(|e| {
ggen_utils::error::Error::new(&format!(
"FMEA validation error for package {}: {}",
package_name, e
))
})?;
if !report.passed {
let mut error_msg = format!(
"Package {} failed FMEA validation (coverage: {:.1}%):\n",
package_name, report.coverage_percentage
);
for mode in &report.critical_modes {
if !mode.has_control {
error_msg.push_str(&format!(
" - CRITICAL [{}] {} (RPN: {}) - NO CONTROL DEFINED\n",
mode.id, mode.mode, mode.rpn
));
}
}
for check in &report.checks {
if check.result.is_fail() {
error_msg.push_str(&format!(" - Check '{}' FAILED\n", check.name));
}
}
error_msg.push_str("\nPackages with critical unmitigated risks cannot be installed.\n");
error_msg.push_str("Please contact the package maintainer to add FMEA controls.\n");
error_msg.push_str(
"Or disable fmea_validation in [marketplace] section of ggen.toml (NOT RECOMMENDED).",
);
return Err(ggen_utils::error::Error::new(&error_msg));
}
if !report.high_risk_modes.is_empty() {
let unmitigated_high: Vec<_> = report
.high_risk_modes
.iter()
.filter(|m| !m.has_control)
.collect();
if !unmitigated_high.is_empty() {
tracing::warn!(
"Package {} has {} high-risk failure modes without controls:",
package_name,
unmitigated_high.len()
);
for mode in &unmitigated_high {
tracing::warn!(" - [{}] {} (RPN: {})", mode.id, mode.mode, mode.rpn);
}
}
}
ggen_utils::alert_success!(
"Package {} passed FMEA validation (coverage: {:.1}%, max RPN: {})",
package_name,
report.coverage_percentage,
report.max_rpn
);
Ok(())
}
async fn load_marketplace_config() -> MarketplaceConfig {
let config_path = find_ggen_toml().await;
if let Some(path) = config_path {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
if let Ok(config) = toml::from_str::<GgenConfigSubset>(&content) {
return config.marketplace.unwrap_or_default();
}
}
}
MarketplaceConfig::default()
}
async fn find_ggen_toml() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let config_path = current.join("ggen.toml");
if config_path.exists() {
return Some(config_path);
}
if !current.pop() {
break;
}
}
None
}
pub async fn install_package(options: &InstallOptions) -> Result<InstallResult> {
validate_package_name(&options.package_name)?;
let packages_dir = if let Some(ref target) = options.target_path {
target.clone()
} else if let Some(home_dir) = dirs::home_dir() {
home_dir.join(".ggen").join("packages")
} else {
std::env::current_dir()
.map_err(|e| ggen_utils::error::Error::new(&format!(
"Cannot determine installation directory (home directory not found and current directory error: {})",
e
)))?
.join(".ggen-packages")
};
tokio::fs::create_dir_all(&packages_dir).await?;
let version_spec = options.version.as_deref().unwrap_or("latest");
let resolved_version = if version_spec == "latest" {
"latest".to_string()
} else {
version_spec.to_string()
};
ggen_utils::alert_info!(
"📦 Resolving {}@{} -> {}",
options.package_name,
version_spec,
resolved_version
);
if options.dry_run {
ggen_utils::alert_info!(
"🔍 Dry run: Would install {}@{}",
options.package_name,
resolved_version
);
return Ok(InstallResult {
package_name: options.package_name.clone(),
version: resolved_version,
install_path: packages_dir.join(&options.package_name),
dependencies_installed: vec![],
});
}
let install_path = packages_dir.join(&options.package_name);
if install_path.exists() && !options.force {
return Err(ggen_utils::error::Error::new(&format!(
"Package {} already installed. Use --force to overwrite",
options.package_name
)));
}
let lockfile_backup = load_lockfile(&packages_dir).await?;
let install_path_exists_before = install_path.exists();
let (package_path, download_url, checksum) =
load_package_info_from_registry(&options.package_name).await?;
let use_local = std::env::var("GGEN_DEV_MODE").is_ok();
let local_package_path = if use_local {
let possible_paths = [
PathBuf::from("marketplace/packages").join(&options.package_name),
PathBuf::from("../marketplace/packages").join(&options.package_name),
PathBuf::from("../../marketplace/packages").join(&options.package_name),
PathBuf::from("/workspace/marketplace/packages").join(&options.package_name),
];
possible_paths
.iter()
.find(|p| p.exists() && p.is_dir())
.cloned()
} else {
None
};
let integrity_result = if let Some(local_path) = local_package_path {
install_from_local_filesystem(&local_path, &install_path).await
} else {
download_and_install_package(
&options.package_name,
&package_path,
download_url.as_deref(),
checksum.as_deref(),
&install_path,
)
.await
};
let integrity = match integrity_result {
Ok(integrity) => integrity,
Err(e) => {
if install_path.exists() && !install_path_exists_before {
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&install_path).await {
tracing::warn!(
"Failed to cleanup partial installation at {}: {}",
install_path.display(),
cleanup_err
);
}
}
return Err(e);
}
};
if let Err(e) = validate_package_fmea(&install_path, &options.package_name).await {
if install_path.exists() && !install_path_exists_before {
if let Err(cleanup_err) = tokio::fs::remove_dir_all(&install_path).await {
tracing::warn!(
"Failed to cleanup installation after FMEA failure at {}: {}",
install_path.display(),
cleanup_err
);
}
}
return Err(e);
}
let mut lockfile = load_lockfile(&packages_dir).await?;
let lockfile_entry = LockfileEntry {
name: options.package_name.clone(),
version: resolved_version.clone(),
resolved: format!("github:{}", download_url.as_deref().unwrap_or("default")),
integrity: Some(integrity.clone()),
dependencies: HashMap::new(),
};
lockfile.packages.insert(
format!("{}@{}", options.package_name, resolved_version),
lockfile_entry,
);
if let Err(e) = save_lockfile(&lockfile, &packages_dir).await {
if install_path.exists() && !install_path_exists_before {
let _ = tokio::fs::remove_dir_all(&install_path).await;
}
let _ = save_lockfile(&lockfile_backup, &packages_dir).await;
return Err(ggen_utils::error::Error::new(&format!(
"Failed to update lockfile after installation: {}. Installation rolled back.",
e
)));
}
Ok(InstallResult {
package_name: options.package_name.clone(),
version: resolved_version,
install_path,
dependencies_installed: vec![],
})
}
pub async fn install_and_report(
package: &str, target: Option<&str>, force: bool, with_dependencies: bool, dry_run: bool,
) -> Result<()> {
let (package_name, version) = match package.rsplit_once('@') {
Some((name, ver)) => (name.to_string(), Some(ver.to_string())),
None => (package.to_string(), None),
};
let mut options = InstallOptions::new(package_name);
if let Some(ver) = version {
options = options.with_version(ver);
}
if let Some(target_path) = target {
options = options.with_target(PathBuf::from(target_path));
}
if force {
options = options.force();
}
if !with_dependencies {
options = options.no_dependencies();
}
if dry_run {
options = options.dry_run();
}
if dry_run {
ggen_utils::alert_info!("🔍 Dry run: Would install package");
ggen_utils::alert_info!(" Package: {}", options.package_name);
if let Some(ref ver) = options.version {
ggen_utils::alert_info!(" Version: {}", ver);
}
if let Some(ref path) = options.target_path {
ggen_utils::alert_info!(" Target: {}", path.display());
}
ggen_utils::alert_info!(
" Dependencies: {}",
if with_dependencies { "yes" } else { "no" }
);
return Ok(());
}
ggen_utils::alert_info!("📦 Installing {}...", package);
match install_package(&options).await {
Ok(result) => {
ggen_utils::alert_success!(
"Successfully installed {} v{}",
result.package_name,
result.version
);
ggen_utils::alert_info!(" Location: {}", result.install_path.display());
if !result.dependencies_installed.is_empty() {
ggen_utils::alert_info!(
" Dependencies: {}",
result.dependencies_installed.join(", ")
);
}
Ok(())
}
Err(e) => {
ggen_utils::alert_critical!(
&format!("Failed to install package: {}", package),
&format!("Error: {}", e)
);
Err(e)
}
}
}
pub async fn execute_install(input: InstallInput) -> Result<InstallResult> {
let (package_name, version) = parse_package_spec(&input.package);
validate_package_name(&package_name)?;
let version = if version == "latest" {
None
} else {
Some(version)
};
install_package(&InstallOptions {
package_name,
target_path: input.target.map(PathBuf::from),
force: input.force,
with_dependencies: !input.no_dependencies,
dry_run: input.dry_run,
version,
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_install_options_builder() {
let options = InstallOptions::new("my-package")
.with_version("1.0.0")
.force()
.dry_run();
assert_eq!(options.package_name, "my-package");
assert_eq!(options.version, Some("1.0.0".to_string()));
assert!(options.force);
assert!(options.dry_run);
assert!(options.with_dependencies);
}
#[test]
fn test_install_options_no_dependencies() {
let options = InstallOptions::new("test").no_dependencies();
assert!(!options.with_dependencies);
}
}