1use 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
63const 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 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 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 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}