File: EnvFileTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Docker.Tests\Aspire.Hosting.Docker.Tests.csproj (Aspire.Hosting.Docker.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Hosting.Docker.Tests;
 
public class EnvFileTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData('\'', true)]
    [InlineData('\'', false)]
    [InlineData('"', true)]
    [InlineData('"', false)]
    public async Task Load_MultilineQuotedValue_PreservesContentOnSave(char quote, bool includeValues)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        var value = string.Join(Environment.NewLine, [
            $"  {quote}hello \\{quote}world",
            "",
            "# This is part of the value",
            "INNER=value",
            $"goodbye{quote} # Trailing comment"
        ]);
        File.WriteAllText(envFilePath, $"# Banner description{Environment.NewLine}BANNER={value}{Environment.NewLine}TAIL=preserved{Environment.NewLine}");
 
        var envFile = EnvFile.Load(envFilePath);
 
        Assert.Collection(envFile.Entries.Values,
            entry => Assert.Equal(new EnvEntry("BANNER", value, "Banner description"), entry),
            entry => Assert.Equal(new EnvEntry("TAIL", "preserved", null), entry));
 
        envFile.Add("NEW_KEY", null, "New publisher placeholder");
        envFile.Save(includeValues);
 
        await Verify(File.ReadAllText(envFilePath), "env")
            .UseParameters(quote == '\'' ? "single" : "double", includeValues);
    }
 
    [Theory]
    [InlineData("unquoted ' value")]
    [InlineData("'single' # comment with '")]
    [InlineData("\"double\" # comment with \"")]
    [InlineData("'escaped \\' quote'")]
    [InlineData("\"escaped \\\" quote\"")]
    [InlineData("'trailing backslashes \\\\'")]
    [InlineData("\"trailing backslashes \\\\\"")]
    public void Load_SingleLineValue_DoesNotConsumeNextEntry(string value)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        File.WriteAllLines(envFilePath, [$"VALUE={value}", "AFTER=preserved"]);
 
        var envFile = EnvFile.Load(envFilePath);
 
        Assert.Collection(envFile.Entries.Values,
            entry => Assert.Equal(new EnvEntry("AFTER", "preserved", null), entry),
            entry => Assert.Equal(new EnvEntry("VALUE", value, null), entry));
    }
 
    [Theory]
    [InlineData("'\n'")]
    [InlineData("\"\n\"")]
    [InlineData("'hello\nescaped \\' quote\nworld'")]
    [InlineData("\"hello\nescaped \\\" quote\nworld\"")]
    [InlineData("'hello\ntrailing backslashes \\\\'")]
    [InlineData("\"hello\ntrailing backslashes \\\\\"")]
    public void Load_MultilineQuotedValue_DoesNotConsumeNextEntry(string value)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        value = value.Replace("\n", Environment.NewLine);
        File.WriteAllText(envFilePath, $"VALUE={value}{Environment.NewLine}AFTER=preserved{Environment.NewLine}");
 
        var envFile = EnvFile.Load(envFilePath);
 
        Assert.Collection(envFile.Entries.Values,
            entry => Assert.Equal(new EnvEntry("AFTER", "preserved", null), entry),
            entry => Assert.Equal(new EnvEntry("VALUE", value, null), entry));
    }
 
    [Theory]
    [InlineData("\n")]
    [InlineData("\r\n")]
    [InlineData("\r")]
    public void Load_MultilineQuotedValue_PreservesOriginalLineEndings(string lineEnding)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        var value = $"'hello{lineEnding}world'";
        File.WriteAllText(envFilePath, $"VALUE={value}{lineEnding}AFTER=preserved{lineEnding}");
 
        var envFile = EnvFile.Load(envFilePath);
        Assert.Equal(value, envFile.Entries["VALUE"].Value);
        envFile.Save(includeValues: false);
        var reloaded = EnvFile.Load(envFilePath);
 
        Assert.Collection(reloaded.Entries.Values,
            entry => Assert.Equal(new EnvEntry("AFTER", "preserved", null), entry),
            entry => Assert.Equal(new EnvEntry("VALUE", value, null), entry));
    }
 
    [Theory]
    [InlineData('\'')]
    [InlineData('"')]
    public void Load_UnterminatedQuotedValue_ThrowsWithoutRewritingFile(char quote)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        File.WriteAllLines(envFilePath, [$"VALUE={quote}hello", "world"]);
 
        var exception = Assert.Throws<FormatException>(() => EnvFile.Load(envFilePath));
 
        Assert.Equal("Unterminated quoted value for environment variable 'VALUE'.", exception.Message);
        Assert.Equal([$"VALUE={quote}hello", "world"], File.ReadAllLines(envFilePath));
    }
 
    [Theory]
    [InlineData('\'')]
    [InlineData('"')]
    public void Load_BackslashBeforeLineEnding_DoesNotEscapeClosingQuoteOnNextLine(char quote)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        var value = $"{quote}one\\{Environment.NewLine}{quote}";
        File.WriteAllText(envFilePath, $"VALUE={value}{Environment.NewLine}AFTER=preserved{Environment.NewLine}");
 
        var envFile = EnvFile.Load(envFilePath);
        Assert.Collection(envFile.Entries.Values,
            entry => Assert.Equal(new EnvEntry("AFTER", "preserved", null), entry),
            entry => Assert.Equal(new EnvEntry("VALUE", value, null), entry));
 
        envFile.Save(includeValues: false);
        var reloaded = EnvFile.Load(envFilePath);
        Assert.Collection(reloaded.Entries.Values,
            entry => Assert.Equal(new EnvEntry("AFTER", "preserved", null), entry),
            entry => Assert.Equal(new EnvEntry("VALUE", value, null), entry));
    }
 
    [Theory]
    [InlineData('\'')]
    [InlineData('"')]
    public void Load_MultilineClosingQuoteAtEndOfFile_DoesNotRequireFinalLineEnding(char quote)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        var value = $"{quote}one{Environment.NewLine}two{quote}";
        File.WriteAllText(envFilePath, $"VALUE={value}");
 
        var envFile = EnvFile.Load(envFilePath);
        Assert.Equal(new EnvEntry("VALUE", value, null), Assert.Single(envFile.Entries.Values));
 
        envFile.Save(includeValues: false);
        var reloaded = EnvFile.Load(envFilePath);
        Assert.Equal(new EnvEntry("VALUE", value, null), Assert.Single(reloaded.Entries.Values));
    }
 
    [Theory]
    [InlineData('\'', true)]
    [InlineData('\'', false)]
    [InlineData('"', true)]
    [InlineData('"', false)]
    public void Load_AssignmentAfterMultilineClosingQuote_PreservesOverridePrecedence(char quote, bool includeValues)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        var value = $"{quote}hello{Environment.NewLine}world{quote}";
        File.WriteAllText(envFilePath, $"Z={value} INNER=evil{Environment.NewLine}INNER=safe{Environment.NewLine}");
 
        var envFile = EnvFile.Load(envFilePath);
        Assert.Collection(envFile.Entries.Values,
            entry => Assert.Equal(new EnvEntry("INNER", "safe", null), entry),
            entry => Assert.Equal(new EnvEntry("Z", value, null), entry));
 
        envFile.Save(includeValues);
        var reloaded = EnvFile.Load(envFilePath);
        Assert.Collection(reloaded.Entries.Values,
            entry => Assert.Equal(new EnvEntry("INNER", "safe", null), entry),
            entry => Assert.Equal(new EnvEntry("Z", value, null), entry));
    }
 
    [Fact]
    public void Load_AdjacentMultilineQuotedAssignments_ParsesEachEntry()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        var firstValue = $"'first{Environment.NewLine}value'";
        var secondValue = $"\"second{Environment.NewLine}value\"";
        File.WriteAllText(envFilePath, $"Z={firstValue} A={secondValue} EXTRA=inline{Environment.NewLine}TAIL=preserved{Environment.NewLine}");
 
        var envFile = EnvFile.Load(envFilePath);
        Assert.Collection(envFile.Entries.Values,
            entry => Assert.Equal(new EnvEntry("A", secondValue, null), entry),
            entry => Assert.Equal(new EnvEntry("EXTRA", "inline", null), entry),
            entry => Assert.Equal(new EnvEntry("TAIL", "preserved", null), entry),
            entry => Assert.Equal(new EnvEntry("Z", firstValue, null), entry));
 
        envFile.Save(includeValues: false);
        var reloaded = EnvFile.Load(envFilePath);
        Assert.Collection(reloaded.Entries.Values,
            entry => Assert.Equal(new EnvEntry("A", secondValue, null), entry),
            entry => Assert.Equal(new EnvEntry("EXTRA", "inline", null), entry),
            entry => Assert.Equal(new EnvEntry("TAIL", "preserved", null), entry),
            entry => Assert.Equal(new EnvEntry("Z", firstValue, null), entry));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void Load_BareCarriageReturnsInUnquotedValue_DoNotCreateAdditionalEntries(bool includeValues)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
        const string value = "one\rINNER=evil\rAFTER=preserved";
        File.WriteAllText(envFilePath, $"VALUE={value}\r");
 
        var envFile = EnvFile.Load(envFilePath);
        Assert.Equal(new EnvEntry("VALUE", value, null), Assert.Single(envFile.Entries.Values));
 
        envFile.Save(includeValues);
        var reloaded = EnvFile.Load(envFilePath);
        Assert.Equal(new EnvEntry("VALUE", value, null), Assert.Single(reloaded.Entries.Values));
    }
 
    [Fact]
    public void Add_WithOnlyIfMissingTrue_DoesNotAddDuplicate()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
 
        // Create initial .env file
        File.WriteAllLines(envFilePath, [
            "# Comment for KEY1",
            "KEY1=value1",
            ""
        ]);
 
        // Load and try to add the same key with onlyIfMissing=true
        var envFile = EnvFile.Load(envFilePath);
        envFile.Add("KEY1", "value2", "New comment", onlyIfMissing: true);
        envFile.Save();
 
        var lines = File.ReadAllLines(envFilePath);
        var keyLines = lines.Where(l => l.StartsWith("KEY1=")).ToArray();
 
        // Should still have only one KEY1 line with original value
        Assert.Single(keyLines);
        Assert.Equal("KEY1=value1", keyLines[0]);
    }
 
    [Fact]
    public void Add_WithOnlyIfMissingFalse_UpdatesExistingKey()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
 
        // Create initial .env file
        File.WriteAllLines(envFilePath, [
            "# Comment for KEY1",
            "KEY1=value1",
            ""
        ]);
 
        // Load and try to add the same key with onlyIfMissing=false
        var envFile = EnvFile.Load(envFilePath);
        envFile.Add("KEY1", "value2", "New comment", onlyIfMissing: false);
        envFile.Save();
 
        var lines = File.ReadAllLines(envFilePath);
        var keyLines = lines.Where(l => l.StartsWith("KEY1=")).ToArray();
 
        // Should still have only one KEY1 line, but with updated value
        Assert.Single(keyLines);
        Assert.Equal("KEY1=value2", keyLines[0]);
    }
 
    [Fact]
    public void Add_WithOnlyIfMissingFalse_UpdatesImageNameWithoutDuplication()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
 
        // Create initial .env file simulating a project resource
        File.WriteAllLines(envFilePath, [
            "# Default container port for project1",
            "PROJECT1_PORT=8080",
            "",
            "# Container image name for project1",
            "PROJECT1_IMAGE=project1:latest",
            ""
        ]);
 
        // Load the file
        var envFile = EnvFile.Load(envFilePath);
 
        // Add PORT with onlyIfMissing=true (should be skipped since it exists)
        envFile.Add("PROJECT1_PORT", "8080", "Default container port for project1", onlyIfMissing: true);
 
        // Add IMAGE with onlyIfMissing=false (should update the existing value)
        envFile.Add("PROJECT1_IMAGE", "project1:1.0.0", "Container image name for project1", onlyIfMissing: false);
 
        envFile.Save();
 
        var lines = File.ReadAllLines(envFilePath);
        var imageLines = lines.Where(l => l.StartsWith("PROJECT1_IMAGE=")).ToArray();
 
        // Should have exactly one IMAGE line with the new value
        Assert.Single(imageLines);
        Assert.Equal("PROJECT1_IMAGE=project1:1.0.0", imageLines[0]);
 
        // PORT should also still be present once
        var portLines = lines.Where(l => l.StartsWith("PROJECT1_PORT=")).ToArray();
        Assert.Single(portLines);
        Assert.Equal("PROJECT1_PORT=8080", portLines[0]);
    }
 
    [Fact]
    public void Add_NewKey_AddsToFile()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
 
        // Create initial .env file
        File.WriteAllLines(envFilePath, [
            "# Comment for KEY1",
            "KEY1=value1",
            ""
        ]);
 
        // Load and add a new key
        var envFile = EnvFile.Load(envFilePath);
        envFile.Add("KEY2", "value2", "Comment for KEY2", onlyIfMissing: true);
        envFile.Save();
 
        var lines = File.ReadAllLines(envFilePath);
 
        // Should have both keys
        Assert.Contains("KEY1=value1", lines);
        Assert.Contains("KEY2=value2", lines);
    }
 
    [Fact]
    public void Load_EmptyFile_ReturnsEmptyEnvFile()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
 
        // Create empty file
        File.WriteAllText(envFilePath, string.Empty);
 
        var envFile = EnvFile.Load(envFilePath);
        envFile.Add("KEY1", "value1", "Comment");
        envFile.Save();
 
        var lines = File.ReadAllLines(envFilePath);
        Assert.Contains("KEY1=value1", lines);
    }
 
    [Fact]
    public void Load_NonExistentFile_ReturnsEmptyEnvFile()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var envFilePath = Path.Combine(workspace.Path, ".env");
 
        // Don't create the file
        var envFile = EnvFile.Load(envFilePath);
        envFile.Add("KEY1", "value1", "Comment");
        envFile.Save();
 
        Assert.True(File.Exists(envFilePath));
        var lines = File.ReadAllLines(envFilePath);
        Assert.Contains("KEY1=value1", lines);
    }
}