File: ProvisioningProfileProvider.cs
Web Access
Project: src\src\Microsoft.DotNet.Helix\Sdk\Microsoft.DotNet.Helix.Sdk.csproj (Microsoft.DotNet.Helix.Sdk)
// 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.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using Microsoft.Arcade.Common;
using Microsoft.Build.Utilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
 
#nullable enable
namespace Microsoft.DotNet.Helix.Sdk
{
    public enum ApplePlatform
    {
        iOS,
        tvOS,
    }
 
    public interface IProvisioningProfileProvider
    {
        void AddProfileToPayload(string archivePath, string testTarget);
    }
 
    /// <summary>
    /// This class embeds Apple provisioning profiles into app bundles.
    /// App bundles are directories with files that represent an iOS or tvOS application.
    /// Provisioning profile is a file used for signing and differs per platform (iOS/tvOS).
    /// This class makes sure each app bundle has one before it is sent to Helix.
    /// It injects the profiles into all top-level app bundles in a given .zip archive.
    /// </summary>
    public class ProvisioningProfileProvider : IProvisioningProfileProvider
    {
        // The name of the profile that Apple expects
        private const string ProfileFileName = "embedded.mobileprovision";
 
        // Matches all paths to .app bundle directories in archive's root
        private static readonly Regex s_topLevelAppPattern = new("^[^" + Regex.Escape(new string(Path.GetInvalidFileNameChars())) + "]+\\.app/.+");
        private static readonly IReadOnlyDictionary<ApplePlatform, string> s_targetNames = new Dictionary<ApplePlatform, string>()
        {
            { ApplePlatform.iOS, "ios-device" },
            { ApplePlatform.tvOS, "tvos-device" },
        };
 
        private readonly TaskLoggingHelper _log;
        private readonly IHelpers _helpers;
        private readonly IFileSystem _fileSystem;
        private readonly IZipArchiveManager _zipArchiveManager;
        private readonly HttpClient _httpClient;
        private readonly IRetryHandler _retryHandler;
        private readonly string? _profileUrlTemplate;
        private readonly string? _tmpDir;
        private readonly Dictionary<ApplePlatform, string> _downloadedProfiles = new();
 
        public ProvisioningProfileProvider(
            TaskLoggingHelper log,
            IHelpers helpers,
            IFileSystem fileSystem,
            IZipArchiveManager zipArchiveManager,
            HttpClient httpClient,
            IRetryHandler retryHandler,
            string? profileUrlTemplate,
            string? tmpDir)
        {
            _log = log ?? throw new ArgumentNullException(nameof(log));
            _helpers = helpers ?? throw new ArgumentNullException(nameof(helpers));
            _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
            _zipArchiveManager = zipArchiveManager ?? throw new ArgumentNullException(nameof(zipArchiveManager));
            _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
            _retryHandler = retryHandler;
            _profileUrlTemplate = profileUrlTemplate;
            _tmpDir = tmpDir;
        }
 
        public void AddProfileToPayload(string archivePath, string testTarget)
        {
            foreach (var pair in s_targetNames)
            {
                ApplePlatform platform = pair.Key;
                string targetName = pair.Value;
 
                // Only app bundles that target iOS/tvOS devices need a profile (simulators don't)
                if (!testTarget.Contains(targetName))
                {
                    continue;
                }
 
                // This makes sure we download the profile the first time we see an app that needs it
                if (!_downloadedProfiles.TryGetValue(platform, out string? profilePath))
                {
                    if (string.IsNullOrEmpty(_tmpDir))
                    {
                        _log.LogError($"{nameof(CreateXHarnessAppleWorkItems.TmpDir)} parameter not set but required for real device targets!");
                        return;
                    }
 
                    if (string.IsNullOrEmpty(_profileUrlTemplate))
                    {
                        _log.LogError($"{nameof(CreateXHarnessAppleWorkItems.ProvisioningProfileUrl)} parameter not set but required for real device targets!");
                        return;
                    }
 
                    profilePath = DownloadProvisioningProfile(platform);
                    _downloadedProfiles.Add(platform, profilePath);
                }
 
                AddProfileToArchive(archivePath, profilePath);
            }
        }
 
