Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
985f6fb
Add JDK installer with discovery, download, verification, and extraction
rmarinho Feb 25, 2026
78d04e3
Fix race condition, checksum fetch, and geteuid guard
rmarinho Feb 25, 2026
815ffa9
Fix architecture validation, path boundary check, and catch pattern
rmarinho Feb 25, 2026
2e4088e
Fix cancellation propagation, mandatory checksum in elevated install,…
rmarinho Feb 25, 2026
9e9fc22
Add comment explaining netstandard2.0 tar path validation
rmarinho Feb 25, 2026
8e589f2
Handle null checksum from FetchChecksumAsync at all call sites
rmarinho Feb 25, 2026
0774d0c
Extract shared utilities from JdkInstaller into FileUtil and Download…
rmarinho Feb 25, 2026
e3f4188
Move file/path utilities from JdkInstaller to FileUtil
rmarinho Feb 25, 2026
4efe463
Fix test compilation and remove dead code
rmarinho Feb 25, 2026
43c9606
Change IsElevated and IsTargetPathWritable to internal
rmarinho Feb 26, 2026
1c1e8a1
Address PR review: rollback safety, cancellation handling, test timeouts
rmarinho Feb 26, 2026
d0f3bea
Create `ProcessUtils.CreateProcessStartInfo()`
jonathanpeppers Feb 26, 2026
9ed5ddc
More ProcessUtils.CreateProcessStartInfo()
jonathanpeppers Feb 26, 2026
8b7446a
Move elevation checks to ProcessUtils
jonathanpeppers Feb 26, 2026
3f2177b
Missing `using`
jonathanpeppers Feb 26, 2026
48ede26
Simplify nullable `progress?`
jonathanpeppers Feb 26, 2026
7c85bd7
Simplify `DownloadUtils.ParseChecksumFile()` write unit tests
jonathanpeppers Feb 26, 2026
e8e9db7
Remove `IsElevated()`
jonathanpeppers Feb 26, 2026
6e4d117
MOAR Tests!
jonathanpeppers Feb 26, 2026
b2a8a7b
We can't use these new lang features
jonathanpeppers Feb 26, 2026
6e167e0
Use ArrayPool.Rent/Return
jonathanpeppers Feb 26, 2026
94b3ac4
Cache WhitespaceChars
jonathanpeppers Feb 26, 2026
212ac43
Pass `CancellationToken`
jonathanpeppers Feb 26, 2026
70966f7
Better `FileUtil.IsTargetPathWritable()`
jonathanpeppers Feb 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
166 changes: 166 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Shared helpers for downloading files, verifying checksums, and extracting archives.
/// </summary>
static class DownloadUtils
{
const int BufferSize = 81920;
const long BytesPerMB = 1024 * 1024;
static readonly char[] WhitespaceChars = [' ', '\t', '\n', '\r'];

static Task<Stream> ReadAsStreamAsync (HttpContent content, CancellationToken cancellationToken)
{
#if NET5_0_OR_GREATER
return content.ReadAsStreamAsync (cancellationToken);
#else
return content.ReadAsStreamAsync ();
#endif
}

static Task<string> ReadAsStringAsync (HttpContent content, CancellationToken cancellationToken)
{
#if NET5_0_OR_GREATER
return content.ReadAsStringAsync (cancellationToken);
#else
return content.ReadAsStringAsync ();
#endif
}

/// <summary>Downloads a file from the given URL with optional progress reporting.</summary>
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<byte>.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<byte>.Shared.Return (buffer);
}
}

/// <summary>Verifies a file's SHA-256 hash against an expected value.</summary>
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}");
}

/// <summary>Extracts a ZIP archive with Zip Slip protection.</summary>
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);
}
}

/// <summary>Extracts a tar.gz archive using the system tar command.</summary>
public static async Task ExtractTarGzAsync (string archivePath, string destinationPath, Action<TraceLevel, string> 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}");
}
}

/// <summary>Fetches a SHA-256 checksum from a remote URL, returning null on failure.</summary>
public static async Task<string?> FetchChecksumAsync (HttpClient httpClient, string checksumUrl, string label, Action<TraceLevel, string> 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;
}
}

/// <summary>Parses "hash filename" or just "hash" from .sha256sum.txt content.</summary>
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;
}
}
}
130 changes: 130 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/FileUtil.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;

Expand Down Expand Up @@ -51,6 +52,135 @@ public static void SystemRename (string sourceFile, string destFile)
}
}

/// <summary>Deletes a file if it exists, logging any failure instead of throwing.</summary>
internal static void TryDeleteFile (string path, Action<TraceLevel, string> logger)
{
if (!File.Exists (path))
return;
try { File.Delete (path); }
catch (Exception ex) { logger (TraceLevel.Warning, $"Could not delete '{path}': {ex.Message}"); }
}

/// <summary>Recursively deletes a directory if it exists, logging any failure instead of throwing.</summary>
internal static void TryDeleteDirectory (string path, string label, Action<TraceLevel, string> 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}"); }
}

/// <summary>Moves a directory to the target path, backing up any existing directory and restoring on failure.</summary>
internal static void MoveWithRollback (string sourcePath, string targetPath, Action<TraceLevel, string> 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);
}

/// <summary>Deletes a backup created by MoveWithRollback. Call after validation succeeds.</summary>
internal static void CommitMove (string targetPath, Action<TraceLevel, string> 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);
}
}

/// <summary>Checks if the target path is writable by probing write access on the nearest existing ancestor.</summary>
/// <remarks>
/// 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
/// </remarks>
internal static bool IsTargetPathWritable (string targetPath, Action<TraceLevel, string> 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;
}
}

/// <summary>Checks if a path is under a given directory.</summary>
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);
}
Expand Down
8 changes: 8 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -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 { }
}
Loading