From db9bfc8f4b46f0c1ea31116e513231bb5f787b24 Mon Sep 17 00:00:00 2001 From: Rafael Hinojosa Lopez Date: Thu, 14 May 2026 18:00:38 -0600 Subject: [PATCH] Add makepkg2 /uploadsource probe and XGPM flag for MSIXVC2 uploads Probe makepkg2 at upload time via 'supports uploadsource' (exit 0 = supported). When supported, pass /uploadsource XGPM to makepkg2 upload command args. - SupportsUploadSourceFlag() probes every call (no cache) with 5s timeout - Graceful fallback: old makepkg2 versions (e.g. v2.2.25) exit non-zero, flag is omitted - Uses IngestionExtensions.XgpmUploadSource constant for consistency with ClientApi - 19 probe tests + 9 adversarial tests (timeout, exit 255, stderr, directory path, etc.) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ViewModel/Msixvc2UploadViewModelTest.cs | 331 +++++++++++++++++- .../ViewModel/Msixvc2UploadViewModel.cs | 55 ++- 2 files changed, 384 insertions(+), 2 deletions(-) diff --git a/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs index 2e0e04e0..4694cd26 100644 --- a/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs +++ b/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.Extensions.Logging; @@ -105,6 +105,119 @@ public void BuildUploadArguments_NoStoreId_NotIncluded() } #endregion + #region UploadSource Probe Tests + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenMakePkg2PathEmpty() + { + _pathConfigurationProvider.MakePkg2Path = string.Empty; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag()); + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenMakePkg2NotFound() + { + _pathConfigurationProvider.MakePkg2Path = @"C:\nonexistent\makepkg2.exe"; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag()); + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsTrue_WhenProbeExitsZero() + { + // Create a temp batch file that exits 0 (simulates makepkg2 supporting /uploadsource) + string tempBat = Path.Combine(Path.GetTempPath(), "probe_test_exit0.bat"); + File.WriteAllText(tempBat, "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkg2Path = tempBat; + Assert.IsTrue(_viewModel.SupportsUploadSourceFlag()); + } + finally + { + File.Delete(tempBat); + } + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenProbeExitsNonZero() + { + string tempBat = Path.Combine(Path.GetTempPath(), "probe_test_exit1.bat"); + File.WriteAllText(tempBat, "@exit /b 1"); + try + { + _pathConfigurationProvider.MakePkg2Path = tempBat; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag()); + } + finally + { + File.Delete(tempBat); + } + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReprobesEveryCall_NoCache() + { + string tempBat = Path.Combine(Path.GetTempPath(), "probe_test_nocache.bat"); + File.WriteAllText(tempBat, "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkg2Path = tempBat; + Assert.IsTrue(_viewModel.SupportsUploadSourceFlag()); + // Replace binary in-place with one that exits 1 (simulates update) + File.WriteAllText(tempBat, "@exit /b 1"); + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag(), + "Must re-probe on every call to detect in-place binary updates"); + } + finally + { + if (File.Exists(tempBat)) File.Delete(tempBat); + } + } + + [TestMethod] + public void BuildUploadArguments_IncludesUploadSource_WhenProbeSucceeds() + { + string tempBat = Path.Combine(Path.GetTempPath(), "probe_build_args.bat"); + File.WriteAllText(tempBat, "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkg2Path = tempBat; + _viewModel.ContentPath = @"C:\game\content"; + _viewModel.BranchOrFlightDisplayName = "Branch: Main"; + _viewModel.MarketGroupName = "default"; + _viewModel.BigId = "None"; + + string args = _viewModel.BuildUploadArguments(); + + Assert.IsTrue(args.Contains("/uploadsource XGPM")); + // /uploadsource must appear before /auth + int uploadSourceIdx = args.IndexOf("/uploadsource"); + int authIdx = args.IndexOf("/auth"); + Assert.IsTrue(uploadSourceIdx < authIdx, "/uploadsource must come before /auth"); + } + finally + { + File.Delete(tempBat); + } + } + + [TestMethod] + public void BuildUploadArguments_OmitsUploadSource_WhenProbeFails() + { + _pathConfigurationProvider.MakePkg2Path = @"C:\nonexistent\makepkg2.exe"; + _viewModel.ContentPath = @"C:\game\content"; + _viewModel.BranchOrFlightDisplayName = "Branch: Main"; + _viewModel.MarketGroupName = "default"; + _viewModel.BigId = "None"; + + string args = _viewModel.BuildUploadArguments(); + + Assert.IsFalse(args.Contains("/uploadsource")); + Assert.IsTrue(args.Contains("/auth CacheableBrowser")); + } + + #endregion + #region CanUpload (via UploadPackageCommand.CanExecute) Tests [TestMethod] @@ -174,4 +287,220 @@ public void ErrorModelProvider_ReceivesCorrectValues_OnError() Assert.AreEqual(string.Empty, _errorModelProvider.Error.MainMessage); } #endregion + + #region UploadSource Adversarial Tests + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenPathIsNull() + { + _pathConfigurationProvider.MakePkg2Path = null!; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag()); + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenProcessHangs() + { + string batPath = Path.Combine(Path.GetTempPath(), $"makepkg2_hang_{Guid.NewGuid():N}.bat"); + File.WriteAllText(batPath, "@ping -n 30 127.0.0.1 > nul"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag(), + "Should return false when probe hangs (timeout)"); + } + finally + { + File.Delete(batPath); + } + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenExitCode255() + { + string batPath = Path.Combine(Path.GetTempPath(), $"makepkg2_255_{Guid.NewGuid():N}.bat"); + File.WriteAllText(batPath, "@exit /b 255"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag(), + "Exit code 255 should be treated as unsupported"); + } + finally + { + File.Delete(batPath); + } + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenProcessWritesStderr() + { + string batPath = Path.Combine(Path.GetTempPath(), $"makepkg2_stderr_{Guid.NewGuid():N}.bat"); + File.WriteAllText(batPath, "@echo ERROR: unknown command 1>&2\r\n@exit /b 1"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag(), + "Should return false despite stderr output when exit code is non-zero"); + } + finally + { + File.Delete(batPath); + } + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsTrue_WhenProcessWritesStdoutAndExitsZero() + { + string batPath = Path.Combine(Path.GetTempPath(), $"makepkg2_stdout_{Guid.NewGuid():N}.bat"); + File.WriteAllText(batPath, "@echo uploadsource is supported\r\n@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + Assert.IsTrue(_viewModel.SupportsUploadSourceFlag(), + "Should return true based on exit code, ignoring stdout content"); + } + finally + { + File.Delete(batPath); + } + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenPathIsDirectory() + { + string dirPath = Path.Combine(Path.GetTempPath(), $"makepkg2_dir_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dirPath); + try + { + _pathConfigurationProvider.MakePkg2Path = dirPath; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag(), + "Should return false when path points to a directory, not a file"); + } + finally + { + Directory.Delete(dirPath); + } + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsTrue_WhenPathHasSpaces() + { + string dirPath = Path.Combine(Path.GetTempPath(), "make pkg2 spaces"); + Directory.CreateDirectory(dirPath); + string batPath = Path.Combine(dirPath, "makepkg2.bat"); + File.WriteAllText(batPath, "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + Assert.IsTrue(_viewModel.SupportsUploadSourceFlag(), + "Should handle paths with spaces correctly"); + } + finally + { + File.Delete(batPath); + Directory.Delete(dirPath); + } + } + + [TestMethod] + public void BuildUploadArguments_UploadSourceBeforeAuth_OrderVerification() + { + string batPath = Path.Combine(Path.GetTempPath(), $"makepkg2_order_{Guid.NewGuid():N}.bat"); + File.WriteAllText(batPath, "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + _viewModel.ContentPath = @"C:\game\content"; + _viewModel.BranchOrFlightDisplayName = "Branch: Dev"; + _viewModel.MarketGroupName = "US"; + _viewModel.BigId = "9NBLGGH4R315"; + + string args = _viewModel.BuildUploadArguments(); + + int storeIdx = args.IndexOf("/storeid"); + int uploadIdx = args.IndexOf("/uploadsource XGPM"); + int authIdx = args.IndexOf("/auth CacheableBrowser"); + + Assert.IsTrue(storeIdx > 0, "Should have /storeid"); + Assert.IsTrue(uploadIdx > storeIdx, "/uploadsource should come after /storeid"); + Assert.IsTrue(authIdx > uploadIdx, "/auth should come after /uploadsource"); + Assert.IsTrue(args.EndsWith("/auth CacheableBrowser"), "/auth should be the last argument"); + } + finally + { + File.Delete(batPath); + } + } + + [TestMethod] + public void SupportsUploadSourceFlag_ReturnsFalse_WhenOldMakePkg2LacksSupportsCommand() + { + // Old makepkg2 (e.g. v2.2.25) doesn't recognize "supports" subcommand, + // prints usage to stderr and exits non-zero — probe must return false. + string batPath = Path.Combine(Path.GetTempPath(), $"makepkg2_old_{Guid.NewGuid():N}.bat"); + File.WriteAllText(batPath, + "@echo Unrecognized command or argument 'supports'. 1>&2\r\n@exit /b 1"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + Assert.IsFalse(_viewModel.SupportsUploadSourceFlag(), + "Old makepkg2 without 'supports' command must return false"); + } + finally + { + File.Delete(batPath); + } + } + + [TestMethod] + public void BuildUploadArguments_OmitsUploadSource_WhenOldMakePkg2() + { + // End-to-end: old makepkg2 → probe fails → /uploadsource NOT in args + string batPath = Path.Combine(Path.GetTempPath(), $"makepkg2_oldargs_{Guid.NewGuid():N}.bat"); + File.WriteAllText(batPath, + "@echo Unrecognized command or argument 'supports'. 1>&2\r\n@exit /b 1"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + _viewModel.ContentPath = @"C:\game\content"; + _viewModel.BranchOrFlightDisplayName = "Branch: Main"; + _viewModel.MarketGroupName = "default"; + + string args = _viewModel.BuildUploadArguments(); + + Assert.IsFalse(args.Contains("/uploadsource"), + "Old makepkg2 must NOT produce /uploadsource in upload args"); + Assert.IsTrue(args.Contains("/auth CacheableBrowser"), + "Other args must still be present"); + } + finally + { + File.Delete(batPath); + } + } + + [TestMethod] + public void BuildUploadArguments_FlightPath_IncludesUploadSource() + { + string batPath = Path.Combine(Path.GetTempPath(), $"makepkg2_flight_{Guid.NewGuid():N}.bat"); + File.WriteAllText(batPath, "@exit /b 0"); + try + { + _pathConfigurationProvider.MakePkg2Path = batPath; + _viewModel.ContentPath = @"C:\game\content"; + _viewModel.BranchOrFlightDisplayName = "Flight: Beta"; + _viewModel.MarketGroupName = "default"; + + string args = _viewModel.BuildUploadArguments(); + + Assert.IsTrue(args.Contains("/flight \"Beta\""), "Should include flight"); + Assert.IsTrue(args.Contains("/uploadsource XGPM"), "Should include /uploadsource XGPM on flight path too"); + } + finally + { + File.Delete(batPath); + } + } + + #endregion } diff --git a/src/PackageUploader.UI/ViewModel/Msixvc2UploadViewModel.cs b/src/PackageUploader.UI/ViewModel/Msixvc2UploadViewModel.cs index fd693459..b6f5f846 100644 --- a/src/PackageUploader.UI/ViewModel/Msixvc2UploadViewModel.cs +++ b/src/PackageUploader.UI/ViewModel/Msixvc2UploadViewModel.cs @@ -1,6 +1,7 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using System.IO; using System.Windows.Forms; using System.Windows.Input; @@ -623,6 +624,53 @@ private void StartPackAndUploadAsync() _windowService.NavigateTo(typeof(Msixvc2UploadingView)); } + /// + /// Probes makepkg2.exe to check if it supports the /uploadsource flag. + /// Runs: makepkg2 supports uploadsource (exit 0 = supported, non-zero = not) + /// Probed on every upload to handle in-place binary updates. + /// + internal bool SupportsUploadSourceFlag() + { + string makePkg2Path = _pathConfigurationService.MakePkg2Path; + if (string.IsNullOrEmpty(makePkg2Path)) + { + return false; + } + + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = makePkg2Path, + Arguments = "supports uploadsource", + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + CreateNoWindow = true + }; + process.Start(); + if (process.WaitForExit(5000)) + { + bool supported = process.ExitCode == 0; + _logger.LogInformation("makepkg2 /uploadsource probe: {Result} (exit code {ExitCode})", + supported ? "supported" : "not supported", process.ExitCode); + return supported; + } + else + { + try { process.Kill(); } catch { /* best effort */ } + _logger.LogWarning("makepkg2 uploadsource probe timed out after 5s."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to probe makepkg2 for /uploadsource support."); + } + + return false; + } + internal string BuildUploadArguments() { var args = $"upload /d \"{ContentPath}\" /msixvc2"; @@ -648,6 +696,11 @@ internal string BuildUploadArguments() args += $" /storeid \"{BigId}\""; } + if (SupportsUploadSourceFlag()) + { + args += $" /uploadsource {IngestionExtensions.XgpmUploadSource}"; + } + args += " /auth CacheableBrowser"; return args;