        /// <summary>
        /// Adds a provisioning profile to a given zip archive.
        /// Either adds it to all .app folders inside or to the root of the archive if no app bundles found.
        /// </summary>
        private void AddProfileToArchive(string archivePath, string profilePath)
        {
            // App comes with a profile already
            using ZipArchive zipArchive = _zipArchiveManager.OpenArchive(archivePath, ZipArchiveMode.Update);
 
            HashSet<string> rootLevelAppBundles = new();
            HashSet<string> appBundlesWithProfile = new();
 
            foreach (ZipArchiveEntry entry in zipArchive.Entries)
            {
                if (!s_topLevelAppPattern.IsMatch(entry.FullName))
                {
                    continue;
                }
 
                string appBundleName = entry.FullName.Split(new[] { '/' }, 2).First();
                
                if (entry.FullName == appBundleName + "/" + ProfileFileName)
                {
                    appBundlesWithProfile.Add(appBundleName);
                    _log.LogMessage($"{appBundleName} already contains provisioning profile");
                }
                else
                {
                    rootLevelAppBundles.Add(appBundleName);
                }
            }
 
            rootLevelAppBundles = rootLevelAppBundles.Except(appBundlesWithProfile).ToHashSet();
 
            // If no .app bundles, add it to the root
            if (!rootLevelAppBundles.Any())
            {
                _log.LogMessage($"No app bundles found in the archive. Adding provisioning profile to root");
 
                // Check if archive comes with a profile already
                if (!zipArchive.Entries.Any(e => e.FullName == ProfileFileName))
                {
                    zipArchive.CreateEntryFromFile(profilePath, ProfileFileName);
                }
 
                return;
            }
 
            // Else inject profile to every app bundle in the root of the archive
            foreach (string appBundle in rootLevelAppBundles)
            {
                var profileDestPath = appBundle + "/" + ProfileFileName;
                _log.LogMessage($"Adding provisioning profile to {appBundle}");
                zipArchive.CreateEntryFromFile(profilePath, profileDestPath);
            }
        }
 
        /// <summary>
        /// Process-safe download of the profile (several clashing msbuild processes should download once).
        /// </summary>
        /// <param name="platform">Which platform to download the profile for</param>
        /// <returns>Path where the profile was downloaded to</returns>
        private string DownloadProvisioningProfile(ApplePlatform platform)
        {
            var targetFile = _fileSystem.PathCombine(_tmpDir!, GetProvisioningProfileFileName(platform));
 
            _helpers.DirectoryMutexExec(async () =>
            {
                if (_fileSystem.FileExists(targetFile))
                {
                    _log.LogMessage($"Using provisioning profile in {targetFile}");
                    return;
                }
 
                _log.LogMessage($"Downloading {platform} provisioning profile to {targetFile}");
 
                var uri = new Uri(GetProvisioningProfileUrl(platform));
                HttpResponseMessage? response = null;
 
                await _retryHandler.RunAsync(async _ =>
                {
                    try
                    {
                        response = await _httpClient.GetAsync(uri);
                        return response.IsSuccessStatusCode;
                    }
                    catch (Exception e)
                    {
                        _log.LogMessage("Failed to download provisioning profile: {error}", e);
                        return false;
                    }
                });
 
                if (response is null)
                {
                    throw new Exception("Failed to download provisioning profile. More details can be found with higher verbosity or in the binlog");
                }
 
                using (response)
                using (var fileStream = _fileSystem.GetFileStream(targetFile, FileMode.Create, FileAccess.Write))
                {
                    await response.Content.CopyToAsync(fileStream);
                }
            }, _tmpDir);
 
            return targetFile;
        }
 
        private string GetProvisioningProfileFileName(ApplePlatform platform)
            => _fileSystem.GetFileName(GetProvisioningProfileUrl(platform))
                ?? throw new InvalidOperationException("Failed to get provision profile file name");
 
        private string GetProvisioningProfileUrl(ApplePlatform platform)
            => _profileUrlTemplate!.Replace("{PLATFORM}", platform.ToString());
    }
 
    public static class ProvisioningProfileProviderRegistration
    {
        public static void TryAddProvisioningProfileProvider(this IServiceCollection collection, string provisioningProfileUrlTemplate, string tmpDir)
        {
            collection.TryAddTransient<IHelpers, Arcade.Common.Helpers>();
            collection.TryAddTransient<IFileSystem, FileSystem>();
            collection.TryAddTransient<IZipArchiveManager, ZipArchiveManager>();
            collection.TryAddTransient<IRetryHandler, ExponentialRetry>();
            collection.TryAddSingleton(_ => new HttpClient(new HttpClientHandler { CheckCertificateRevocationList = true }));
            collection.TryAddSingleton<IProvisioningProfileProvider>(serviceProvider =>
            {
                return new ProvisioningProfileProvider(
                    serviceProvider.GetRequiredService<TaskLoggingHelper>(),
                    serviceProvider.GetRequiredService<IHelpers>(),
                    serviceProvider.GetRequiredService<IFileSystem>(),
                    serviceProvider.GetRequiredService<IZipArchiveManager>(),
                    serviceProvider.GetRequiredService<HttpClient>(),
                    serviceProvider.GetRequiredService<IRetryHandler>(),
                    provisioningProfileUrlTemplate,
                    tmpDir);
            });
        }
    }
}