File: EnvFile.cs
Web Access
Project: src\src\Aspire.Hosting.Docker\Aspire.Hosting.Docker.csproj (Aspire.Hosting.Docker)
// 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;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Docker;
 
internal sealed record EnvEntry(string Key, string? Value, string? Comment);
 
internal sealed class EnvFile
{
    private string? _path;
    private readonly ILogger? _logger;
 
    internal SortedDictionary<string, EnvEntry> Entries { get; } = [];
 
    private EnvFile(ILogger? logger = null)
    {
        _logger = logger;
    }
 
    public static EnvFile Create(string path, ILogger? logger = null)
    {
        return new EnvFile(logger) { _path = path };
    }
 
    public static EnvFile Load(string path, ILogger? logger = null)
    {
        var envFile = new EnvFile(logger) { _path = path };
        if (!File.Exists(path))
        {
            return envFile;
        }
 
        string? currentComment = null;
 
        var content = File.ReadAllText(path);
        var position = 0;
        while (ReadLine(content, ref position, out var lineEnding) is { } line)
        {
            var trimmed = line.TrimStart();
            if (trimmed.StartsWith('#'))
            {
                // Extract comment text (remove # and trim)
                currentComment = trimmed.Length > 1 ? trimmed[1..].Trim() : string.Empty;
            }
            else if (TryParseKeyValue(line, out var key, out var value))
            {
                var trimmedValue = value.AsSpan().TrimStart();
                if (!trimmedValue.IsEmpty && trimmedValue[0] is '\'' or '"')
                {
                    var quote = trimmedValue[0];
                    var quoteStart = value.Length - trimmedValue.Length;
                    var closingQuote = FindClosingQuote(trimmedValue[1..], quote);
                    if (closingQuote < 0)
                    {
                        // Compose accepts values such as BANNER='hello\nworld'. Keep the raw
                        // quoted text, including blank lines, '#' and '=', so rewriting does
                        // not interpret value content as comments or additional entries.
                        // https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/#env-file-syntax
                        var multilineValue = new StringBuilder(value);
                        while (true)
                        {
                            multilineValue.Append(lineEnding);
                            var continuation = ReadLine(content, ref position, out lineEnding);
                            if (continuation is null)
                            {
                                throw new FormatException($"Unterminated quoted value for environment variable '{key}'.");
                            }
 
                            var continuationStart = multilineValue.Length;
                            multilineValue.Append(continuation);
                            closingQuote = FindClosingQuote(continuation, quote);
                            if (closingQuote >= 0)
                            {
                                closingQuote += continuationStart;
                                break;
                            }
                        }
 
                        value = multilineValue.ToString();
                    }
                    else
                    {
                        closingQuote += quoteStart + 1;
                    }
 
                    var suffix = value[(closingQuote + 1)..];
                    var trimmedSuffix = suffix.AsSpan().TrimStart();
                    if (!trimmedSuffix.IsEmpty && trimmedSuffix[0] != '#')
                    {
                        // Compose also accepts A='first\nsecond' B=value. Parse B separately;
                        // keeping it in A's raw value would change override precedence on sorting.
                        // See https://github.com/compose-spec/compose-go/blob/main/dotenv/parser.go.
                        position -= suffix.Length + lineEnding.Length;
                        value = value[..(closingQuote + 1)];
                    }
                }
                else
                {
                    // A terminal CR is trailing whitespace; only internal CRs belong to the value.
                    value = value.TrimEnd('\r');
                }
 
                envFile.Entries[key] = new EnvEntry(key, value, currentComment);
                currentComment = null; // Reset comment after associating it with a key
            }
            else
            {
                // Reset comment if we encounter a non-comment, non-key line
                currentComment = null;
            }
        }
        return envFile;
    }
 
    public void Add(string key, string? value, string? comment, bool onlyIfMissing = true)
    {
        if (Entries.ContainsKey(key) && onlyIfMissing)
        {
            return;
        }
 
        Entries[key] = new EnvEntry(key, value, comment);
    }
 
    private static bool TryParseKeyValue(string line, out string key, out string value)
    {
        key = string.Empty;
        value = string.Empty;
        var trimmed = line.TrimStart();
        if (!trimmed.StartsWith('#') && trimmed.Contains('='))
        {
            var eqIndex = trimmed.IndexOf('=');
            if (eqIndex > 0)
            {
                key = trimmed[..eqIndex].Trim();
                value = eqIndex < trimmed.Length - 1 ? trimmed[(eqIndex + 1)..] : string.Empty;
                return true;
            }
        }
        return false;
    }
 
    private static int FindClosingQuote(ReadOnlySpan<char> value, char quote)
    {
        for (var i = 0; i < value.Length; i++)
        {
            if (value[i] == '\\')
            {
                // A backslash escapes the next character, including a matching quote
                // or another backslash. An escaped quote cannot end the value.
                i++;
            }
            else if (value[i] == quote)
            {
                return i;
            }
        }
 
        return -1;
    }
 
    private static string? ReadLine(string content, ref int position, out string lineEnding)
    {
        lineEnding = string.Empty;
        if (position >= content.Length)
        {
            return null;
        }
 
        var start = position;
        // Compose ends unquoted values at LF: VALUE=one\rOTHER=two is one assignment.
        // Bare CR is content or whitespace between assignments, not a line delimiter.
        var relativeEnd = content.AsSpan(start).IndexOf('\n');
        if (relativeEnd < 0)
        {
            position = content.Length;
            return content[start..];
        }
 
        var end = start + relativeEnd;
        position = end + 1;
        if (end > start && content[end - 1] == '\r')
        {
            end--;
        }
        // Preserve the original separator inside quoted values. Compose treats a
        // carriage return as value content, so using Environment.NewLine can change it.
        lineEnding = content[end..position];
 
        return content[start..end];
    }
 
    public void Save()
    {
        if (_path is null)
        {
            throw new InvalidOperationException("Cannot save EnvFile without a path. Use Load() to create an EnvFile with a path.");
        }
 
        // Log if we're about to overwrite an existing file
        if (File.Exists(_path))
        {
            _logger?.LogInformation("Environment file '{EnvFilePath}' already exists and will be overwritten", _path);
        }
 
        var lines = new List<string>();
 
        foreach (var entry in Entries.Values)
        {
            if (!string.IsNullOrWhiteSpace(entry.Comment))
            {
                lines.Add($"# {entry.Comment}");
            }
            lines.Add(entry.Value is not null ? $"{entry.Key}={entry.Value}" : $"{entry.Key}=");
            lines.Add(string.Empty);
        }
 
        File.WriteAllLines(_path, lines);
    }
 
    public void Save(bool includeValues)
    {
        if (includeValues)
        {
            Save();
        }
        else
        {
            SaveKeysOnly();
        }
    }
 
    private void SaveKeysOnly()
    {
        if (_path is null)
        {
            throw new InvalidOperationException("Cannot save EnvFile without a path. Use Load() to create an EnvFile with a path.");
        }
 
        var lines = new List<string>();
 
        foreach (var entry in Entries.Values)
        {
            if (!string.IsNullOrWhiteSpace(entry.Comment))
            {
                lines.Add($"# {entry.Comment}");
            }
 
            // If the entry already has a non-empty value (loaded from disk), preserve it
            // This ensures user-modified values are not overwritten when we save keys only
            if (!string.IsNullOrEmpty(entry.Value))
            {
                lines.Add($"{entry.Key}={entry.Value}");
            }
            else
            {
                lines.Add($"{entry.Key}=");
            }
 
            lines.Add(string.Empty);
        }
 
        File.WriteAllLines(_path, lines);
    }
}