1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::Write;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7use crate::catalog::package::{CanonicalInstallerKey, CatalogInstaller, CatalogPackage};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum CommandSource {
13 PackageLevel,
14 InstallerLevel,
15 Moniker,
16 SourceId,
17 Inferred,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Confidence {
24 High,
25 Low,
26 Unresolved,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum VersionScope {
33 All,
34 Specific(String),
35 Latest,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum UnresolvedReason {
42 NoMetadata,
43 AmbiguousMatch,
44 InferenceTooRisky,
45 VersionConflict { versions: Vec<String> },
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "state", rename_all = "snake_case")]
51pub enum ResolverResult {
52 Resolved {
53 commands: Vec<String>,
54 confidence: Confidence,
55 sources: Vec<CommandSource>,
56 version_scope: VersionScope,
57 catalog_fingerprint: String,
58 },
59 Unresolved {
60 reason: UnresolvedReason,
61 },
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65struct CanonicalFingerprintPayload {
66 package_commands: Vec<String>,
67 package_bin: Option<String>,
68 package_moniker: Option<String>,
69 installer_commands: Vec<String>,
70 installer_identity: CanonicalFingerprintInstallerIdentity,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74struct CanonicalFingerprintInstallerIdentity {
75 package_id: String,
76 url: String,
77 hash: String,
78 hash_algorithm: String,
79 installer_type: String,
80 installer_switches: Option<String>,
81 scope: Option<String>,
82 arch: String,
83 kind: String,
84 nested_kind: Option<String>,
85}
86
87pub type CatalogFingerprintError = serde_json::Error;
89
90pub fn resolve_command_exposure(
92 package: &CatalogPackage,
93 installer: &CatalogInstaller,
94) -> Result<ResolverResult, CatalogFingerprintError> {
95 let package_commands = parse_command_list(package.commands.as_deref())?;
96 let installer_commands = parse_command_list(installer.commands.as_deref())?;
97
98 if !package_commands.is_empty() {
99 let catalog_fingerprint = catalog_fingerprint(
100 &package_commands,
101 package.bin.as_deref(),
102 package.moniker.as_deref(),
103 &installer_commands,
104 &installer.canonical_key(),
105 )?;
106
107 return Ok(ResolverResult::Resolved {
108 commands: package_commands,
109 confidence: Confidence::High,
110 sources: vec![CommandSource::PackageLevel],
111 version_scope: VersionScope::Specific(package.version.to_string()),
112 catalog_fingerprint,
113 });
114 }
115
116 if !installer_commands.is_empty() {
117 let catalog_fingerprint = catalog_fingerprint(
118 &package_commands,
119 package.bin.as_deref(),
120 package.moniker.as_deref(),
121 &installer_commands,
122 &installer.canonical_key(),
123 )?;
124
125 return Ok(ResolverResult::Resolved {
126 commands: installer_commands,
127 confidence: Confidence::Low,
128 sources: vec![CommandSource::InstallerLevel],
129 version_scope: VersionScope::Specific(package.version.to_string()),
130 catalog_fingerprint,
131 });
132 }
133
134 if let Some(bin_commands) = package.bin.as_deref().map(commands_from_bin).transpose()?
135 && !bin_commands.is_empty()
136 {
137 let catalog_fingerprint = catalog_fingerprint(
138 &package_commands,
139 package.bin.as_deref(),
140 package.moniker.as_deref(),
141 &installer_commands,
142 &installer.canonical_key(),
143 )?;
144
145 return Ok(ResolverResult::Resolved {
146 commands: bin_commands,
147 confidence: Confidence::Low,
148 sources: vec![CommandSource::Inferred],
149 version_scope: VersionScope::Specific(package.version.to_string()),
150 catalog_fingerprint,
151 });
152 }
153
154 Ok(ResolverResult::Unresolved {
155 reason: UnresolvedReason::NoMetadata,
156 })
157}
158
159impl ResolverResult {
160 pub fn confidence(&self) -> Confidence {
162 match self {
163 Self::Resolved { confidence, .. } => *confidence,
164 Self::Unresolved { .. } => Confidence::Unresolved,
165 }
166 }
167}
168
169pub fn catalog_fingerprint(
171 package_commands: &[String],
172 package_bin: Option<&str>,
173 package_moniker: Option<&str>,
174 installer_commands: &[String],
175 installer_identity: &CanonicalInstallerKey,
176) -> Result<String, CatalogFingerprintError> {
177 let payload = CanonicalFingerprintPayload {
178 package_commands: normalize_command_list(package_commands),
179 package_bin: normalize_bin_json(package_bin)?,
180 package_moniker: normalize_text(package_moniker),
181 installer_commands: normalize_command_list(installer_commands),
182 installer_identity: CanonicalFingerprintInstallerIdentity::from(installer_identity),
183 };
184
185 let bytes = serde_json::to_vec(&payload)?;
186 let digest = Sha256::digest(bytes);
187
188 let mut encoded = String::with_capacity(digest.len() * 2);
189 for byte in digest.as_slice() {
190 write!(&mut encoded, "{:02x}", byte).expect("hex encoding should not fail");
191 }
192
193 Ok(format!("sha256:{encoded}"))
194}
195
196fn normalize_command_list(values: &[String]) -> Vec<String> {
197 let mut normalized = BTreeSet::new();
198
199 for value in values {
200 let value = value.trim().to_ascii_lowercase();
201 if !value.is_empty() {
202 normalized.insert(value);
203 }
204 }
205
206 normalized.into_iter().collect()
207}
208
209fn parse_command_list(raw: Option<&str>) -> Result<Vec<String>, serde_json::Error> {
210 let Some(raw) = raw else {
211 return Ok(Vec::new());
212 };
213
214 let commands: Vec<String> = serde_json::from_str(raw)?;
215 Ok(normalize_command_names(commands))
216}
217
218fn commands_from_bin(raw: &str) -> Result<Vec<String>, serde_json::Error> {
219 let parsed: serde_json::Value = serde_json::from_str(raw)?;
220 let commands = match parsed {
221 serde_json::Value::String(command) => vec![command],
222 serde_json::Value::Array(values) => values
223 .into_iter()
224 .filter_map(|value| value.as_str().map(str::to_string))
225 .collect(),
226 _ => Vec::new(),
227 };
228
229 Ok(normalize_command_names(commands.into_iter().filter_map(
230 |command| command_name_from_bin_entry(&command),
231 )))
232}
233
234fn normalize_command_names<I, S>(commands: I) -> Vec<String>
235where
236 I: IntoIterator<Item = S>,
237 S: AsRef<str>,
238{
239 let mut normalized = BTreeMap::new();
240
241 for command in commands {
242 let trimmed = command.as_ref().trim();
243 if trimmed.is_empty() {
244 continue;
245 }
246 normalized
247 .entry(trimmed.to_ascii_lowercase())
248 .or_insert_with(|| trimmed.to_string());
249 }
250
251 normalized.into_values().collect()
252}
253
254fn command_name_from_bin_entry(command: &str) -> Option<String> {
255 let trimmed = command.trim();
256 if trimmed.is_empty() {
257 return None;
258 }
259
260 let file_name = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed);
261 let command_name = match file_name.rfind('.') {
262 Some(index) if index > 0 => &file_name[..index],
263 _ => file_name,
264 }
265 .trim();
266
267 if command_name.is_empty() {
268 None
269 } else {
270 Some(command_name.to_string())
271 }
272}
273
274fn normalize_text(value: Option<&str>) -> Option<String> {
275 value
276 .map(str::trim)
277 .filter(|value| !value.is_empty())
278 .map(|value| value.to_ascii_lowercase())
279}
280
281fn normalize_bin_json(value: Option<&str>) -> Result<Option<String>, serde_json::Error> {
282 let Some(value) = value else {
283 return Ok(None);
284 };
285
286 let trimmed = value.trim();
287 if trimmed.is_empty() {
288 return Ok(None);
289 }
290
291 let parsed = serde_json::from_str::<serde_json::Value>(trimmed)?;
292 Ok(Some(serde_json::to_string(&parsed)?))
293}
294
295impl From<&CanonicalInstallerKey> for CanonicalFingerprintInstallerIdentity {
296 fn from(value: &CanonicalInstallerKey) -> Self {
297 Self {
298 package_id: value.package_id.clone(),
299 url: value.url.clone(),
300 hash: value.hash.clone(),
301 hash_algorithm: value.hash_algorithm.clone(),
302 installer_type: value.installer_type.clone(),
303 installer_switches: value.installer_switches.clone(),
304 scope: value.scope.clone(),
305 arch: value.arch.clone(),
306 kind: value.kind.clone(),
307 nested_kind: value.nested_kind.clone(),
308 }
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::{
315 CommandSource, Confidence, ResolverResult, UnresolvedReason, VersionScope,
316 catalog_fingerprint, commands_from_bin, resolve_command_exposure,
317 };
318 use crate::catalog::package::{CanonicalInstallerKey, CatalogInstaller, CatalogPackage};
319 use crate::package::PackageId;
320 use crate::shared::Version;
321
322 fn catalog_installer(package_id: crate::shared::CatalogId, url: &str) -> CatalogInstaller {
323 CatalogInstaller {
324 package_id,
325 url: url.to_string(),
326 hash: "abc123".to_string(),
327 hash_algorithm: crate::shared::HashAlgorithm::Sha256,
328 installer_type: crate::catalog::installer_type::CatalogInstallerType::Unknown,
329 installer_switches: None,
330 platform: None,
331 commands: None,
332 protocols: None,
333 file_extensions: None,
334 capabilities: None,
335 arch: crate::install::Architecture::X64,
336 kind: crate::install::InstallerType::Exe,
337 nested_kind: None,
338 scope: None,
339 }
340 }
341
342 fn catalog_package(
343 id: crate::shared::CatalogId,
344 name: &str,
345 version: Version,
346 ) -> CatalogPackage {
347 let package_id = PackageId::parse(id.as_ref()).expect("catalog id should parse");
348
349 CatalogPackage {
350 id,
351 name: name.to_string(),
352 version,
353 source: package_id.source(),
354 namespace: package_id.namespace().map(str::to_string),
355 source_id: package_id.source_id().to_string(),
356 created_at: None,
357 updated_at: None,
358 description: None,
359 homepage: None,
360 license: None,
361 publisher: None,
362 locale: None,
363 moniker: None,
364 platform: None,
365 commands: None,
366 protocols: None,
367 file_extensions: None,
368 capabilities: None,
369 tags: None,
370 bin: None,
371 env_add_path: None,
372 }
373 }
374
375 #[test]
376 fn resolves_package_commands_with_high_confidence() {
377 let mut package = catalog_package(
378 "winget/Contoso.App".into(),
379 "Contoso App",
380 Version::parse("1.2.3").expect("version should parse"),
381 );
382 package.moniker = Some("contoso".to_string());
383 let mut installer = catalog_installer(
384 "winget/Contoso.App".into(),
385 "https://example.invalid/app.exe",
386 );
387 installer.kind = crate::install::InstallerType::Exe;
388 package.commands = Some(r#"["Contoso", "contoso"]"#.to_string());
389 installer.commands = Some(r#"["Installer"]"#.to_string());
390
391 let resolved = resolve_command_exposure(&package, &installer).expect("resolve commands");
392
393 match resolved {
394 ResolverResult::Resolved {
395 commands,
396 confidence,
397 sources,
398 version_scope,
399 catalog_fingerprint,
400 } => {
401 assert_eq!(commands, vec!["Contoso".to_string()]);
402 assert_eq!(confidence, Confidence::High);
403 assert_eq!(sources, vec![CommandSource::PackageLevel]);
404 assert_eq!(version_scope, VersionScope::Specific("1.2.3".to_string()));
405 assert!(catalog_fingerprint.starts_with("sha256:"));
406 }
407 other => panic!("expected resolved commands, got {other:?}"),
408 }
409 }
410
411 #[test]
412 fn resolves_installer_commands_when_package_metadata_is_empty() {
413 let package = catalog_package(
414 "winget/Contoso.App".into(),
415 "Contoso App",
416 Version::parse("1.2.3").expect("version should parse"),
417 );
418 let mut installer = catalog_installer(
419 "winget/Contoso.App".into(),
420 "https://example.invalid/app.exe",
421 );
422 installer.kind = crate::install::InstallerType::Exe;
423 installer.commands = Some(r#"["contoso", "Contoso"]"#.to_string());
424
425 let resolved = resolve_command_exposure(&package, &installer).expect("resolve commands");
426
427 match resolved {
428 ResolverResult::Resolved {
429 commands,
430 confidence,
431 sources,
432 version_scope,
433 catalog_fingerprint,
434 } => {
435 assert_eq!(commands, vec!["contoso".to_string()]);
436 assert_eq!(confidence, Confidence::Low);
437 assert_eq!(sources, vec![CommandSource::InstallerLevel]);
438 assert_eq!(version_scope, VersionScope::Specific("1.2.3".to_string()));
439 assert!(catalog_fingerprint.starts_with("sha256:"));
440 }
441 other => panic!("expected resolved commands, got {other:?}"),
442 }
443 }
444
445 #[test]
446 fn resolves_bin_commands_when_package_and_installer_commands_are_missing() {
447 let mut package = catalog_package(
448 "scoop/main/jq".into(),
449 "jq",
450 Version::parse("1.7.1").expect("version should parse"),
451 );
452 let installer = catalog_installer("scoop/main/jq".into(), "https://example.invalid/jq.exe");
453 package.bin = Some(r#"["jq.exe", "jq2.exe"]"#.to_string());
454
455 let resolved = resolve_command_exposure(&package, &installer).expect("resolve commands");
456
457 match resolved {
458 ResolverResult::Resolved {
459 commands,
460 confidence,
461 sources,
462 version_scope,
463 catalog_fingerprint,
464 } => {
465 assert_eq!(commands, vec!["jq".to_string(), "jq2".to_string()]);
466 assert_eq!(confidence, Confidence::Low);
467 assert_eq!(sources, vec![CommandSource::Inferred]);
468 assert_eq!(version_scope, VersionScope::Specific("1.7.1".to_string()));
469 assert!(catalog_fingerprint.starts_with("sha256:"));
470 }
471 other => panic!("expected resolved commands, got {other:?}"),
472 }
473 }
474
475 #[test]
476 fn parses_bin_commands_from_json_string_or_array() {
477 assert_eq!(
478 commands_from_bin(r#""jq.exe""#).expect("bin"),
479 vec!["jq".to_string()]
480 );
481 assert_eq!(
482 commands_from_bin(r#"["jq.exe", "jq2.exe"]"#).expect("bin"),
483 vec!["jq".to_string(), "jq2".to_string(),]
484 );
485 }
486
487 #[test]
488 fn unresolved_when_no_command_metadata_exists() {
489 let mut package = catalog_package(
490 "winget/Contoso.App".into(),
491 "Contoso App",
492 Version::parse("1.2.3").expect("version should parse"),
493 );
494 package.moniker = Some("contoso".to_string());
495 let mut installer = catalog_installer(
496 "winget/Contoso.App".into(),
497 "https://example.invalid/app.exe",
498 );
499 installer.kind = crate::install::InstallerType::Exe;
500
501 let resolved = resolve_command_exposure(&package, &installer).expect("resolve commands");
502
503 assert_eq!(
504 resolved,
505 ResolverResult::Unresolved {
506 reason: UnresolvedReason::NoMetadata,
507 }
508 );
509 assert_eq!(resolved.confidence(), Confidence::Unresolved);
510 }
511
512 #[test]
513 fn resolver_result_round_trips() {
514 let result = ResolverResult::Resolved {
515 commands: vec!["alacritty".to_string()],
516 confidence: Confidence::High,
517 sources: vec![CommandSource::PackageLevel, CommandSource::InstallerLevel],
518 version_scope: VersionScope::Latest,
519 catalog_fingerprint: "sha256:deadbeef".to_string(),
520 };
521
522 let json = serde_json::to_string(&result).expect("serialize result");
523 let restored: ResolverResult = serde_json::from_str(&json).expect("deserialize result");
524
525 assert_eq!(restored, result);
526 assert_eq!(restored.confidence(), Confidence::High);
527 }
528
529 #[test]
530 fn unresolved_result_round_trips() {
531 let result = ResolverResult::Unresolved {
532 reason: UnresolvedReason::VersionConflict {
533 versions: vec!["1.0.0".to_string(), "2.0.0".to_string()],
534 },
535 };
536
537 let json = serde_json::to_string(&result).expect("serialize result");
538 let restored: ResolverResult = serde_json::from_str(&json).expect("deserialize result");
539
540 assert_eq!(restored, result);
541 assert_eq!(restored.confidence(), Confidence::Unresolved);
542 }
543
544 #[test]
545 fn catalog_fingerprint_is_stable_for_normalized_inputs() {
546 let identity = CanonicalInstallerKey {
547 package_id: "winget/Contoso.App".to_string(),
548 url: "https://example.invalid/app.exe".to_string(),
549 hash: "sha256:deadbeef".to_string(),
550 hash_algorithm: "sha256".to_string(),
551 installer_type: "portable".to_string(),
552 installer_switches: Some("/S".to_string()),
553 scope: Some("machine".to_string()),
554 arch: "x64".to_string(),
555 kind: "portable".to_string(),
556 nested_kind: None,
557 };
558
559 let first = catalog_fingerprint(
560 &["Alacritty".to_string(), "alacritty".to_string()],
561 Some(r#"["bin\\tool.exe"]"#),
562 Some("Alacritty"),
563 &["ALACRITTY".to_string()],
564 &identity,
565 )
566 .expect("fingerprint");
567
568 let second = catalog_fingerprint(
569 &["alacritty".to_string()],
570 Some(" [\n \"bin\\\\tool.exe\"\n] "),
571 Some("alacritty"),
572 &["alacritty".to_string()],
573 &identity,
574 )
575 .expect("fingerprint");
576
577 assert_eq!(first, second);
578 assert!(first.starts_with("sha256:"));
579 }
580}