Skip to main content

atomcode_core/plugin/
installer.rs

1use anyhow::{anyhow, bail, Context, Result};
2use std::path::{Component, Path, PathBuf};
3use std::process::Command;
4
5use super::manifest::{ExternalSource, GitPin, PluginEntry, PluginSource};
6use super::marketplace::sanitize_name;
7use super::paths;
8use super::state::{
9    load_installed_plugins_file, load_marketplaces_file, plugin_id, save_installed_plugins_file,
10    InstalledPluginEntry,
11};
12use super::url::validate_git_url;
13
14#[derive(Debug, Clone)]
15pub struct InstalledPluginInfo {
16    pub plugin: String,
17    pub marketplace: String,
18    pub plugin_dir: String,
19}
20
21/// Resolve an inline (relative-to-marketplace-root) source string into the
22/// canonical `plugin_dir` recorded in `installed_plugins.json`. Rejects path
23/// traversal up front.
24fn resolve_inline_dir(source: &str, mp_root_rel: &str) -> Result<String> {
25    validate_plugin_source(source)?;
26    let normalized = source.trim_start_matches("./");
27    if normalized.is_empty() {
28        Ok(mp_root_rel.to_string())
29    } else {
30        Ok(format!("{}/{}", mp_root_rel, normalized.trim_end_matches('/')))
31    }
32}
33
34/// Realize an external plugin source by cloning (url/git/github) or copying
35/// (local) into `installed/<marketplace>/<plugin>/`. Returns the relative
36/// `plugin_dir` to record in state.
37fn install_external(
38    plugin_key: &str,
39    marketplace: &str,
40    ext: &ExternalSource,
41) -> Result<String> {
42    let plugins_root = paths::plugins_root().ok_or_else(|| anyhow!("no plugin home"))?;
43    let target_rel = format!("installed/{}/{}", marketplace, plugin_key);
44    let target_abs = plugins_root.join(&target_rel);
45    if target_abs.exists() {
46        bail!(
47            "plugin install dir already exists: {}",
48            target_abs.display()
49        );
50    }
51    if let Some(parent) = target_abs.parent() {
52        std::fs::create_dir_all(parent).ok();
53    }
54
55    match ext {
56        ExternalSource::Url { url, pin } | ExternalSource::Git { url, pin } => {
57            validate_git_url(url)?;
58            git_clone_with_pin(url, &target_abs, pin)
59                .with_context(|| format!("clone {}", url))?;
60        }
61        ExternalSource::Github { repo, pin } => {
62            let url = expand_github_repo(repo)?;
63            git_clone_with_pin(&url, &target_abs, pin)
64                .with_context(|| format!("clone {}", url))?;
65        }
66        ExternalSource::Local { path } => {
67            let src = expand_local_path(path)?;
68            copy_dir_recursive(&src, &target_abs)
69                .with_context(|| format!("copy {}", src.display()))?;
70        }
71    }
72    Ok(target_rel)
73}
74
75/// Expand a `github` shorthand (`owner/name`) into the canonical clone URL.
76/// Reject anything that doesn't look like a single `owner/name` segment to
77/// avoid command injection or path traversal in the resulting URL.
78fn expand_github_repo(repo: &str) -> Result<String> {
79    let trimmed = repo.trim().trim_end_matches(".git").trim_matches('/');
80    let parts: Vec<&str> = trimmed.split('/').collect();
81    if parts.len() != 2 || parts.iter().any(|s| s.is_empty()) {
82        bail!("github repo must be in `owner/name` form, got `{}`", repo);
83    }
84    for seg in &parts {
85        if !seg
86            .chars()
87            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
88            || seg.contains("..")
89        {
90            bail!("github repo `{}` contains disallowed characters", repo);
91        }
92        // Reject leading `-`: `git clone https://github.com/-x/foo.git`
93        // (or any URL whose path component begins with `-`) lets git
94        // interpret the segment as a flag — CVE-2017-1000117 family.
95        if seg.starts_with('-') {
96            bail!("github repo `{}` segment must not start with '-'", repo);
97        }
98    }
99    Ok(format!("https://github.com/{}/{}.git", parts[0], parts[1]))
100}
101
102/// Expand a local filesystem path. `~` is expanded relative to the user's
103/// home dir; relative paths are interpreted from the current working dir.
104fn expand_local_path(path: &str) -> Result<PathBuf> {
105    let expanded = if let Some(rest) = path.strip_prefix("~/") {
106        crate::tool::real_home_dir()
107            .ok_or_else(|| anyhow!("no home dir to expand `~`"))?
108            .join(rest)
109    } else if path == "~" {
110        crate::tool::real_home_dir().ok_or_else(|| anyhow!("no home dir to expand `~`"))?
111    } else {
112        PathBuf::from(path)
113    };
114    if !expanded.exists() {
115        bail!("local plugin source does not exist: {}", expanded.display());
116    }
117    Ok(expanded)
118}
119
120fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
121    std::fs::create_dir_all(dst)?;
122    for entry in std::fs::read_dir(src)? {
123        let entry = entry?;
124        let ty = entry.file_type()?;
125        let from = entry.path();
126        let to = dst.join(entry.file_name());
127        if ty.is_dir() {
128            copy_dir_recursive(&from, &to)?;
129        } else if ty.is_symlink() {
130            // Resolve symlinks by copying the target file. Avoids leaving
131            // dangling links inside the install dir.
132            let resolved = std::fs::read_link(&from)?;
133            let abs = if resolved.is_absolute() {
134                resolved
135            } else {
136                from.parent().unwrap_or(Path::new(".")).join(resolved)
137            };
138            if abs.is_dir() {
139                copy_dir_recursive(&abs, &to)?;
140            } else {
141                std::fs::copy(&abs, &to)?;
142            }
143        } else {
144            std::fs::copy(&from, &to)?;
145        }
146    }
147    Ok(())
148}
149
150fn git_clone_with_pin(url: &str, target: &Path, pin: &GitPin) -> Result<()> {
151    let mut cmd = Command::new("git");
152    cmd.arg("clone");
153    let needs_full_history = pin.commit.is_some() || pin.tag.is_some() || pin.git_ref.is_some();
154    if !needs_full_history {
155        cmd.args(["--depth", "1"]);
156    }
157    if let Some(branch) = &pin.branch {
158        cmd.args(["--branch", branch]);
159    }
160    cmd.arg(url).arg(target);
161    let out = cmd.output().context("spawn git clone")?;
162    if !out.status.success() {
163        bail!("git clone failed: {}", String::from_utf8_lossy(&out.stderr));
164    }
165
166    // Apply commit/tag/ref pin via post-clone checkout.
167    let pin_ref = pin
168        .commit
169        .as_deref()
170        .or(pin.tag.as_deref())
171        .or(pin.git_ref.as_deref());
172    if let Some(rev) = pin_ref {
173        let out = Command::new("git")
174            .args(["checkout", "--detach", rev])
175            .current_dir(target)
176            .output()
177            .context("spawn git checkout")?;
178        if !out.status.success() {
179            bail!(
180                "git checkout {} failed: {}",
181                rev,
182                String::from_utf8_lossy(&out.stderr)
183            );
184        }
185    }
186    Ok(())
187}
188
189/// Strip surface-level differences (trailing slash, `.git` suffix, whitespace)
190/// so two URLs that point at the same repo compare equal. Case is preserved
191/// because path components are case-sensitive on most git hosts.
192fn normalize_git_url(u: &str) -> String {
193    u.trim().trim_end_matches('/').trim_end_matches(".git").to_string()
194}
195
196/// Decide whether an external source points at the same repo as the
197/// marketplace's own clone URL. Returns false whenever a `GitPin` is set,
198/// since the marketplace working tree is on its default branch and may not
199/// match the requested revision.
200fn external_matches_marketplace(ext: &ExternalSource, mp_url: &str) -> bool {
201    let (url, pin) = match ext {
202        ExternalSource::Url { url, pin } | ExternalSource::Git { url, pin } => {
203            (url.clone(), pin)
204        }
205        ExternalSource::Github { repo, pin } => match expand_github_repo(repo) {
206            Ok(u) => (u, pin),
207            Err(_) => return false,
208        },
209        ExternalSource::Local { .. } => return false,
210    };
211    if pin.branch.is_some()
212        || pin.tag.is_some()
213        || pin.commit.is_some()
214        || pin.git_ref.is_some()
215    {
216        return false;
217    }
218    normalize_git_url(&url) == normalize_git_url(mp_url)
219}
220
221/// Validate that an inline plugin source path (declared in marketplace.json)
222/// only contains plain forward components. Reject `..`, absolute paths, and
223/// any other non-`Normal` component to prevent escaping the marketplace root.
224fn validate_plugin_source(source: &str) -> Result<()> {
225    if source.is_empty() {
226        return Ok(());
227    }
228    let p = Path::new(source);
229    for comp in p.components() {
230        match comp {
231            Component::Normal(s) => {
232                let s = s.to_string_lossy();
233                if s.is_empty() || s == ".." || s.contains('\0') {
234                    bail!("plugin source path '{}' contains disallowed components", source);
235                }
236            }
237            Component::CurDir => {
238                // "./" is fine; skip.
239            }
240            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
241                bail!("plugin source path '{}' contains disallowed components", source);
242            }
243        }
244    }
245    Ok(())
246}
247
248pub fn install(plugin: &str, marketplace: &str) -> Result<InstalledPluginInfo> {
249    let mp_state = load_marketplaces_file(&paths::marketplaces_file().unwrap())?;
250    let entry = mp_state
251        .marketplaces
252        .get(marketplace)
253        .ok_or_else(|| anyhow!("marketplace `{}` not registered", marketplace))?;
254    if !entry.plugins.iter().any(|p| p == plugin) {
255        bail!("plugin `{}` not found in marketplace `{}`", plugin, marketplace);
256    }
257
258    // Resolve plugin source dir relative to marketplace root.
259    let mp_root_rel = format!("marketplaces/{}", marketplace);
260    let mp_root_abs = paths::plugins_root().unwrap().join(&mp_root_rel);
261    let manifest = super::manifest::load_marketplace_manifest(&mp_root_abs)?;
262    let plugin_entry: PluginEntry = match manifest {
263        Some(m) => m
264            .plugins
265            .into_iter()
266            .find(|p| sanitize_name(&p.name) == plugin || p.name == plugin)
267            .ok_or_else(|| anyhow!("plugin `{}` missing from manifest", plugin))?,
268        None => PluginEntry {
269            name: plugin.to_string(),
270            source: PluginSource::Inline("./".into()),
271            description: None,
272        },
273    };
274
275    // Sanitize the plugin name component of the canonical id; the marketplace
276    // is already a sanitized key (enforced in add_marketplace).
277    let plugin_key = sanitize_name(plugin);
278    if plugin_key.is_empty() {
279        bail!("plugin name `{}` sanitized to empty string", plugin);
280    }
281
282    let plugin_dir_rel = match &plugin_entry.source {
283        PluginSource::Inline(s) => resolve_inline_dir(s, &mp_root_rel)?,
284        PluginSource::External(ext) => {
285            // Dedup: if the external source resolves to the same git URL as
286            // the marketplace itself (and no pin overrides the working tree),
287            // reuse the marketplace clone instead of cloning twice.
288            if external_matches_marketplace(ext, &entry.source) {
289                mp_root_rel.clone()
290            } else {
291                install_external(&plugin_key, marketplace, ext)?
292            }
293        }
294    };
295
296    let id = plugin_id(&plugin_key, marketplace);
297    let installed_path = paths::installed_plugins_file().unwrap();
298    let mut installed = load_installed_plugins_file(&installed_path)?;
299    if installed.plugins.contains_key(&id) {
300        // Roll back any external clone that we just created so retries work.
301        if plugin_dir_rel.starts_with("installed/") {
302            let abs = paths::plugins_root().unwrap().join(&plugin_dir_rel);
303            std::fs::remove_dir_all(&abs).ok();
304        }
305        bail!("plugin `{}` already installed; uninstall first", id);
306    }
307    installed.plugins.insert(
308        id.clone(),
309        InstalledPluginEntry {
310            marketplace: marketplace.to_string(),
311            plugin: plugin_key.clone(),
312            plugin_dir: plugin_dir_rel.clone(),
313            installed_at: chrono::Utc::now().to_rfc3339(),
314        },
315    );
316    save_installed_plugins_file(&installed_path, &installed)?;
317
318    Ok(InstalledPluginInfo {
319        plugin: plugin_key,
320        marketplace: marketplace.to_string(),
321        plugin_dir: plugin_dir_rel,
322    })
323}
324
325pub fn uninstall(plugin: &str, marketplace: &str) -> Result<()> {
326    let plugin_key = sanitize_name(plugin);
327    let id = plugin_id(&plugin_key, marketplace);
328    let installed_path = paths::installed_plugins_file().unwrap();
329    let mut installed = load_installed_plugins_file(&installed_path)?;
330    let entry = installed
331        .plugins
332        .remove(&id)
333        .ok_or_else(|| anyhow!("plugin `{}` not installed", id))?;
334    save_installed_plugins_file(&installed_path, &installed)?;
335
336    // Garbage-collect external clones. `marketplaces/*` belongs to the
337    // marketplace itself and must be left intact for any sibling plugins.
338    if entry.plugin_dir.starts_with("installed/") {
339        if let Some(root) = paths::plugins_root() {
340            let abs = root.join(&entry.plugin_dir);
341            if abs.exists() {
342                std::fs::remove_dir_all(&abs).ok();
343            }
344        }
345    }
346    Ok(())
347}
348
349pub fn list_installed() -> Result<Vec<InstalledPluginInfo>> {
350    let installed = load_installed_plugins_file(&paths::installed_plugins_file().unwrap())?;
351    Ok(installed
352        .plugins
353        .into_values()
354        .map(|e| InstalledPluginInfo {
355            plugin: e.plugin,
356            marketplace: e.marketplace,
357            plugin_dir: e.plugin_dir,
358        })
359        .collect())
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::plugin::marketplace::add_marketplace;
366    use crate::plugin::test_support::isolated_home;
367    use std::path::PathBuf;
368    use std::process::Command;
369
370    fn make_repo(name: &str, manifest: Option<&str>) -> PathBuf {
371        let work = tempfile::tempdir().unwrap().keep();
372        let repo = work.join(name);
373        std::fs::create_dir_all(&repo).unwrap();
374        Command::new("git").args(["init", "-q"]).current_dir(&repo).status().unwrap();
375        Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&repo).status().unwrap();
376        Command::new("git").args(["config", "user.name", "t"]).current_dir(&repo).status().unwrap();
377        if let Some(m) = manifest {
378            std::fs::create_dir_all(repo.join(".atomcode-plugin")).unwrap();
379            std::fs::write(repo.join(".atomcode-plugin/marketplace.json"), m).unwrap();
380        }
381        std::fs::write(repo.join("README"), "x").unwrap();
382        Command::new("git").args(["add", "-A"]).current_dir(&repo).status().unwrap();
383        Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&repo).status().unwrap();
384        repo
385    }
386
387    #[test]
388    #[serial_test::serial]
389    fn install_single_plugin_fallback() {
390        let _home = isolated_home();
391        let repo = make_repo("solo", None);
392        add_marketplace(&format!("file://{}", repo.display())).unwrap();
393        let info = install("solo", "solo").unwrap();
394        assert_eq!(info.plugin_dir, "marketplaces/solo");
395    }
396
397    #[test]
398    #[serial_test::serial]
399    fn install_rejects_duplicate() {
400        let _home = isolated_home();
401        let repo = make_repo("dup", None);
402        add_marketplace(&format!("file://{}", repo.display())).unwrap();
403        install("dup", "dup").unwrap();
404        assert!(install("dup", "dup").is_err());
405    }
406
407    #[test]
408    #[serial_test::serial]
409    fn uninstall_works() {
410        let _home = isolated_home();
411        let repo = make_repo("u", None);
412        add_marketplace(&format!("file://{}", repo.display())).unwrap();
413        install("u", "u").unwrap();
414        uninstall("u", "u").unwrap();
415        assert!(list_installed().unwrap().is_empty());
416    }
417
418    #[test]
419    #[serial_test::serial]
420    fn install_with_subdir_source() {
421        let _home = isolated_home();
422        let manifest = r#"{"name":"mp","plugins":[{"name":"sub","source":"plugins/sub"}]}"#;
423        let repo = make_repo("mp", Some(manifest));
424        // Pre-populate the subdirectory so the commit includes it.
425        std::fs::create_dir_all(repo.join("plugins/sub")).unwrap();
426        std::fs::write(repo.join("plugins/sub/plugin.json"), "{}").unwrap();
427        Command::new("git").args(["add", "-A"]).current_dir(&repo).status().unwrap();
428        Command::new("git").args(["commit", "-q", "-m", "add sub"]).current_dir(&repo).status().unwrap();
429        add_marketplace(&format!("file://{}", repo.display())).unwrap();
430        let info = install("sub", "mp").unwrap();
431        assert_eq!(info.plugin_dir, "marketplaces/mp/plugins/sub");
432    }
433
434    /// B2 regression: a plugin whose `source` contains `..` must be
435    /// rejected, otherwise the resulting `plugin_dir` could escape the
436    /// marketplace root.
437    #[test]
438    #[serial_test::serial]
439    fn install_rejects_traversal_in_plugin_source() {
440        let _home = isolated_home();
441        let manifest = r#"{"name":"mp2","plugins":[{"name":"esc","source":"../../etc"}]}"#;
442        let repo = make_repo("mp2", Some(manifest));
443        add_marketplace(&format!("file://{}", repo.display())).unwrap();
444        let err = install("esc", "mp2").unwrap_err();
445        assert!(
446            err.to_string().contains("disallowed components"),
447            "expected traversal rejection, got: {}",
448            err
449        );
450    }
451
452    /// External `url` source: marketplace declares one URL but the plugin
453    /// lives in a separate repo. Installer must clone that repo into
454    /// `installed/<mp>/<plugin>/`.
455    #[test]
456    #[serial_test::serial]
457    fn install_external_url_clones_separate_repo() {
458        let _home = isolated_home();
459        // The plugin's own repo (cloned by install_external).
460        let plugin_repo = make_repo("upstream", None);
461        // Pre-create a marker file so we can verify the clone landed.
462        std::fs::write(plugin_repo.join("PLUGIN_MARKER"), "yes").unwrap();
463        Command::new("git").args(["add", "-A"]).current_dir(&plugin_repo).status().unwrap();
464        Command::new("git").args(["commit", "-q", "-m", "marker"]).current_dir(&plugin_repo).status().unwrap();
465
466        // Marketplace repo whose manifest references the plugin repo by URL.
467        let plugin_url = format!("file://{}", plugin_repo.display());
468        let manifest = format!(
469            r#"{{"name":"mp_ext","plugins":[{{"name":"ext","source":{{"source":"url","url":"{}"}}}}]}}"#,
470            plugin_url
471        );
472        let mp_repo = make_repo("mp_ext", Some(&manifest));
473        add_marketplace(&format!("file://{}", mp_repo.display())).unwrap();
474
475        let info = install("ext", "mp_ext").unwrap();
476        assert_eq!(info.plugin_dir, "installed/mp_ext/ext");
477
478        let abs = paths::plugins_root().unwrap().join(&info.plugin_dir);
479        assert!(abs.join("PLUGIN_MARKER").exists(), "external clone missing");
480
481        // uninstall must wipe the installed/* dir.
482        uninstall("ext", "mp_ext").unwrap();
483        assert!(!abs.exists(), "uninstall should remove installed/* clone");
484    }
485
486    /// External `local` source: copy a directory tree into the install dir.
487    #[test]
488    #[serial_test::serial]
489    fn install_external_local_copies_tree() {
490        let _home = isolated_home();
491        let local_src = tempfile::tempdir().unwrap().keep();
492        std::fs::create_dir_all(local_src.join("skills/x")).unwrap();
493        std::fs::write(local_src.join("skills/x/SKILL.md"), "body").unwrap();
494
495        let manifest = format!(
496            r#"{{"name":"mp_local","plugins":[{{"name":"loc","source":{{"source":"local","path":"{}"}}}}]}}"#,
497            local_src.display()
498        );
499        let mp_repo = make_repo("mp_local", Some(&manifest));
500        add_marketplace(&format!("file://{}", mp_repo.display())).unwrap();
501        let info = install("loc", "mp_local").unwrap();
502
503        let abs = paths::plugins_root().unwrap().join(&info.plugin_dir);
504        assert!(abs.join("skills/x/SKILL.md").exists(), "local copy missing");
505    }
506
507    /// Real-world ascend pattern: the marketplace.json's plugin source URL
508    /// is the same repo as the marketplace itself. Installer must reuse the
509    /// marketplace clone instead of cloning a second copy.
510    #[test]
511    #[serial_test::serial]
512    fn install_external_url_dedups_with_marketplace() {
513        let _home = isolated_home();
514        // Single repo whose manifest references its own clone URL.
515        let work = tempfile::tempdir().unwrap().keep();
516        let repo = work.join("self_ref");
517        std::fs::create_dir_all(&repo).unwrap();
518        Command::new("git").args(["init", "-q"]).current_dir(&repo).status().unwrap();
519        Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&repo).status().unwrap();
520        Command::new("git").args(["config", "user.name", "t"]).current_dir(&repo).status().unwrap();
521        std::fs::create_dir_all(repo.join(".atomcode-plugin")).unwrap();
522        let url = format!("file://{}", repo.display());
523        let manifest = format!(
524            r#"{{"name":"self_ref","plugins":[{{"name":"self_ref","source":{{"source":"url","url":"{}"}}}}]}}"#,
525            url
526        );
527        std::fs::write(repo.join(".atomcode-plugin/marketplace.json"), manifest).unwrap();
528        std::fs::write(repo.join("README"), "x").unwrap();
529        Command::new("git").args(["add", "-A"]).current_dir(&repo).status().unwrap();
530        Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&repo).status().unwrap();
531
532        add_marketplace(&url).unwrap();
533        let info = install("self_ref", "self_ref").unwrap();
534
535        // Dedup must land in marketplaces/, not installed/.
536        assert_eq!(info.plugin_dir, "marketplaces/self_ref");
537        let installed_root = paths::plugins_root().unwrap().join("installed");
538        assert!(
539            !installed_root.exists() || std::fs::read_dir(&installed_root).unwrap().next().is_none(),
540            "dedup should skip the installed/ tree entirely"
541        );
542    }
543
544    /// Same external URL but with a branch pin must NOT dedup — the
545    /// marketplace clone is on the default branch, which may differ.
546    #[test]
547    fn dedup_skipped_when_pin_set() {
548        let url = "https://example.com/r.git";
549        let mut pin = GitPin::default();
550        pin.branch = Some("dev".into());
551        let ext = ExternalSource::Url { url: url.into(), pin };
552        assert!(!external_matches_marketplace(&ext, url));
553    }
554
555    #[test]
556    fn normalize_git_url_strips_suffix_and_slash() {
557        assert_eq!(normalize_git_url("https://x/r.git"), "https://x/r");
558        assert_eq!(normalize_git_url("https://x/r/"), "https://x/r");
559        assert_eq!(normalize_git_url("https://x/r.git/"), "https://x/r");
560        assert_eq!(normalize_git_url("https://x/r"), "https://x/r");
561    }
562
563    #[test]
564    fn expand_github_repo_basic() {
565        assert_eq!(
566            expand_github_repo("anthropic/claude").unwrap(),
567            "https://github.com/anthropic/claude.git"
568        );
569        assert_eq!(
570            expand_github_repo("anthropic/claude.git").unwrap(),
571            "https://github.com/anthropic/claude.git"
572        );
573        assert!(expand_github_repo("just-name").is_err());
574        assert!(expand_github_repo("a/b/c").is_err());
575        assert!(expand_github_repo("../etc/passwd").is_err());
576        assert!(expand_github_repo("a/..").is_err());
577        assert!(expand_github_repo("$(rm -rf)/x").is_err());
578        // CVE-2017-1000117 family: `-x` would be treated as a git flag.
579        assert!(expand_github_repo("-x/repo").is_err());
580        assert!(expand_github_repo("repo/-x").is_err());
581    }
582
583    /// `Local` source must never dedup against the marketplace clone — a
584    /// local path could point anywhere on disk, so reusing the marketplace
585    /// dir would silently swap the user's intended files for the
586    /// marketplace's.
587    #[test]
588    fn dedup_skipped_for_local_source() {
589        let ext = ExternalSource::Local { path: "/tmp/x".into() };
590        assert!(!external_matches_marketplace(&ext, "/tmp/x"));
591    }
592
593    #[test]
594    fn validate_plugin_source_unit() {
595        assert!(validate_plugin_source("").is_ok());
596        assert!(validate_plugin_source("./").is_ok());
597        assert!(validate_plugin_source("plugins/foo").is_ok());
598        assert!(validate_plugin_source("./plugins/foo").is_ok());
599        assert!(validate_plugin_source("../etc").is_err());
600        assert!(validate_plugin_source("plugins/../etc").is_err());
601        assert!(validate_plugin_source("/etc/passwd").is_err());
602        assert!(validate_plugin_source("plugins/foo/../bar").is_err());
603    }
604}