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..1a686c21 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs @@ -0,0 +1,166 @@ +// 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.Buffers; +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 + { + const int BufferSize = 81920; + const long BytesPerMB = 1024 * 1024; + static readonly char[] WhitespaceChars = [' ', '\t', '\n', '\r']; + + static Task ReadAsStreamAsync (HttpContent content, CancellationToken cancellationToken) + { +#if NET5_0_OR_GREATER + return content.ReadAsStreamAsync (cancellationToken); +#else + return content.ReadAsStreamAsync (); +#endif + } + + static Task ReadAsStringAsync (HttpContent content, CancellationToken cancellationToken) + { +#if NET5_0_OR_GREATER + return content.ReadAsStringAsync (cancellationToken); +#else + return content.ReadAsStringAsync (); +#endif + } + + /// 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 ReadAsStreamAsync (response.Content, cancellationToken).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, BufferSize, useAsync: true); + + var buffer = ArrayPool.Shared.Rent (BufferSize); + try { + 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 / BytesPerMB} MB / {totalBytes / BytesPerMB} MB")); + } + } + } + finally { + ArrayPool.Shared.Return (buffer); + } + } + + /// 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 (!FileUtil.IsUnderDirectory (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 = ProcessUtils.CreateProcessStartInfo ("/usr/bin/tar", "-xzf", archivePath, "-C", destinationPath); + + using var stdout = new StringWriter (); + using 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}"); + } + } + + /// Fetches a SHA-256 checksum from a remote URL, returning null on failure. + public static async Task FetchChecksumAsync (HttpClient httpClient, string checksumUrl, string label, Action logger, CancellationToken cancellationToken) + { + try { + using var response = await httpClient.GetAsync (checksumUrl, cancellationToken).ConfigureAwait (false); + response.EnsureSuccessStatusCode (); + var content = await ReadAsStringAsync (response.Content, cancellationToken).ConfigureAwait (false); + var checksum = ParseChecksumFile (content); + logger (TraceLevel.Verbose, $"{label}: checksum={checksum}"); + return checksum; + } + catch (OperationCanceledException) { + throw; + } + catch (Exception ex) { + logger (TraceLevel.Warning, $"Could not fetch checksum for {label}: {ex.Message}"); + return null; + } + } + + /// Parses "hash filename" or just "hash" from .sha256sum.txt content. + public static string? ParseChecksumFile (string content) + { + if (string.IsNullOrWhiteSpace (content)) + return null; + + var trimmed = content.Trim (); + var end = trimmed.IndexOfAny (WhitespaceChars); + return end >= 0 ? trimmed.Substring (0, end) : trimmed; + } + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/FileUtil.cs b/src/Xamarin.Android.Tools.AndroidSdk/FileUtil.cs index 1b845754..69012b88 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/FileUtil.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/FileUtil.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; @@ -51,6 +52,135 @@ public static void SystemRename (string sourceFile, string destFile) } } + /// Deletes a file if it exists, logging any failure instead of throwing. + internal static void TryDeleteFile (string path, Action logger) + { + if (!File.Exists (path)) + return; + try { File.Delete (path); } + catch (Exception ex) { logger (TraceLevel.Warning, $"Could not delete '{path}': {ex.Message}"); } + } + + /// Recursively deletes a directory if it exists, logging any failure instead of throwing. + internal static void TryDeleteDirectory (string path, string label, Action logger) + { + 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}"); } + } + + /// Moves a directory to the target path, backing up any existing directory and restoring on failure. + internal static void MoveWithRollback (string sourcePath, string targetPath, Action logger) + { + 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); + } + 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 directory from backup '{backupPath}'."); + } + catch (Exception restoreEx) { + logger (TraceLevel.Error, $"Failed to restore from backup: {restoreEx.Message}"); + } + } + throw; + } + + // Delete backup only after move and caller validation succeed + if (backupPath is not null) + TryDeleteDirectory (backupPath, "old backup", logger); + } + + /// Deletes a backup created by MoveWithRollback. Call after validation succeeds. + internal static void CommitMove (string targetPath, Action logger) + { + // Find and clean up any leftover backup directories + var parentDir = Path.GetDirectoryName (targetPath); + if (string.IsNullOrEmpty (parentDir) || !Directory.Exists (parentDir)) + return; + + var dirName = Path.GetFileName (targetPath); + foreach (var dir in Directory.GetDirectories (parentDir, $"{dirName}.old-*")) { + TryDeleteDirectory (dir, "old backup", logger); + } + } + + /// Checks if the target path is writable by probing write access on the nearest existing ancestor. + /// + /// Follows the same pattern as dotnet/sdk WorkloadInstallerFactory.CanWriteToDotnetRoot: + /// probe with File.Create + DeleteOnClose, only catch UnauthorizedAccessException. + /// See https://github.com/dotnet/sdk/blob/db01067a9c4b67dc1806956393ec63b032032166/src/Cli/dotnet/Commands/Workload/Install/WorkloadInstallerFactory.cs + /// + internal static bool IsTargetPathWritable (string targetPath, Action logger) + { + if (string.IsNullOrEmpty (targetPath)) + return false; + + try { + targetPath = Path.GetFullPath (targetPath); + } + catch { + return false; + } + + try { + // Walk up to the nearest existing ancestor directory + var testDir = targetPath; + while (!string.IsNullOrEmpty (testDir) && !Directory.Exists (testDir)) + testDir = Path.GetDirectoryName (testDir); + + if (string.IsNullOrEmpty (testDir)) + return false; + + var testFile = Path.Combine (testDir, Path.GetRandomFileName ()); + using (File.Create (testFile, 1, FileOptions.DeleteOnClose)) { } + return true; + } + catch (UnauthorizedAccessException) { + logger (TraceLevel.Warning, $"Target path '{targetPath}' is not writable."); + return false; + } + } + + /// Checks if a path is under a given directory. + internal 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); + } + + // Returns .msi (Windows), .pkg (macOS), or null (Linux) + internal static string? GetInstallerExtension () + { + if (OS.IsWindows) return ".msi"; + if (OS.IsMac) return ".pkg"; + return null; + } + + internal static string GetArchiveExtension () + { + return OS.IsWindows ? ".zip" : ".tar.gz"; + } + [DllImport ("libc", SetLastError=true)] static extern int rename (string old, string @new); } 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..c9825425 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs @@ -0,0 +1,336 @@ +// 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; +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 ]; + + static readonly IProgress NullProgress = new Progress (); + + 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 DownloadUtils.FetchChecksumAsync (httpClient, info.ChecksumUrl, $"JDK {version}", logger, cancellationToken).ConfigureAwait (false); + if (string.IsNullOrEmpty (info.Checksum)) + logger (TraceLevel.Warning, $"Could not fetch checksum for JDK {version}, integrity verification will be skipped."); + + results.Add (info); + logger (TraceLevel.Info, $"Discovered {info.DisplayName} (size={info.Size})"); + } + catch (OperationCanceledException) { + throw; + } + 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. + /// When running elevated, uses the platform installer (.msi/.pkg) which chooses its own install location; + /// is ignored in that case. When non-elevated, extracts to . + /// + public async Task InstallAsync (int majorVersion, string targetPath, IProgress? progress = null, CancellationToken cancellationToken = default) + { + progress ??= NullProgress; + + 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 (ProcessUtils.IsElevated () && FileUtil.GetInstallerExtension () is not null) { + logger (TraceLevel.Info, "Running elevated — using platform installer (.msi/.pkg)."); + await InstallWithPlatformInstallerAsync (majorVersion, progress, cancellationToken).ConfigureAwait (false); + return; + } + + if (!FileUtil.IsTargetPathWritable (targetPath, logger)) { + 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 DownloadUtils.FetchChecksumAsync (httpClient, versionInfo.ChecksumUrl, $"JDK {majorVersion}", logger, 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 ()}{FileUtil.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, + 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."); + FileUtil.TryDeleteDirectory (targetPath, "invalid installation", logger); + throw new InvalidOperationException ($"JDK installation at '{targetPath}' failed validation. The extracted files may be corrupted."); + } + + // Validation passed — commit the move by cleaning up any backup + FileUtil.CommitMove (targetPath, logger); + 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 (OperationCanceledException) { + throw; + } + 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; + } + finally { + FileUtil.TryDeleteFile (tempArchivePath, logger); + } + } + + /// 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 = FileUtil.GetInstallerExtension ()!; + var info = BuildVersionInfo (majorVersion, installerExt); + + // Fetch checksum before download for supply-chain integrity + var checksum = await DownloadUtils.FetchChecksumAsync (httpClient, info.ChecksumUrl, $"JDK {majorVersion} installer", logger, cancellationToken).ConfigureAwait (false); + if (string.IsNullOrEmpty (checksum)) + throw new InvalidOperationException ($"Failed to fetch SHA-256 checksum for JDK {majorVersion} installer. Cannot verify download integrity."); + info.Checksum = checksum; + + 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, + new Progress<(double pct, string msg)> (p => progress.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, p.pct, p.msg))), + cancellationToken).ConfigureAwait (false); + + 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 { + FileUtil.TryDeleteFile (tempInstallerPath, logger); + } + } + + async Task RunPlatformInstallerAsync (string installerPath, CancellationToken cancellationToken) + { + var psi = OS.IsWindows + ? ProcessUtils.CreateProcessStartInfo ("msiexec", "/i", installerPath, "/quiet", "/norestart") + : ProcessUtils.CreateProcessStartInfo ("/usr/sbin/installer", "-pkg", installerPath, "-target", "/"); + + using var stdout = new StringWriter (); + using 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}"); + } + } + + /// 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 ?? FileUtil.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; + } + + FileUtil.MoveWithRollback (jdkRoot, targetPath, logger); + } + finally { + FileUtil.TryDeleteDirectory (tempExtractPath, "temp extract directory", logger); + } + } + + 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.X64 => "x64", + Architecture.Arm64 => "aarch64", + _ => throw new PlatformNotSupportedException ($"Unsupported architecture: {RuntimeInformation.OSArchitecture}"), + }; + } + } +} 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/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index 00074a72..86dce45b 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -2,9 +2,14 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; +#if !NET5_0_OR_GREATER +using System.Runtime.InteropServices; +#endif + namespace Xamarin.Android.Tools { public static class ProcessUtils @@ -159,6 +164,40 @@ internal static void Exec (ProcessStartInfo processStartInfo, DataReceivedEventH } } + /// + /// Creates a with the given filename and arguments. + /// On .NET 5+ uses to avoid shell-escaping issues; + /// on older frameworks falls back to a single string. + /// + public static ProcessStartInfo CreateProcessStartInfo (string fileName, params string[] args) + { + var psi = new ProcessStartInfo { + FileName = fileName, + UseShellExecute = false, + CreateNoWindow = true, + }; +#if NET5_0_OR_GREATER + foreach (var arg in args) + psi.ArgumentList.Add (arg); +#else + psi.Arguments = JoinArguments (args); +#endif + return psi; + } + +#if !NET5_0_OR_GREATER + static string JoinArguments (string[] args) + { + var sb = new StringBuilder (); + for (int i = 0; i < args.Length; i++) { + if (i > 0) + sb.Append (' '); + sb.Append ('"').Append (args [i]).Append ('"'); + } + return sb.ToString (); + } +#endif + internal static IEnumerable FindExecutablesInPath (string executable) { var path = Environment.GetEnvironmentVariable ("PATH") ?? ""; @@ -198,6 +237,27 @@ internal static IEnumerable ExecutableFiles (string executable) yield return Path.ChangeExtension (executable, ext); yield return executable; } + + /// Checks if running as Administrator (Windows) or root (macOS/Linux). + public static bool IsElevated () + { +#if NET5_0_OR_GREATER + return Environment.IsPrivilegedProcess; +#else + if (OS.IsWindows) + return IsUserAnAdmin (); + return geteuid () == 0; +#endif + } + +#if !NET5_0_OR_GREATER + [DllImport ("shell32.dll")] + [return: MarshalAs (UnmanagedType.Bool)] + static extern bool IsUserAnAdmin (); + + [DllImport ("libc", SetLastError = true)] + static extern uint geteuid (); +#endif } } 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..13032f2d 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 - - + + + @@ -37,6 +38,7 @@ runtime; build; native; contentfiles; analyzers + diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/DownloadUtilsTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/DownloadUtilsTests.cs new file mode 100644 index 00000000..995870df --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/DownloadUtilsTests.cs @@ -0,0 +1,221 @@ +// 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.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Threading; + +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests +{ + [TestFixture] + public class DownloadUtilsTests + { + string tempDir = null!; + + [SetUp] + public void SetUp () + { + tempDir = Path.Combine (Path.GetTempPath (), $"DownloadUtilsTests-{Guid.NewGuid ():N}"); + Directory.CreateDirectory (tempDir); + } + + [TearDown] + public void TearDown () + { + if (Directory.Exists (tempDir)) + Directory.Delete (tempDir, recursive: true); + } + [Test] + public void ParseChecksumFile_Null_ReturnsNull () + { + Assert.IsNull (DownloadUtils.ParseChecksumFile (null!)); + } + + [Test] + public void ParseChecksumFile_Empty_ReturnsNull () + { + Assert.IsNull (DownloadUtils.ParseChecksumFile ("")); + } + + [Test] + public void ParseChecksumFile_WhitespaceOnly_ReturnsNull () + { + Assert.IsNull (DownloadUtils.ParseChecksumFile (" \n\t ")); + } + + [Test] + public void ParseChecksumFile_HashOnly () + { + Assert.AreEqual ("abc123def456", DownloadUtils.ParseChecksumFile ("abc123def456")); + } + + [Test] + public void ParseChecksumFile_HashOnly_WithTrailingNewline () + { + Assert.AreEqual ("abc123def456", DownloadUtils.ParseChecksumFile ("abc123def456\n")); + } + + [Test] + public void ParseChecksumFile_HashAndFilename () + { + // Standard sha256sum format: " " + Assert.AreEqual ("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + DownloadUtils.ParseChecksumFile ("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 microsoft-jdk-21-linux-x64.tar.gz")); + } + + [Test] + public void ParseChecksumFile_HashAndFilename_WithTab () + { + Assert.AreEqual ("abc123", DownloadUtils.ParseChecksumFile ("abc123\tfilename.zip")); + } + + [Test] + public void ParseChecksumFile_MultipleLines_ReturnsFirstHash () + { + var content = "abc123 file1.zip\ndef456 file2.zip\n"; + Assert.AreEqual ("abc123", DownloadUtils.ParseChecksumFile (content)); + } + + [Test] + public void ParseChecksumFile_LeadingAndTrailingWhitespace () + { + Assert.AreEqual ("abc123", DownloadUtils.ParseChecksumFile (" abc123 filename.zip \n")); + } + + [TestCase ("abc123\r\n")] + [TestCase ("abc123\r")] + [TestCase ("abc123\n")] + public void ParseChecksumFile_VariousLineEndings (string content) + { + Assert.AreEqual ("abc123", DownloadUtils.ParseChecksumFile (content)); + } + + // --- VerifyChecksum tests --- + + [Test] + public void VerifyChecksum_MatchingHash_DoesNotThrow () + { + var filePath = Path.Combine (tempDir, "test.bin"); + var content = new byte [] { 0x48, 0x65, 0x6c, 0x6c, 0x6f }; // "Hello" + File.WriteAllBytes (filePath, content); + + using var sha = SHA256.Create (); + var expected = BitConverter.ToString (sha.ComputeHash (content)).Replace ("-", "").ToLowerInvariant (); + + Assert.DoesNotThrow (() => DownloadUtils.VerifyChecksum (filePath, expected)); + } + + [Test] + public void VerifyChecksum_MismatchedHash_Throws () + { + var filePath = Path.Combine (tempDir, "test.bin"); + File.WriteAllBytes (filePath, new byte [] { 1, 2, 3 }); + + var ex = Assert.Throws (() => + DownloadUtils.VerifyChecksum (filePath, "0000000000000000000000000000000000000000000000000000000000000000")); + Assert.That (ex!.Message, Does.Contain ("Checksum verification failed")); + } + + [Test] + public void VerifyChecksum_CaseInsensitive () + { + var filePath = Path.Combine (tempDir, "test.bin"); + var content = new byte [] { 0xFF }; + File.WriteAllBytes (filePath, content); + + using var sha = SHA256.Create (); + var upperHash = BitConverter.ToString (sha.ComputeHash (content)).Replace ("-", "").ToUpperInvariant (); + + Assert.DoesNotThrow (() => DownloadUtils.VerifyChecksum (filePath, upperHash)); + } + + [Test] + public void VerifyChecksum_NonExistentFile_Throws () + { + var filePath = Path.Combine (tempDir, "nonexistent.bin"); + Assert.Throws (() => + DownloadUtils.VerifyChecksum (filePath, "abc123")); + } + + // --- ExtractZipSafe tests --- + + [Test] + public void ExtractZipSafe_ValidZip_ExtractsFiles () + { + var zipPath = Path.Combine (tempDir, "test.zip"); + var extractPath = Path.Combine (tempDir, "extracted"); + Directory.CreateDirectory (extractPath); + + using (var archive = ZipFile.Open (zipPath, ZipArchiveMode.Create)) { + var entry = archive.CreateEntry ("subdir/hello.txt"); + using var writer = new StreamWriter (entry.Open ()); + writer.Write ("hello world"); + } + + DownloadUtils.ExtractZipSafe (zipPath, extractPath, CancellationToken.None); + + var extractedFile = Path.Combine (extractPath, "subdir", "hello.txt"); + Assert.IsTrue (File.Exists (extractedFile), "Extracted file should exist"); + Assert.AreEqual ("hello world", File.ReadAllText (extractedFile)); + } + + [Test] + public void ExtractZipSafe_ZipSlip_Throws () + { + var zipPath = Path.Combine (tempDir, "evil.zip"); + var extractPath = Path.Combine (tempDir, "extracted"); + Directory.CreateDirectory (extractPath); + + using (var archive = ZipFile.Open (zipPath, ZipArchiveMode.Create)) { + // Create an entry with a path traversal + var entry = archive.CreateEntry ("../evil.txt"); + using var writer = new StreamWriter (entry.Open ()); + writer.Write ("malicious"); + } + + var ex = Assert.Throws (() => + DownloadUtils.ExtractZipSafe (zipPath, extractPath, CancellationToken.None)); + Assert.That (ex!.Message, Does.Contain ("outside target directory")); + } + + [Test] + public void ExtractZipSafe_EmptyZip_NoFilesExtracted () + { + var zipPath = Path.Combine (tempDir, "empty.zip"); + var extractPath = Path.Combine (tempDir, "extracted"); + Directory.CreateDirectory (extractPath); + + using (ZipFile.Open (zipPath, ZipArchiveMode.Create)) { } + + DownloadUtils.ExtractZipSafe (zipPath, extractPath, CancellationToken.None); + + Assert.AreEqual (0, Directory.GetFiles (extractPath, "*", SearchOption.AllDirectories).Length); + } + + [Test] + public void ExtractZipSafe_CancellationToken_Throws () + { + var zipPath = Path.Combine (tempDir, "test.zip"); + var extractPath = Path.Combine (tempDir, "extracted"); + Directory.CreateDirectory (extractPath); + + using (var archive = ZipFile.Open (zipPath, ZipArchiveMode.Create)) { + for (int i = 0; i < 10; i++) { + var entry = archive.CreateEntry ($"file{i}.txt"); + using var writer = new StreamWriter (entry.Open ()); + writer.Write ($"content {i}"); + } + } + + using var cts = new CancellationTokenSource (); + cts.Cancel (); + + Assert.Throws (() => + DownloadUtils.ExtractZipSafe (zipPath, extractPath, cts.Token)); + } + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/FileUtilTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/FileUtilTests.cs new file mode 100644 index 00000000..c94d2cc2 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/FileUtilTests.cs @@ -0,0 +1,151 @@ +// 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 NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests +{ + [TestFixture] + public class FileUtilTests + { + string tempDir = null!; + Action logger = null!; + + [SetUp] + public void SetUp () + { + tempDir = Path.Combine (Path.GetTempPath (), $"FileUtilTests-{Guid.NewGuid ():N}"); + Directory.CreateDirectory (tempDir); + logger = (level, msg) => TestContext.WriteLine ($"[{level}] {msg}"); + } + + [TearDown] + public void TearDown () + { + if (Directory.Exists (tempDir)) + Directory.Delete (tempDir, recursive: true); + } + + [Test] + public void MoveWithRollback_NewTarget_Succeeds () + { + var source = Path.Combine (tempDir, "source"); + var target = Path.Combine (tempDir, "target"); + Directory.CreateDirectory (source); + File.WriteAllText (Path.Combine (source, "file.txt"), "hello"); + + FileUtil.MoveWithRollback (source, target, logger); + + Assert.IsFalse (Directory.Exists (source), "Source should no longer exist"); + Assert.IsTrue (Directory.Exists (target), "Target should exist"); + Assert.AreEqual ("hello", File.ReadAllText (Path.Combine (target, "file.txt"))); + } + + [Test] + public void MoveWithRollback_ExistingTarget_BacksUpAndReplaces () + { + var source = Path.Combine (tempDir, "source"); + var target = Path.Combine (tempDir, "target"); + Directory.CreateDirectory (source); + File.WriteAllText (Path.Combine (source, "new.txt"), "new"); + + Directory.CreateDirectory (target); + File.WriteAllText (Path.Combine (target, "old.txt"), "old"); + + FileUtil.MoveWithRollback (source, target, logger); + + Assert.IsFalse (Directory.Exists (source), "Source should no longer exist"); + Assert.IsTrue (File.Exists (Path.Combine (target, "new.txt")), "New file should exist"); + Assert.IsFalse (File.Exists (Path.Combine (target, "old.txt")), "Old file should be gone"); + } + + [Test] + public void MoveWithRollback_SourceDoesNotExist_RestoresBackup () + { + var source = Path.Combine (tempDir, "nonexistent"); + var target = Path.Combine (tempDir, "target"); + + // Create an existing target that should be backed up and restored + Directory.CreateDirectory (target); + File.WriteAllText (Path.Combine (target, "original.txt"), "preserve me"); + + Assert.Throws (() => FileUtil.MoveWithRollback (source, target, logger)); + + // The original target should be restored from backup + Assert.IsTrue (Directory.Exists (target), "Target should be restored"); + Assert.AreEqual ("preserve me", File.ReadAllText (Path.Combine (target, "original.txt"))); + } + + [Test] + public void MoveWithRollback_SourceDoesNotExist_NoExistingTarget_Throws () + { + var source = Path.Combine (tempDir, "nonexistent"); + var target = Path.Combine (tempDir, "also-nonexistent"); + + Assert.Throws (() => FileUtil.MoveWithRollback (source, target, logger)); + Assert.IsFalse (Directory.Exists (target)); + } + + [Test] + public void IsUnderDirectory_ChildPath_ReturnsTrue () + { + var parent = Path.Combine ($"{Path.DirectorySeparatorChar}opt", "programs"); + var child = Path.Combine (parent, "java", "jdk-21"); + Assert.IsTrue (FileUtil.IsUnderDirectory (child, parent)); + } + + [Test] + public void IsUnderDirectory_ExactMatch_ReturnsTrue () + { + var dir = Path.Combine ($"{Path.DirectorySeparatorChar}opt", "programs"); + Assert.IsTrue (FileUtil.IsUnderDirectory (dir, dir)); + } + + [Test] + public void IsUnderDirectory_SiblingPath_ReturnsFalse () + { + Assert.IsFalse (FileUtil.IsUnderDirectory ( + Path.Combine ($"{Path.DirectorySeparatorChar}opt", "data", "java"), + Path.Combine ($"{Path.DirectorySeparatorChar}opt", "programs"))); + } + + [Test] + public void IsUnderDirectory_DifferentRoot_ReturnsFalse () + { + Assert.IsFalse (FileUtil.IsUnderDirectory ( + Path.Combine ($"{Path.DirectorySeparatorChar}other", "java"), + Path.Combine ($"{Path.DirectorySeparatorChar}opt", "programs"))); + } + + [TestCase (null, "/dir")] + [TestCase ("/dir", null)] + [TestCase ("", "/dir")] + [TestCase ("/dir", "")] + [TestCase (null, null)] + public void IsUnderDirectory_NullOrEmpty_ReturnsFalse (string path, string directory) + { + Assert.IsFalse (FileUtil.IsUnderDirectory (path!, directory!)); + } + + [Test] + public void IsUnderDirectory_CaseInsensitive () + { + var parent = Path.Combine ($"{Path.DirectorySeparatorChar}opt", "Programs"); + var child = Path.Combine ($"{Path.DirectorySeparatorChar}opt", "PROGRAMS", "java"); + Assert.IsTrue (FileUtil.IsUnderDirectory (child, parent)); + } + + [Test] + public void IsUnderDirectory_PartialDirNameMatch_ReturnsFalse () + { + var parent = Path.Combine ($"{Path.DirectorySeparatorChar}opt", "programs"); + Assert.IsFalse (FileUtil.IsUnderDirectory ( + Path.Combine ($"{Path.DirectorySeparatorChar}opt", "programs-extra", "java"), + parent)); + } + } +} 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..2e153056 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkInstallerTests.cs @@ -0,0 +1,266 @@ +// 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 { + using (var cts = new CancellationTokenSource (TimeSpan.FromSeconds (15))) { + versions = await installer.DiscoverAsync (cts.Token); + } + } + catch (Exception ex) when (ex is System.Net.Http.HttpRequestException || ex is TaskCanceledException || ex is OperationCanceledException) { + 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 (FileUtil.IsTargetPathWritable (dir, (level, msg) => TestContext.WriteLine ($"[{level}] {msg}"))); + } + finally { + if (Directory.Exists (dir)) + Directory.Delete (dir, recursive: true); + } + } + + [Test] + public void IsTargetPathWritable_NullOrEmpty_ReturnsFalse () + { + var logger = new Action ((level, msg) => TestContext.WriteLine ($"[{level}] {msg}")); + Assert.IsFalse (FileUtil.IsTargetPathWritable (null!, logger)); + Assert.IsFalse (FileUtil.IsTargetPathWritable ("", logger)); + } + + [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)); + } + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkVersionInfoTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkVersionInfoTests.cs new file mode 100644 index 00000000..b37f486e --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkVersionInfoTests.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests +{ + [TestFixture] + public class JdkVersionInfoTests + { + [Test] + public void Constructor_SetsAllProperties () + { + var info = new JdkVersionInfo ( + majorVersion: 21, + displayName: "Microsoft OpenJDK 21", + downloadUrl: "https://example.com/jdk-21.zip", + checksumUrl: "https://example.com/jdk-21.zip.sha256sum.txt", + size: 123456789, + checksum: "abc123"); + + Assert.AreEqual (21, info.MajorVersion); + Assert.AreEqual ("Microsoft OpenJDK 21", info.DisplayName); + Assert.AreEqual ("https://example.com/jdk-21.zip", info.DownloadUrl); + Assert.AreEqual ("https://example.com/jdk-21.zip.sha256sum.txt", info.ChecksumUrl); + Assert.AreEqual (123456789, info.Size); + Assert.AreEqual ("abc123", info.Checksum); + } + + [Test] + public void Constructor_DefaultSizeAndChecksum () + { + var info = new JdkVersionInfo ( + majorVersion: 17, + displayName: "Microsoft OpenJDK 17", + downloadUrl: "https://example.com/jdk-17.zip", + checksumUrl: "https://example.com/jdk-17.zip.sha256sum.txt"); + + Assert.AreEqual (0, info.Size); + Assert.IsNull (info.Checksum); + } + + [Test] + public void ToString_ReturnsDisplayName () + { + var info = new JdkVersionInfo (21, "Microsoft OpenJDK 21", "https://example.com/dl", "https://example.com/cs"); + Assert.AreEqual ("Microsoft OpenJDK 21", info.ToString ()); + } + + [Test] + public void MutableProperties_CanBeSet () + { + var info = new JdkVersionInfo (21, "Test", "https://example.com/dl", "https://example.com/cs"); + + info.Size = 999; + info.Checksum = "deadbeef"; + info.ResolvedUrl = "https://resolved.example.com/jdk-21.0.5.zip"; + + Assert.AreEqual (999, info.Size); + Assert.AreEqual ("deadbeef", info.Checksum); + Assert.AreEqual ("https://resolved.example.com/jdk-21.0.5.zip", info.ResolvedUrl); + } + + [Test] + public void ResolvedUrl_DefaultsToNull () + { + var info = new JdkVersionInfo (21, "Test", "https://example.com/dl", "https://example.com/cs"); + Assert.IsNull (info.ResolvedUrl); + } + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ProcessUtilsTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ProcessUtilsTests.cs new file mode 100644 index 00000000..a9e7be31 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ProcessUtilsTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests +{ + [TestFixture] + public class ProcessUtilsTests + { + [Test] + public void CreateProcessStartInfo_SetsFileName () + { + var psi = ProcessUtils.CreateProcessStartInfo ("myapp"); + Assert.AreEqual ("myapp", psi.FileName); + } + + [Test] + public void CreateProcessStartInfo_SetsShellAndWindow () + { + var psi = ProcessUtils.CreateProcessStartInfo ("myapp"); + Assert.IsFalse (psi.UseShellExecute, "UseShellExecute should be false"); + Assert.IsTrue (psi.CreateNoWindow, "CreateNoWindow should be true"); + } + + [Test] + public void CreateProcessStartInfo_NoArgs () + { + var psi = ProcessUtils.CreateProcessStartInfo ("myapp"); + Assert.AreEqual (0, psi.ArgumentList.Count); + } + + [Test] + public void CreateProcessStartInfo_SingleArg () + { + var psi = ProcessUtils.CreateProcessStartInfo ("myapp", "--version"); + Assert.AreEqual (1, psi.ArgumentList.Count); + Assert.AreEqual ("--version", psi.ArgumentList [0]); + } + + [Test] + public void CreateProcessStartInfo_MultipleArgs () + { + var psi = ProcessUtils.CreateProcessStartInfo ("tar", "-xzf", "archive.tar.gz", "-C", "/tmp/output"); + Assert.AreEqual (4, psi.ArgumentList.Count); + Assert.AreEqual ("-xzf", psi.ArgumentList [0]); + Assert.AreEqual ("archive.tar.gz", psi.ArgumentList [1]); + Assert.AreEqual ("-C", psi.ArgumentList [2]); + Assert.AreEqual ("/tmp/output", psi.ArgumentList [3]); + } + + [Test] + public void CreateProcessStartInfo_ArgWithSpaces () + { + var psi = ProcessUtils.CreateProcessStartInfo ("cmd", "/c", "path with spaces"); + Assert.AreEqual (2, psi.ArgumentList.Count); + Assert.AreEqual ("path with spaces", psi.ArgumentList [1]); + } + + [Test] + public void IsElevated_DoesNotThrow () + { + // Smoke test: just verify it returns without crashing + bool result = ProcessUtils.IsElevated (); + Assert.That (result, Is.TypeOf ()); + } + } +}