File: Acquisition\InstallSidecarWriter.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.Text.Json;
 
namespace Aspire.Cli.Acquisition;
 
/// <summary>
/// Prepares install-route sidecar updates while preserving
/// route-specific and forward-compatible fields.
/// </summary>
internal static class InstallSidecarWriter
{
    /// <summary>
    /// Prepares an atomic sidecar update for a CLI self-update.
    /// The selected channel is written while version and commit are removed so the
    /// replacement binary's assembly metadata supplies those executable-specific values.
    /// A missing sidecar is left absent because the update path cannot infer the original
    /// install route's required <c>source</c> value.
    /// </summary>
    /// <param name="binaryDirectory">Directory containing the CLI binary.</param>
    /// <param name="channel">Channel selected for the installed CLI.</param>
    /// <returns>The prepared update, or <see langword="null"/> when no sidecar exists.</returns>
    internal static PreparedInstallSidecarUpdate? PrepareForSelfUpdate(string binaryDirectory, string channel)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(binaryDirectory);
        ArgumentException.ThrowIfNullOrWhiteSpace(channel);
 
        var sidecarPath = Path.Combine(binaryDirectory, InstallSidecarReader.SidecarFileName);
        if (!File.Exists(sidecarPath))
        {
            return null;
        }
 
        using var existingSidecar = ReadExistingSidecar(sidecarPath);
 
        var temporaryPath = $"{sidecarPath}.{Guid.NewGuid():N}.tmp";
        try
        {
            using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
            {
                using (var writer = new Utf8JsonWriter(stream))
                {
                    writer.WriteStartObject();
 
                    foreach (var property in existingSidecar.RootElement.EnumerateObject())
                    {
                        if (!property.NameEquals("channel") &&
                            !property.NameEquals("version") &&
                            !property.NameEquals("commit"))
                        {
                            property.WriteTo(writer);
                        }
                    }
 
                    writer.WriteString("channel", channel);
                    writer.WriteEndObject();
                }
 
                stream.WriteByte((byte)'\n');
                if (stream.Position > InstallSidecarReader.MaxSidecarBytes)
                {
                    throw new InvalidDataException(
                        $"Prepared sidecar file size {stream.Position} bytes exceeds the {InstallSidecarReader.MaxSidecarBytes}-byte limit.");
                }
 
                stream.Flush(flushToDisk: true);
            }
 
            return new PreparedInstallSidecarUpdate(temporaryPath, sidecarPath);
        }
        catch
        {
            if (File.Exists(temporaryPath))
            {
                File.Delete(temporaryPath);
            }
 
            throw;
        }
    }
 
    private static JsonDocument ReadExistingSidecar(string sidecarPath)
    {
        var length = new FileInfo(sidecarPath).Length;
        if (length > InstallSidecarReader.MaxSidecarBytes)
        {
            throw new InvalidDataException(
                $"Sidecar file size {length} bytes exceeds the {InstallSidecarReader.MaxSidecarBytes}-byte limit.");
        }
 
        using var stream = File.OpenRead(sidecarPath);
        var document = JsonDocument.Parse(stream);
        if (document.RootElement.ValueKind != JsonValueKind.Object)
        {
            document.Dispose();
            throw new InvalidDataException("Install sidecar root must be a JSON object.");
        }
 
        return document;
    }
}
 
/// <summary>
/// Represents a sidecar update that has been fully written and is ready to commit atomically.
/// </summary>
internal sealed class PreparedInstallSidecarUpdate(string temporaryPath, string sidecarPath) : IDisposable
{
    private string? _temporaryPath = temporaryPath;
 
    /// <summary>
    /// Replaces the existing sidecar with the prepared update.
    /// </summary>
    internal void Commit()
    {
        if (_temporaryPath is null)
        {
            throw new InvalidOperationException("The prepared sidecar update has already been committed or disposed.");
        }
 
        File.Move(_temporaryPath, sidecarPath, overwrite: true);
        _temporaryPath = null;
    }
 
    public void Dispose()
    {
        if (_temporaryPath is not null && File.Exists(_temporaryPath))
        {
            File.Delete(_temporaryPath);
        }
 
        _temporaryPath = null;
    }
}