diff --git a/codex-rs/core/src/plugins/marketplace_add.rs b/codex-rs/core/src/plugins/marketplace_add.rs index ae913625e9..55c50099a7 100644 --- a/codex-rs/core/src/plugins/marketplace_add.rs +++ b/codex-rs/core/src/plugins/marketplace_add.rs @@ -1,20 +1,27 @@ use super::OPENAI_CURATED_MARKETPLACE_NAME; use super::marketplace_install_root; -use super::validate_marketplace_root; -use super::validate_plugin_segment; -use codex_config::CONFIG_TOML_FILE; -use codex_config::MarketplaceConfigUpdate; -use codex_config::record_user_marketplace; use codex_utils_absolute_path::AbsolutePathBuf; use std::fs; -use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; -use std::process::Command; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; use tempfile::Builder; +mod install; +mod metadata; +mod source; + +use install::clone_git_source; +use install::ensure_marketplace_destination_is_inside_install_root; +use install::marketplace_staging_root; +use install::replace_marketplace_root; +use install::safe_marketplace_dir_name; +use metadata::MarketplaceInstallMetadata; +use metadata::installed_marketplace_root_for_source; +use metadata::record_added_marketplace_entry; +use source::MarketplaceSource; +use source::parse_marketplace_source; +use source::validate_marketplace_source_root; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplaceAddRequest { pub source: String, @@ -38,28 +45,6 @@ pub enum MarketplaceAddError { Internal(String), } -#[derive(Debug, Clone, PartialEq, Eq)] -enum MarketplaceSource { - Git { - url: String, - ref_name: Option, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MarketplaceInstallMetadata { - source: InstalledMarketplaceSource, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum InstalledMarketplaceSource { - Git { - url: String, - ref_name: Option, - sparse_paths: Vec, - }, -} - pub async fn add_marketplace( codex_home: PathBuf, request: MarketplaceAddRequest, @@ -103,12 +88,7 @@ where if let Some(existing_root) = installed_marketplace_root_for_source(codex_home, &install_root, &install_metadata)? { - let marketplace_name = validate_marketplace_root(&existing_root).map_err(|err| { - MarketplaceAddError::Internal(format!( - "failed to validate installed marketplace at {}: {err}", - existing_root.display() - )) - })?; + let marketplace_name = validate_marketplace_source_root(&existing_root)?; record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?; return Ok(MarketplaceAddOutcome { marketplace_name, @@ -190,405 +170,6 @@ where }) } -fn record_added_marketplace_entry( - codex_home: &Path, - marketplace_name: &str, - install_metadata: &MarketplaceInstallMetadata, -) -> Result<(), MarketplaceAddError> { - let source = install_metadata.config_source(); - let timestamp = utc_timestamp_now()?; - let update = MarketplaceConfigUpdate { - last_updated: ×tamp, - source_type: install_metadata.config_source_type(), - source: &source, - ref_name: install_metadata.ref_name(), - sparse_paths: install_metadata.sparse_paths(), - }; - - record_user_marketplace(codex_home, marketplace_name, &update).map_err(|err| { - MarketplaceAddError::Internal(format!( - "failed to add marketplace '{marketplace_name}' to user config.toml: {err}" - )) - }) -} - -fn installed_marketplace_root_for_source( - codex_home: &Path, - install_root: &Path, - install_metadata: &MarketplaceInstallMetadata, -) -> Result, MarketplaceAddError> { - let config_path = codex_home.join(CONFIG_TOML_FILE); - let config = match fs::read_to_string(&config_path) { - Ok(config) => config, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => { - return Err(MarketplaceAddError::Internal(format!( - "failed to read user config {}: {err}", - config_path.display() - ))); - } - }; - let config: toml::Value = toml::from_str(&config).map_err(|err| { - MarketplaceAddError::Internal(format!( - "failed to parse user config {}: {err}", - config_path.display() - )) - })?; - let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else { - return Ok(None); - }; - - for (marketplace_name, marketplace) in marketplaces { - if !install_metadata.matches_config(marketplace) { - continue; - } - let root = install_root.join(marketplace_name); - if validate_marketplace_root(&root).is_ok() { - return Ok(Some(root)); - } - } - - Ok(None) -} - -impl MarketplaceInstallMetadata { - fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self { - let source = match source { - MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git { - url: url.clone(), - ref_name: ref_name.clone(), - sparse_paths: sparse_paths.to_vec(), - }, - }; - Self { source } - } - - fn config_source_type(&self) -> &'static str { - match &self.source { - InstalledMarketplaceSource::Git { .. } => "git", - } - } - - fn config_source(&self) -> String { - match &self.source { - InstalledMarketplaceSource::Git { url, .. } => url.clone(), - } - } - - fn ref_name(&self) -> Option<&str> { - match &self.source { - InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(), - } - } - - fn sparse_paths(&self) -> &[String] { - match &self.source { - InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths, - } - } - - fn matches_config(&self, marketplace: &toml::Value) -> bool { - marketplace.get("source_type").and_then(toml::Value::as_str) - == Some(self.config_source_type()) - && marketplace.get("source").and_then(toml::Value::as_str) - == Some(self.config_source().as_str()) - && marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name() - && config_sparse_paths(marketplace) == self.sparse_paths() - } -} - -fn config_sparse_paths(marketplace: &toml::Value) -> Vec { - marketplace - .get("sparse_paths") - .and_then(toml::Value::as_array) - .map(|paths| { - paths - .iter() - .filter_map(toml::Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn parse_marketplace_source( - source: &str, - explicit_ref: Option, -) -> Result { - let source = source.trim(); - if source.is_empty() { - return Err(MarketplaceAddError::InvalidRequest( - "marketplace source must not be empty".to_string(), - )); - } - - let (base_source, parsed_ref) = split_source_ref(source); - let ref_name = explicit_ref.or(parsed_ref); - - if looks_like_local_path(&base_source) { - return Err(MarketplaceAddError::InvalidRequest( - "local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo".to_string(), - )); - } - - if is_ssh_git_url(&base_source) || is_git_url(&base_source) { - return Ok(MarketplaceSource::Git { - url: normalize_git_url(&base_source), - ref_name, - }); - } - - if looks_like_github_shorthand(&base_source) { - return Ok(MarketplaceSource::Git { - url: format!("https://github.com/{base_source}.git"), - ref_name, - }); - } - - Err(MarketplaceAddError::InvalidRequest(format!( - "invalid marketplace source format: {source}" - ))) -} - -fn split_source_ref(source: &str) -> (String, Option) { - if let Some((base, ref_name)) = source.rsplit_once('#') { - return (base.to_string(), non_empty_ref(ref_name)); - } - if !source.contains("://") - && !is_ssh_git_url(source) - && let Some((base, ref_name)) = source.rsplit_once('@') - { - return (base.to_string(), non_empty_ref(ref_name)); - } - (source.to_string(), None) -} - -fn non_empty_ref(ref_name: &str) -> Option { - let ref_name = ref_name.trim(); - (!ref_name.is_empty()).then(|| ref_name.to_string()) -} - -fn normalize_git_url(url: &str) -> String { - let url = url.trim_end_matches('/'); - if url.starts_with("https://github.com/") && !url.ends_with(".git") { - format!("{url}.git") - } else { - url.to_string() - } -} - -fn looks_like_local_path(source: &str) -> bool { - source.starts_with("./") - || source.starts_with("../") - || source.starts_with('/') - || source.starts_with("~/") - || source == "." - || source == ".." -} - -fn is_ssh_git_url(source: &str) -> bool { - source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':') -} - -fn is_git_url(source: &str) -> bool { - source.starts_with("http://") || source.starts_with("https://") -} - -fn looks_like_github_shorthand(source: &str) -> bool { - let mut segments = source.split('/'); - let owner = segments.next(); - let repo = segments.next(); - let extra = segments.next(); - owner.is_some_and(is_github_shorthand_segment) - && repo.is_some_and(is_github_shorthand_segment) - && extra.is_none() -} - -fn is_github_shorthand_segment(segment: &str) -> bool { - !segment.is_empty() - && segment - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) -} - -fn validate_marketplace_source_root(root: &Path) -> Result { - let marketplace_name = validate_marketplace_root(root) - .map_err(|err| MarketplaceAddError::InvalidRequest(err.to_string()))?; - validate_plugin_segment(&marketplace_name, "marketplace name") - .map_err(MarketplaceAddError::InvalidRequest)?; - Ok(marketplace_name) -} - -fn safe_marketplace_dir_name(marketplace_name: &str) -> Result { - let safe = marketplace_name - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { - ch - } else { - '-' - } - }) - .collect::(); - let safe = safe.trim_matches('.').to_string(); - if safe.is_empty() || safe == ".." { - return Err(MarketplaceAddError::InvalidRequest(format!( - "marketplace name '{marketplace_name}' cannot be used as an install directory" - ))); - } - Ok(safe) -} - -fn ensure_marketplace_destination_is_inside_install_root( - install_root: &Path, - destination: &Path, -) -> Result<(), MarketplaceAddError> { - let install_root = install_root.canonicalize().map_err(|err| { - MarketplaceAddError::Internal(format!( - "failed to resolve marketplace install root {}: {err}", - install_root.display() - )) - })?; - let destination_parent = destination - .parent() - .ok_or_else(|| { - MarketplaceAddError::Internal("marketplace destination has no parent".to_string()) - })? - .canonicalize() - .map_err(|err| { - MarketplaceAddError::Internal(format!( - "failed to resolve marketplace destination parent {}: {err}", - destination.display() - )) - })?; - if !destination_parent.starts_with(&install_root) { - return Err(MarketplaceAddError::InvalidRequest(format!( - "marketplace destination {} is outside install root {}", - destination.display(), - install_root.display() - ))); - } - Ok(()) -} - -fn utc_timestamp_now() -> Result { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|err| { - MarketplaceAddError::Internal(format!("system clock is before Unix epoch: {err}")) - })?; - Ok(format_utc_timestamp(duration.as_secs() as i64)) -} - -fn format_utc_timestamp(seconds_since_epoch: i64) -> String { - const SECONDS_PER_DAY: i64 = 86_400; - let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY); - let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY); - let (year, month, day) = civil_from_days(days); - let hour = seconds_of_day / 3_600; - let minute = (seconds_of_day % 3_600) / 60; - let second = seconds_of_day % 60; - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") -} - -fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { - let days = days_since_epoch + 719_468; - let era = if days >= 0 { days } else { days - 146_096 } / 146_097; - let day_of_era = days - era * 146_097; - let year_of_era = - (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let mut year = year_of_era + era * 400; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_prime = (5 * day_of_year + 2) / 153; - let day = day_of_year - (153 * month_prime + 2) / 5 + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - year += if month <= 2 { 1 } else { 0 }; - (year, month, day) -} - -impl MarketplaceSource { - fn display(&self) -> String { - match self { - Self::Git { url, ref_name } => match ref_name { - Some(ref_name) => format!("{url}#{ref_name}"), - None => url.clone(), - }, - } - } -} - -fn clone_git_source( - url: &str, - ref_name: Option<&str>, - sparse_paths: &[String], - destination: &Path, -) -> Result<(), MarketplaceAddError> { - let destination_string = destination.to_string_lossy().to_string(); - if sparse_paths.is_empty() { - run_git(&["clone", url, destination_string.as_str()], None)?; - if let Some(ref_name) = ref_name { - run_git( - &["checkout", ref_name], - Some(Path::new(&destination_string)), - )?; - } - return Ok(()); - } - - run_git( - &[ - "clone", - "--filter=blob:none", - "--no-checkout", - url, - destination_string.as_str(), - ], - None, - )?; - let mut sparse_args = vec!["sparse-checkout", "set"]; - sparse_args.extend(sparse_paths.iter().map(String::as_str)); - run_git(&sparse_args, Some(destination))?; - run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?; - Ok(()) -} - -fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), MarketplaceAddError> { - let mut command = Command::new("git"); - command.args(args); - command.env("GIT_TERMINAL_PROMPT", "0"); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - - let output = command.output().map_err(|err| { - MarketplaceAddError::Internal(format!("failed to run git {}: {err}", args.join(" "))) - })?; - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - Err(MarketplaceAddError::Internal(format!( - "git {} failed with status {}\nstdout:\n{}\nstderr:\n{}", - args.join(" "), - output.status, - stdout.trim(), - stderr.trim() - ))) -} - -fn replace_marketplace_root(staged_root: &Path, destination: &Path) -> std::io::Result<()> { - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; - } - fs::rename(staged_root, destination) -} - -fn marketplace_staging_root(install_root: &Path) -> PathBuf { - install_root.join(".staging") -} - #[cfg(test)] mod tests { use super::*; @@ -596,114 +177,6 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; - #[test] - fn github_shorthand_parses_ref_suffix() { - assert_eq!( - parse_marketplace_source("owner/repo@main", None).unwrap(), - MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: Some("main".to_string()), - } - ); - } - - #[test] - fn git_url_parses_fragment_ref() { - assert_eq!( - parse_marketplace_source("https://example.com/team/repo.git#v1", None).unwrap(), - MarketplaceSource::Git { - url: "https://example.com/team/repo.git".to_string(), - ref_name: Some("v1".to_string()), - } - ); - } - - #[test] - fn explicit_ref_overrides_source_ref() { - assert_eq!( - parse_marketplace_source("owner/repo@main", Some("release".to_string())).unwrap(), - MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: Some("release".to_string()), - } - ); - } - - #[test] - fn github_shorthand_and_git_url_normalize_to_same_source() { - let shorthand = parse_marketplace_source("owner/repo", None).unwrap(); - let git_url = parse_marketplace_source("https://github.com/owner/repo.git", None).unwrap(); - - assert_eq!(shorthand, git_url); - assert_eq!( - shorthand, - MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: None, - } - ); - } - - #[test] - fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() { - assert_eq!( - parse_marketplace_source("https://github.com/owner/repo/", None).unwrap(), - MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: None, - } - ); - } - - #[test] - fn non_github_https_source_parses_as_git_url() { - assert_eq!( - parse_marketplace_source("https://gitlab.com/owner/repo", None).unwrap(), - MarketplaceSource::Git { - url: "https://gitlab.com/owner/repo".to_string(), - ref_name: None, - } - ); - } - - #[test] - fn file_url_source_is_rejected() { - let err = parse_marketplace_source("file:///tmp/marketplace.git", None).unwrap_err(); - - assert!( - err.to_string() - .contains("invalid marketplace source format"), - "unexpected error: {err}" - ); - } - - #[test] - fn parse_marketplace_source_rejects_local_directory_source() { - let err = parse_marketplace_source("./marketplace", None).unwrap_err(); - - assert_eq!( - err.to_string(), - "local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo" - ); - } - - #[test] - fn ssh_url_parses_as_git_url() { - assert_eq!( - parse_marketplace_source("ssh://git@github.com/owner/repo.git#main", None).unwrap(), - MarketplaceSource::Git { - url: "ssh://git@github.com/owner/repo.git".to_string(), - ref_name: Some("main".to_string()), - } - ); - } - - #[test] - fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() { - assert_eq!(format_utc_timestamp(0), "1970-01-01T00:00:00Z"); - assert_eq!(format_utc_timestamp(1_775_779_200), "2026-04-10T00:00:00Z"); - } - #[test] fn add_marketplace_sync_installs_marketplace_and_updates_config() -> Result<()> { let codex_home = TempDir::new()?; @@ -734,44 +207,13 @@ mod tests { .is_file() ); - let config = fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; + let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?; assert!(config.contains("[marketplaces.debug]")); assert!(config.contains("source_type = \"git\"")); assert!(config.contains("source = \"https://github.com/owner/repo.git\"")); Ok(()) } - #[test] - fn installed_marketplace_root_for_source_propagates_config_read_errors() -> Result<()> { - let codex_home = TempDir::new()?; - let config_path = codex_home.path().join(CONFIG_TOML_FILE); - fs::create_dir(&config_path)?; - - let install_root = codex_home.path().join("marketplaces"); - let source = MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: None, - }; - let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]); - - let err = installed_marketplace_root_for_source( - codex_home.path(), - &install_root, - &install_metadata, - ) - .unwrap_err(); - - assert!( - err.to_string().contains(&format!( - "failed to read user config {}:", - config_path.display() - )), - "unexpected error: {err}" - ); - - Ok(()) - } - fn write_marketplace_source(source: &Path, marker: &str) -> std::io::Result<()> { fs::create_dir_all(source.join(".agents/plugins"))?; fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?; diff --git a/codex-rs/core/src/plugins/marketplace_add/install.rs b/codex-rs/core/src/plugins/marketplace_add/install.rs new file mode 100644 index 0000000000..b54f89a815 --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_add/install.rs @@ -0,0 +1,134 @@ +use super::MarketplaceAddError; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +pub(super) fn clone_git_source( + url: &str, + ref_name: Option<&str>, + sparse_paths: &[String], + destination: &Path, +) -> Result<(), MarketplaceAddError> { + let destination_string = destination.to_string_lossy().to_string(); + if sparse_paths.is_empty() { + run_git(&["clone", url, destination_string.as_str()], None)?; + if let Some(ref_name) = ref_name { + run_git( + &["checkout", ref_name], + Some(Path::new(&destination_string)), + )?; + } + return Ok(()); + } + + run_git( + &[ + "clone", + "--filter=blob:none", + "--no-checkout", + url, + destination_string.as_str(), + ], + None, + )?; + let mut sparse_args = vec!["sparse-checkout", "set"]; + sparse_args.extend(sparse_paths.iter().map(String::as_str)); + run_git(&sparse_args, Some(destination))?; + run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?; + Ok(()) +} + +pub(super) fn safe_marketplace_dir_name( + marketplace_name: &str, +) -> Result { + let safe = marketplace_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '-' + } + }) + .collect::(); + let safe = safe.trim_matches('.').to_string(); + if safe.is_empty() || safe == ".." { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace name '{marketplace_name}' cannot be used as an install directory" + ))); + } + Ok(safe) +} + +pub(super) fn ensure_marketplace_destination_is_inside_install_root( + install_root: &Path, + destination: &Path, +) -> Result<(), MarketplaceAddError> { + let install_root = install_root.canonicalize().map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve marketplace install root {}: {err}", + install_root.display() + )) + })?; + let destination_parent = destination + .parent() + .ok_or_else(|| { + MarketplaceAddError::Internal("marketplace destination has no parent".to_string()) + })? + .canonicalize() + .map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve marketplace destination parent {}: {err}", + destination.display() + )) + })?; + if !destination_parent.starts_with(&install_root) { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace destination {} is outside install root {}", + destination.display(), + install_root.display() + ))); + } + Ok(()) +} + +pub(super) fn replace_marketplace_root( + staged_root: &Path, + destination: &Path, +) -> std::io::Result<()> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(staged_root, destination) +} + +pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf { + install_root.join(".staging") +} + +fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), MarketplaceAddError> { + let mut command = Command::new("git"); + command.args(args); + command.env("GIT_TERMINAL_PROMPT", "0"); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + + let output = command.output().map_err(|err| { + MarketplaceAddError::Internal(format!("failed to run git {}: {err}", args.join(" "))) + })?; + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(MarketplaceAddError::Internal(format!( + "git {} failed with status {}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + output.status, + stdout.trim(), + stderr.trim() + ))) +} diff --git a/codex-rs/core/src/plugins/marketplace_add/metadata.rs b/codex-rs/core/src/plugins/marketplace_add/metadata.rs new file mode 100644 index 0000000000..576d180eed --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_add/metadata.rs @@ -0,0 +1,224 @@ +use super::MarketplaceAddError; +use super::MarketplaceSource; +use crate::plugins::validate_marketplace_root; +use codex_config::CONFIG_TOML_FILE; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct MarketplaceInstallMetadata { + source: InstalledMarketplaceSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InstalledMarketplaceSource { + Git { + url: String, + ref_name: Option, + sparse_paths: Vec, + }, +} + +pub(super) fn record_added_marketplace_entry( + codex_home: &Path, + marketplace_name: &str, + install_metadata: &MarketplaceInstallMetadata, +) -> Result<(), MarketplaceAddError> { + let source = install_metadata.config_source(); + let timestamp = utc_timestamp_now()?; + let update = MarketplaceConfigUpdate { + last_updated: ×tamp, + source_type: install_metadata.config_source_type(), + source: &source, + ref_name: install_metadata.ref_name(), + sparse_paths: install_metadata.sparse_paths(), + }; + + record_user_marketplace(codex_home, marketplace_name, &update).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to add marketplace '{marketplace_name}' to user config.toml: {err}" + )) + }) +} + +pub(super) fn installed_marketplace_root_for_source( + codex_home: &Path, + install_root: &Path, + install_metadata: &MarketplaceInstallMetadata, +) -> Result, MarketplaceAddError> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let config = match fs::read_to_string(&config_path) { + Ok(config) => config, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(MarketplaceAddError::Internal(format!( + "failed to read user config {}: {err}", + config_path.display() + ))); + } + }; + let config: toml::Value = toml::from_str(&config).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to parse user config {}: {err}", + config_path.display() + )) + })?; + let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else { + return Ok(None); + }; + + for (marketplace_name, marketplace) in marketplaces { + if !install_metadata.matches_config(marketplace) { + continue; + } + let root = install_root.join(marketplace_name); + if validate_marketplace_root(&root).is_ok() { + return Ok(Some(root)); + } + } + + Ok(None) +} + +impl MarketplaceInstallMetadata { + pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self { + let source = match source { + MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git { + url: url.clone(), + ref_name: ref_name.clone(), + sparse_paths: sparse_paths.to_vec(), + }, + }; + Self { source } + } + + fn config_source_type(&self) -> &'static str { + match &self.source { + InstalledMarketplaceSource::Git { .. } => "git", + } + } + + fn config_source(&self) -> String { + match &self.source { + InstalledMarketplaceSource::Git { url, .. } => url.clone(), + } + } + + fn ref_name(&self) -> Option<&str> { + match &self.source { + InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(), + } + } + + fn sparse_paths(&self) -> &[String] { + match &self.source { + InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths, + } + } + + fn matches_config(&self, marketplace: &toml::Value) -> bool { + marketplace.get("source_type").and_then(toml::Value::as_str) + == Some(self.config_source_type()) + && marketplace.get("source").and_then(toml::Value::as_str) + == Some(self.config_source().as_str()) + && marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name() + && config_sparse_paths(marketplace) == self.sparse_paths() + } +} + +fn config_sparse_paths(marketplace: &toml::Value) -> Vec { + marketplace + .get("sparse_paths") + .and_then(toml::Value::as_array) + .map(|paths| { + paths + .iter() + .filter_map(toml::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn utc_timestamp_now() -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| { + MarketplaceAddError::Internal(format!("system clock is before Unix epoch: {err}")) + })?; + Ok(format_utc_timestamp(duration.as_secs() as i64)) +} + +fn format_utc_timestamp(seconds_since_epoch: i64) -> String { + const SECONDS_PER_DAY: i64 = 86_400; + let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY); + let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY); + let (year, month, day) = civil_from_days(days); + let hour = seconds_of_day / 3_600; + let minute = (seconds_of_day % 3_600) / 60; + let second = seconds_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { + let days = days_since_epoch + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += if month <= 2 { 1 } else { 0 }; + (year, month, day) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() { + assert_eq!(format_utc_timestamp(0), "1970-01-01T00:00:00Z"); + assert_eq!(format_utc_timestamp(1_775_779_200), "2026-04-10T00:00:00Z"); + } + + #[test] + fn installed_marketplace_root_for_source_propagates_config_read_errors() { + let codex_home = TempDir::new().unwrap(); + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + fs::create_dir(&config_path).unwrap(); + + let install_root = codex_home.path().join("marketplaces"); + let source = MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + }; + let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]); + + let err = installed_marketplace_root_for_source( + codex_home.path(), + &install_root, + &install_metadata, + ) + .unwrap_err(); + + assert!( + err.to_string().contains(&format!( + "failed to read user config {}:", + config_path.display() + )), + "unexpected error: {err}" + ); + } +} diff --git a/codex-rs/core/src/plugins/marketplace_add/source.rs b/codex-rs/core/src/plugins/marketplace_add/source.rs new file mode 100644 index 0000000000..ce0793227c --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_add/source.rs @@ -0,0 +1,239 @@ +use super::MarketplaceAddError; +use crate::plugins::validate_marketplace_root; +use crate::plugins::validate_plugin_segment; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum MarketplaceSource { + Git { + url: String, + ref_name: Option, + }, +} + +pub(super) fn parse_marketplace_source( + source: &str, + explicit_ref: Option, +) -> Result { + let source = source.trim(); + if source.is_empty() { + return Err(MarketplaceAddError::InvalidRequest( + "marketplace source must not be empty".to_string(), + )); + } + + let (base_source, parsed_ref) = split_source_ref(source); + let ref_name = explicit_ref.or(parsed_ref); + + if looks_like_local_path(&base_source) { + return Err(MarketplaceAddError::InvalidRequest( + "local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo".to_string(), + )); + } + + if is_ssh_git_url(&base_source) || is_git_url(&base_source) { + return Ok(MarketplaceSource::Git { + url: normalize_git_url(&base_source), + ref_name, + }); + } + + if looks_like_github_shorthand(&base_source) { + return Ok(MarketplaceSource::Git { + url: format!("https://github.com/{base_source}.git"), + ref_name, + }); + } + + Err(MarketplaceAddError::InvalidRequest(format!( + "invalid marketplace source format: {source}" + ))) +} + +pub(super) fn validate_marketplace_source_root(root: &Path) -> Result { + let marketplace_name = validate_marketplace_root(root) + .map_err(|err| MarketplaceAddError::InvalidRequest(err.to_string()))?; + validate_plugin_segment(&marketplace_name, "marketplace name") + .map_err(MarketplaceAddError::InvalidRequest)?; + Ok(marketplace_name) +} + +fn split_source_ref(source: &str) -> (String, Option) { + if let Some((base, ref_name)) = source.rsplit_once('#') { + return (base.to_string(), non_empty_ref(ref_name)); + } + if !source.contains("://") + && !is_ssh_git_url(source) + && let Some((base, ref_name)) = source.rsplit_once('@') + { + return (base.to_string(), non_empty_ref(ref_name)); + } + (source.to_string(), None) +} + +fn non_empty_ref(ref_name: &str) -> Option { + let ref_name = ref_name.trim(); + (!ref_name.is_empty()).then(|| ref_name.to_string()) +} + +fn normalize_git_url(url: &str) -> String { + let url = url.trim_end_matches('/'); + if url.starts_with("https://github.com/") && !url.ends_with(".git") { + format!("{url}.git") + } else { + url.to_string() + } +} + +fn looks_like_local_path(source: &str) -> bool { + source.starts_with("./") + || source.starts_with("../") + || source.starts_with('/') + || source.starts_with("~/") + || source == "." + || source == ".." +} + +fn is_ssh_git_url(source: &str) -> bool { + source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':') +} + +fn is_git_url(source: &str) -> bool { + source.starts_with("http://") || source.starts_with("https://") +} + +fn looks_like_github_shorthand(source: &str) -> bool { + let mut segments = source.split('/'); + let owner = segments.next(); + let repo = segments.next(); + let extra = segments.next(); + owner.is_some_and(is_github_shorthand_segment) + && repo.is_some_and(is_github_shorthand_segment) + && extra.is_none() +} + +fn is_github_shorthand_segment(segment: &str) -> bool { + !segment.is_empty() + && segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) +} + +impl MarketplaceSource { + pub(super) fn display(&self) -> String { + match self { + Self::Git { url, ref_name } => match ref_name { + Some(ref_name) => format!("{url}#{ref_name}"), + None => url.clone(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn github_shorthand_parses_ref_suffix() { + assert_eq!( + parse_marketplace_source("owner/repo@main", None).unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: Some("main".to_string()), + } + ); + } + + #[test] + fn git_url_parses_fragment_ref() { + assert_eq!( + parse_marketplace_source("https://example.com/team/repo.git#v1", None).unwrap(), + MarketplaceSource::Git { + url: "https://example.com/team/repo.git".to_string(), + ref_name: Some("v1".to_string()), + } + ); + } + + #[test] + fn explicit_ref_overrides_source_ref() { + assert_eq!( + parse_marketplace_source("owner/repo@main", Some("release".to_string())).unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: Some("release".to_string()), + } + ); + } + + #[test] + fn github_shorthand_and_git_url_normalize_to_same_source() { + let shorthand = parse_marketplace_source("owner/repo", None).unwrap(); + let git_url = parse_marketplace_source("https://github.com/owner/repo.git", None).unwrap(); + + assert_eq!(shorthand, git_url); + assert_eq!( + shorthand, + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() { + assert_eq!( + parse_marketplace_source("https://github.com/owner/repo/", None).unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn non_github_https_source_parses_as_git_url() { + assert_eq!( + parse_marketplace_source("https://gitlab.com/owner/repo", None).unwrap(), + MarketplaceSource::Git { + url: "https://gitlab.com/owner/repo".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn file_url_source_is_rejected() { + let err = parse_marketplace_source("file:///tmp/marketplace.git", None).unwrap_err(); + + assert!( + err.to_string() + .contains("invalid marketplace source format"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_marketplace_source_rejects_local_directory_source() { + let err = parse_marketplace_source("./marketplace", None).unwrap_err(); + + assert_eq!( + err.to_string(), + "local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo" + ); + } + + #[test] + fn ssh_url_parses_as_git_url() { + assert_eq!( + parse_marketplace_source("ssh://git@github.com/owner/repo.git#main", None).unwrap(), + MarketplaceSource::Git { + url: "ssh://git@github.com/owner/repo.git".to_string(), + ref_name: Some("main".to_string()), + } + ); + } +}