From 985f6fb02ea3bf38fe9e2948d611ebf058464488 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 25 Feb 2026 18:56:25 +0000 Subject: [PATCH 1/5] Add JDK installer with discovery, download, verification, and extraction Implements JdkInstaller class for automated JDK installation: - JDK discovery via Microsoft OpenJDK release API (supports JDK 21) - Platform-aware download (Windows .zip/.msi, macOS .tar.gz/.pkg, Linux .tar.gz) - SHA-256 checksum verification with Zip Slip protection - Elevated install mode: uses native installers (.msi/.pkg) when running as admin/root - Non-elevated mode: extracts archives to user-writable paths - Atomic extraction with rollback on failure - Cancellation support throughout the pipeline - Progress reporting via IProgress Models organized in Models/Jdk/ folder: - JdkVersionInfo, JdkReleaseInfo, JdkPlatformAsset - JdkInstallProgress (record type for progress reporting) Includes comprehensive tests for discovery, download, extraction, and error handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 13 + .../DownloadUtils.cs | 131 +++++ .../IsExternalInit.cs | 8 + .../JdkInstaller.cs | 501 ++++++++++++++++++ .../Models/Jdk/JdkInstallPhase.cs | 17 + .../Models/Jdk/JdkInstallProgress.cs | 10 + .../Models/Jdk/JdkVersionInfo.cs | 44 ++ .../Xamarin.Android.Tools.AndroidSdk.csproj | 5 +- .../JdkInstallerTests.cs | 263 +++++++++ 9 files changed, 990 insertions(+), 2 deletions(-) create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/IsExternalInit.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallPhase.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallProgress.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkVersionInfo.cs create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkInstallerTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f972e466..5a936e49 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,8 +24,21 @@ dotnet test tests/Microsoft.Android.Build.BaseTasks-Tests/Microsoft.Android.Buil Output: `bin\$(Configuration)\` (redistributables), `bin\Test$(Configuration)\` (tests). `$(DotNetTargetFrameworkVersion)` = `10.0` in `Directory.Build.props`. Versioning: `nuget.version` has `major.minor`; patch = git commit count since file changed. +## Android Environment Variables + +Per the [official Android docs](https://developer.android.com/tools/variables#envar): + +- **`ANDROID_HOME`** — the canonical variable for the Android SDK root path. Use this everywhere. +- **`ANDROID_SDK_ROOT`** — **deprecated**. Do not introduce new usages. Existing code may still read it for backward compatibility but always prefer `ANDROID_HOME`. +- **`ANDROID_USER_HOME`** — user-level config/AVD storage (defaults to `~/.android`). +- **`ANDROID_EMULATOR_HOME`** — emulator config (defaults to `$ANDROID_USER_HOME`). +- **`ANDROID_AVD_HOME`** — AVD data (defaults to `$ANDROID_USER_HOME/avd`). + +When setting environment variables for SDK tools (e.g. `sdkmanager`, `avdmanager`), set `ANDROID_HOME`. The `EnvironmentVariableNames` class in this repo defines the constants. + ## Conventions +- **One type per file**: each public class, struct, enum, or interface must be in its own `.cs` file named after the type (e.g. `JdkVersionInfo` → `JdkVersionInfo.cs`). Do not combine multiple top-level types in a single file. - [Mono Coding Guidelines](http://www.mono-project.com/community/contributing/coding-guidelines/): tabs, K&R braces, `PascalCase` public members. - Nullable enabled in `AndroidSdk`. `NullableAttributes.cs` excluded on `net10.0+`. - Strong-named via `product.snk`. In the AndroidSdk project, tests use `InternalsVisibleTo` with full public key (`Properties/AssemblyInfo.cs`). diff --git a/src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs new file mode 100644 index 00000000..64c4469d --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools +{ + /// + /// Shared helpers for downloading files, verifying checksums, and extracting archives. + /// + static class DownloadUtils + { + /// Downloads a file from the given URL with optional progress reporting. + public static async Task DownloadFileAsync (HttpClient client, string url, string destinationPath, long expectedSize, IProgress<(double percent, string message)>? progress, CancellationToken cancellationToken) + { + using var response = await client.GetAsync (url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait (false); + response.EnsureSuccessStatusCode (); + + var totalBytes = response.Content.Headers.ContentLength ?? expectedSize; + + using var contentStream = await response.Content.ReadAsStreamAsync ().ConfigureAwait (false); + + var dirPath = Path.GetDirectoryName (destinationPath); + if (!string.IsNullOrEmpty (dirPath)) + Directory.CreateDirectory (dirPath); + + using var fileStream = new FileStream (destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); + + var buffer = new byte [81920]; + long totalRead = 0; + int bytesRead; + + while ((bytesRead = await contentStream.ReadAsync (buffer, 0, buffer.Length, cancellationToken).ConfigureAwait (false)) > 0) { + await fileStream.WriteAsync (buffer, 0, bytesRead, cancellationToken).ConfigureAwait (false); + totalRead += bytesRead; + + if (progress is not null && totalBytes > 0) { + var pct = (double) totalRead / totalBytes * 100; + progress.Report ((pct, $"Downloaded {totalRead / (1024 * 1024)} MB / {totalBytes / (1024 * 1024)} MB")); + } + } + } + + /// Verifies a file's SHA-256 hash against an expected value. + public static void VerifyChecksum (string filePath, string expectedChecksum) + { + using var sha256 = SHA256.Create (); + using var stream = File.OpenRead (filePath); + + var hash = sha256.ComputeHash (stream); + var actual = BitConverter.ToString (hash).Replace ("-", "").ToLowerInvariant (); + + if (!string.Equals (actual, expectedChecksum, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException ($"Checksum verification failed. Expected: {expectedChecksum}, Actual: {actual}"); + } + + /// Extracts a ZIP archive with Zip Slip protection. + public static void ExtractZipSafe (string archivePath, string destinationPath, CancellationToken cancellationToken) + { + using var archive = ZipFile.OpenRead (archivePath); + var fullExtractRoot = Path.GetFullPath (destinationPath); + + foreach (var entry in archive.Entries) { + cancellationToken.ThrowIfCancellationRequested (); + + if (string.IsNullOrEmpty (entry.Name)) + continue; + + var destinationFile = Path.GetFullPath (Path.Combine (fullExtractRoot, entry.FullName)); + + // Zip Slip protection + if (!destinationFile.StartsWith (fullExtractRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + destinationFile != fullExtractRoot) { + throw new InvalidOperationException ($"Archive entry '{entry.FullName}' would extract outside target directory."); + } + + var entryDir = Path.GetDirectoryName (destinationFile); + if (!string.IsNullOrEmpty (entryDir)) + Directory.CreateDirectory (entryDir); + + entry.ExtractToFile (destinationFile, overwrite: true); + } + } + + /// Extracts a tar.gz archive using the system tar command. + public static async Task ExtractTarGzAsync (string archivePath, string destinationPath, Action logger, CancellationToken cancellationToken) + { + var psi = new ProcessStartInfo { + FileName = "/usr/bin/tar", + UseShellExecute = false, + CreateNoWindow = true, + }; +#if NET5_0_OR_GREATER + psi.ArgumentList.Add ("-xzf"); + psi.ArgumentList.Add (archivePath); + psi.ArgumentList.Add ("-C"); + psi.ArgumentList.Add (destinationPath); +#else + psi.Arguments = $"-xzf \"{archivePath}\" -C \"{destinationPath}\""; +#endif + + var stdout = new StringWriter (); + var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, stdout: stdout, stderr: stderr, cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) { + var errorOutput = stderr.ToString (); + logger (TraceLevel.Error, $"tar extraction failed (exit code {exitCode}): {errorOutput}"); + throw new IOException ($"Failed to extract archive '{archivePath}': {errorOutput}"); + } + } + + /// Parses "hash filename" or just "hash" from .sha256sum.txt content. + public static string? ParseChecksumFile (string content) + { + if (string.IsNullOrWhiteSpace (content)) + return null; + + var line = content.Trim ().Split ('\n') [0].Trim (); + var parts = line.Split (new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 0 ? parts [0] : null; + } + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/IsExternalInit.cs b/src/Xamarin.Android.Tools.AndroidSdk/IsExternalInit.cs new file mode 100644 index 00000000..4f2414e6 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/IsExternalInit.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Runtime.CompilerServices +{ + // Polyfill for netstandard2.0 to support C# 9+ records and init-only properties. + internal static class IsExternalInit { } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs new file mode 100644 index 00000000..d29cdca1 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs @@ -0,0 +1,501 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +#if NET5_0_OR_GREATER +using System.Security.Principal; +#endif +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools +{ + /// + /// Provides JDK installation capabilities using the Microsoft Build of OpenJDK. + /// Downloads from https://aka.ms/download-jdk with SHA-256 verification. + /// See https://www.microsoft.com/openjdk for more information. + /// + public class JdkInstaller : IDisposable + { + const string DownloadUrlBase = "https://aka.ms/download-jdk"; + + /// Gets the recommended JDK major version for .NET Android development. + public static int RecommendedMajorVersion => 21; + + /// Gets the supported JDK major versions available for installation. + public static IReadOnlyList SupportedVersions { get; } = [ RecommendedMajorVersion ]; + + readonly HttpClient httpClient = new(); + readonly Action logger; + + public JdkInstaller (Action? logger = null) + { + this.logger = logger ?? AndroidSdkInfo.DefaultConsoleLogger; + } + + public void Dispose () => httpClient.Dispose (); + + /// Discovers available Microsoft OpenJDK versions for the current platform. + public async Task> DiscoverAsync (CancellationToken cancellationToken = default) + { + var results = new List (); + + foreach (var version in SupportedVersions) { + cancellationToken.ThrowIfCancellationRequested (); + try { + var info = BuildVersionInfo (version); + + // Verify the download URL is valid with a HEAD request + using var request = new HttpRequestMessage (HttpMethod.Head, info.DownloadUrl); + using var response = await httpClient.SendAsync (request, cancellationToken).ConfigureAwait (false); + + if (!response.IsSuccessStatusCode) { + logger (TraceLevel.Warning, $"JDK {version} not available: HEAD {info.DownloadUrl} returned {(int) response.StatusCode}"); + continue; + } + + if (response.Content.Headers.ContentLength.HasValue) + info.Size = response.Content.Headers.ContentLength.Value; + + if (response.RequestMessage?.RequestUri is not null) + info.ResolvedUrl = response.RequestMessage.RequestUri.ToString (); + + info.Checksum = await FetchChecksumAsync (info.ChecksumUrl, $"JDK {version}", cancellationToken).ConfigureAwait (false); + + results.Add (info); + logger (TraceLevel.Info, $"Discovered {info.DisplayName} (size={info.Size})"); + } + catch (Exception ex) { + logger (TraceLevel.Warning, $"Failed to discover JDK {version}: {ex.Message}"); + logger (TraceLevel.Verbose, ex.ToString ()); + } + } + + return results.AsReadOnly (); + } + + /// Downloads and installs a Microsoft OpenJDK to the target path. + public async Task InstallAsync (int majorVersion, string targetPath, IProgress? progress = null, CancellationToken cancellationToken = default) + { + if (!SupportedVersions.Contains (majorVersion)) + throw new ArgumentException ($"JDK version {majorVersion} is not supported. Supported versions: {string.Join (", ", SupportedVersions)}", nameof (majorVersion)); + + if (string.IsNullOrEmpty (targetPath)) + throw new ArgumentNullException (nameof (targetPath)); + + // When elevated and a platform installer is available, use it and let the installer handle paths + if (IsElevated () && GetInstallerExtension () is not null) { + logger (TraceLevel.Info, "Running elevated — using platform installer (.msi/.pkg)."); + await InstallWithPlatformInstallerAsync (majorVersion, progress, cancellationToken).ConfigureAwait (false); + return; + } + + if (!IsTargetPathWritable (targetPath)) { + logger (TraceLevel.Error, $"Target path '{targetPath}' is not writable or is in a restricted location."); + throw new ArgumentException ($"Target path '{targetPath}' is not writable or is in a restricted location.", nameof (targetPath)); + } + + var versionInfo = BuildVersionInfo (majorVersion); + + // Fetch checksum - required for supply-chain integrity + var checksum = await FetchChecksumAsync (versionInfo.ChecksumUrl, $"JDK {majorVersion}", cancellationToken).ConfigureAwait (false); + if (string.IsNullOrEmpty (checksum)) + throw new InvalidOperationException ($"Failed to fetch SHA-256 checksum for JDK {majorVersion}. Cannot verify download integrity."); + versionInfo.Checksum = checksum; + + var tempArchivePath = Path.Combine (Path.GetTempPath (), $"microsoft-jdk-{majorVersion}-{Guid.NewGuid ()}{GetArchiveExtension ()}"); + + try { + // Download + logger (TraceLevel.Info, $"Downloading Microsoft OpenJDK {majorVersion} from {versionInfo.DownloadUrl}"); + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, 0, $"Downloading Microsoft OpenJDK {majorVersion}...")); + await DownloadUtils.DownloadFileAsync (httpClient, versionInfo.DownloadUrl, tempArchivePath, versionInfo.Size, + progress is null ? null : new Progress<(double pct, string msg)> (p => progress.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, p.pct, p.msg))), + cancellationToken).ConfigureAwait (false); + logger (TraceLevel.Info, $"Download complete: {tempArchivePath}"); + + // Verify checksum + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 0, "Verifying SHA-256 checksum...")); + DownloadUtils.VerifyChecksum (tempArchivePath, versionInfo.Checksum!); + logger (TraceLevel.Info, "Checksum verified."); + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 100, "Checksum verified.")); + + // Extract + logger (TraceLevel.Info, $"Extracting JDK to {targetPath}"); + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Extracting, 0, "Extracting JDK...")); + await ExtractArchiveAsync (tempArchivePath, targetPath, cancellationToken).ConfigureAwait (false); + logger (TraceLevel.Info, "Extraction complete."); + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Extracting, 100, "Extraction complete.")); + + // Validate + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Validating, 0, "Validating installation...")); + if (!IsValid (targetPath)) { + logger (TraceLevel.Error, $"JDK installation at '{targetPath}' failed validation."); + TryDeleteDirectory (targetPath, "invalid installation"); + throw new InvalidOperationException ($"JDK installation at '{targetPath}' failed validation. The extracted files may be corrupted."); + } + logger (TraceLevel.Info, $"Microsoft OpenJDK {majorVersion} installed successfully at {targetPath}"); + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Validating, 100, "Validation complete.")); + + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Complete, 100, $"Microsoft OpenJDK {majorVersion} installed successfully.")); + } + catch (Exception ex) when (!(ex is ArgumentException) && !(ex is ArgumentNullException)) { + logger (TraceLevel.Error, $"JDK installation failed: {ex.Message}"); + logger (TraceLevel.Verbose, ex.ToString ()); + throw; + } + finally { + TryDeleteFile (tempArchivePath); + } + } + + /// Validates whether the path contains a valid JDK installation. + public bool IsValid (string jdkPath) + { + if (string.IsNullOrEmpty (jdkPath) || !Directory.Exists (jdkPath)) + return false; + + try { + var jdk = new JdkInfo (jdkPath, logger: logger); + return jdk.Version is not null; + } + catch (Exception ex) { + logger (TraceLevel.Warning, $"JDK validation failed for '{jdkPath}': {ex.Message}"); + logger (TraceLevel.Verbose, ex.ToString ()); + return false; + } + } + + async Task InstallWithPlatformInstallerAsync (int majorVersion, IProgress? progress, CancellationToken cancellationToken) + { + var installerExt = GetInstallerExtension ()!; + var info = BuildVersionInfo (majorVersion, installerExt); + + var tempInstallerPath = Path.Combine (Path.GetTempPath (), $"microsoft-jdk-{majorVersion}-{Guid.NewGuid ()}{installerExt}"); + + try { + // Download installer + logger (TraceLevel.Info, $"Downloading installer from {info.DownloadUrl}"); + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, 0, $"Downloading Microsoft OpenJDK {majorVersion} installer...")); + await DownloadUtils.DownloadFileAsync (httpClient, info.DownloadUrl, tempInstallerPath, info.Size, + progress is null ? null : new Progress<(double pct, string msg)> (p => progress.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, p.pct, p.msg))), + cancellationToken).ConfigureAwait (false); + + // Verify checksum if available + if (!string.IsNullOrEmpty (info.Checksum)) { + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 0, "Verifying SHA-256 checksum...")); + DownloadUtils.VerifyChecksum (tempInstallerPath, info.Checksum!); + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 100, "Checksum verified.")); + } + + // Run the installer silently + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Extracting, 0, "Running platform installer...")); + await RunPlatformInstallerAsync (tempInstallerPath, cancellationToken).ConfigureAwait (false); + + logger (TraceLevel.Info, $"Microsoft OpenJDK {majorVersion} installed successfully via platform installer."); + progress?.Report (new JdkInstallProgress (JdkInstallPhase.Complete, 100, $"Microsoft OpenJDK {majorVersion} installed successfully.")); + } + finally { + TryDeleteFile (tempInstallerPath); + } + } + + async Task RunPlatformInstallerAsync (string installerPath, CancellationToken cancellationToken) + { + ProcessStartInfo psi; + if (OS.IsWindows) { + psi = new ProcessStartInfo { + FileName = "msiexec", + UseShellExecute = false, + CreateNoWindow = true, + }; +#if NET5_0_OR_GREATER + psi.ArgumentList.Add ("/i"); + psi.ArgumentList.Add (installerPath); + psi.ArgumentList.Add ("/quiet"); + psi.ArgumentList.Add ("/norestart"); +#else + psi.Arguments = $"/i \"{installerPath}\" /quiet /norestart"; +#endif + } + else { + psi = new ProcessStartInfo { + FileName = "/usr/sbin/installer", + UseShellExecute = false, + CreateNoWindow = true, + }; +#if NET5_0_OR_GREATER + psi.ArgumentList.Add ("-pkg"); + psi.ArgumentList.Add (installerPath); + psi.ArgumentList.Add ("-target"); + psi.ArgumentList.Add ("/"); +#else + psi.Arguments = $"-pkg \"{installerPath}\" -target /"; +#endif + } + + var stdout = new StringWriter (); + var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, stdout: stdout, stderr: stderr, cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) { + var errorOutput = stderr.ToString (); + logger (TraceLevel.Error, $"Installer failed (exit code {exitCode}): {errorOutput}"); + throw new InvalidOperationException ($"Platform installer failed with exit code {exitCode}: {errorOutput}"); + } + } + + /// Checks if the target path is writable and not in a restricted location. + public bool IsTargetPathWritable (string targetPath) + { + if (string.IsNullOrEmpty (targetPath)) + return false; + + // Normalize the path to prevent path traversal bypasses (e.g., "C:\Program Files\..\Users") + string normalizedPath; + try { + normalizedPath = Path.GetFullPath (targetPath); + } + catch { + normalizedPath = targetPath; + } + + if (OS.IsWindows) { + var programFiles = Environment.GetFolderPath (Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath (Environment.SpecialFolder.ProgramFilesX86); + if ((!string.IsNullOrEmpty (programFiles) && normalizedPath.StartsWith (programFiles, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrEmpty (programFilesX86) && normalizedPath.StartsWith (programFilesX86, StringComparison.OrdinalIgnoreCase))) { + logger (TraceLevel.Warning, $"Target path '{targetPath}' is in Program Files which typically requires elevation."); + return false; + } + } + + try { + Directory.CreateDirectory (targetPath); + var testFile = Path.Combine (targetPath, $".write-test-{Guid.NewGuid ()}"); + using (File.Create (testFile, 1, FileOptions.DeleteOnClose)) { } + return true; + } + catch (Exception ex) { + logger (TraceLevel.Warning, $"Target path '{targetPath}' is not writable: {ex.Message}"); + return false; + } + } + + /// Removes a JDK installation at the specified path. + public bool Remove (string jdkPath) + { + if (string.IsNullOrEmpty (jdkPath) || !Directory.Exists (jdkPath)) + return false; + + try { + Directory.Delete (jdkPath, recursive: true); + logger (TraceLevel.Info, $"Removed JDK at '{jdkPath}'."); + return true; + } + catch (Exception ex) { + logger (TraceLevel.Error, $"Failed to remove JDK at '{jdkPath}': {ex.Message}"); + logger (TraceLevel.Verbose, ex.ToString ()); + return false; + } + } + + static JdkVersionInfo BuildVersionInfo (int majorVersion, string? extensionOverride = null) + { + var os = GetMicrosoftOpenJDKOSName (); + var arch = GetArchitectureName (); + var ext = extensionOverride ?? GetArchiveExtension (); + + var filename = $"microsoft-jdk-{majorVersion}-{os}-{arch}{ext}"; + var downloadUrl = $"{DownloadUrlBase}/{filename}"; + var checksumUrl = $"{downloadUrl}.sha256sum.txt"; + var displayName = extensionOverride is not null + ? $"Microsoft OpenJDK {majorVersion} ({ext})" + : $"Microsoft OpenJDK {majorVersion}"; + + return new JdkVersionInfo ( + majorVersion: majorVersion, + displayName: displayName, + downloadUrl: downloadUrl, + checksumUrl: checksumUrl + ); + } + + async Task ExtractArchiveAsync (string archivePath, string targetPath, CancellationToken cancellationToken) + { + var targetParent = Path.GetDirectoryName (targetPath); + if (string.IsNullOrEmpty (targetParent)) + targetParent = Path.GetTempPath (); + else + Directory.CreateDirectory (targetParent); + var tempExtractPath = Path.Combine (targetParent, $".jdk-extract-{Guid.NewGuid ()}"); + + try { + Directory.CreateDirectory (tempExtractPath); + + if (OS.IsWindows) + DownloadUtils.ExtractZipSafe (archivePath, tempExtractPath, cancellationToken); + else + await DownloadUtils.ExtractTarGzAsync (archivePath, tempExtractPath, logger, cancellationToken).ConfigureAwait (false); + + // Find the actual JDK root (archives contain a single top-level directory) + var extractedDirs = Directory.GetDirectories (tempExtractPath); + var jdkRoot = extractedDirs.Length == 1 ? extractedDirs [0] : tempExtractPath; + + // On macOS, the JDK is inside Contents/Home + if (OS.IsMac) { + var contentsHome = Path.Combine (jdkRoot, "Contents", "Home"); + if (Directory.Exists (contentsHome)) + jdkRoot = contentsHome; + } + + MoveWithRollback (jdkRoot, targetPath); + } + finally { + TryDeleteDirectory (tempExtractPath, "temp extract directory"); + } + } + + void MoveWithRollback (string sourcePath, string targetPath) + { + string? backupPath = null; + if (Directory.Exists (targetPath)) { + backupPath = targetPath + $".old-{Guid.NewGuid ():N}"; + Directory.Move (targetPath, backupPath); + } + + var parentDir = Path.GetDirectoryName (targetPath); + if (!string.IsNullOrEmpty (parentDir)) + Directory.CreateDirectory (parentDir); + + try { + Directory.Move (sourcePath, targetPath); + + // Only delete backup after successful move + if (backupPath is not null) + TryDeleteDirectory (backupPath, "old JDK backup"); + } + catch (Exception ex) { + logger (TraceLevel.Error, $"Failed to move to '{targetPath}': {ex.Message}"); + if (backupPath is not null && Directory.Exists (backupPath)) { + try { + if (Directory.Exists (targetPath)) + Directory.Delete (targetPath, recursive: true); + Directory.Move (backupPath, targetPath); + logger (TraceLevel.Warning, $"Restored previous JDK from backup '{backupPath}'."); + } + catch (Exception restoreEx) { + logger (TraceLevel.Error, $"Failed to restore from backup: {restoreEx.Message}"); + } + } + throw; + } + } + + async Task FetchChecksumAsync (string checksumUrl, string label, CancellationToken cancellationToken) + { + try { + using var response = await httpClient.GetAsync (checksumUrl, cancellationToken).ConfigureAwait (false); + response.EnsureSuccessStatusCode (); +#if NET5_0_OR_GREATER + var content = await response.Content.ReadAsStringAsync (cancellationToken).ConfigureAwait (false); +#else + var content = await response.Content.ReadAsStringAsync ().ConfigureAwait (false); +#endif + var checksum = DownloadUtils.ParseChecksumFile (content); + logger (TraceLevel.Verbose, $"{label}: checksum={checksum}"); + return checksum; + } + catch (Exception ex) { + logger (TraceLevel.Warning, $"Could not fetch checksum for {label}: {ex.Message}"); + return null; + } + } + + void TryDeleteFile (string path) + { + if (!File.Exists (path)) + return; + try { File.Delete (path); } + catch (Exception ex) { logger (TraceLevel.Warning, $"Could not delete '{path}': {ex.Message}"); } + } + + void TryDeleteDirectory (string path, string label) + { + if (!Directory.Exists (path)) + return; + try { Directory.Delete (path, recursive: true); } + catch (Exception ex) { logger (TraceLevel.Warning, $"Could not clean up {label} at '{path}': {ex.Message}"); } + } + + static string GetMicrosoftOpenJDKOSName () + { + if (OS.IsMac) return "macOS"; + if (OS.IsWindows) return "windows"; + if (OS.IsLinux) return "linux"; + throw new PlatformNotSupportedException ("Unsupported platform"); + } + + static string GetArchitectureName () + { + return RuntimeInformation.OSArchitecture switch { + Architecture.Arm64 => "aarch64", + _ => "x64", + }; + } + + static string GetArchiveExtension () + { + return OS.IsWindows ? ".zip" : ".tar.gz"; + } + + // Returns .msi (Windows), .pkg (macOS), or null (Linux) + static string? GetInstallerExtension () + { + if (OS.IsWindows) return ".msi"; + if (OS.IsMac) return ".pkg"; + return null; + } + + /// Checks if running as Administrator (Windows) or root (macOS/Linux). + public static bool IsElevated () + { + if (OS.IsWindows) { +#if NET5_0_OR_GREATER +#pragma warning disable CA1416 // Guarded by OS.IsWindows runtime check above + return IsElevatedWindows (); +#pragma warning restore CA1416 +#else + return false; +#endif + } + // Unix: geteuid() == 0 means root + return IsElevatedUnix (); + } + +#if NET5_0_OR_GREATER + [System.Runtime.Versioning.SupportedOSPlatform ("windows")] + static bool IsElevatedWindows () + { + using var identity = WindowsIdentity.GetCurrent (); + var principal = new WindowsPrincipal (identity); + return principal.IsInRole (WindowsBuiltInRole.Administrator); + } +#endif + + // Separate method so geteuid P/Invoke is never JIT-compiled on Windows + static bool IsElevatedUnix () + { + return geteuid () == 0; + } + + [DllImport ("libc")] + static extern uint geteuid (); + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallPhase.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallPhase.cs new file mode 100644 index 00000000..fff2af61 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallPhase.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools +{ + /// + /// Phases of JDK installation. + /// + public enum JdkInstallPhase + { + Downloading, + Verifying, + Extracting, + Validating, + Complete + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallProgress.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallProgress.cs new file mode 100644 index 00000000..16a3e016 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallProgress.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools +{ + /// + /// Progress information for JDK installation. + /// + public record JdkInstallProgress (JdkInstallPhase Phase, double PercentComplete, string? Message = null); +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkVersionInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkVersionInfo.cs new file mode 100644 index 00000000..0003cc7e --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkVersionInfo.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools +{ + /// + /// Represents information about an available JDK version from Microsoft OpenJDK. + /// + public class JdkVersionInfo + { + /// Major version number (e.g. 17, 21). + public int MajorVersion { get; } + + /// Display name for the version (e.g. "Microsoft OpenJDK 17"). + public string DisplayName { get; } + + /// Download URL for the current platform. + public string DownloadUrl { get; } + + /// URL for the SHA-256 checksum file. + public string ChecksumUrl { get; } + + /// Expected file size in bytes, or 0 if unknown. + public long Size { get; internal set; } + + /// SHA-256 checksum for download verification, if fetched. + public string? Checksum { get; internal set; } + + /// The actual download URL after following redirects (reveals the specific version). + public string? ResolvedUrl { get; internal set; } + + public JdkVersionInfo (int majorVersion, string displayName, string downloadUrl, string checksumUrl, long size = 0, string? checksum = null) + { + MajorVersion = majorVersion; + DisplayName = displayName; + DownloadUrl = downloadUrl; + ChecksumUrl = checksumUrl; + Size = size; + Checksum = checksum; + } + + public override string ToString () => DisplayName; + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj b/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj index 4fb13f81..5944520a 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj +++ b/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj @@ -27,8 +27,9 @@ true - - + + + diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkInstallerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkInstallerTests.cs new file mode 100644 index 00000000..b312074e --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkInstallerTests.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests +{ + [TestFixture] + public class JdkInstallerTests + { + JdkInstaller installer; + + [SetUp] + public void SetUp () + { + installer = new JdkInstaller (logger: (level, message) => { + TestContext.WriteLine ($"[{level}] {message}"); + }); + } + + [TearDown] + public void TearDown () + { + installer?.Dispose (); + installer = null!; + } + + [Test] + public void IsValid_NullPath_ReturnsFalse () + { + Assert.IsFalse (installer.IsValid (null!)); + } + + [Test] + public void IsValid_EmptyPath_ReturnsFalse () + { + Assert.IsFalse (installer.IsValid ("")); + } + + [Test] + public void IsValid_NonExistentPath_ReturnsFalse () + { + Assert.IsFalse (installer.IsValid (Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ()))); + } + + [Test] + public void IsValid_EmptyDirectory_ReturnsFalse () + { + var dir = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ()); + Directory.CreateDirectory (dir); + try { + Assert.IsFalse (installer.IsValid (dir)); + } + finally { + Directory.Delete (dir, recursive: true); + } + } + + [Test] + public void IsValid_FauxJdk_ReturnsTrue () + { + var dir = Path.Combine (Path.GetTempPath (), $"jdk-test-{Guid.NewGuid ()}"); + try { + JdkInfoTests.CreateFauxJdk (dir, releaseVersion: "17.0.1", releaseBuildNumber: "1", javaVersion: "17.0.1-1"); + Assert.IsTrue (installer.IsValid (dir)); + } + finally { + if (Directory.Exists (dir)) + Directory.Delete (dir, recursive: true); + } + } + + [Test] + public void IsValid_SystemJdk_ReturnsTrue () + { + // Find first known JDK on the system + var jdk = JdkInfo.GetKnownSystemJdkInfos ().FirstOrDefault (); + if (jdk is null) { + Assert.Ignore ("No system JDK found to validate."); + return; + } + Assert.IsTrue (installer.IsValid (jdk.HomePath)); + } + + [Test] + public async Task DiscoverAsync_ReturnsVersions () + { + IReadOnlyList versions; + try { + versions = await installer.DiscoverAsync (); + } + catch (Exception ex) when (ex is System.Net.Http.HttpRequestException || ex is TaskCanceledException) { + Assert.Ignore ($"Network unavailable: {ex.Message}"); + return; + } + + // We should get at least one version (if network is available) + Assert.IsNotNull (versions); + if (versions.Count == 0) { + Assert.Ignore ("No versions returned (network may be restricted)."); + return; + } + + // Verify structure of returned info + foreach (var v in versions) { + Assert.Greater (v.MajorVersion, 0, "MajorVersion should be positive"); + Assert.IsNotEmpty (v.DisplayName, "DisplayName should not be empty"); + Assert.IsNotEmpty (v.DownloadUrl, "DownloadUrl should not be empty"); + Assert.That (v.DownloadUrl, Does.Contain ("aka.ms/download-jdk"), "DownloadUrl should use Microsoft OpenJDK"); + } + } + + [Test] + public async Task DiscoverAsync_ContainsExpectedMajorVersions () + { + IReadOnlyList versions; + try { + versions = await installer.DiscoverAsync (); + } + catch (Exception ex) when (ex is System.Net.Http.HttpRequestException || ex is TaskCanceledException) { + Assert.Ignore ($"Network unavailable: {ex.Message}"); + return; + } + + if (versions.Count == 0) { + Assert.Ignore ("No versions returned."); + return; + } + + var majorVersions = versions.Select (v => v.MajorVersion).Distinct ().ToList (); + Assert.That (majorVersions, Does.Contain (21), "Should contain JDK 21"); + } + + [Test] + public async Task DiscoverAsync_CancellationToken_Cancels () + { + using var cts = new CancellationTokenSource (); + cts.Cancel (); + + Assert.ThrowsAsync ( + async () => await installer.DiscoverAsync (cts.Token)); + } + + [Test] + public void InstallAsync_InvalidVersion_Throws () + { + Assert.ThrowsAsync ( + async () => await installer.InstallAsync (8, Path.GetTempPath ())); + } + + [Test] + public void InstallAsync_NullPath_Throws () + { + Assert.ThrowsAsync ( + async () => await installer.InstallAsync (21, null!)); + } + + [Test] + public async Task InstallAsync_ReportsProgress () + { + // This test actually downloads and installs a JDK, so it may be slow. + // Skip if running in CI or if network is unavailable. + if (Environment.GetEnvironmentVariable ("CI") is not null || + Environment.GetEnvironmentVariable ("TF_BUILD") is not null) { + Assert.Ignore ("Skipping download test in CI environment."); + return; + } + + var targetPath = Path.Combine (Path.GetTempPath (), $"jdk-install-test-{Guid.NewGuid ()}"); + var reportedPhases = new List (); + var progress = new Progress (p => { + reportedPhases.Add (p.Phase); + }); + + try { + using var cts = new CancellationTokenSource (TimeSpan.FromMinutes (10)); + await installer.InstallAsync (21, targetPath, progress, cts.Token); + + // Verify installation + Assert.IsTrue (installer.IsValid (targetPath), "Installed JDK should be valid"); + Assert.IsTrue (reportedPhases.Contains (JdkInstallPhase.Downloading), "Should report Downloading phase"); + Assert.IsTrue (reportedPhases.Contains (JdkInstallPhase.Extracting), "Should report Extracting phase"); + Assert.IsTrue (reportedPhases.Contains (JdkInstallPhase.Complete), "Should report Complete phase"); + + // Verify we can create a JdkInfo from it + var jdkInfo = new JdkInfo (targetPath); + Assert.IsNotNull (jdkInfo.Version); + Assert.AreEqual (21, jdkInfo.Version!.Major); + } + catch (Exception ex) when (ex is System.Net.Http.HttpRequestException || ex is TaskCanceledException) { + Assert.Ignore ($"Network unavailable: {ex.Message}"); + } + finally { + if (Directory.Exists (targetPath)) + Directory.Delete (targetPath, recursive: true); + } + } + + [Test] + public void Constructor_DefaultLogger_DoesNotThrow () + { + using var defaultInstaller = new JdkInstaller (); + Assert.IsNotNull (defaultInstaller); + } + + [Test] + public void RecommendedMajorVersion_Is21 () + { + Assert.AreEqual (21, JdkInstaller.RecommendedMajorVersion); + } + + [Test] + public void SupportedVersions_ContainsExpected () + { + Assert.That (JdkInstaller.SupportedVersions, Does.Contain (21)); + } + + [Test] + public void IsTargetPathWritable_TempDir_ReturnsTrue () + { + var dir = Path.Combine (Path.GetTempPath (), $"jdk-write-test-{Guid.NewGuid ()}"); + try { + Assert.IsTrue (installer.IsTargetPathWritable (dir)); + } + finally { + if (Directory.Exists (dir)) + Directory.Delete (dir, recursive: true); + } + } + + [Test] + public void IsTargetPathWritable_NullOrEmpty_ReturnsFalse () + { + Assert.IsFalse (installer.IsTargetPathWritable (null!)); + Assert.IsFalse (installer.IsTargetPathWritable ("")); + } + + [Test] + public void Remove_NonExistentPath_ReturnsFalse () + { + Assert.IsFalse (installer.Remove (Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ()))); + } + + [Test] + public void Remove_ExistingDirectory_RemovesIt () + { + var dir = Path.Combine (Path.GetTempPath (), $"jdk-remove-test-{Guid.NewGuid ()}"); + Directory.CreateDirectory (dir); + File.WriteAllText (Path.Combine (dir, "test.txt"), "test"); + + Assert.IsTrue (installer.Remove (dir)); + Assert.IsFalse (Directory.Exists (dir)); + } + } +} From 78d04e3d87332a19d2c6b53d12f3e65b2325dfa9 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 25 Feb 2026 19:20:07 +0000 Subject: [PATCH 2/5] Fix race condition, checksum fetch, and geteuid guard - IsTargetPathWritable: test nearest existing ancestor instead of creating target dir - InstallWithPlatformInstallerAsync: fetch checksum before download - IsElevatedUnix: guard geteuid P/Invoke with OperatingSystem.IsWindows() check - Add SetLastError=true to geteuid DllImport Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JdkInstaller.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs index d29cdca1..ffaa63d1 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs @@ -177,6 +177,10 @@ async Task InstallWithPlatformInstallerAsync (int majorVersion, IProgress (p => progress.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, p.pct, p.msg))), cancellationToken).ConfigureAwait (false); - // Verify checksum if available + // Verify checksum if (!string.IsNullOrEmpty (info.Checksum)) { progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 0, "Verifying SHA-256 checksum...")); DownloadUtils.VerifyChecksum (tempInstallerPath, info.Checksum!); @@ -276,9 +280,16 @@ public bool IsTargetPathWritable (string targetPath) } } + // Test writability on the nearest existing ancestor directory without creating the target try { - Directory.CreateDirectory (targetPath); - var testFile = Path.Combine (targetPath, $".write-test-{Guid.NewGuid ()}"); + var testDir = normalizedPath; + while (!string.IsNullOrEmpty (testDir) && !Directory.Exists (testDir)) + testDir = Path.GetDirectoryName (testDir); + + if (string.IsNullOrEmpty (testDir)) + return false; + + var testFile = Path.Combine (testDir, $".write-test-{Guid.NewGuid ()}"); using (File.Create (testFile, 1, FileOptions.DeleteOnClose)) { } return true; } @@ -492,10 +503,16 @@ static bool IsElevatedWindows () // Separate method so geteuid P/Invoke is never JIT-compiled on Windows static bool IsElevatedUnix () { +#if NET5_0_OR_GREATER + if (!OperatingSystem.IsWindows ()) + return geteuid () == 0; + return false; +#else return geteuid () == 0; +#endif } - [DllImport ("libc")] + [DllImport ("libc", SetLastError = true)] static extern uint geteuid (); } } From 815ffa9399d29e514f95967dfc50b663638c5234 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 25 Feb 2026 19:44:03 +0000 Subject: [PATCH 3/5] Fix architecture validation, path boundary check, and catch pattern - GetArchitectureName: explicitly handle X64, throw for unsupported archs - IsTargetPathWritable: add directory boundary check (prevents false match on 'Program FilesX') - Modernize catch filter to use 'is not' pattern - Extract IsUnderDirectory helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JdkInstaller.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs index ffaa63d1..7d76474d 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs @@ -145,7 +145,7 @@ await DownloadUtils.DownloadFileAsync (httpClient, versionInfo.DownloadUrl, temp progress?.Report (new JdkInstallProgress (JdkInstallPhase.Complete, 100, $"Microsoft OpenJDK {majorVersion} installed successfully.")); } - catch (Exception ex) when (!(ex is ArgumentException) && !(ex is ArgumentNullException)) { + catch (Exception ex) when (ex is not ArgumentException and not ArgumentNullException) { logger (TraceLevel.Error, $"JDK installation failed: {ex.Message}"); logger (TraceLevel.Verbose, ex.ToString ()); throw; @@ -273,8 +273,7 @@ public bool IsTargetPathWritable (string targetPath) if (OS.IsWindows) { var programFiles = Environment.GetFolderPath (Environment.SpecialFolder.ProgramFiles); var programFilesX86 = Environment.GetFolderPath (Environment.SpecialFolder.ProgramFilesX86); - if ((!string.IsNullOrEmpty (programFiles) && normalizedPath.StartsWith (programFiles, StringComparison.OrdinalIgnoreCase)) || - (!string.IsNullOrEmpty (programFilesX86) && normalizedPath.StartsWith (programFilesX86, StringComparison.OrdinalIgnoreCase))) { + if (IsUnderDirectory (normalizedPath, programFiles) || IsUnderDirectory (normalizedPath, programFilesX86)) { logger (TraceLevel.Warning, $"Target path '{targetPath}' is in Program Files which typically requires elevation."); return false; } @@ -456,8 +455,9 @@ static string GetMicrosoftOpenJDKOSName () static string GetArchitectureName () { return RuntimeInformation.OSArchitecture switch { + Architecture.X64 => "x64", Architecture.Arm64 => "aarch64", - _ => "x64", + _ => throw new PlatformNotSupportedException ($"Unsupported architecture: {RuntimeInformation.OSArchitecture}"), }; } @@ -512,6 +512,15 @@ static bool IsElevatedUnix () #endif } + static bool IsUnderDirectory (string path, string directory) + { + if (string.IsNullOrEmpty (directory) || string.IsNullOrEmpty (path)) + return false; + if (path.Equals (directory, StringComparison.OrdinalIgnoreCase)) + return true; + return path.StartsWith (directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + [DllImport ("libc", SetLastError = true)] static extern uint geteuid (); } From 7f0ff1271418521d5f44b02106428bef3d7e28d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:47:35 +0000 Subject: [PATCH 4/5] Initial plan From c05b23af1c5ec2f60ecebc705e8d998d77aad1e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:51:20 +0000 Subject: [PATCH 5/5] Handle MSI reboot-required exit codes (3010, 1641) as success on Windows Co-authored-by: rmarinho <1235097+rmarinho@users.noreply.github.com> --- src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs index 7d76474d..07618c23 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs @@ -248,11 +248,20 @@ async Task RunPlatformInstallerAsync (string installerPath, CancellationToken ca var stderr = new StringWriter (); var exitCode = await ProcessUtils.StartProcess (psi, stdout: stdout, stderr: stderr, cancellationToken).ConfigureAwait (false); - if (exitCode != 0) { + // On Windows, msiexec can return non-zero exit codes that still indicate success, + // such as 3010 (success, reboot required) or 1641 (success, restart initiated). + // Treat these as success while logging that a reboot is required. + var rebootRequired = OS.IsWindows && (exitCode == 3010 || exitCode == 1641); + + if (exitCode != 0 && !rebootRequired) { var errorOutput = stderr.ToString (); logger (TraceLevel.Error, $"Installer failed (exit code {exitCode}): {errorOutput}"); throw new InvalidOperationException ($"Platform installer failed with exit code {exitCode}: {errorOutput}"); } + + if (rebootRequired) { + logger (TraceLevel.Warning, $"Installer completed successfully but a reboot is required (exit code {exitCode})."); + } } /// Checks if the target path is writable and not in a restricted location.