winbrew_models/
command_resolution.rs

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/// Provenance for a resolved command list.
10#[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/// Confidence assigned to a resolved command set.
21#[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/// Version scope covered by a resolved command set.
30#[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/// Why a resolver could not produce a trusted command set.
39#[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/// Resolver output for command exposure.
49#[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
87/// Error returned when a catalog fingerprint cannot be serialized.
88pub type CatalogFingerprintError = serde_json::Error;
89
90/// Resolve command exposure from catalog metadata with conservative precedence.
91pub 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    /// Return the caller-facing confidence classification.
161    pub fn confidence(&self) -> Confidence {
162        match self {
163            Self::Resolved { confidence, .. } => *confidence,
164            Self::Unresolved { .. } => Confidence::Unresolved,
165        }
166    }
167}
168
169/// Compute a stable SHA-256 catalog fingerprint for a resolved exposure decision.
170pub 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}