diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 20:45:50 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 20:45:59 +0300 |
| commit | d3be4f8b431587b14975d0e595e4131e3c119bdb (patch) | |
| tree | 80bbbbf6c0ab295acb67cf6bdb05cc75f435e3f5 /src/api/schema/plugins.rs | |
| parent | 54b58bb3452bd493a2881623a01371705ff806f1 (diff) | |
fix: add best-effort windows plugin support
Diffstat (limited to 'src/api/schema/plugins.rs')
| -rw-r--r-- | src/api/schema/plugins.rs | 107 |
1 files changed, 104 insertions, 3 deletions
diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs index 535733ec..be9336c8 100644 --- a/src/api/schema/plugins.rs +++ b/src/api/schema/plugins.rs @@ -106,16 +106,117 @@ pub enum PluginSourceKind { } pub(crate) fn plugin_managed_path_component(value: &str) -> String { - value + let slug = readable_plugin_path_slug(value); + let hash = short_plugin_id_hash(value); + format!("{slug}-{hash}") +} + +fn readable_plugin_path_slug(value: &str) -> String { + let mut slug = value .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { ch } else { - '_' + '-' } }) - .collect() + .collect::<String>(); + while slug.contains("--") { + slug = slug.replace("--", "-"); + } + let slug = slug + .trim_matches(|ch| matches!(ch, '-' | '_' | '.')) + .to_string(); + let slug = if slug.is_empty() { + "plugin".to_string() + } else { + slug.chars().take(80).collect() + }; + if has_windows_reserved_stem(&slug) { + slug.replace('.', "-") + } else { + slug + } +} + +fn short_plugin_id_hash(value: &str) -> String { + use sha2::{Digest, Sha256}; + + let digest = Sha256::digest(value.as_bytes()); + let mut hash = String::with_capacity(12); + for byte in &digest[..6] { + use std::fmt::Write as _; + let _ = write!(hash, "{byte:02x}"); + } + hash +} + +fn has_windows_reserved_stem(value: &str) -> bool { + let stem = value.split('.').next().unwrap_or(value); + matches!( + stem.to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_managed_path_component_is_windows_safe_and_collision_free() { + let dotdot = plugin_managed_path_component(".."); + assert_ne!(dotdot, "."); + assert_ne!(dotdot, ".."); + assert!(dotdot.starts_with("plugin-")); + assert_ne!( + plugin_managed_path_component("a:b"), + plugin_managed_path_component("a_b") + ); + assert_ne!(plugin_managed_path_component("con"), "con"); + assert!(!plugin_managed_path_component("example.").ends_with('.')); + assert!(plugin_managed_path_component("con.example").starts_with("con-example-")); + assert!(plugin_managed_path_component("aux.plugin").starts_with("aux-plugin-")); + assert!(plugin_managed_path_component("nul.x").starts_with("nul-x-")); + assert!(plugin_managed_path_component("com1.tool").starts_with("com1-tool-")); + } + + #[test] + fn plugin_managed_path_component_keeps_readable_slug() { + let component = plugin_managed_path_component("example.worktree-bootstrap"); + assert!(component.starts_with("example.worktree-bootstrap-")); + assert!(component.len() <= "example.worktree-bootstrap-".len() + 12); + } + + #[test] + fn plugin_managed_path_component_hash_distinguishes_same_slug_shape() { + assert_ne!( + plugin_managed_path_component("example:a"), + plugin_managed_path_component("example/a") + ); + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
