File: Agents\AspireSkills\AspireSkillsBundleProvider.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Semver;
 
namespace Aspire.Cli.Agents.AspireSkills;
 
/// <summary>
/// Creates and loads validated Aspire skills bundles.
/// </summary>
internal interface IAspireSkillsBundleProvider
{
    /// <summary>
    /// Creates an Aspire skills bundle from an archive and materializes its validated files
    /// in a dedicated staging directory.
    /// </summary>
    Task<AspireSkillsBundle> CreateAsync(
        FileInfo archive,
        DirectoryInfo bundleDirectory,
        string expectedArchiveSha512,
        CancellationToken cancellationToken,
        bool skipCompatibilityCheck = false);
 
    /// <summary>
    /// Loads an Aspire skills bundle from disk.
    /// </summary>
    Task<AspireSkillsBundle> LoadAsync(DirectoryInfo bundleDirectory, CancellationToken cancellationToken, bool skipCompatibilityCheck = false);
}
 
internal sealed class AspireSkillsBundleProvider : IAspireSkillsBundleProvider
{
    private const string ManifestFileName = "skill-manifest.json";
    private const string SkillsDirectoryName = "skills";
    private const string SkillFileName = "SKILL.md";
    private const int MaxSkillNameLength = 64;
    private const int MaxSkillDescriptionLength = 1024;
 
    private readonly string _currentCliVersion;
    private readonly string _currentSdkVersion;
 
    public AspireSkillsBundleProvider(CliExecutionContext executionContext)
    {
        ArgumentNullException.ThrowIfNull(executionContext);
 
        // The CLI and SDK share one resolved identity version. IdentitySdkVersion removes
        // build metadata so both values can be compared with manifest SemVer ranges.
        _currentCliVersion = executionContext.IdentitySdkVersion;
        _currentSdkVersion = executionContext.IdentitySdkVersion;
    }
 
    internal AspireSkillsBundleProvider()
        : this(VersionHelper.GetDefaultSdkVersion(), VersionHelper.GetDefaultSdkVersion())
    {
        // physical-binary-version-by-design (see docs/specs/cli-identity-sidecar.md):
        // this convenience constructor is only used by tests. Production resolves the
        // effective CLI identity through CliExecutionContext.
    }
 
    internal AspireSkillsBundleProvider(string currentCliVersion, string currentSdkVersion)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(currentCliVersion);
        ArgumentException.ThrowIfNullOrWhiteSpace(currentSdkVersion);
 
