extension_manifest.rs

  1use anyhow::{Context as _, Result, bail};
  2use collections::{BTreeMap, HashMap};
  3use fs::Fs;
  4use language::LanguageName;
  5use lsp::LanguageServerName;
  6use semantic_version::SemanticVersion;
  7use serde::{Deserialize, Serialize};
  8use std::{
  9    ffi::OsStr,
 10    fmt,
 11    path::{Path, PathBuf},
 12    sync::Arc,
 13};
 14use url::Url;
 15
 16/// This is the old version of the extension manifest, from when it was `extension.json`.
 17#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
 18pub struct OldExtensionManifest {
 19    pub name: String,
 20    pub version: Arc<str>,
 21
 22    #[serde(default)]
 23    pub description: Option<String>,
 24    #[serde(default)]
 25    pub repository: Option<String>,
 26    #[serde(default)]
 27    pub authors: Vec<String>,
 28
 29    #[serde(default)]
 30    pub themes: BTreeMap<Arc<str>, PathBuf>,
 31    #[serde(default)]
 32    pub languages: BTreeMap<Arc<str>, PathBuf>,
 33    #[serde(default)]
 34    pub grammars: BTreeMap<Arc<str>, PathBuf>,
 35}
 36
 37/// The schema version of the [`ExtensionManifest`].
 38#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
 39pub struct SchemaVersion(pub i32);
 40
 41impl fmt::Display for SchemaVersion {
 42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
 43        write!(f, "{}", self.0)
 44    }
 45}
 46
 47impl SchemaVersion {
 48    pub const ZERO: Self = Self(0);
 49
 50    pub fn is_v0(&self) -> bool {
 51        self == &Self::ZERO
 52    }
 53}
 54
 55#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
 56pub struct ExtensionManifest {
 57    pub id: Arc<str>,
 58    pub name: String,
 59    pub version: Arc<str>,
 60    pub schema_version: SchemaVersion,
 61
 62    #[serde(default)]
 63    pub description: Option<String>,
 64    #[serde(default)]
 65    pub repository: Option<String>,
 66    #[serde(default)]
 67    pub authors: Vec<String>,
 68    #[serde(default)]
 69    pub lib: LibManifestEntry,
 70
 71    #[serde(default)]
 72    pub themes: Vec<PathBuf>,
 73    #[serde(default)]
 74    pub icon_themes: Vec<PathBuf>,
 75    #[serde(default)]
 76    pub languages: Vec<PathBuf>,
 77    #[serde(default)]
 78    pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
 79    #[serde(default)]
 80    pub language_servers: BTreeMap<LanguageServerName, LanguageServerManifestEntry>,
 81    #[serde(default)]
 82    pub context_servers: BTreeMap<Arc<str>, ContextServerManifestEntry>,
 83    #[serde(default)]
 84    pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
 85    #[serde(default)]
 86    pub indexed_docs_providers: BTreeMap<Arc<str>, IndexedDocsProviderEntry>,
 87    #[serde(default)]
 88    pub snippets: Option<PathBuf>,
 89    #[serde(default)]
 90    pub capabilities: Vec<ExtensionCapability>,
 91    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
 92    pub debug_adapters: BTreeMap<Arc<str>, DebugAdapterManifestEntry>,
 93    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
 94    pub debug_locators: BTreeMap<Arc<str>, DebugLocatorManifestEntry>,
 95}
 96
 97impl ExtensionManifest {
 98    pub fn allow_exec(
 99        &self,
100        desired_command: &str,
101        desired_args: &[impl AsRef<str> + std::fmt::Debug],
102    ) -> Result<()> {
103        let is_allowed = self.capabilities.iter().any(|capability| match capability {
104            ExtensionCapability::ProcessExec(capability) => {
105                capability.allows(desired_command, desired_args)
106            }
107            _ => false,
108        });
109
110        if !is_allowed {
111            bail!(
112                "capability for process:exec {desired_command} {desired_args:?} was not listed in the extension manifest",
113            );
114        }
115
116        Ok(())
117    }
118
119    pub fn allow_remote_load(&self) -> bool {
120        !self.language_servers.is_empty()
121            || !self.debug_adapters.is_empty()
122            || !self.debug_locators.is_empty()
123    }
124}
125
126pub fn build_debug_adapter_schema_path(
127    adapter_name: &Arc<str>,
128    meta: &DebugAdapterManifestEntry,
129) -> PathBuf {
130    meta.schema_path.clone().unwrap_or_else(|| {
131        Path::new("debug_adapter_schemas")
132            .join(Path::new(adapter_name.as_ref()).with_extension("json"))
133    })
134}
135
136/// A capability for an extension.
137#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
138#[serde(tag = "kind", rename_all = "snake_case")]
139pub enum ExtensionCapability {
140    #[serde(rename = "process:exec")]
141    ProcessExec(ProcessExecCapability),
142    DownloadFile(DownloadFileCapability),
143}
144
145#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub struct ProcessExecCapability {
148    /// The command to execute.
149    pub command: String,
150    /// The arguments to pass to the command. Use `*` for a single wildcard argument.
151    /// If the last element is `**`, then any trailing arguments are allowed.
152    pub args: Vec<String>,
153}
154
155impl ProcessExecCapability {
156    /// Returns whether the capability allows the given command and arguments.
157    pub fn allows(
158        &self,
159        desired_command: &str,
160        desired_args: &[impl AsRef<str> + std::fmt::Debug],
161    ) -> bool {
162        if self.command != desired_command && self.command != "*" {
163            return false;
164        }
165
166        for (ix, arg) in self.args.iter().enumerate() {
167            if arg == "**" {
168                return true;
169            }
170
171            if ix >= desired_args.len() {
172                return false;
173            }
174
175            if arg != "*" && arg != desired_args[ix].as_ref() {
176                return false;
177            }
178        }
179
180        if self.args.len() < desired_args.len() {
181            return false;
182        }
183
184        true
185    }
186}
187
188#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub struct DownloadFileCapability {
191    pub host: String,
192    pub path: Vec<String>,
193}
194
195impl DownloadFileCapability {
196    /// Returns whether the capability allows downloading a file from the given URL.
197    pub fn allows(&self, url: &Url) -> bool {
198        let Some(desired_host) = url.host_str() else {
199            return false;
200        };
201
202        let Some(desired_path) = url.path_segments() else {
203            return false;
204        };
205        let desired_path = desired_path.collect::<Vec<_>>();
206
207        if self.host != desired_host && self.host != "*" {
208            return false;
209        }
210
211        for (ix, path_segment) in self.path.iter().enumerate() {
212            if path_segment == "**" {
213                return true;
214            }
215
216            if ix >= desired_path.len() {
217                return false;
218            }
219
220            if path_segment != "*" && path_segment != desired_path[ix] {
221                return false;
222            }
223        }
224
225        if self.path.len() < desired_path.len() {
226            return false;
227        }
228
229        true
230    }
231}
232
233#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
234pub struct LibManifestEntry {
235    pub kind: Option<ExtensionLibraryKind>,
236    pub version: Option<SemanticVersion>,
237}
238
239#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
240pub enum ExtensionLibraryKind {
241    Rust,
242}
243
244#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
245pub struct GrammarManifestEntry {
246    pub repository: String,
247    #[serde(alias = "commit")]
248    pub rev: String,
249    #[serde(default)]
250    pub path: Option<String>,
251}
252
253#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
254pub struct LanguageServerManifestEntry {
255    /// Deprecated in favor of `languages`.
256    #[serde(default)]
257    language: Option<LanguageName>,
258    /// The list of languages this language server should work with.
259    #[serde(default)]
260    languages: Vec<LanguageName>,
261    #[serde(default)]
262    pub language_ids: HashMap<String, String>,
263    #[serde(default)]
264    pub code_action_kinds: Option<Vec<lsp::CodeActionKind>>,
265}
266
267impl LanguageServerManifestEntry {
268    /// Returns the list of languages for the language server.
269    ///
270    /// Prefer this over accessing the `language` or `languages` fields directly,
271    /// as we currently support both.
272    ///
273    /// We can replace this with just field access for the `languages` field once
274    /// we have removed `language`.
275    pub fn languages(&self) -> impl IntoIterator<Item = LanguageName> + '_ {
276        let language = if self.languages.is_empty() {
277            self.language.clone()
278        } else {
279            None
280        };
281        self.languages.iter().cloned().chain(language)
282    }
283}
284
285#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
286pub struct ContextServerManifestEntry {}
287
288#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
289pub struct SlashCommandManifestEntry {
290    pub description: String,
291    pub requires_argument: bool,
292}
293
294#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
295pub struct IndexedDocsProviderEntry {}
296
297#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
298pub struct DebugAdapterManifestEntry {
299    pub schema_path: Option<PathBuf>,
300}
301
302#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
303pub struct DebugLocatorManifestEntry {}
304
305impl ExtensionManifest {
306    pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
307        let extension_name = extension_dir
308            .file_name()
309            .and_then(OsStr::to_str)
310            .context("invalid extension name")?;
311
312        let mut extension_manifest_path = extension_dir.join("extension.json");
313        if fs.is_file(&extension_manifest_path).await {
314            let manifest_content = fs
315                .load(&extension_manifest_path)
316                .await
317                .with_context(|| format!("failed to load {extension_name} extension.json"))?;
318            let manifest_json = serde_json::from_str::<OldExtensionManifest>(&manifest_content)
319                .with_context(|| {
320                    format!("invalid extension.json for extension {extension_name}")
321                })?;
322
323            Ok(manifest_from_old_manifest(manifest_json, extension_name))
324        } else {
325            extension_manifest_path.set_extension("toml");
326            let manifest_content = fs
327                .load(&extension_manifest_path)
328                .await
329                .with_context(|| format!("failed to load {extension_name} extension.toml"))?;
330            toml::from_str(&manifest_content)
331                .with_context(|| format!("invalid extension.toml for extension {extension_name}"))
332        }
333    }
334}
335
336fn manifest_from_old_manifest(
337    manifest_json: OldExtensionManifest,
338    extension_id: &str,
339) -> ExtensionManifest {
340    ExtensionManifest {
341        id: extension_id.into(),
342        name: manifest_json.name,
343        version: manifest_json.version,
344        description: manifest_json.description,
345        repository: manifest_json.repository,
346        authors: manifest_json.authors,
347        schema_version: SchemaVersion::ZERO,
348        lib: Default::default(),
349        themes: {
350            let mut themes = manifest_json.themes.into_values().collect::<Vec<_>>();
351            themes.sort();
352            themes.dedup();
353            themes
354        },
355        icon_themes: Vec::new(),
356        languages: {
357            let mut languages = manifest_json.languages.into_values().collect::<Vec<_>>();
358            languages.sort();
359            languages.dedup();
360            languages
361        },
362        grammars: manifest_json
363            .grammars
364            .into_keys()
365            .map(|grammar_name| (grammar_name, Default::default()))
366            .collect(),
367        language_servers: Default::default(),
368        context_servers: BTreeMap::default(),
369        slash_commands: BTreeMap::default(),
370        indexed_docs_providers: BTreeMap::default(),
371        snippets: None,
372        capabilities: Vec::new(),
373        debug_adapters: Default::default(),
374        debug_locators: Default::default(),
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use pretty_assertions::assert_eq;
381
382    use super::*;
383
384    fn extension_manifest() -> ExtensionManifest {
385        ExtensionManifest {
386            id: "test".into(),
387            name: "Test".to_string(),
388            version: "1.0.0".into(),
389            schema_version: SchemaVersion::ZERO,
390            description: None,
391            repository: None,
392            authors: vec![],
393            lib: Default::default(),
394            themes: vec![],
395            icon_themes: vec![],
396            languages: vec![],
397            grammars: BTreeMap::default(),
398            language_servers: BTreeMap::default(),
399            context_servers: BTreeMap::default(),
400            slash_commands: BTreeMap::default(),
401            indexed_docs_providers: BTreeMap::default(),
402            snippets: None,
403            capabilities: vec![],
404            debug_adapters: Default::default(),
405            debug_locators: Default::default(),
406        }
407    }
408
409    #[test]
410    fn test_build_adapter_schema_path_with_schema_path() {
411        let adapter_name = Arc::from("my_adapter");
412        let entry = DebugAdapterManifestEntry {
413            schema_path: Some(PathBuf::from("foo/bar")),
414        };
415
416        let path = build_debug_adapter_schema_path(&adapter_name, &entry);
417        assert_eq!(path, PathBuf::from("foo/bar"));
418    }
419
420    #[test]
421    fn test_build_adapter_schema_path_without_schema_path() {
422        let adapter_name = Arc::from("my_adapter");
423        let entry = DebugAdapterManifestEntry { schema_path: None };
424
425        let path = build_debug_adapter_schema_path(&adapter_name, &entry);
426        assert_eq!(
427            path,
428            PathBuf::from("debug_adapter_schemas").join("my_adapter.json")
429        );
430    }
431
432    #[test]
433    fn test_allow_exec_exact_match() {
434        let manifest = ExtensionManifest {
435            capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
436                command: "ls".to_string(),
437                args: vec!["-la".to_string()],
438            })],
439            ..extension_manifest()
440        };
441
442        assert!(manifest.allow_exec("ls", &["-la"]).is_ok());
443        assert!(manifest.allow_exec("ls", &["-l"]).is_err());
444        assert!(manifest.allow_exec("pwd", &[] as &[&str]).is_err());
445    }
446
447    #[test]
448    fn test_allow_exec_wildcard_arg() {
449        let manifest = ExtensionManifest {
450            capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
451                command: "git".to_string(),
452                args: vec!["*".to_string()],
453            })],
454            ..extension_manifest()
455        };
456
457        assert!(manifest.allow_exec("git", &["status"]).is_ok());
458        assert!(manifest.allow_exec("git", &["commit"]).is_ok());
459        assert!(manifest.allow_exec("git", &["status", "-s"]).is_err()); // too many args
460        assert!(manifest.allow_exec("npm", &["install"]).is_err()); // wrong command
461    }
462
463    #[test]
464    fn test_allow_exec_double_wildcard() {
465        let manifest = ExtensionManifest {
466            capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
467                command: "cargo".to_string(),
468                args: vec!["test".to_string(), "**".to_string()],
469            })],
470            ..extension_manifest()
471        };
472
473        assert!(manifest.allow_exec("cargo", &["test"]).is_ok());
474        assert!(manifest.allow_exec("cargo", &["test", "--all"]).is_ok());
475        assert!(
476            manifest
477                .allow_exec("cargo", &["test", "--all", "--no-fail-fast"])
478                .is_ok()
479        );
480        assert!(manifest.allow_exec("cargo", &["build"]).is_err()); // wrong first arg
481    }
482
483    #[test]
484    fn test_allow_exec_mixed_wildcards() {
485        let manifest = ExtensionManifest {
486            capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
487                command: "docker".to_string(),
488                args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
489            })],
490            ..extension_manifest()
491        };
492
493        assert!(manifest.allow_exec("docker", &["run", "nginx"]).is_ok());
494        assert!(manifest.allow_exec("docker", &["run"]).is_err());
495        assert!(
496            manifest
497                .allow_exec("docker", &["run", "ubuntu", "bash"])
498                .is_ok()
499        );
500        assert!(
501            manifest
502                .allow_exec("docker", &["run", "alpine", "sh", "-c", "echo hello"])
503                .is_ok()
504        );
505        assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
506    }
507
508    #[test]
509    fn test_download_file_capability_allows() {
510        let capability = DownloadFileCapability {
511            host: "*".to_string(),
512            path: vec!["**".to_string()],
513        };
514        assert_eq!(
515            capability.allows(&"https://example.com/some/path".parse().unwrap()),
516            true
517        );
518
519        let capability = DownloadFileCapability {
520            host: "github.com".to_string(),
521            path: vec!["**".to_string()],
522        };
523        assert_eq!(
524            capability.allows(&"https://github.com/some-owner/some-repo".parse().unwrap()),
525            true
526        );
527        assert_eq!(
528            capability.allows(
529                &"https://fake-github.com/some-owner/some-repo"
530                    .parse()
531                    .unwrap()
532            ),
533            false
534        );
535
536        let capability = DownloadFileCapability {
537            host: "github.com".to_string(),
538            path: vec!["specific-owner".to_string(), "*".to_string()],
539        };
540        assert_eq!(
541            capability.allows(&"https://github.com/some-owner/some-repo".parse().unwrap()),
542            false
543        );
544        assert_eq!(
545            capability.allows(
546                &"https://github.com/specific-owner/some-repo"
547                    .parse()
548                    .unwrap()
549            ),
550            true
551        );
552
553        let capability = DownloadFileCapability {
554            host: "github.com".to_string(),
555            path: vec!["specific-owner".to_string(), "*".to_string()],
556        };
557        assert_eq!(
558            capability.allows(
559                &"https://github.com/some-owner/some-repo/extra"
560                    .parse()
561                    .unwrap()
562            ),
563            false
564        );
565        assert_eq!(
566            capability.allows(
567                &"https://github.com/specific-owner/some-repo/extra"
568                    .parse()
569                    .unwrap()
570            ),
571            false
572        );
573    }
574}