Skip to main content

provenant/parsers/
vscode_extension.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for VS Code extension `.vsixmanifest` metadata files.
5//!
6//! This parser handles the already-extracted `extension.vsixmanifest` XML file
7//! found in packaged VSIX extensions. It does not unpack `.vsix` archives and it
8//! does not parse OpenVSX API responses.
9
10use std::collections::HashMap;
11use std::path::Path;
12
13use packageurl::PackageUrl;
14use quick_xml::events::{BytesStart, Event};
15use quick_xml::{Reader, XmlVersion};
16use serde_json::Value as JsonValue;
17
18use super::PackageParser;
19use super::metadata::ParserMetadata;
20use crate::models::{DatasourceId, PackageData, PackageType, Party, PartyType};
21use crate::parser_warn as warn;
22use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
23
24const PACKAGE_TYPE: PackageType = PackageType::VscodeExtension;
25const DATASOURCE_ID: DatasourceId = DatasourceId::VscodeExtensionVsixManifest;
26
27pub struct VscodeExtensionManifestParser;
28
29#[derive(Default)]
30struct VsixManifestMetadata {
31    id: Option<String>,
32    publisher: Option<String>,
33    version: Option<String>,
34    language: Option<String>,
35    target_platform: Option<String>,
36    display_name: Option<String>,
37    description: Option<String>,
38    more_info: Option<String>,
39    license_file: Option<String>,
40    release_notes: Option<String>,
41    icon: Option<String>,
42    preview_image: Option<String>,
43    tags: Option<String>,
44    categories: Option<String>,
45    extension_type: Option<String>,
46    properties: HashMap<String, String>,
47}
48
49#[derive(Clone, Copy, Eq, PartialEq)]
50enum MetadataTextElement {
51    DisplayName,
52    Description,
53    MoreInfo,
54    License,
55    ReleaseNotes,
56    Icon,
57    PreviewImage,
58    Tags,
59    Categories,
60    ExtensionType,
61}
62
63// `<Property Id="..." Value="..."/>` entries carry the marketplace-facing links
64// and engine constraints that VS Code / Open VSX manifests use instead of the
65// Visual Studio schema's top-level elements.
66const PROPERTY_SOURCE: &str = "Microsoft.VisualStudio.Services.Links.Source";
67const PROPERTY_GITHUB: &str = "Microsoft.VisualStudio.Services.Links.GitHub";
68const PROPERTY_SUPPORT: &str = "Microsoft.VisualStudio.Services.Links.Support";
69const PROPERTY_LEARN: &str = "Microsoft.VisualStudio.Services.Links.Learn";
70const PROPERTY_ENGINE: &str = "Microsoft.VisualStudio.Code.Engine";
71
72impl PackageParser for VscodeExtensionManifestParser {
73    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
74
75    fn is_match(path: &Path) -> bool {
76        path.file_name()
77            .and_then(|name| name.to_str())
78            .is_some_and(|name| name == "extension.vsixmanifest")
79    }
80
81    fn extract_packages(path: &Path) -> Vec<PackageData> {
82        let content = match read_file_to_string(path, None) {
83            Ok(content) => content,
84            Err(error) => {
85                warn!(
86                    "Failed to read extension.vsixmanifest at {:?}: {}",
87                    path, error
88                );
89                return vec![default_package_data()];
90            }
91        };
92
93        vec![parse_vscode_extension_manifest(&content, path)]
94    }
95
96    fn metadata() -> Vec<ParserMetadata> {
97        vec![ParserMetadata {
98            description: "VS Code extension VSIX manifest",
99            file_patterns: &["**/extension.vsixmanifest"],
100            package_type: "vscode-extension",
101            primary_language: "",
102            documentation_url: Some(
103                "https://learn.microsoft.com/en-us/visualstudio/extensibility/vsix-extension-schema-2-0-reference",
104            ),
105        }]
106    }
107}
108
109fn default_package_data() -> PackageData {
110    PackageData {
111        package_type: Some(PACKAGE_TYPE),
112        datasource_id: Some(DATASOURCE_ID),
113        ..Default::default()
114    }
115}
116
117fn parse_vscode_extension_manifest(content: &str, path: &Path) -> PackageData {
118    let mut reader = Reader::from_str(content);
119    reader.config_mut().trim_text(false);
120
121    let mut metadata = VsixManifestMetadata::default();
122    let mut in_metadata = false;
123    let mut current_text = None;
124    let mut buf = Vec::new();
125    let mut iteration_count = 0usize;
126
127    loop {
128        iteration_count += 1;
129        if iteration_count > MAX_ITERATION_COUNT {
130            warn!(
131                "Iteration limit exceeded in extension.vsixmanifest at {:?}; stopping at {} items",
132                path, MAX_ITERATION_COUNT
133            );
134            break;
135        }
136
137        match reader.read_event_into(&mut buf) {
138            Ok(Event::Start(element)) => {
139                let name = local_xml_name(element.name().as_ref());
140                match name.as_str() {
141                    "Metadata" => in_metadata = true,
142                    "Identity" if in_metadata => parse_identity(&element, &mut metadata),
143                    "Property" if in_metadata => parse_property(&element, &mut metadata),
144                    _ if in_metadata => {
145                        if let Some(element) = metadata_text_element(name.as_str()) {
146                            current_text = Some((element, String::new()));
147                        }
148                    }
149                    _ => {}
150                }
151            }
152            Ok(Event::Empty(element)) => {
153                if in_metadata {
154                    match local_xml_name(element.name().as_ref()).as_str() {
155                        "Identity" => parse_identity(&element, &mut metadata),
156                        "Property" => parse_property(&element, &mut metadata),
157                        _ => {}
158                    }
159                }
160            }
161            Ok(Event::Text(text)) => {
162                append_decoded_metadata_text(
163                    &mut current_text,
164                    text.decode().ok().map(|value| value.to_string()),
165                );
166            }
167            Ok(Event::CData(text)) => {
168                append_decoded_metadata_text(
169                    &mut current_text,
170                    text.decode().ok().map(|value| value.to_string()),
171                );
172            }
173            Ok(Event::End(element)) => {
174                let name = local_xml_name(element.name().as_ref());
175                if let Some((text_element, value)) = current_text.take() {
176                    if metadata_text_element(name.as_str()) == Some(text_element) {
177                        apply_metadata_text(&mut metadata, text_element, value);
178                    } else if name != "Metadata" {
179                        current_text = Some((text_element, value));
180                    }
181                }
182                if name == "Metadata" {
183                    in_metadata = false;
184                    current_text = None;
185                }
186            }
187            Ok(Event::Eof) => break,
188            Err(error) => {
189                warn!(
190                    "Error parsing extension.vsixmanifest at {:?}: {}",
191                    path, error
192                );
193                break;
194            }
195            _ => {}
196        }
197
198        buf.clear();
199    }
200
201    package_data_from_metadata(metadata)
202}
203
204fn parse_identity(element: &BytesStart, metadata: &mut VsixManifestMetadata) {
205    metadata.id = attr_value(element, b"Id").map(truncate_field);
206    metadata.publisher = attr_value(element, b"Publisher").map(truncate_field);
207    metadata.version = attr_value(element, b"Version").map(truncate_field);
208    metadata.language = attr_value(element, b"Language").map(truncate_field);
209    metadata.target_platform = attr_value(element, b"TargetPlatform")
210        .or_else(|| attr_value(element, b"targetPlatform"))
211        .map(truncate_field);
212}
213
214fn parse_property(element: &BytesStart, metadata: &mut VsixManifestMetadata) {
215    if let (Some(id), Some(value)) = (attr_value(element, b"Id"), attr_value(element, b"Value")) {
216        metadata.properties.insert(id, truncate_field(value));
217    }
218}
219
220fn attr_value(element: &BytesStart, key: &[u8]) -> Option<String> {
221    element
222        .attributes()
223        .filter_map(|attr| attr.ok())
224        .find(|attr| local_xml_name(attr.key.as_ref()).as_bytes() == key)
225        .and_then(|attr| {
226            attr.normalized_value(XmlVersion::Implicit1_0)
227                .ok()
228                .map(|value| value.into_owned())
229        })
230        .map(|value| value.trim().to_string())
231        .filter(|value| !value.is_empty())
232}
233
234fn local_xml_name(name: &[u8]) -> String {
235    let name = String::from_utf8_lossy(name);
236    name.rsplit(':').next().unwrap_or(&name).to_string()
237}
238
239fn metadata_text_element(name: &str) -> Option<MetadataTextElement> {
240    match name {
241        "DisplayName" => Some(MetadataTextElement::DisplayName),
242        "Description" => Some(MetadataTextElement::Description),
243        "MoreInfo" => Some(MetadataTextElement::MoreInfo),
244        "License" => Some(MetadataTextElement::License),
245        "ReleaseNotes" => Some(MetadataTextElement::ReleaseNotes),
246        "Icon" => Some(MetadataTextElement::Icon),
247        "PreviewImage" => Some(MetadataTextElement::PreviewImage),
248        "Tags" => Some(MetadataTextElement::Tags),
249        "Categories" => Some(MetadataTextElement::Categories),
250        "ExtensionType" => Some(MetadataTextElement::ExtensionType),
251        _ => None,
252    }
253}
254
255fn apply_metadata_text(
256    metadata: &mut VsixManifestMetadata,
257    element: MetadataTextElement,
258    value: String,
259) {
260    let value = value.trim();
261    if value.is_empty() {
262        return;
263    }
264    let value = truncate_field(value.to_string());
265    match element {
266        MetadataTextElement::DisplayName => metadata.display_name = Some(value),
267        MetadataTextElement::Description => metadata.description = Some(value),
268        MetadataTextElement::MoreInfo => metadata.more_info = Some(value),
269        MetadataTextElement::License => metadata.license_file = Some(value),
270        MetadataTextElement::ReleaseNotes => metadata.release_notes = Some(value),
271        MetadataTextElement::Icon => metadata.icon = Some(value),
272        MetadataTextElement::PreviewImage => metadata.preview_image = Some(value),
273        MetadataTextElement::Tags => metadata.tags = Some(value),
274        MetadataTextElement::Categories => metadata.categories = Some(value),
275        MetadataTextElement::ExtensionType => metadata.extension_type = Some(value),
276    }
277}
278
279fn append_decoded_metadata_text(
280    current_text: &mut Option<(MetadataTextElement, String)>,
281    value: Option<String>,
282) {
283    if let Some((_, text)) = current_text
284        && let Some(value) = value
285    {
286        text.push_str(&value);
287    }
288}
289
290fn package_data_from_metadata(mut metadata: VsixManifestMetadata) -> PackageData {
291    // VS Code / Open VSX marketplace manifests use comma-separated `<Tags>` and
292    // `<Categories>`; the Visual Studio schema uses semicolons, so accept both.
293    let keywords = metadata
294        .tags
295        .as_deref()
296        .map(parse_delimited_list)
297        .unwrap_or_default();
298
299    let categories = metadata
300        .categories
301        .as_deref()
302        .map(parse_delimited_list)
303        .unwrap_or_default();
304
305    // Marketplace links and the engine constraint live in `<Properties>` on real
306    // VS Code / Open VSX manifests, not in top-level Visual Studio schema elements.
307    let vcs_url = metadata
308        .properties
309        .remove(PROPERTY_SOURCE)
310        .or_else(|| metadata.properties.remove(PROPERTY_GITHUB));
311    let bug_tracking_url = metadata.properties.remove(PROPERTY_SUPPORT);
312    let learn_url = metadata.properties.remove(PROPERTY_LEARN);
313    let engine = metadata.properties.remove(PROPERTY_ENGINE);
314    // `<MoreInfo>` is a Visual Studio schema element; VS Code manifests instead
315    // expose the marketplace page via the `Learn` link property.
316    let homepage_url = metadata.more_info.take().or(learn_url);
317
318    let mut extra_data = HashMap::new();
319    insert_extra_string(
320        &mut extra_data,
321        "display_name",
322        metadata.display_name.clone(),
323    );
324    insert_extra_string(
325        &mut extra_data,
326        "identity_language",
327        metadata.language.clone(),
328    );
329    insert_extra_string(
330        &mut extra_data,
331        "target_platform",
332        metadata.target_platform.clone(),
333    );
334    insert_extra_string(
335        &mut extra_data,
336        "license_file",
337        metadata.license_file.clone(),
338    );
339    insert_extra_string(
340        &mut extra_data,
341        "release_notes",
342        metadata.release_notes.clone(),
343    );
344    insert_extra_string(&mut extra_data, "icon", metadata.icon.clone());
345    insert_extra_string(
346        &mut extra_data,
347        "preview_image",
348        metadata.preview_image.clone(),
349    );
350    insert_extra_string(
351        &mut extra_data,
352        "extension_type",
353        metadata.extension_type.clone(),
354    );
355    insert_extra_string(&mut extra_data, "engine", engine);
356    if !categories.is_empty() {
357        extra_data.insert(
358            "categories".to_string(),
359            JsonValue::Array(categories.into_iter().map(JsonValue::String).collect()),
360        );
361    }
362
363    let qualifiers = metadata
364        .target_platform
365        .clone()
366        .map(|target_platform| HashMap::from([("platform".to_string(), target_platform)]));
367
368    let purl = match (&metadata.publisher, &metadata.id) {
369        (Some(publisher), Some(id)) => build_vscode_extension_purl(
370            publisher,
371            id,
372            metadata.version.as_deref(),
373            metadata.target_platform.as_deref(),
374        ),
375        _ => None,
376    };
377
378    let parties = metadata
379        .publisher
380        .clone()
381        .map_or_else(Vec::new, |publisher| {
382            vec![Party {
383                r#type: Some(PartyType::Organization),
384                role: Some("publisher".to_string()),
385                name: Some(publisher),
386                email: None,
387                url: None,
388                organization: None,
389                organization_url: None,
390                timezone: None,
391            }]
392        });
393
394    PackageData {
395        package_type: Some(PACKAGE_TYPE),
396        namespace: metadata.publisher,
397        name: metadata.id,
398        version: metadata.version,
399        qualifiers,
400        description: metadata.description,
401        parties,
402        keywords,
403        homepage_url,
404        bug_tracking_url,
405        vcs_url,
406        extra_data: (!extra_data.is_empty()).then_some(extra_data),
407        datasource_id: Some(DATASOURCE_ID),
408        purl,
409        ..Default::default()
410    }
411}
412
413fn insert_extra_string(
414    extra_data: &mut HashMap<String, JsonValue>,
415    key: &str,
416    value: Option<String>,
417) {
418    if let Some(value) = value {
419        extra_data.insert(key.to_string(), JsonValue::String(value));
420    }
421}
422
423fn parse_delimited_list(value: &str) -> Vec<String> {
424    value
425        .split([',', ';'])
426        .map(str::trim)
427        .filter(|item| !item.is_empty())
428        .map(|item| truncate_field(item.to_string()))
429        .collect()
430}
431
432fn build_vscode_extension_purl(
433    publisher: &str,
434    id: &str,
435    version: Option<&str>,
436    target_platform: Option<&str>,
437) -> Option<String> {
438    let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), id).ok()?;
439    purl.with_namespace(publisher.to_string()).ok()?;
440
441    if let Some(version) = version {
442        purl.with_version(version.to_string()).ok()?;
443    }
444
445    if let Some(target_platform) = target_platform {
446        purl.add_qualifier("platform", target_platform.to_string())
447            .ok()?;
448    }
449
450    Some(truncate_field(purl.to_string()))
451}