        _currentCliVersion = currentCliVersion;
        _currentSdkVersion = currentSdkVersion;
    }
 
    public async Task<AspireSkillsBundle> CreateAsync(
        FileInfo archive,
        DirectoryInfo bundleDirectory,
        string expectedArchiveSha512,
        CancellationToken cancellationToken,
        bool skipCompatibilityCheck = false)
    {
        ArgumentNullException.ThrowIfNull(archive);
        ArgumentNullException.ThrowIfNull(bundleDirectory);
        ArgumentException.ThrowIfNullOrWhiteSpace(expectedArchiveSha512);
 
        cancellationToken.ThrowIfCancellationRequested();
        ValidateArchiveSha512(archive.FullName, expectedArchiveSha512);
 
        Directory.CreateDirectory(bundleDirectory.FullName);
        var temporaryDirectoryRoot = bundleDirectory.Parent
            ?? throw new InvalidOperationException("The Aspire skills bundle staging directory must have a parent directory.");
        // Keep extraction beside the staging directory rather than inside it. If Windows AV or
        // indexing holds an extracted file open, best-effort cleanup must not block the later
        // atomic move that publishes the validated staging directory.
        using var extractionDirectory = TemporaryCacheDirectory.Create(
            temporaryDirectoryRoot.FullName,
            "extract",
            path => FileDeleteHelper.TryDeleteDirectory(path),
            path => FileDeleteHelper.TryDeleteFile(path));
 
        ExtractArchive(archive.FullName, extractionDirectory.FullName);
        cancellationToken.ThrowIfCancellationRequested();
 
        var bundleRoot = FindBundleRoot(extractionDirectory.FullName);
        var bundle = await LoadAsync(bundleRoot, cancellationToken, skipCompatibilityCheck).ConfigureAwait(false);
 
        CopyDirectory(bundleRoot.FullName, bundleDirectory.FullName);
        return bundle;
    }
 
    public async Task<AspireSkillsBundle> LoadAsync(
        DirectoryInfo bundleDirectory,
        CancellationToken cancellationToken,
        bool skipCompatibilityCheck = false)
    {
        ArgumentNullException.ThrowIfNull(bundleDirectory);
 
        var manifestPath = Path.Combine(bundleDirectory.FullName, ManifestFileName);
        if (!File.Exists(manifestPath))
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle manifest was not found at '{0}'.", manifestPath));
        }
 
        SkillBundleManifest? manifest;
        try
        {
            await using var manifestStream = File.OpenRead(manifestPath);
            manifest = await JsonSerializer.DeserializeAsync(
                manifestStream,
                AspireSkillsJsonSerializerContext.Default.SkillBundleManifest,
                cancellationToken).ConfigureAwait(false);
        }
        catch (JsonException ex)
        {
            throw new InvalidOperationException("Aspire skills bundle manifest is invalid.", ex);
        }
 
        if (manifest is null)
        {
            throw new InvalidOperationException("Aspire skills bundle manifest is empty or invalid.");
        }
 
        cancellationToken.ThrowIfCancellationRequested();
        return CreateBundle(bundleDirectory, manifest, _currentCliVersion, _currentSdkVersion, skipCompatibilityCheck);
    }
 
    private static AspireSkillsBundle CreateBundle(
        DirectoryInfo bundleDirectory,
        SkillBundleManifest manifest,
        string currentCliVersion,
        string currentSdkVersion,
        bool skipCompatibilityCheck)
    {
        var version = manifest.Version;
        if (string.IsNullOrWhiteSpace(version))
        {
            throw new InvalidOperationException("Aspire skills bundle manifest must specify a version.");
        }
 
        // The bundle's `supports` range gates remotely acquired bundles, including cache
        // entries that another CLI version may have written. The exact snapshot embedded
        // in the current CLI may skip this check because its stamped range can lag the
        // binary version (e.g., a dogfood build of 13.5.x using a snapshot stamped
        // ">=13.4.0 <13.5.0").
        if (!skipCompatibilityCheck)
        {
            ValidateCompatibility(manifest.Supports, currentCliVersion, currentSdkVersion);
        }
 
        var skills = manifest.Skills;
        if (skills is not { Length: > 0 })
        {
            throw new InvalidOperationException("Aspire skills bundle manifest must contain at least one skill.");
        }
 
        var skillNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        List<ValidatedAspireSkill> validatedSkills = [];
        foreach (var skill in skills)
        {
            if (skill is null)
            {
                throw new InvalidOperationException("Aspire skills bundle manifest contains an empty skill entry.");
            }
 
            var skillName = skill.Name;
            if (string.IsNullOrWhiteSpace(skillName))
            {
                throw new InvalidOperationException("Aspire skills bundle manifest contains a skill without a name.");
            }
 
            ValidateSkillName(skillName);
            if (!skillNames.Add(skillName))
            {
                throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle manifest contains duplicate skill '{0}'.", skillName));
            }
 
            if (string.IsNullOrWhiteSpace(skill.Description))
            {
                throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must specify a description.", skillName));
            }
 
            var skillFiles = skill.Files;
            if (skillFiles is not { Length: > 0 })
            {
                throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' does not contain any files.", skillName));
            }
 
            var installExcludedRelativePaths = (skill.InstallExcludedRelativePaths ?? [])
                .Select(NormalizeRelativePath)
                .ToArray();
            if (installExcludedRelativePaths.Contains(SkillFileName, StringComparer.OrdinalIgnoreCase))
            {
                throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' cannot exclude SKILL.md from installation.", skillName));
            }
 
            var definition = SkillDefinition.CreateAspireSkillsBundle(
                skillName,
                skill.Description,
                installExcludedRelativePaths,
                skill.ApplicableLanguages ?? []);
 
            var filePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var hasSkillFile = false;
            List<SkillAssetFile> files = [];
            foreach (var file in skillFiles)
            {
                if (file is null)
                {
                    throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' contains an empty file entry.", skillName));
                }
 
                var validatedFile = ValidateFile(bundleDirectory, skillName, file);
                if (!filePaths.Add(validatedFile.RelativePath))
                {
                    throw new InvalidOperationException(string.Format(
                        CultureInfo.InvariantCulture,
                        "Aspire skills bundle skill '{0}' contains duplicate file '{1}'.",
                        skillName,
                        validatedFile.RelativePath));
                }
 
                files.Add(validatedFile);
                hasSkillFile |= string.Equals(validatedFile.RelativePath, SkillFileName, StringComparison.Ordinal);
            }
 
            if (!hasSkillFile)
            {
                throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must contain SKILL.md.", skillName));
            }
 
            validatedSkills.Add(new ValidatedAspireSkill(definition, files));
        }
 
        return new AspireSkillsBundle(version, validatedSkills);
    }
 
    private static void ValidateSkillName(string skillName)
    {
        // Agent hosts use this grammar to discover skills consistently.
        // See https://agentskills.io/specification.
        if (skillName.Length > MaxSkillNameLength ||
            skillName[0] == '-' ||
            skillName[^1] == '-' ||
            skillName.Contains("--", StringComparison.Ordinal) ||
            skillName.Any(static character => !char.IsAsciiLetterLower(character) && !char.IsAsciiDigit(character) && character is not '-'))
        {
            throw new InvalidOperationException(string.Format(
                CultureInfo.InvariantCulture,
                "Aspire skills bundle skill name '{0}' must be 1-{1} characters, use only lowercase ASCII letters, digits, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.",
                skillName,
                MaxSkillNameLength));
        }
    }
 
    private static SkillAssetFile ValidateFile(DirectoryInfo bundleDirectory, string skillName, SkillBundleFile file)
    {
        var relativePath = NormalizeRelativePath(file.RelativePath);
        var fullPath = Path.Combine(bundleDirectory.FullName, SkillsDirectoryName, skillName, relativePath);
        if (!File.Exists(fullPath))
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' was not found.", relativePath, skillName));
        }
 
        // Hash and decode the same bytes so a concurrent filesystem change cannot
        // produce validated content that differs from the content retained in memory.
        var bytes = File.ReadAllBytes(fullPath);
        string expectedHash;
        string actualHash;
        string algorithmName;
        // The attestation-verified v0.0.1 archive predates the SHA-512 switch and cannot
        // be rebuilt without changing its signed subject digest. Prefer SHA-512 for current
        // bundles while continuing to validate that embedded archive's per-file SHA-256 hashes.
        if (!string.IsNullOrWhiteSpace(file.Sha512))
        {
            expectedHash = NormalizeSha512(file.Sha512);
            actualHash = Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant();
            algorithmName = "SHA-512";
        }
        else if (!string.IsNullOrWhiteSpace(file.Sha256))
        {
            expectedHash = NormalizeSha256(file.Sha256);
            actualHash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
            algorithmName = "SHA-256";
        }
        else
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' does not specify a SHA-512 or SHA-256 hash.", relativePath, skillName));
        }
 
        if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' failed {2} verification.", relativePath, skillName, algorithmName));
        }
 
        using var stream = new MemoryStream(bytes, writable: false);
        using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
        var content = reader.ReadToEnd();
        if (string.Equals(relativePath, SkillFileName, StringComparison.Ordinal))
        {
            ValidateSkillFileFrontmatter(skillName, content);
        }
 
        return new SkillAssetFile(relativePath, content);
    }
 
    private static void ValidateSkillFileFrontmatter(string skillName, string content)
    {
        var frontmatterName = GetFrontmatterValue(content, "name");
        if (string.IsNullOrWhiteSpace(frontmatterName))
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must define a frontmatter name in SKILL.md.", skillName));
        }
 
        if (!string.Equals(frontmatterName, skillName, StringComparison.Ordinal))
        {
            throw new InvalidOperationException(string.Format(
                CultureInfo.InvariantCulture,
                "Aspire skills bundle skill '{0}' SKILL.md frontmatter name '{1}' must match its manifest and directory name.",
                skillName,
                frontmatterName));
        }
 
        var description = GetFrontmatterValue(content, "description");
        if (string.IsNullOrWhiteSpace(description))
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must define a frontmatter description in SKILL.md.", skillName));
        }
 
        if (description.Length > MaxSkillDescriptionLength)
        {
            throw new InvalidOperationException(string.Format(
                CultureInfo.InvariantCulture,
                "Aspire skills bundle skill '{0}' SKILL.md description is {1} characters; agent hosts accept at most {2}.",
                skillName,
                description.Length,
                MaxSkillDescriptionLength));
        }
    }
 
    private static string? GetFrontmatterValue(string content, string key)
    {
        var normalizedContent = content.ReplaceLineEndings("\n");
        if (!normalizedContent.StartsWith("---\n", StringComparison.Ordinal))
        {
            return null;
        }
 
        var frontmatterEndIndex = normalizedContent.IndexOf("\n---\n", 4, StringComparison.Ordinal);
        if (frontmatterEndIndex < 0)
        {
            return null;
        }
 
        // Skill files use simple YAML frontmatter:
        //   ---
        //   name: aspire
        //   description: "Use when working with an Aspire distributed application"
        //   ---
        // Agent hosts read these fields directly, so validate the bundled SKILL.md
        // before caching content that they would reject or ignore.
        var frontmatter = normalizedContent[4..frontmatterEndIndex];
        var keyPrefix = $"{key}:";
        foreach (var line in frontmatter.Split('\n'))
        {
            if (!line.StartsWith(keyPrefix, StringComparison.Ordinal))
            {
                continue;
            }
 
            var value = line[keyPrefix.Length..].Trim();
            return value.Length >= 2 &&
                   ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\''))
                ? value[1..^1]
                : value;
        }
 
        return null;
    }
 
    internal static string NormalizeRelativePath(string? relativePath)
    {
        if (string.IsNullOrWhiteSpace(relativePath))
        {
            throw new InvalidOperationException("Aspire skills bundle contains an empty relative path.");
        }
 
        var normalizedPath = relativePath
            .Replace('/', Path.DirectorySeparatorChar)
            .Replace('\\', Path.DirectorySeparatorChar);
 
        if (Path.IsPathRooted(normalizedPath))
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle path '{0}' must be relative.", relativePath));
        }
 
        var segments = normalizedPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
        if (segments.Length == 0 || segments.Any(static segment => !IsPortablePathSegment(segment)))
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle path '{0}' is not safe.", relativePath));
        }
 
        return Path.Combine(segments);
    }
 
    private static bool IsPortablePathSegment(string segment)
    {
        // Bundle paths can be validated on one platform and installed on another. Reject the
        // Windows-invalid character set everywhere so ':' cannot create an NTFS alternate data
        // stream and other invalid filenames cannot enter a cached bundle.
        // See https://learn.microsoft.com/windows/win32/fileio/naming-a-file.
        return segment is not "." and not ".." &&
            !segment.Any(static character => char.IsControl(character) || character is '<' or '>' or ':' or '"' or '|' or '?' or '*');
    }
 
    internal static string NormalizeSha512(string sha512)
    {
        return sha512.StartsWith("sha512-", StringComparison.OrdinalIgnoreCase) ||
            sha512.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase)
                ? sha512[7..]
                : sha512;
    }
 
    internal static string NormalizeSha256(string sha256)
    {
        return sha256.StartsWith("sha256-", StringComparison.OrdinalIgnoreCase) ||
            sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
                ? sha256[7..]
                : sha256;
    }
 
    private static void ValidateArchiveSha512(string archivePath, string expectedSha512)
    {
        var expectedHash = NormalizeSha512(expectedSha512);
        using var stream = File.OpenRead(archivePath);
        var actualHash = Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant();
 
        if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException(string.Format(
                CultureInfo.CurrentCulture,
                AgentCommandStrings.AspireSkillsInstaller_ArchiveHashVerificationFailed,
                expectedHash,
                actualHash));
        }
    }
 
    private static void ExtractArchive(string archivePath, string destinationDirectory)
    {
        if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
        {
            ExtractZipArchive(archivePath, destinationDirectory);
            return;
        }
 
        ExtractTarball(archivePath, destinationDirectory);
    }
 
    private static void ExtractTarball(string tarballPath, string destinationDirectory)
    {
        var destinationRoot = Path.GetFullPath(destinationDirectory);
        Directory.CreateDirectory(destinationRoot);
 
        using var fileStream = File.OpenRead(tarballPath);
        using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
        using var tarReader = new TarReader(gzipStream);
 
        while (tarReader.GetNextEntry() is { } entry)
        {
            if (string.IsNullOrWhiteSpace(entry.Name))
            {
                continue;
            }
 
            var destinationPath = GetSafeArchiveDestinationPath(destinationRoot, entry.Name);
 
            switch (entry.EntryType)
            {
                case TarEntryType.Directory:
                    Directory.CreateDirectory(destinationPath);
                    break;
 
                case TarEntryType.RegularFile:
                case TarEntryType.V7RegularFile:
                    var destinationFileDirectory = Path.GetDirectoryName(destinationPath);
                    if (!string.IsNullOrEmpty(destinationFileDirectory))
                    {
                        Directory.CreateDirectory(destinationFileDirectory);
                    }
 
                    entry.ExtractToFile(destinationPath, overwrite: false);
                    break;
 
                case TarEntryType.GlobalExtendedAttributes:
                case TarEntryType.ExtendedAttributes:
                    break;
 
                default:
                    throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' has unsupported type '{1}'.", entry.Name, entry.EntryType));
            }
        }
    }
 
    private static void ExtractZipArchive(string archivePath, string destinationDirectory)
    {
        var destinationRoot = Path.GetFullPath(destinationDirectory);
        Directory.CreateDirectory(destinationRoot);
 
        using var archive = ZipFile.OpenRead(archivePath);
        foreach (var entry in archive.Entries)
        {
            if (string.IsNullOrWhiteSpace(entry.FullName))
            {
                continue;
            }
 
            var destinationPath = GetSafeArchiveDestinationPath(destinationRoot, entry.FullName);
            if (entry.FullName.EndsWith("/", StringComparison.Ordinal) || entry.FullName.EndsWith("\\", StringComparison.Ordinal))
            {
                Directory.CreateDirectory(destinationPath);
                continue;
            }
 
            var destinationFileDirectory = Path.GetDirectoryName(destinationPath);
            if (!string.IsNullOrEmpty(destinationFileDirectory))
            {
                Directory.CreateDirectory(destinationFileDirectory);
            }
 
            entry.ExtractToFile(destinationPath, overwrite: false);
        }
    }
 
    private static string GetSafeArchiveDestinationPath(string destinationRoot, string entryName)
    {
        var normalizedEntryName = entryName.Replace('\\', '/');
        var segments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
        if (Path.IsPathRooted(normalizedEntryName) ||
            segments.Length == 0 ||
            segments.Any(static segment => !IsPortablePathSegment(segment)))
        {
            throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' is not safe.", entryName));
        }
 
        var destinationPath = Path.GetFullPath(Path.Combine(destinationRoot, normalizedEntryName.Replace('/', Path.DirectorySeparatorChar)));
        if (!destinationPath.StartsWith(destinationRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
            !string.Equals(destinationPath, destinationRoot, StringComparison.Ordinal))
        {
            throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' escapes the extraction directory.", entryName));
        }
 
        return destinationPath;
    }
 
    private static DirectoryInfo FindBundleRoot(string extractionDirectory)
    {
        var rootManifestPath = Path.Combine(extractionDirectory, ManifestFileName);
        if (File.Exists(rootManifestPath))
        {
            return new DirectoryInfo(extractionDirectory);
        }
 
        var packageDirectory = Path.Combine(extractionDirectory, "package");
        var packageManifestPath = Path.Combine(packageDirectory, ManifestFileName);
        if (File.Exists(packageManifestPath))
        {
            return new DirectoryInfo(packageDirectory);
        }
 
        var topLevelBundleDirectories = Directory
            .EnumerateDirectories(extractionDirectory)
            .Where(directory => File.Exists(Path.Combine(directory, ManifestFileName)))
            .ToArray();
 
        if (topLevelBundleDirectories.Length == 1)
        {
            return new DirectoryInfo(topLevelBundleDirectories[0]);
        }
 
        if (topLevelBundleDirectories.Length > 1)
        {
            throw new InvalidOperationException("Downloaded Aspire skills package contains multiple skill-manifest.json files.");
        }
 
        throw new InvalidOperationException("Downloaded Aspire skills package does not contain skill-manifest.json.");
    }
 
    private static void CopyDirectory(string sourceDirectory, string targetDirectory)
    {
        Directory.CreateDirectory(targetDirectory);
 
        foreach (var sourceFile in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories))
        {
            var relativePath = Path.GetRelativePath(sourceDirectory, sourceFile);
            var targetFile = Path.Combine(targetDirectory, relativePath);
            var targetFileDirectory = Path.GetDirectoryName(targetFile);
            if (!string.IsNullOrEmpty(targetFileDirectory))
            {
                Directory.CreateDirectory(targetFileDirectory);
            }
 
            File.Copy(sourceFile, targetFile, overwrite: true);
        }
    }
 
    private static void ValidateCompatibility(SkillBundleSupports? supports, string currentCliVersion, string currentSdkVersion)
    {
        if (supports is null)
        {
            throw new InvalidOperationException("Aspire skills bundle manifest must specify supported Aspire versions.");
        }
 
        if (string.IsNullOrWhiteSpace(supports.AspireCli))
        {
            throw new InvalidOperationException("Aspire skills bundle manifest must specify supports.aspireCli.");
        }
 
        if (!IsVersionInRange(currentCliVersion, supports.AspireCli))
        {
            throw new InvalidOperationException(string.Format(
                CultureInfo.InvariantCulture,
                "Aspire skills bundle supports Aspire CLI versions '{0}', but the current CLI version is '{1}'.",
                supports.AspireCli,
                currentCliVersion));
        }
 
        if (!string.IsNullOrWhiteSpace(supports.AspireSdk) &&
            !IsVersionInRange(currentSdkVersion, supports.AspireSdk))
        {
            throw new InvalidOperationException(string.Format(
                CultureInfo.InvariantCulture,
                "Aspire skills bundle supports Aspire SDK versions '{0}', but the current SDK version is '{1}'.",
                supports.AspireSdk,
                currentSdkVersion));
        }
    }
 
    private static bool IsVersionInRange(string version, string range)
    {
        var normalizedVersion = ParseCompatibilityVersion(version);
        var comparators = range.Replace(',', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (comparators.Length == 0)
        {
            throw new InvalidOperationException("Aspire skills bundle contains an empty version range.");
        }
 
        foreach (var comparator in comparators)
        {
            if (comparator is "*" or "x" or "X")
            {
                continue;
            }
 
            if (!SatisfiesComparator(normalizedVersion, comparator))
            {
                return false;
            }
        }
 
        return true;
    }
 
    private static bool SatisfiesComparator(SemVersion version, string comparator)
    {
        var (op, operandText) = ParseComparator(comparator);
        var operand = ParseCompatibilityVersion(operandText);
        var comparison = SemVersion.ComparePrecedence(version, operand);
 
        return op switch
        {
            ">" => comparison > 0,
            ">=" => comparison >= 0,
            "<" => comparison < 0,
            "<=" => comparison <= 0,
            "=" or "==" => comparison == 0,
            _ => throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Unsupported Aspire skills bundle version comparator '{0}'.", op))
        };
    }
 
    private static (string Operator, string Operand) ParseComparator(string comparator)
    {
        foreach (var op in new[] { ">=", "<=", "==", ">", "<", "=" })
        {
            if (comparator.StartsWith(op, StringComparison.Ordinal))
            {
                var operand = comparator[op.Length..];
                if (string.IsNullOrWhiteSpace(operand))
                {
                    throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle contains an invalid version comparator '{0}'.", comparator));
                }
 
                return (op, operand);
            }
        }
 
        return ("=", comparator);
    }
 
    private static SemVersion ParseCompatibilityVersion(string version)
    {
        if (!SemVersion.TryParse(version, SemVersionStyles.Any, out var parsedVersion))
        {
            throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle contains an invalid version value '{0}'.", version));
        }
 
        return SemVersion.Parse(
            string.Create(
                CultureInfo.InvariantCulture,
                $"{parsedVersion.Major}.{parsedVersion.Minor}.{parsedVersion.Patch}"),
            SemVersionStyles.Strict);
    }
}