diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 23:01:47 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 23:01:47 +0300 |
| commit | 3015ce0f9d120df7583f838d1a47cb469fe5a727 (patch) | |
| tree | 94db93e9fa6f7b83e4eb3ab09581ce101d536cfd /src/plugin_paths.rs | |
| parent | 001a6b2fc4218d232967f285f945b313ce9ca19a (diff) | |
fix: stabilize plugin config directories
Diffstat (limited to 'src/plugin_paths.rs')
| -rw-r--r-- | src/plugin_paths.rs | 128 |
1 files changed, 128 insertions, 0 deletions
diff --git a/src/plugin_paths.rs b/src/plugin_paths.rs new file mode 100644 index 00000000..18a16a77 --- /dev/null +++ b/src/plugin_paths.rs @@ -0,0 +1,128 @@ +use std::path::{Path, PathBuf}; + +const PLUGIN_CONFIG_PATH_COMPONENT_MAX_CHARS: usize = 120; + +pub(crate) fn plugin_config_dir(plugin_id: &str) -> PathBuf { + crate::config::config_dir() + .join("plugins") + .join("config") + .join(plugin_config_path_component(plugin_id)) +} + +pub(crate) fn plugin_state_dir(plugin_id: &str) -> PathBuf { + crate::config::state_dir() + .join("plugins") + .join(plugin_config_path_component(plugin_id)) +} + +pub(crate) fn ensure_plugin_user_dirs(plugin_id: &str) -> std::io::Result<()> { + ensure_plugin_config_dir(plugin_id)?; + std::fs::create_dir_all(plugin_state_dir(plugin_id))?; + Ok(()) +} + +fn ensure_plugin_config_dir(plugin_id: &str) -> std::io::Result<()> { + let config_dir = plugin_config_dir(plugin_id); + if config_dir.exists() { + return std::fs::create_dir_all(config_dir); + } + if let Some(legacy_dir) = legacy_plugin_config_dirs(plugin_id) + .into_iter() + .find(|path| path.is_dir()) + { + copy_dir_all(&legacy_dir, &config_dir)?; + return Ok(()); + } + std::fs::create_dir_all(config_dir) +} + +fn legacy_plugin_config_dirs(plugin_id: &str) -> Vec<PathBuf> { + let plugins_dir = crate::config::config_dir().join("plugins"); + let old_unhashed = + (!matches!(plugin_id, "config" | "github")).then(|| plugins_dir.join(plugin_id)); + let current_hashed = + plugins_dir.join(crate::api::schema::plugin_managed_path_component(plugin_id)); + let mut candidates = Vec::new(); + if let Some(old_unhashed) = old_unhashed { + if old_unhashed != current_hashed { + candidates.push(old_unhashed); + } + } + candidates.push(current_hashed); + candidates +} + +fn plugin_config_path_component(value: &str) -> String { + let mut component = String::new(); + for byte in value.bytes() { + if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + { + component.push(byte as char); + } else { + use std::fmt::Write as _; + let _ = write!(component, "%{byte:02X}"); + } + } + if component.ends_with('.') { + component.pop(); + component.push_str("%2E"); + } + if component.is_empty() { + return "%plugin".to_string(); + } + if crate::api::schema::has_windows_reserved_stem_for_path_component(&component) { + component = format!("%{component}"); + } + if component.chars().count() > PLUGIN_CONFIG_PATH_COMPONENT_MAX_CHARS { + let hash = crate::api::schema::short_plugin_id_hash_for_path_component(value); + let prefix_len = PLUGIN_CONFIG_PATH_COMPONENT_MAX_CHARS - hash.len() - 1; + let prefix = component.chars().take(prefix_len).collect::<String>(); + return format!("{prefix}-{hash}"); + } + component +} + +fn copy_dir_all(source: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let file_type = entry.file_type()?; + let destination_path = destination.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_all(&entry.path(), &destination_path)?; + } else { + std::fs::copy(entry.path(), destination_path)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_config_path_component_is_readable_and_collision_free() { + assert_eq!( + plugin_config_path_component("examples.agent-telegram-notify"), + "examples.agent-telegram-notify" + ); + assert_eq!(plugin_config_path_component("example:a"), "example%3Aa"); + assert_eq!(plugin_config_path_component("Example"), "%45xample"); + assert_ne!( + plugin_config_path_component("example:a"), + plugin_config_path_component("example-a") + ); + assert_ne!( + plugin_config_path_component("Example"), + plugin_config_path_component("example") + ); + assert_ne!( + plugin_config_path_component(&"A".repeat(120)), + plugin_config_path_component(&"B".repeat(120)) + ); + assert!(plugin_config_path_component(&"A".repeat(120)).len() <= 120); + assert_eq!(plugin_config_path_component("con"), "%con"); + assert_eq!(plugin_config_path_component("example."), "example%2E"); + } +} |
