Codex Handbook
core-plugins/src/store.rs 410 lines
use crate::manifest::PluginManifest;use crate::manifest::load_plugin_manifest;use codex_plugin::PluginId;use codex_plugin::validate_plugin_segment;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_plugins::find_plugin_manifest_path;use semver::Version;use serde::Deserialize;use serde_json::Value as JsonValue;use std::cmp::Ordering;use std::fs;use std::io;use std::path::Path;use std::path::PathBuf;pub const DEFAULT_PLUGIN_VERSION: &str = "local";pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";pub const PLUGINS_DATA_DIR: &str = "plugins/data";#[derive(Debug, Clone, PartialEq, Eq)]pub struct PluginInstallResult {    pub plugin_id: PluginId,    pub plugin_version: String,    pub installed_path: AbsolutePathBuf,}#[derive(Debug, Clone)]pub struct PluginStore {    root: AbsolutePathBuf,    data_root: AbsolutePathBuf,}impl PluginStore {    pub fn new(codex_home: PathBuf) -> Self {        Self::try_new(codex_home)            .unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}"))    }    pub fn try_new(codex_home: PathBuf) -> Result<Self, PluginStoreError> {        let root = AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_CACHE_DIR))            .map_err(|err| PluginStoreError::io("failed to resolve plugin cache root", err))?;        let data_root =            AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR))                .map_err(|err| PluginStoreError::io("failed to resolve plugin data root", err))?;        Ok(Self { root, data_root })    }    pub fn root(&self) -> &AbsolutePathBuf {        &self.root    }    pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {        self.root            .join(&plugin_id.marketplace_name)            .join(&plugin_id.plugin_name)    }    pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {        self.plugin_base_root(plugin_id).join(plugin_version)    }    pub fn plugin_data_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {        self.data_root.join(format!(            "{}-{}",            plugin_id.plugin_name, plugin_id.marketplace_name        ))    }    pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {        let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())            .ok()?            .filter_map(Result::ok)            .filter_map(|entry| {                entry.file_type().ok().filter(std::fs::FileType::is_dir)?;                entry.file_name().into_string().ok()            })            .filter(|version| validate_plugin_version_segment(version).is_ok())            .collect::<Vec<_>>();        discovered_versions.sort_unstable_by(|left, right| compare_plugin_versions(left, right));        if discovered_versions.is_empty() {            None        } else if discovered_versions            .iter()            .any(|version| version == DEFAULT_PLUGIN_VERSION)        {            Some(DEFAULT_PLUGIN_VERSION.to_string())        } else {            discovered_versions.pop()        }    }    pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option<AbsolutePathBuf> {        self.active_plugin_version(plugin_id)            .map(|plugin_version| self.plugin_root(plugin_id, &plugin_version))    }    pub fn is_installed(&self, plugin_id: &PluginId) -> bool {        self.active_plugin_version(plugin_id).is_some()    }    pub fn install(        &self,        source_path: AbsolutePathBuf,        plugin_id: PluginId,    ) -> Result<PluginInstallResult, PluginStoreError> {        let plugin_version = plugin_version_for_source(source_path.as_path())?;        self.install_with_version(source_path, plugin_id, plugin_version)    }    pub fn install_with_version(        &self,        source_path: AbsolutePathBuf,        plugin_id: PluginId,        plugin_version: String,    ) -> Result<PluginInstallResult, PluginStoreError> {        if !source_path.as_path().is_dir() {            return Err(PluginStoreError::Invalid(format!(                "plugin source path is not a directory: {}",                source_path.display()            )));        }        let plugin_name = plugin_name_for_source(source_path.as_path())?;        if plugin_name != plugin_id.plugin_name {            return Err(PluginStoreError::Invalid(format!(                "plugin.json name `{plugin_name}` does not match marketplace plugin name `{}`",                plugin_id.plugin_name            )));        }        validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;        let installed_path = self.plugin_root(&plugin_id, &plugin_version);        replace_plugin_root_atomically(            source_path.as_path(),            self.plugin_base_root(&plugin_id).as_path(),            &plugin_version,        )?;        Ok(PluginInstallResult {            plugin_id,            plugin_version,            installed_path,        })    }    pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> {        remove_existing_target(self.plugin_base_root(plugin_id).as_path())    }}#[derive(Debug, thiserror::Error)]pub enum PluginStoreError {    #[error("{context}: {source}")]    Io {        context: &'static str,        #[source]        source: io::Error,    },    #[error("{0}")]    Invalid(String),}impl PluginStoreError {    fn io(context: &'static str, source: io::Error) -> Self {        Self::Io { context, source }    }}pub fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginStoreError> {    let plugin_version = plugin_manifest_version_for_source(source_path)?        .unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());    validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;    Ok(plugin_version)}pub fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> {    if plugin_version.is_empty() {        return Err("invalid plugin version: must not be empty".to_string());    }    if matches!(plugin_version, "." | "..") {        return Err("invalid plugin version: path traversal is not allowed".to_string());    }    if !plugin_version        .chars()        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+'))    {        return Err(            "invalid plugin version: only ASCII letters, digits, `.`, `+`, `_`, and `-` are allowed"                .to_string(),        );    }    Ok(())}fn plugin_manifest_for_source(source_path: &Path) -> Result<PluginManifest, PluginStoreError> {    load_plugin_manifest(source_path)        .ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string()))}#[derive(Debug, Deserialize)]#[serde(rename_all = "camelCase")]struct RawPluginManifestVersion {    #[serde(default)]    version: Option<JsonValue>,}fn plugin_manifest_version_for_source(    source_path: &Path,) -> Result<Option<String>, PluginStoreError> {    let manifest_path = find_plugin_manifest_path(source_path)        .ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?;    let contents = fs::read_to_string(&manifest_path)        .map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?;    let manifest: RawPluginManifestVersion = serde_json::from_str(&contents)        .map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?;    let Some(version) = manifest.version else {        return Ok(None);    };    let Some(version) = version.as_str() else {        return Err(PluginStoreError::Invalid(            "invalid plugin version in plugin.json: expected string".to_string(),        ));    };    let version = version.trim();    if version.is_empty() {        return Err(PluginStoreError::Invalid(            "invalid plugin version in plugin.json: must not be blank".to_string(),        ));    }    Ok(Some(version.to_string()))}fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {    let manifest = plugin_manifest_for_source(source_path)?;    let plugin_name = manifest.name;    validate_plugin_segment(&plugin_name, "plugin name")        .map_err(PluginStoreError::Invalid)        .map(|_| plugin_name)}fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {    if !path.exists() {        return Ok(());    }    if path.is_dir() {        fs::remove_dir_all(path).map_err(|err| {            PluginStoreError::io("failed to remove existing plugin cache entry", err)        })    } else {        fs::remove_file(path).map_err(|err| {            PluginStoreError::io("failed to remove existing plugin cache entry", err)        })    }}fn replace_plugin_root_atomically(    source: &Path,    target_root: &Path,    plugin_version: &str,) -> Result<(), PluginStoreError> {    let Some(parent) = target_root.parent() else {        return Err(PluginStoreError::Invalid(format!(            "plugin cache path has no parent: {}",            target_root.display()        )));    };    fs::create_dir_all(parent)        .map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?;    let Some(plugin_dir_name) = target_root.file_name() else {        return Err(PluginStoreError::Invalid(format!(            "plugin cache path has no directory name: {}",            target_root.display()        )));    };    let staged_dir = tempfile::Builder::new()        .prefix("plugin-install-")        .tempdir_in(parent)        .map_err(|err| {            PluginStoreError::io("failed to create temporary plugin cache directory", err)        })?;    let staged_root = staged_dir.path().join(plugin_dir_name);    let staged_version_root = staged_root.join(plugin_version);    copy_dir_recursive(source, &staged_version_root)?;    let target_version_root = target_root.join(plugin_version);    if target_root.exists() && !target_version_root.exists() {        fs::rename(&staged_version_root, &target_version_root).map_err(|err| {            PluginStoreError::io("failed to activate updated plugin cache version", err)        })?;        remove_old_plugin_versions(target_root, plugin_version)?;        return Ok(());    }    if target_root.exists() {        let backup_dir = tempfile::Builder::new()            .prefix("plugin-backup-")            .tempdir_in(parent)            .map_err(|err| {                PluginStoreError::io("failed to create plugin cache backup directory", err)            })?;        let backup_root = backup_dir.path().join(plugin_dir_name);        fs::rename(target_root, &backup_root)            .map_err(|err| PluginStoreError::io("failed to back up plugin cache entry", err))?;        if let Err(err) = fs::rename(&staged_root, target_root) {            let rollback_result = fs::rename(&backup_root, target_root);            return match rollback_result {                Ok(()) => Err(PluginStoreError::io(                    "failed to activate updated plugin cache entry",                    err,                )),                Err(rollback_err) => {                    let backup_path = backup_dir.keep().join(plugin_dir_name);                    Err(PluginStoreError::Invalid(format!(                        "failed to activate updated plugin cache entry at {}: {err}; failed to restore previous cache entry (left at {}): {rollback_err}",                        target_root.display(),                        backup_path.display()                    )))                }            };        }    } else {        fs::rename(&staged_root, target_root)            .map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))?;    }    Ok(())}fn remove_old_plugin_versions(    target_root: &Path,    plugin_version: &str,) -> Result<(), PluginStoreError> {    let Ok(entries) = fs::read_dir(target_root) else {        return Ok(());    };    for entry in entries.filter_map(Result::ok) {        let Ok(file_type) = entry.file_type() else {            continue;        };        if !file_type.is_dir() {            continue;        }        let Ok(version) = entry.file_name().into_string() else {            continue;        };        if version == plugin_version || validate_plugin_version_segment(&version).is_err() {            continue;        }        if fs::remove_dir_all(entry.path()).is_err()            && old_plugin_version_would_stay_active(&version, plugin_version)        {            return Err(PluginStoreError::Invalid(format!(                "failed to activate updated plugin cache version `{plugin_version}` while `{version}` remains active"            )));        }    }    Ok(())}fn old_plugin_version_would_stay_active(old_version: &str, new_version: &str) -> bool {    old_version == DEFAULT_PLUGIN_VERSION        || compare_plugin_versions(old_version, new_version).is_gt()}fn compare_plugin_versions(left: &str, right: &str) -> Ordering {    match (Version::parse(left), Version::parse(right)) {        (Ok(left), Ok(right)) => left.cmp(&right),        _ => left.cmp(right),    }}fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {    fs::create_dir_all(target)        .map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?;    for entry in fs::read_dir(source)        .map_err(|err| PluginStoreError::io("failed to read plugin source directory", err))?    {        let entry =            entry.map_err(|err| PluginStoreError::io("failed to enumerate plugin source", err))?;        let source_path = entry.path();        let target_path = target.join(entry.file_name());        let file_type = entry            .file_type()            .map_err(|err| PluginStoreError::io("failed to inspect plugin source entry", err))?;        if file_type.is_dir() {            copy_dir_recursive(&source_path, &target_path)?;        } else if file_type.is_file() {            fs::copy(&source_path, &target_path)                .map_err(|err| PluginStoreError::io("failed to copy plugin file", err))?;        }    }    Ok(())}#[cfg(test)]#[path = "store_tests.rs"]mod tests;