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}