diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs index ff8b2d5..5c5506f 100644 --- a/dotnet/src/Devolutions.Pinget.Cli/Program.cs +++ b/dotnet/src/Devolutions.Pinget.Cli/Program.cs @@ -765,9 +765,10 @@ ctx.ParseResult.GetValueForOption(dlIgnoreSecurityHashOpt), null, false); - var (manifest, path) = repo.DownloadInstaller(request, dir); - Console.WriteLine($"Downloaded {manifest.Name} v{manifest.Version}"); - Console.WriteLine($" Path: {path}"); + var result = repo.DownloadInstaller(request, dir); + Console.WriteLine($"Downloaded {result.Manifest.Name} v{result.Manifest.Version}"); + Console.WriteLine($" Installer: {result.InstallerPath}"); + Console.WriteLine($" Manifest: {result.ManifestPath}"); }); // ── Pin commands ── diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index dbade0d..eb2077a 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -1137,9 +1137,10 @@ public void DownloadInstaller_IgnoresHashMismatchWhenRequested() IgnoreSecurityHash = true, }; - var (_, installerPath) = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads")); - Assert.True(File.Exists(installerPath)); - Assert.Equal(payload, File.ReadAllBytes(installerPath)); + var downloadResult = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads")); + Assert.True(File.Exists(downloadResult.InstallerPath)); + Assert.Equal(payload, File.ReadAllBytes(downloadResult.InstallerPath)); + Assert.True(File.Exists(downloadResult.ManifestPath)); } finally { @@ -1179,8 +1180,8 @@ public void DownloadInstaller_SendsConfiguredRequestHeaders() ManifestPath = manifestPath, }; - var (_, installerPath) = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads")); - Assert.True(File.Exists(installerPath)); + var downloadResult = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads")); + Assert.True(File.Exists(downloadResult.InstallerPath)); Assert.Equal("Bearer test-token", authorizationHeader); } finally @@ -1189,6 +1190,54 @@ public void DownloadInstaller_SendsConfiguredRequestHeaders() } } + [Fact] + public void DownloadInstaller_WritesManifestYamlAlongsideInstaller() + { + var payload = "pinget-test-payload"u8.ToArray(); + using var server = new TestHttpServer(payload); + var appRoot = TestPaths.CreateTempAppRoot(); + try + { + var manifestPath = TestPaths.WriteManifest(appRoot, $$""" + PackageIdentifier: Vendor.App + PackageVersion: 3.4.5 + PackageName: Vendor App + Publisher: Vendor + ManifestType: merged + ManifestVersion: 1.10.0 + Installers: + - Architecture: x64 + InstallerType: exe + Scope: user + InstallerLocale: en-US + InstallerUrl: {{server.Url}} + """); + + using var repo = Repository.Open(new RepositoryOptions { AppRoot = appRoot }); + var request = new InstallRequest + { + Query = new PackageQuery(), + ManifestPath = manifestPath, + IgnoreSecurityHash = true, + }; + + var result = repo.DownloadInstaller(request, Path.Combine(appRoot, "downloads")); + Assert.True(File.Exists(result.ManifestPath)); + + // Filename follows winget's `{Name}_{Version}_{Scope}_{Arch}_{Type}_{Locale}.yaml`. + Assert.EndsWith("Vendor App_3.4.5_User_X64_exe_en-US.yaml", result.ManifestPath); + + var yaml = File.ReadAllText(result.ManifestPath); + Assert.Contains("PackageIdentifier: Vendor.App", yaml); + Assert.Contains("PackageVersion: 3.4.5", yaml); + Assert.Contains("ManifestType: merged", yaml); + } + finally + { + TestPaths.DeleteAppRoot(appRoot); + } + } + [Fact] public void BuildArguments_UsesManifestSwitchesByMode() { diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index 75498c0..59f38d6 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -515,6 +515,18 @@ public record InstallResult public List Warnings { get; init; } = []; } +/// +/// Result of a download operation. Mirrors winget download by +/// returning both the installer file written to disk and a flattened YAML +/// manifest emitted next to it. +/// +public record DownloadResult +{ + public required Manifest Manifest { get; init; } + public required string InstallerPath { get; init; } + public required string ManifestPath { get; init; } +} + // Internal type for installed package tracking internal record InstalledPackage { diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 5cb854e..c245084 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -591,12 +591,34 @@ public void AddPin(string packageId, string version, string sourceId, PinType pi // ── Install / Uninstall ── - public (Manifest Manifest, string InstallerPath) DownloadInstaller(PackageQuery query, string downloadDir) + public DownloadResult DownloadInstaller(PackageQuery query, string downloadDir) => DownloadInstaller(new InstallRequest { Query = query }, downloadDir); - public (Manifest Manifest, string InstallerPath) DownloadInstaller(InstallRequest request, string downloadDir) + public DownloadResult DownloadInstaller(InstallRequest request, string downloadDir) { - var manifest = ResolveManifestForInstall(request); + var fetched = FetchInstaller(request, downloadDir); + var manifestYaml = BuildDownloadManifestYaml(fetched.Manifest, fetched.ManifestDocuments, fetched.Installer); + var manifestFileName = DeriveDownloadManifestFilename(fetched.Manifest, fetched.Installer); + var manifestPath = Path.Combine(downloadDir, manifestFileName); + File.WriteAllText(manifestPath, manifestYaml); + + return new DownloadResult + { + Manifest = fetched.Manifest, + InstallerPath = fetched.InstallerPath, + ManifestPath = manifestPath, + }; + } + + /// + /// Fetches the installer file without emitting the YAML manifest. Used by + /// the install flow, which downloads into a temp dir and only needs the + /// installer path — emitting a manifest there would litter the temp dir + /// and turn a benign YAML-write failure into an installation failure. + /// + private FetchedInstaller FetchInstaller(InstallRequest request, string downloadDir) + { + var (manifest, manifestDocuments) = ResolveManifestAndDocumentsForInstall(request); var installer = SelectInstaller(manifest.Installers, request.Query) ?? throw new InvalidOperationException("No applicable installer found for the current system"); var url = installer.Url ?? throw new InvalidOperationException("Installer has no URL"); @@ -611,7 +633,6 @@ public void AddPin(string packageId, string version, string sourceId, PinType pi var bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); File.WriteAllBytes(dest, bytes); - // Verify hash if (installer.Sha256 is not null) { var actual = Sha256Hex(bytes); @@ -623,9 +644,11 @@ public void AddPin(string packageId, string version, string sourceId, PinType pi } } - return (manifest, dest); + return new FetchedInstaller(manifest, manifestDocuments, installer, dest); } + private sealed record FetchedInstaller(Manifest Manifest, object ManifestDocuments, Installer Installer, string InstallerPath); + public InstallResult Install(PackageQuery query, bool silent) { return Install(new InstallRequest @@ -697,7 +720,7 @@ public InstallResult Install(InstallRequest request) } var tempDir = Path.Combine(Path.GetTempPath(), "pinget-install"); - var (_, installerPath) = DownloadInstaller(request, tempDir); + var installerPath = FetchInstaller(request, tempDir).InstallerPath; var installerType = (selectedInstaller.InstallerType ?? "exe").ToLowerInvariant(); var exitCode = InstallerDispatch.Execute(installerPath, installerType, request, manifest, selectedInstaller); @@ -787,13 +810,16 @@ public InstallResult Uninstall(UninstallRequest request) } private Manifest ResolveManifestForInstall(InstallRequest request) + => ResolveManifestAndDocumentsForInstall(request).Manifest; + + private (Manifest Manifest, object Documents) ResolveManifestAndDocumentsForInstall(InstallRequest request) { if (!string.IsNullOrWhiteSpace(request.ManifestPath)) - return LoadManifestFromPath(request.ManifestPath!); + return LoadManifestFromPathWithDocuments(request.ManifestPath!); var (located, _, _) = FindSingleMatch(request.Query); - var (manifest, _, _) = ManifestForMatch(located, request.Query); - return manifest; + var (manifest, documents, _) = ManifestForMatch(located, request.Query); + return (manifest, documents); } private ListMatch? FindInstalledPackageForInstall(InstallRequest request, Manifest manifest) @@ -1008,9 +1034,119 @@ private List ResolveUninstallMatches(UninstallRequest request) } private static Manifest LoadManifestFromPath(string manifestPath) + => LoadManifestFromPathWithDocuments(manifestPath).Manifest; + + private static (Manifest Manifest, object Documents) LoadManifestFromPathWithDocuments(string manifestPath) { var resolved = ResolveManifestPath(manifestPath); - return ParseYamlManifest(File.ReadAllBytes(resolved)); + var bytes = File.ReadAllBytes(resolved); + return (ParseYamlManifest(bytes), ParseYamlManifestDocuments(bytes)); + } + + /// + /// Builds the YAML manifest emitted next to the installer by + /// winget download. The structured document is the merged singleton + /// the source provided; we narrow the Installers array to the + /// resolved installer so the file describes exactly the binary written. + /// Identity fields are overridden from the typed manifest because + /// corrects them after the initial YAML + /// parse. + /// + internal static string BuildDownloadManifestYaml(Manifest manifest, object documents, Installer installer) + { + var document = StructuredOutput.CollapseManifestDocuments(documents); + + document["PackageIdentifier"] = manifest.Id; + document["PackageVersion"] = manifest.Version; + document["PackageName"] = manifest.Name; + if (!string.IsNullOrEmpty(manifest.Channel)) + document["Channel"] = manifest.Channel; + else + document.Remove("Channel"); + + var singleInstaller = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty(installer.Architecture)) singleInstaller["Architecture"] = installer.Architecture; + if (!string.IsNullOrEmpty(installer.InstallerType)) singleInstaller["InstallerType"] = installer.InstallerType; + if (!string.IsNullOrEmpty(installer.Url)) singleInstaller["InstallerUrl"] = installer.Url; + if (!string.IsNullOrEmpty(installer.Sha256)) singleInstaller["InstallerSha256"] = installer.Sha256; + if (!string.IsNullOrEmpty(installer.Scope)) singleInstaller["Scope"] = installer.Scope; + if (!string.IsNullOrEmpty(installer.Locale)) singleInstaller["InstallerLocale"] = installer.Locale; + if (!string.IsNullOrEmpty(installer.ProductCode)) singleInstaller["ProductCode"] = installer.ProductCode; + if (!string.IsNullOrEmpty(installer.ReleaseDate)) singleInstaller["ReleaseDate"] = installer.ReleaseDate; + + document["Installers"] = new List { singleInstaller }; + document["ManifestType"] = "merged"; + + return YamlEmitter.EmitDocument(document); + } + + /// + /// Computes the file name winget download uses for the YAML + /// manifest emitted next to the installer: + /// {PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml. + /// Mirrors GetInstallerDownloadOnlyFileName in winget-cli's + /// DownloadFlow. + /// + internal static string DeriveDownloadManifestFilename(Manifest manifest, Installer installer) + => DeriveDownloadFilenameStem(manifest, installer) + ".yaml"; + + internal static string DeriveDownloadFilenameStem(Manifest manifest, Installer installer) + { + var stem = new System.Text.StringBuilder(); + stem.Append(SanitizeFilenameComponent(manifest.Name)); + if (!string.IsNullOrEmpty(manifest.Version) && + !string.Equals(manifest.Version, "Unknown", StringComparison.OrdinalIgnoreCase)) + { + stem.Append('_'); + stem.Append(SanitizeFilenameComponent(manifest.Version)); + } + if (!string.IsNullOrEmpty(installer.Scope)) + { + stem.Append('_'); + stem.Append(SanitizeFilenameComponent(CapitalizeFirst(installer.Scope))); + } + + var architecture = string.IsNullOrEmpty(installer.Architecture) ? "neutral" : installer.Architecture; + stem.Append('_'); + stem.Append(SanitizeFilenameComponent(FormatArchitecture(architecture))); + + if (!string.IsNullOrEmpty(installer.InstallerType)) + { + stem.Append('_'); + stem.Append(SanitizeFilenameComponent(installer.InstallerType.ToLowerInvariant())); + } + if (!string.IsNullOrEmpty(installer.Locale)) + { + stem.Append('_'); + stem.Append(SanitizeFilenameComponent(installer.Locale)); + } + + return stem.ToString(); + } + + private static string CapitalizeFirst(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + return char.ToUpperInvariant(value[0]) + value[1..].ToLowerInvariant(); + } + + private static string FormatArchitecture(string value) => value.ToLowerInvariant() switch + { + "x64" => "X64", + "x86" => "X86", + "arm" => "Arm", + "arm64" => "Arm64", + "neutral" => "Neutral", + _ => CapitalizeFirst(value), + }; + + private static string SanitizeFilenameComponent(string value) + { + var invalid = new HashSet(Path.GetInvalidFileNameChars()); + var builder = new System.Text.StringBuilder(value.Length); + foreach (var ch in value) + builder.Append(invalid.Contains(ch) || char.IsControl(ch) ? '_' : ch); + return builder.ToString(); } private static string ResolveManifestPath(string manifestPath) @@ -1678,29 +1814,29 @@ private static bool ReadBool(IDictionary dict, string key) parser.MoveNext(); return ScalarToValue(scalar); case YamlDotNet.Core.Events.MappingStart: - { - parser.MoveNext(); - var dict = new Dictionary(); - while (parser.Current is not YamlDotNet.Core.Events.MappingEnd) { - var key = ReadYamlNode(parser); - var value = ReadYamlNode(parser); - if (key is not null) dict[key] = value; + parser.MoveNext(); + var dict = new Dictionary(); + while (parser.Current is not YamlDotNet.Core.Events.MappingEnd) + { + var key = ReadYamlNode(parser); + var value = ReadYamlNode(parser); + if (key is not null) dict[key] = value; + } + parser.MoveNext(); + return dict; } - parser.MoveNext(); - return dict; - } case YamlDotNet.Core.Events.SequenceStart: - { - parser.MoveNext(); - var list = new List(); - while (parser.Current is not YamlDotNet.Core.Events.SequenceEnd) { - list.Add(ReadYamlNode(parser)); + parser.MoveNext(); + var list = new List(); + while (parser.Current is not YamlDotNet.Core.Events.SequenceEnd) + { + list.Add(ReadYamlNode(parser)); + } + parser.MoveNext(); + return list; } - parser.MoveNext(); - return list; - } default: parser.MoveNext(); return null; diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PSObjects/PSDownloadResult.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PSObjects/PSDownloadResult.cs index 8f7526b..9f8280b 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PSObjects/PSDownloadResult.cs +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PSObjects/PSDownloadResult.cs @@ -20,6 +20,8 @@ public sealed class PSDownloadResult public required string DownloadedInstallerPath { get; init; } + public string? DownloadedManifestPath { get; init; } + public bool Succeeded() => string.Equals(Status, "Ok", StringComparison.OrdinalIgnoreCase); public string ErrorMessage() => diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PingetClient.Net48.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PingetClient.Net48.cs index 7fcfa68..08c7670 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PingetClient.Net48.cs +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PingetClient.Net48.cs @@ -183,6 +183,7 @@ public CommandResult DownloadPackage( var response = RequiredObject(_packageManager.DownloadInstallerJson(ToJsonString(request), outputDirectory)); var manifest = RequiredObject(response, "manifest"); var installerPath = RequiredString(response, "installer_path"); + var manifestPath = OptionalString(response, "manifest_path"); return new CommandResult( new PSDownloadResult { @@ -194,6 +195,7 @@ public CommandResult DownloadPackage( Version = RequiredString(manifest, "version"), DownloadDirectory = outputDirectory, DownloadedInstallerPath = installerPath, + DownloadedManifestPath = manifestPath, }, warnings); } diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PingetClient.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PingetClient.cs index 6dadfac..aee0d8b 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PingetClient.cs +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PingetClient.cs @@ -183,18 +183,19 @@ public CommandResult DownloadPackage( ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads") : downloadDirectory; - var (manifest, installerPath) = _repository.DownloadInstaller(request, outputDirectory); + var downloadResult = _repository.DownloadInstaller(request, outputDirectory); return new CommandResult( new PSDownloadResult { - Id = manifest.Id, - Name = manifest.Name, + Id = downloadResult.Manifest.Id, + Name = downloadResult.Manifest.Name, Source = request.Query.Source ?? inputObject?.Source ?? string.Empty, - CorrelationData = installerPath, + CorrelationData = downloadResult.InstallerPath, Status = "Ok", - Version = manifest.Version, + Version = downloadResult.Manifest.Version, DownloadDirectory = outputDirectory, - DownloadedInstallerPath = installerPath, + DownloadedInstallerPath = downloadResult.InstallerPath, + DownloadedManifestPath = downloadResult.ManifestPath, }, warnings); } diff --git a/rust/crates/pinget-cli/src/main.rs b/rust/crates/pinget-cli/src/main.rs index 93e01f5..0e0dd4c 100644 --- a/rust/crates/pinget-cli/src/main.rs +++ b/rust/crates/pinget-cli/src/main.rs @@ -2234,9 +2234,10 @@ fn do_download(repository: &mut Repository, request: &InstallRequest, download_d None => std::env::current_dir()?, }; - let (manifest, path) = repository.download_installer_for_request(request, &dir)?; - println!("Downloaded {} v{}", manifest.name, manifest.version); - println!(" Path: {}", path.display()); + let output = repository.download_installer_for_request(request, &dir)?; + println!("Downloaded {} v{}", output.manifest.name, output.manifest.version); + println!(" Installer: {}", output.installer_path.display()); + println!(" Manifest: {}", output.manifest_path.display()); Ok(()) } diff --git a/rust/crates/pinget-com/src/lib.rs b/rust/crates/pinget-com/src/lib.rs index 00064f6..776f7ec 100644 --- a/rust/crates/pinget-com/src/lib.rs +++ b/rust/crates/pinget-com/src/lib.rs @@ -684,11 +684,11 @@ unsafe extern "system" fn package_manager_download_installer_json( let request = install_request_from_json(&json_from_bstr(request)?)?; let download_dir = required_bstr(download_dir).map_err(|_| anyhow::anyhow!("download directory is required"))?; - let (manifest, installer_path) = - repository.download_installer_for_request(&request, Path::new(&download_dir))?; + let output = repository.download_installer_for_request(&request, Path::new(&download_dir))?; Ok(json!({ - "manifest": manifest, - "installer_path": installer_path, + "manifest": output.manifest, + "installer_path": output.installer_path, + "manifest_path": output.manifest_path, })) }) } diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index b09b975..8155514 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -469,6 +469,27 @@ impl ShowResult { } } +/// Result of a `download` operation. In addition to the resolved installer +/// file on disk, pinget mirrors `winget download` by writing a flattened YAML +/// manifest next to the installer so users get a self-contained snapshot of +/// the package version they downloaded. +#[derive(Debug, Clone, serde::Serialize)] +pub struct DownloadOutput { + pub manifest: Manifest, + pub installer_path: PathBuf, + pub manifest_path: PathBuf, +} + +/// Internal result of fetching only the installer file. Used by the install +/// flow which downloads into a temp dir and intentionally skips the manifest +/// YAML side effect. +struct FetchedInstaller { + manifest: Manifest, + manifest_documents: JsonValue, + installer: Installer, + installer_path: PathBuf, +} + #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "PascalCase")] pub struct VersionsResult { @@ -1668,7 +1689,7 @@ impl Repository { // ── Install / download ── - pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<(Manifest, PathBuf)> { + pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result { self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir) } @@ -1676,8 +1697,33 @@ impl Repository { &mut self, request: &InstallRequest, download_dir: &Path, - ) -> Result<(Manifest, PathBuf)> { - let manifest = self.resolve_manifest_for_install(request)?; + ) -> Result { + let fetched = self.fetch_installer_for_request(request, download_dir)?; + let manifest_yaml = + build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer) + .context("failed to render manifest YAML")?; + let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer); + let manifest_path = download_dir.join(&manifest_filename); + fs::write(&manifest_path, manifest_yaml.as_bytes()) + .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?; + + Ok(DownloadOutput { + manifest: fetched.manifest, + installer_path: fetched.installer_path, + manifest_path, + }) + } + + /// Fetches the installer file without emitting the YAML manifest. Used by + /// the install flow, which downloads into a temp dir and only needs the + /// installer path — emitting a manifest there would litter the temp dir + /// and turn a benign YAML-write failure into an installation failure. + fn fetch_installer_for_request( + &mut self, + request: &InstallRequest, + download_dir: &Path, + ) -> Result { + let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?; let installer = select_installer(&manifest.installers, &request.query) .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?; let url = installer @@ -1708,7 +1754,12 @@ impl Repository { return Err(error); } - Ok((manifest, dest)) + Ok(FetchedInstaller { + manifest, + manifest_documents, + installer, + installer_path: dest, + }) } pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result { @@ -1783,7 +1834,7 @@ impl Repository { } let temp_dir = std::env::temp_dir().join("pinget-install"); - let (_, installer_path) = self.download_installer_for_request(request, &temp_dir)?; + let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path; let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase(); @@ -1976,13 +2027,21 @@ impl Repository { } fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result { + self.resolve_manifest_and_documents_for_install(request) + .map(|(manifest, _)| manifest) + } + + fn resolve_manifest_and_documents_for_install( + &mut self, + request: &InstallRequest, + ) -> Result<(Manifest, JsonValue)> { if let Some(path) = &request.manifest_path { - return Self::load_manifest_from_path(path); + return Self::load_manifest_from_path_with_documents(path); } let (located, _warnings) = self.find_single_match(&request.query)?; - let (manifest, _, _cached_files) = self.manifest_for_match(&located, &request.query)?; - Ok(manifest) + let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?; + Ok((manifest, documents)) } fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> { @@ -2172,10 +2231,14 @@ impl Repository { } fn load_manifest_from_path(manifest_path: &Path) -> Result { + Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest) + } + + fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> { let resolved = resolve_manifest_path(manifest_path)?; let bytes = fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?; - parse_yaml_manifest(&bytes) + parse_yaml_manifest_bundle(&bytes) } fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec)> { @@ -5619,6 +5682,138 @@ fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool { .is_some_and(|value| value.eq_ignore_ascii_case("Unknown")) } +/// Builds the YAML manifest emitted next to the installer by `winget download`. +/// +/// `documents` is the merged singleton JSON the source provided; we narrow the +/// `Installers` array down to just the resolved installer so the file describes +/// exactly the binary that was written, mirroring native winget behavior. The +/// typed `manifest` overrides identity fields (id, version, name, channel) on +/// the JSON because callers like `manifest_for_match` correct those after the +/// initial YAML parse. +fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result { + let mut document = match documents { + JsonValue::Object(map) => map.clone(), + _ => serde_json::Map::new(), + }; + + document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone())); + document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone())); + document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone())); + if !manifest.channel.is_empty() { + document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone())); + } else { + document.remove("Channel"); + } + + // Replace the multi-installer array with the single resolved installer so + // the emitted manifest describes the file the user actually downloaded. + let mut single_installer = serde_json::Map::new(); + if let Some(arch) = installer.architecture.as_ref() { + single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone())); + } + if let Some(installer_type) = installer.installer_type.as_ref() { + single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone())); + } + if let Some(url) = installer.url.as_ref() { + single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone())); + } + if let Some(sha) = installer.sha256.as_ref() { + single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone())); + } + if let Some(scope) = installer.scope.as_ref() { + single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone())); + } + if let Some(locale) = installer.locale.as_ref() { + single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone())); + } + if let Some(product_code) = installer.product_code.as_ref() { + single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone())); + } + if let Some(release_date) = installer.release_date.as_ref() { + single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone())); + } + + document.insert( + "Installers".to_owned(), + JsonValue::Array(vec![JsonValue::Object(single_installer)]), + ); + document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned())); + + let value = JsonValue::Object(document); + serde_yaml::to_string(&value).context("failed to serialize manifest YAML") +} + +/// Computes the file name `winget download` uses for the YAML manifest +/// emitted next to the installer: +/// `{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml`. +/// Mirrors `GetInstallerDownloadOnlyFileName` in winget-cli's DownloadFlow. +fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String { + let mut filename = derive_download_filename_stem(manifest, installer); + filename.push_str(".yaml"); + filename +} + +fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String { + let mut stem = sanitize_filename_component(&manifest.name); + if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") { + stem.push('_'); + stem.push_str(&sanitize_filename_component(&manifest.version)); + } + if let Some(scope) = installer.scope.as_deref() { + stem.push('_'); + stem.push_str(&sanitize_filename_component(&capitalize_first(scope))); + } + let architecture = installer.architecture.as_deref().unwrap_or("neutral"); + stem.push('_'); + stem.push_str(&sanitize_filename_component(&format_architecture(architecture))); + + let installer_type = installer.installer_type.as_deref().unwrap_or(""); + if !installer_type.is_empty() { + stem.push('_'); + stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase())); + } + if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) { + stem.push('_'); + stem.push_str(&sanitize_filename_component(locale)); + } + + stem +} + +fn capitalize_first(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => { + let mut out = first.to_ascii_uppercase().to_string(); + out.push_str(&chars.as_str().to_ascii_lowercase()); + out + } + None => String::new(), + } +} + +fn format_architecture(value: &str) -> String { + match value.to_ascii_lowercase().as_str() { + "x64" => "X64".to_owned(), + "x86" => "X86".to_owned(), + "arm" => "Arm".to_owned(), + "arm64" => "Arm64".to_owned(), + "neutral" => "Neutral".to_owned(), + other => capitalize_first(other), + } +} + +fn sanitize_filename_component(value: &str) -> String { + value + .chars() + .map(|ch| match ch { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + ch if ch.is_control() => '_', + ch => ch, + }) + .collect() +} + fn resolve_manifest_path(manifest_path: &Path) -> Result { if manifest_path.is_file() { return Ok(manifest_path.to_path_buf()); @@ -8322,6 +8517,143 @@ mod tests { use super::*; + fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest { + Manifest { + id: id.to_owned(), + name: name.to_owned(), + version: version.to_owned(), + channel: String::new(), + publisher: Some("Acme".to_owned()), + description: Some("desc".to_owned()), + moniker: None, + package_url: None, + publisher_url: None, + publisher_support_url: None, + license: None, + license_url: None, + privacy_url: None, + author: None, + copyright: None, + copyright_url: None, + release_notes: None, + release_notes_url: None, + tags: Vec::new(), + agreements: Vec::new(), + package_dependencies: Vec::new(), + documentation: Vec::new(), + installers: Vec::new(), + require_explicit_upgrade: false, + } + } + + #[test] + fn derive_download_filename_matches_winget_pattern() { + let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify"); + let installer = Installer { + architecture: Some("arm64".to_owned()), + installer_type: Some("exe".to_owned()), + scope: Some("user".to_owned()), + locale: Some("en-US".to_owned()), + ..Installer::default() + }; + assert_eq!( + derive_download_manifest_filename(&manifest, &installer), + "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml" + ); + } + + #[test] + fn derive_download_filename_omits_optional_components() { + let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip"); + let installer = Installer { + architecture: Some("x64".to_owned()), + installer_type: Some("exe".to_owned()), + ..Installer::default() + }; + // No scope, no locale → both omitted; architecture remains PascalCase. + assert_eq!( + derive_download_manifest_filename(&manifest, &installer), + "7-Zip_26.01_X64_exe.yaml" + ); + } + + #[test] + fn derive_download_filename_skips_unknown_version() { + let manifest = sample_manifest("Test.Package", "Unknown", "Test"); + let installer = Installer { + architecture: Some("neutral".to_owned()), + installer_type: Some("msi".to_owned()), + ..Installer::default() + }; + assert_eq!( + derive_download_manifest_filename(&manifest, &installer), + "Test_Neutral_msi.yaml" + ); + } + + #[test] + fn derive_download_filename_sanitizes_illegal_chars() { + let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name"); + let installer = Installer { + architecture: Some("x64".to_owned()), + installer_type: Some("exe".to_owned()), + ..Installer::default() + }; + // `/` and `:` are illegal in Windows filenames; both must be replaced. + let filename = derive_download_manifest_filename(&manifest, &installer); + assert!(!filename.contains('/') && !filename.contains(':')); + assert!(filename.starts_with("Some_Bad_Name_1.0")); + } + + #[test] + fn build_download_manifest_yaml_overrides_identity_fields() { + let manifest = sample_manifest("App.Id", "2.0.0", "App Name"); + // Documents contain a stale PackageVersion (e.g. empty) — the typed + // manifest's corrected version must win. + let documents = serde_json::json!({ + "PackageIdentifier": "stale-id", + "PackageVersion": "", + "PackageName": "Old Name", + "Channel": "preview", + "Installers": [ + { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" }, + { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" }, + ], + }); + let installer = Installer { + architecture: Some("x64".to_owned()), + installer_type: Some("exe".to_owned()), + url: Some("https://example.com/a".to_owned()), + ..Installer::default() + }; + let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml"); + assert!(yaml.contains("PackageIdentifier: App.Id")); + assert!(yaml.contains("PackageVersion: 2.0.0")); + assert!(yaml.contains("PackageName: App Name")); + assert!(yaml.contains("ManifestType: merged")); + // Channel had a value in docs but the typed manifest's channel is empty + // → expect it stripped, since winget download manifests don't carry it. + assert!(!yaml.contains("Channel: preview")); + // Installers array narrows to the single selected installer. + assert_eq!(yaml.matches("InstallerUrl").count(), 1); + assert!(yaml.contains("https://example.com/a")); + assert!(!yaml.contains("https://example.com/b")); + } + + #[test] + fn build_download_manifest_yaml_preserves_typed_channel() { + let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name"); + manifest.channel = "beta".to_owned(); + let documents = serde_json::json!({ + "PackageIdentifier": "App.Id", + "PackageVersion": "2.0.0", + "Installers": [], + }); + let installer = Installer::default(); + let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml"); + assert!(yaml.contains("Channel: beta")); + } + fn temp_app_root(label: &str) -> PathBuf { std::env::temp_dir().join(format!( "pinget-rs-tests-{label}-{}",