diff --git a/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifact.cs b/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifact.cs new file mode 100644 index 0000000000..c5c0335ae8 --- /dev/null +++ b/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifact.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Azure.Functions.WorkerProxy.ExtensionArtifacts; + +/// +/// Represents an extension artifact payload. +/// +internal sealed record ExtensionArtifact +{ + /// + /// Initializes a new instance of the class. + /// + /// The extension artifact tar archive. + public ExtensionArtifact(ReadOnlyMemory payload) + { + Payload = payload; + } + + /// + /// Gets the tar archive containing extensions.json and the contents of + /// the .azurefunctions directory. + /// + public ReadOnlyMemory Payload { get; init; } +} diff --git a/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifactShim.Log.cs b/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifactShim.Log.cs new file mode 100644 index 0000000000..52de1278be --- /dev/null +++ b/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifactShim.Log.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Extensions.Logging; + +namespace Azure.Functions.WorkerProxy.ExtensionArtifacts; + +internal sealed partial class ExtensionArtifactShim +{ + // Reported when the inputs an artifact needs are not usable: absent, of the wrong kind, or + // an extensions directory holding no files. The worker SDK writes extensions.json and + // .azurefunctions into every publish output, so an input that is missing or malformed means + // the deployment package was assembled incorrectly rather than that there is nothing to + // shim. That is the customer's to fix, so it is reported at the level the host already uses + // for its equivalent .azurefunctions checks and stays visible where Debug is off. + [LoggerMessage(1, LogLevel.Warning, "Extension artifacts are unavailable. Reason: {Reason}")] + private static partial void LogArtifactsUnavailable(ILogger logger, string reason); + + // Kept at Information: it is emitted once per artifact creation, and the entry count and + // size are what correlate a running worker with a deployment when Debug is off in + // production. + [LoggerMessage(2, LogLevel.Information, "Extension artifact archive prepared. Entries={EntryCount}, Size={SizeBytes} bytes.")] + private static partial void LogArchivePrepared(ILogger logger, int entryCount, long sizeBytes); +} diff --git a/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifactShim.cs b/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifactShim.cs new file mode 100644 index 0000000000..7cf70d70fa --- /dev/null +++ b/src/Functions.WorkerProxy/ExtensionArtifacts/ExtensionArtifactShim.cs @@ -0,0 +1,247 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Azure.Functions.WorkerProxy.ExtensionArtifacts; + +/// +/// Compatibility shim that creates extension artifacts for worker SDKs that do not provide them. +/// +internal sealed partial class ExtensionArtifactShim(ILogger logger) : IExtensionArtifactShim +{ + private const string AzureFunctionsDirectoryName = ".azurefunctions"; + private const string ExtensionsJsonFileName = "extensions.json"; + + /// + /// Permissions stamped on every archive entry. Fixed so that the permissions a worker sees + /// after extraction do not vary with how the deployment happened to be unpacked. + /// + private const UnixFileMode ArtifactEntryMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | + UnixFileMode.GroupRead | UnixFileMode.OtherRead; + + /// + /// Enumeration options for the extensions directory. + /// defaults to Hidden | System, + /// which would drop the dotfiles this archive is made of, so it is retargeted at reparse + /// points: a link is not guaranteed to point inside the deployment, and skipping links + /// also keeps the walk from descending into a linked directory. + /// is cleared so that an unreadable + /// file faults the walk instead of quietly shrinking the archive. + /// + private static readonly EnumerationOptions ExtensionFileEnumerationOptions = new() + { + RecurseSubdirectories = true, + AttributesToSkip = FileAttributes.ReparsePoint, + IgnoreInaccessible = false, + }; + + /// + /// Why an artifact input cannot be used, or when it can. + /// + private enum ArtifactPathState + { + /// The path exists and is of the expected kind. + Usable, + + /// Nothing exists at the path. + NotFound, + + /// A file exists where a directory was expected, or the reverse. + WrongType, + } + + /// + public async Task CreateAsync(string functionAppDirectory, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(functionAppDirectory); + cancellationToken.ThrowIfCancellationRequested(); + + string extensionsJsonPath = Path.Combine(functionAppDirectory, ExtensionsJsonFileName); + + ArtifactPathState extensionsJsonState = ProbeArtifactPath(extensionsJsonPath, expectDirectory: false); + + if (extensionsJsonState is not ArtifactPathState.Usable) + { + string reason = extensionsJsonState switch + { + ArtifactPathState.NotFound => DescribeMissingExtensionsJson(functionAppDirectory, extensionsJsonPath), + _ => $"'{extensionsJsonPath}' is not a file", + }; + + LogArtifactsUnavailable(logger, reason); + + return null; + } + + string azureFunctionsDirectory = Path.Combine(functionAppDirectory, AzureFunctionsDirectoryName); + ArtifactPathState azureFunctionsState = ProbeArtifactPath(azureFunctionsDirectory, expectDirectory: true); + + if (azureFunctionsState is not ArtifactPathState.Usable) + { + string reason = azureFunctionsState switch + { + ArtifactPathState.NotFound => $"No {AzureFunctionsDirectoryName} directory found at '{azureFunctionsDirectory}'", + _ => $"'{azureFunctionsDirectory}' is not a directory", + }; + + LogArtifactsUnavailable(logger, reason); + + return null; + } + + List<(string EntryName, string FilePath)> extensionEntries = CollectExtensionEntries(azureFunctionsDirectory, cancellationToken); + + if (extensionEntries.Count == 0) + { + LogArtifactsUnavailable(logger, $"No extension files found under '{azureFunctionsDirectory}'"); + + return null; + } + + byte[] payload = await CreateArchiveAsync(extensionsJsonPath, extensionEntries, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + LogArchivePrepared(logger, extensionEntries.Count + 1, payload.LongLength); + + return new ExtensionArtifact(payload); + } + + /// + /// Reports whether an artifact input can be used, distinguishing an absent path from one + /// that cannot be inspected. and + /// answer for a denied or + /// failing path just as they do for a missing one, which would report an unreadable + /// deployment as one that simply carries no extensions. Access and I/O failures propagate + /// instead, matching the enumeration walk, which faults rather than yielding an archive + /// that covers less than the deployment. + /// + /// The path to inspect. + /// + /// when a directory is required, when a file + /// is required. + /// + /// The path cannot be inspected. + /// Inspecting the path failed. + private static ArtifactPathState ProbeArtifactPath(string path, bool expectDirectory) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return ArtifactPathState.NotFound; + } + + // Attributes are readable for a directory standing where a file belongs, so the kind is + // what separates a usable input from one that only fails later, on open or on walk. + bool isDirectory = (attributes & FileAttributes.Directory) != 0; + + return isDirectory == expectDirectory ? ArtifactPathState.Usable : ArtifactPathState.WrongType; + } + + /// + /// Describes an absent extensions.json. A function app directory that is empty, or + /// that does not exist, means the deployment never landed, which is a different failure from + /// a publish output that carries content but not this file, so the two are reported apart. + /// Only reached once the file is known to be absent, so the walk costs nothing in the + /// ordinary case. + /// + private static string DescribeMissingExtensionsJson(string functionAppDirectory, string extensionsJsonPath) + { + bool hasContent; + try + { + using IEnumerator entries = Directory.EnumerateFileSystemEntries(functionAppDirectory).GetEnumerator(); + hasContent = entries.MoveNext(); + } + catch (DirectoryNotFoundException) + { + return $"Function app directory '{functionAppDirectory}' does not exist"; + } + + return hasContent + ? $"No {ExtensionsJsonFileName} found at '{extensionsJsonPath}'" + : $"Function app directory '{functionAppDirectory}' is empty"; + } + + /// + /// Collects an archive entry for every file under the extensions directory, ordered by + /// entry name. Symbolic links, and the contents of symbolically linked directories, are + /// excluded so that the archive covers only files that belong to the deployment. + /// + private static List<(string EntryName, string FilePath)> CollectExtensionEntries( + string azureFunctionsDirectory, + CancellationToken cancellationToken) + { + List<(string EntryName, string FilePath)> extensionEntries = []; + + foreach (string filePath in Directory.EnumerateFiles(azureFunctionsDirectory, "*", ExtensionFileEnumerationOptions)) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = Path.GetRelativePath(azureFunctionsDirectory, filePath).Replace(Path.DirectorySeparatorChar, '/'); + extensionEntries.Add(($"{AzureFunctionsDirectoryName}/{relativePath}", filePath)); + } + + // Order by the emitted entry name rather than the source path. Directory enumeration + // order is a filesystem property on Linux and differs between overlayfs, ext4, tmpfs + // and file shares, so it cannot produce a reproducible archive on its own. + extensionEntries.Sort(static (left, right) => string.CompareOrdinal(left.EntryName, right.EntryName)); + + return extensionEntries; + } + + private static async Task CreateArchiveAsync( + string extensionsJsonPath, + List<(string EntryName, string FilePath)> extensionEntries, + CancellationToken cancellationToken) + { + using MemoryStream archiveStream = new(); + + await using (TarWriter tarWriter = new(archiveStream, leaveOpen: true)) + { + await WriteEntryAsync(tarWriter, ExtensionsJsonFileName, extensionsJsonPath, cancellationToken); + + foreach ((string entryName, string filePath) in extensionEntries) + { + cancellationToken.ThrowIfCancellationRequested(); + await WriteEntryAsync(tarWriter, entryName, filePath, cancellationToken); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + return archiveStream.ToArray(); + } + + /// + /// Writes a single file entry using fixed metadata. + /// + /// + /// Writing a path directly would copy the file's last write time and, on Unix, its + /// permissions and owner into the entry header, so the archive a worker receives would vary + /// with how and by whom the deployment was unpacked. + /// + private static async Task WriteEntryAsync(TarWriter tarWriter, string entryName, string filePath, CancellationToken cancellationToken) + { + await using FileStream content = File.OpenRead(filePath); + + PaxTarEntry entry = new(TarEntryType.RegularFile, entryName) + { + ModificationTime = DateTimeOffset.UnixEpoch, + Mode = ArtifactEntryMode, + DataStream = content, + }; + + await tarWriter.WriteEntryAsync(entry, cancellationToken); + } +} diff --git a/src/Functions.WorkerProxy/ExtensionArtifacts/IExtensionArtifactShim.cs b/src/Functions.WorkerProxy/ExtensionArtifacts/IExtensionArtifactShim.cs new file mode 100644 index 0000000000..b0d869ea01 --- /dev/null +++ b/src/Functions.WorkerProxy/ExtensionArtifacts/IExtensionArtifactShim.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Functions.WorkerProxy.ExtensionArtifacts; + +/// +/// Provides extension artifacts as a compatibility shim for worker SDKs that do not provide them. +/// +internal interface IExtensionArtifactShim +{ + /// + /// Creates an extension artifact from the supplied function app directory. + /// + /// + /// The function app directory path. + /// + /// The token that cancels artifact creation. + /// + /// A task whose result is the artifact, or when the required + /// artifact inputs are unavailable, which includes an extensions directory that holds + /// no files. + /// + /// + /// is . + /// + /// + /// is empty or whitespace. + /// + /// + /// was canceled. + /// + /// + /// An artifact input exists but cannot be read. Reported rather than treated as an absent + /// input, so that an unreadable deployment is not mistaken for one carrying no extensions. + /// + /// + /// Reading the function app directory failed. + /// + Task CreateAsync(string functionAppDirectory, CancellationToken cancellationToken); +} diff --git a/src/Functions.WorkerProxy/WorkerProxyApplication.cs b/src/Functions.WorkerProxy/WorkerProxyApplication.cs index 14c3bb2d4a..5d1faf9d2f 100644 --- a/src/Functions.WorkerProxy/WorkerProxyApplication.cs +++ b/src/Functions.WorkerProxy/WorkerProxyApplication.cs @@ -5,6 +5,7 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Azure.Functions.WorkerProxy.ExtensionArtifacts; using Azure.Functions.WorkerProxy.Http; using Azure.Functions.WorkerProxy.Rpc; using Microsoft.AspNetCore.Builder; @@ -51,6 +52,7 @@ public static WebApplication Build(string[] args) }); builder.Services.AddSingleton(); builder.Services.AddHostedService(static services => services.GetRequiredService()); + builder.Services.AddSingleton(); ConfigureHttpForwarding(builder); WebApplication app = builder.Build(); diff --git a/test/Functions.WorkerProxy.Tests/ExtensionArtifactShimRegistrationTests.cs b/test/Functions.WorkerProxy.Tests/ExtensionArtifactShimRegistrationTests.cs new file mode 100644 index 0000000000..128915cbcd --- /dev/null +++ b/test/Functions.WorkerProxy.Tests/ExtensionArtifactShimRegistrationTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Azure.Functions.WorkerProxy.ExtensionArtifacts; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Azure.Functions.WorkerProxy.Tests; + +public class ExtensionArtifactShimRegistrationTests +{ + [Fact] + public async Task ApplicationRegistration_UsesSingletonShim() + { + await using WorkerProxyWebApplicationFactory webApplicationFactory = new(); + + IExtensionArtifactShim firstArtifactShim = webApplicationFactory.Services.GetRequiredService(); + IExtensionArtifactShim secondArtifactShim = webApplicationFactory.Services.GetRequiredService(); + + Assert.IsType(firstArtifactShim); + Assert.Same(firstArtifactShim, secondArtifactShim); + } +} diff --git a/test/Functions.WorkerProxy.Tests/ExtensionArtifactShimTests.cs b/test/Functions.WorkerProxy.Tests/ExtensionArtifactShimTests.cs new file mode 100644 index 0000000000..33f105688d --- /dev/null +++ b/test/Functions.WorkerProxy.Tests/ExtensionArtifactShimTests.cs @@ -0,0 +1,543 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.Functions.WorkerProxy.ExtensionArtifacts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Azure.Functions.WorkerProxy.Tests; + +public class ExtensionArtifactShimTests +{ + /// + /// Permissions every archive entry is expected to carry: owner read and write, group and + /// other read. Restated here rather than shared with the shim so that relaxing the + /// production value has to be a deliberate edit in both places. + /// + private const UnixFileMode ExpectedEntryMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | + UnixFileMode.GroupRead | UnixFileMode.OtherRead; + + [Fact] + public async Task CreateAsync_ReturnsOrderedArchiveWithCompletePayload() + { + using TestDirectory contentRoot = new(); + + (byte[] ExtensionsJson, byte[] FirstAssembly, byte[] NestedAssembly, byte[] LastAssembly) expectedFiles = + await CreateFunctionAppLayoutAsync(contentRoot.Path); + + // Stamp metadata the archive must not inherit. Without this the asserted mode would + // match whatever the umask produced and prove nothing. + StampMetadata( + contentRoot.Path, + new DateTime(2021, 3, 4, 5, 6, 7, DateTimeKind.Utc), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + ExtensionArtifactShim shim = CreateShim(); + + ExtensionArtifact artifact = Assert.IsType(await shim.CreateAsync(contentRoot.Path, CancellationToken.None)); + + byte[] payload = artifact.Payload.ToArray(); + // Verify root manifest placement, preserved .azurefunctions relative paths, and + // ordinal archive entry ordering. + IReadOnlyList entries = ReadArchive(payload); + Assert.Collection( + entries, + entry => AssertArchiveEntry(entry, "extensions.json", expectedFiles.ExtensionsJson), + entry => AssertArchiveEntry(entry, ".azurefunctions/a-extension.dll", expectedFiles.FirstAssembly), + entry => AssertArchiveEntry(entry, ".azurefunctions/nested/m-extension.dll", expectedFiles.NestedAssembly), + entry => AssertArchiveEntry(entry, ".azurefunctions/zz-extension.dll", expectedFiles.LastAssembly)); + } + + [Fact] + public async Task CreateAsync_IdenticalContentProducesIdenticalPayload() + { + using TestDirectory firstRoot = new(); + using TestDirectory secondRoot = new(); + await CreateFunctionAppLayoutAsync(firstRoot.Path); + await CreateFunctionAppLayoutAsync(secondRoot.Path); + + // Same bytes, but the filesystem metadata a deployment happens to carry differs. + const UnixFileMode RestrictiveMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + const UnixFileMode PermissiveMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.OtherRead; + StampMetadata(firstRoot.Path, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), RestrictiveMode); + StampMetadata(secondRoot.Path, new DateTime(2024, 7, 7, 12, 30, 0, DateTimeKind.Utc), PermissiveMode); + + ExtensionArtifactShim shim = CreateShim(); + + ExtensionArtifact first = Assert.IsType(await shim.CreateAsync(firstRoot.Path, CancellationToken.None)); + ExtensionArtifact second = Assert.IsType(await shim.CreateAsync(secondRoot.Path, CancellationToken.None)); + + Assert.Equal(first.Payload.ToArray(), second.Payload.ToArray()); + } + + [Fact] + public async Task CreateAsync_DifferingContentProducesDifferingPayload() + { + using TestDirectory firstRoot = new(); + using TestDirectory secondRoot = new(); + await CreateFunctionAppLayoutAsync(firstRoot.Path); + await CreateFunctionAppLayoutAsync(secondRoot.Path); + await File.WriteAllBytesAsync(Path.Combine(secondRoot.Path, ".azurefunctions", "a-extension.dll"), [9, 9, 9, 9]); + + ExtensionArtifactShim shim = CreateShim(); + + ExtensionArtifact first = Assert.IsType(await shim.CreateAsync(firstRoot.Path, CancellationToken.None)); + ExtensionArtifact second = Assert.IsType(await shim.CreateAsync(secondRoot.Path, CancellationToken.None)); + + Assert.NotEqual(first.Payload.ToArray(), second.Payload.ToArray()); + } + + [Fact] + public async Task CreateAsync_NullFunctionAppDirectoryThrows() + { + ExtensionArtifactShim shim = CreateShim(); + + await Assert.ThrowsAsync(() => shim.CreateAsync(null!, CancellationToken.None)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task CreateAsync_EmptyOrWhitespaceFunctionAppDirectoryThrows(string functionAppDirectory) + { + ExtensionArtifactShim shim = CreateShim(); + + await Assert.ThrowsAsync(() => shim.CreateAsync(functionAppDirectory, CancellationToken.None)); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public async Task CreateAsync_ReturnsNullWhenRequiredInputIsUnavailable(bool createExtensionsJson, bool createAzureFunctionsDirectory) + { + using TestDirectory contentRoot = new(); + + if (createExtensionsJson) + { + await File.WriteAllTextAsync(Path.Combine(contentRoot.Path, "extensions.json"), "{}"); + } + + if (createAzureFunctionsDirectory) + { + Directory.CreateDirectory(Path.Combine(contentRoot.Path, ".azurefunctions")); + } + + ExtensionArtifactShim shim = CreateShim(); + + ExtensionArtifact? artifact = await shim.CreateAsync(contentRoot.Path, CancellationToken.None); + + Assert.Null(artifact); + } + + [Fact] + public async Task CreateAsync_ReturnsNullWhenAzureFunctionsDirectoryHoldsNoFiles() + { + using TestDirectory contentRoot = new(); + await File.WriteAllTextAsync(Path.Combine(contentRoot.Path, "extensions.json"), "{}"); + // A directory carrying no extension assemblies is the same logical state as no + // directory at all, so an empty subdirectory must not make it look otherwise. + Directory.CreateDirectory(Path.Combine(contentRoot.Path, ".azurefunctions", "empty-nested")); + + RecordingLogger logger = new(); + ExtensionArtifactShim shim = CreateShim(logger); + + ExtensionArtifact? artifact = await shim.CreateAsync(contentRoot.Path, CancellationToken.None); + + Assert.Null(artifact); + (LogLevel Level, string Message) entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(false, false)] + public async Task CreateAsync_ReportsMissingInputsAsWarning(bool createExtensionsJson, bool createAzureFunctionsDirectory) + { + using TestDirectory contentRoot = new(); + + if (createExtensionsJson) + { + await File.WriteAllTextAsync(Path.Combine(contentRoot.Path, "extensions.json"), "{}"); + } + + if (createAzureFunctionsDirectory) + { + Directory.CreateDirectory(Path.Combine(contentRoot.Path, ".azurefunctions")); + } + + RecordingLogger logger = new(); + ExtensionArtifactShim shim = CreateShim(logger); + + Assert.Null(await shim.CreateAsync(contentRoot.Path, CancellationToken.None)); + + // Both inputs are part of every publish output, so an absent one points at a deployment + // package that was assembled incorrectly. That has to survive a production log level. + (LogLevel Level, string Message) entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + } + + [Fact] + public async Task CreateAsync_ReportsEmptyFunctionAppDirectoryAsWarning() + { + using TestDirectory contentRoot = new(); + + RecordingLogger logger = new(); + ExtensionArtifactShim shim = CreateShim(logger); + + Assert.Null(await shim.CreateAsync(contentRoot.Path, CancellationToken.None)); + + // An app directory holding nothing means the deployment never landed, which points + // somewhere different than a publish output that is only missing extensions.json. + (LogLevel Level, string Message) entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains("is empty", entry.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CreateAsync_ReportsMissingFunctionAppDirectoryAsWarning() + { + using TestDirectory contentRoot = new(); + string missingDirectory = Path.Combine(contentRoot.Path, "never-deployed"); + + RecordingLogger logger = new(); + ExtensionArtifactShim shim = CreateShim(logger); + + Assert.Null(await shim.CreateAsync(missingDirectory, CancellationToken.None)); + + (LogLevel Level, string Message) entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains("does not exist", entry.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CreateAsync_ReportsMissingExtensionsJsonWhenDirectoryHasOtherContent() + { + using TestDirectory contentRoot = new(); + Directory.CreateDirectory(Path.Combine(contentRoot.Path, ".azurefunctions")); + + RecordingLogger logger = new(); + ExtensionArtifactShim shim = CreateShim(logger); + + Assert.Null(await shim.CreateAsync(contentRoot.Path, CancellationToken.None)); + + // A directory whose only entry is the dotfile directory still counts as deployed, so + // this must report the absent file rather than an empty directory. + (LogLevel Level, string Message) entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains("No extensions.json found", entry.Message, StringComparison.Ordinal); + } + + [RequiresSymbolicLinkSupportFact] + public async Task CreateAsync_ExcludesSymbolicLinkedContent() + { + using TestDirectory linkedRoot = new(); + using TestDirectory cleanRoot = new(); + using TestDirectory outsideRoot = new(); + await CreateFunctionAppLayoutAsync(linkedRoot.Path); + await CreateFunctionAppLayoutAsync(cleanRoot.Path); + + // Content the deployment does not own: a file a link can point at, and a directory + // whose file only enters the archive if enumeration follows a linked directory. + string outsideFile = Path.Combine(outsideRoot.Path, "outside-extension.dll"); + await File.WriteAllBytesAsync(outsideFile, [7, 7, 7, 7]); + string outsideDirectory = Path.Combine(outsideRoot.Path, "outside-directory"); + Directory.CreateDirectory(outsideDirectory); + await File.WriteAllBytesAsync(Path.Combine(outsideDirectory, "leaked-extension.dll"), [8, 8, 8, 8]); + CreateSymbolicLinks(Path.Combine(linkedRoot.Path, ".azurefunctions"), outsideFile, outsideDirectory); + + ExtensionArtifactShim shim = CreateShim(); + + ExtensionArtifact linked = Assert.IsType(await shim.CreateAsync(linkedRoot.Path, CancellationToken.None)); + ExtensionArtifact clean = Assert.IsType(await shim.CreateAsync(cleanRoot.Path, CancellationToken.None)); + + // A link's target is not guaranteed to belong to the deployment, so neither the link + // itself nor the content behind a linked directory may reach the archive. A dangling + // link must be skipped rather than fail the read. + IEnumerable entryNames = ReadArchive(linked.Payload.ToArray()) + .Select(static entry => entry.Name); + Assert.DoesNotContain( + entryNames, + static name => name.Contains("link", StringComparison.Ordinal) + || name.Contains("leaked", StringComparison.Ordinal)); + // Identical to a layout that never held links, so linked content cannot move the + // archive either. + Assert.Equal(clean.Payload.ToArray(), linked.Payload.ToArray()); + } + + [Fact] + public async Task CreateAsync_ArchivesHiddenExtensionFiles() + { + using TestDirectory contentRoot = new(); + await CreateFunctionAppLayoutAsync(contentRoot.Path); + // Every dot-prefixed file is hidden on Unix. Filtering hidden entries would drop them + // from the archive silently, leaving an archive that covers less than the deployment. + await File.WriteAllBytesAsync(Path.Combine(contentRoot.Path, ".azurefunctions", ".hidden-extension.dll"), [3, 3, 3, 3]); + + ExtensionArtifactShim shim = CreateShim(); + + ExtensionArtifact artifact = Assert.IsType(await shim.CreateAsync(contentRoot.Path, CancellationToken.None)); + + Assert.Contains( + ReadArchive(artifact.Payload.ToArray()), + entry => string.Equals(entry.Name, ".azurefunctions/.hidden-extension.dll", StringComparison.Ordinal)); + } + + [Fact] + public async Task CreateAsync_CanceledTokenCancelsArtifactCreation() + { + using TestDirectory contentRoot = new(); + await File.WriteAllTextAsync(Path.Combine(contentRoot.Path, "extensions.json"), "{}"); + Directory.CreateDirectory(Path.Combine(contentRoot.Path, ".azurefunctions")); + using CancellationTokenSource cancellationSource = new(); + cancellationSource.Cancel(); + ExtensionArtifactShim shim = CreateShim(); + + await Assert.ThrowsAnyAsync(() => shim.CreateAsync(contentRoot.Path, cancellationSource.Token)); + } + + [Fact] + public async Task CreateAsync_ReportsUnavailableWhenExtensionsJsonIsADirectory() + { + using TestDirectory contentRoot = new(); + Directory.CreateDirectory(Path.Combine(contentRoot.Path, "extensions.json")); + Directory.CreateDirectory(Path.Combine(contentRoot.Path, ".azurefunctions")); + RecordingLogger logger = new(); + ExtensionArtifactShim shim = CreateShim(logger); + + Assert.Null(await shim.CreateAsync(contentRoot.Path, CancellationToken.None)); + + // Attributes read back fine for a directory, so only the kind check rejects it here. + (LogLevel Level, string Message) entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains("is not a file", entry.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CreateAsync_ReportsUnavailableWhenAzureFunctionsIsAFile() + { + using TestDirectory contentRoot = new(); + await File.WriteAllTextAsync(Path.Combine(contentRoot.Path, "extensions.json"), "{}"); + await File.WriteAllTextAsync(Path.Combine(contentRoot.Path, ".azurefunctions"), "not a directory"); + RecordingLogger logger = new(); + ExtensionArtifactShim shim = CreateShim(logger); + + Assert.Null(await shim.CreateAsync(contentRoot.Path, CancellationToken.None)); + + (LogLevel Level, string Message) entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains("is not a directory", entry.Message, StringComparison.Ordinal); + } + + [RequiresPermissionEnforcementFact] + public async Task CreateAsync_PropagatesAccessFailureInsteadOfReportingMissingInputs() + { + using TestDirectory contentRoot = new(); + string appDirectory = Path.Combine(contentRoot.Path, "app"); + Directory.CreateDirectory(appDirectory); + await File.WriteAllTextAsync(Path.Combine(appDirectory, "extensions.json"), "{}"); + Directory.CreateDirectory(Path.Combine(appDirectory, ".azurefunctions")); + DenyDirectoryAccess(appDirectory); + + try + { + ExtensionArtifactShim shim = CreateShim(); + + // The inputs are present; only permission hides them. Reporting that as an app + // carrying no extensions would start a worker that silently lacks them. + await Assert.ThrowsAsync(() => shim.CreateAsync(appDirectory, CancellationToken.None)); + } + finally + { + RestoreDirectoryAccess(appDirectory); + } + } + + /// + /// Removes every permission from a directory. The caller is responsible for restoring + /// access, and for carrying so the + /// denial is known to bind. + /// + private static void DenyDirectoryAccess(string directory) + { + // Unreachable on Windows, because callers carry + // RequiresPermissionEnforcementFactAttribute and are skipped there. The guard is what + // lets the platform analyzer see that. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(directory, UnixFileMode.None); + } + } + + private static void RestoreDirectoryAccess(string directory) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + private static ExtensionArtifactShim CreateShim() + { + return new ExtensionArtifactShim(NullLogger.Instance); + } + + private static ExtensionArtifactShim CreateShim(ILogger logger) + { + return new ExtensionArtifactShim(logger); + } + + /// Creates the temporary function-app layout used by the archive test. + private static async Task<( + byte[] ExtensionsJson, + byte[] FirstAssembly, + byte[] NestedAssembly, + byte[] LastAssembly)> CreateFunctionAppLayoutAsync(string contentRoot) + { + byte[] extensionsJson = Encoding.UTF8.GetBytes("""{"extensions":[]}"""); + byte[] firstAssembly = [1, 2, 3, 4]; + byte[] nestedAssembly = [5, 6, 7, 8]; + byte[] lastAssembly = [9, 10, 11, 12]; + await File.WriteAllBytesAsync(Path.Combine(contentRoot, "extensions.json"), extensionsJson); + string azureFunctionsDirectory = Path.Combine(contentRoot, ".azurefunctions"); + string nestedDirectory = Path.Combine(azureFunctionsDirectory, "nested"); + Directory.CreateDirectory(nestedDirectory); + // zz-extension.dll sits beside the nested directory but sorts after everything inside + // it. Enumeration always yields a directory's own files before recursing, so this + // layout can only produce the asserted order if the entries are explicitly sorted. + await File.WriteAllBytesAsync(Path.Combine(nestedDirectory, "m-extension.dll"), nestedAssembly); + await File.WriteAllBytesAsync(Path.Combine(azureFunctionsDirectory, "a-extension.dll"), firstAssembly); + await File.WriteAllBytesAsync(Path.Combine(azureFunctionsDirectory, "zz-extension.dll"), lastAssembly); + + return (extensionsJson, firstAssembly, nestedAssembly, lastAssembly); + } + + /// Creates a single symbolic link. + private static void CreateSymbolicLink(string path, string target, bool isDirectory) + { + if (isDirectory) + { + Directory.CreateSymbolicLink(path, target); + } + else + { + File.CreateSymbolicLink(path, target); + } + } + + /// + /// Links a file, a directory and a missing target into the extensions directory. Callers + /// carry so the privilege links + /// require is known to be held. + /// + private static void CreateSymbolicLinks(string extensionsDirectory, string outsideFile, string outsideDirectory) + { + CreateSymbolicLink(Path.Combine(extensionsDirectory, "file-link-extension.dll"), outsideFile, isDirectory: false); + CreateSymbolicLink( + Path.Combine(extensionsDirectory, "dangling-link-extension.dll"), + Path.Combine(outsideDirectory, "no-such-extension.dll"), + isDirectory: false); + CreateSymbolicLink(Path.Combine(extensionsDirectory, "directory-link"), outsideDirectory, isDirectory: true); + } + + /// Applies uniform timestamps and, on Unix, permissions to every file in a layout. + private static void StampMetadata(string contentRoot, DateTime lastWriteTimeUtc, UnixFileMode mode) + { + foreach (string filePath in Directory.EnumerateFiles(contentRoot, "*", SearchOption.AllDirectories)) + { + File.SetLastWriteTimeUtc(filePath, lastWriteTimeUtc); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(filePath, mode); + } + } + } + + /// Reads file entries from an artifact archive. + private static IReadOnlyList ReadArchive(byte[] archive) + { + List entries = []; + using MemoryStream archiveStream = new(archive, writable: false); + using TarReader reader = new(archiveStream); + TarEntry? entry; + while ((entry = reader.GetNextEntry()) is not null) + { + Stream dataStream = Assert.IsAssignableFrom(entry.DataStream); + using MemoryStream entryContent = new(); + dataStream.CopyTo(entryContent); + entries.Add(new ArchiveEntry(entry.Name, entryContent.ToArray(), entry.Mode, entry.ModificationTime)); + } + + return entries; + } + + private static void AssertArchiveEntry(ArchiveEntry entry, string expectedName, byte[] expectedContent) + { + Assert.Equal(expectedName, entry.Name); + Assert.Equal(expectedContent, entry.Content); + // Fixed rather than inherited, so an extracted extension is never writable by another + // account and never gains the execute bit from however the deployment was unpacked. + Assert.Equal(ExpectedEntryMode, entry.Mode); + Assert.Equal(DateTimeOffset.UnixEpoch, entry.ModificationTime); + } + + /// A file entry read back from an artifact archive. + private readonly record struct ArchiveEntry(string Name, byte[] Content, UnixFileMode Mode, DateTimeOffset ModificationTime); + + private sealed class RecordingLogger : ILogger + { + private readonly List<(LogLevel Level, string Message)> _entries = []; + + public IReadOnlyList<(LogLevel Level, string Message)> Entries => _entries; + + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + _entries.Add((logLevel, formatter(state, exception))); + } + } + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"worker-proxy-artifact-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/test/Functions.WorkerProxy.Tests/RequiresPermissionEnforcementFactAttribute.cs b/test/Functions.WorkerProxy.Tests/RequiresPermissionEnforcementFactAttribute.cs new file mode 100644 index 0000000000..a5d1e41410 --- /dev/null +++ b/test/Functions.WorkerProxy.Tests/RequiresPermissionEnforcementFactAttribute.cs @@ -0,0 +1,129 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.IO; +using Xunit; + +namespace Azure.Functions.WorkerProxy.Tests; + +/// +/// Marks a test that asserts against denied filesystem access, and reports it as skipped where +/// a denial does not take hold. Root, and any process holding CAP_DAC_OVERRIDE, reads a path +/// whose permissions forbid it, so such a test proves nothing there; returning early instead +/// would report that as a pass. +/// +internal sealed class RequiresPermissionEnforcementFactAttribute : FactAttribute +{ + private static readonly Lazy LazySkipReason = new(Probe); + + /// + /// Initializes a new instance of the + /// class. + /// + public RequiresPermissionEnforcementFactAttribute() + { + Skip = LazySkipReason.Value; + } + + /// + /// Denies access to a probe directory and confirms the denial is observed, because whether + /// permissions bind depends on the account a run uses rather than on the platform. Any failure + /// to set the probe up counts as unavailable, because this runs during discovery: an exception + /// leaving here removes the whole test class from the run and reports a pass. + /// + /// + /// The reason to skip, or when denied access is enforced. + /// + private static string? Probe() + { + if (OperatingSystem.IsWindows()) + { + return "Requires Unix permission semantics."; + } + + DirectoryInfo? probeRoot = null; + string? deniedDirectory = null; + try + { + probeRoot = Directory.CreateTempSubdirectory("workerproxy-permission-probe"); + deniedDirectory = Path.Combine(probeRoot.FullName, "denied"); + Directory.CreateDirectory(deniedDirectory); + string deniedFile = Path.Combine(deniedDirectory, "denied-file"); + File.WriteAllBytes(deniedFile, []); + File.SetUnixFileMode(deniedDirectory, UnixFileMode.None); + + return ReadsDeniedPath(deniedFile) + ? "Requires permission enforcement; this process reads paths that deny it." + : null; + } + catch (Exception exception) + { + return $"Requires permission enforcement, which could not be probed here ({exception.GetType().Name})."; + } + finally + { + TryRestore(deniedDirectory); + TryDelete(probeRoot); + } + } + + /// + /// Reports whether the denied path is still readable, which is what a privileged account does. + /// The denial is read only here, so that a failure to prepare the probe is never mistaken for + /// permissions being enforced. + /// + /// A path inside a directory whose permissions forbid access. + /// when the path is read in spite of the denial. + private static bool ReadsDeniedPath(string deniedFile) + { + try + { + _ = File.GetAttributes(deniedFile); + + return true; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Restores access to the probe directory so it can be removed, tolerating failure so that + /// cleanup cannot be what drops the test class from discovery. + /// + /// The directory to restore, or when none was created. + private static void TryRestore(string? deniedDirectory) + { + if (deniedDirectory is null || OperatingSystem.IsWindows()) + { + return; + } + + try + { + File.SetUnixFileMode(deniedDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch (Exception) + { + // Only worth attempting; the directory below is removed on a best-effort basis too. + } + } + + /// + /// Removes the probe directory, tolerating failure for the same reason as . + /// + /// The directory to remove, or when none was created. + private static void TryDelete(DirectoryInfo? probeRoot) + { + try + { + probeRoot?.Delete(recursive: true); + } + catch (Exception) + { + // A directory left in the temp path is not worth losing the tests over. + } + } +} diff --git a/test/Functions.WorkerProxy.Tests/RequiresSymbolicLinkSupportFactAttribute.cs b/test/Functions.WorkerProxy.Tests/RequiresSymbolicLinkSupportFactAttribute.cs new file mode 100644 index 0000000000..d353c2d8b1 --- /dev/null +++ b/test/Functions.WorkerProxy.Tests/RequiresSymbolicLinkSupportFactAttribute.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.IO; +using Xunit; + +namespace Azure.Functions.WorkerProxy.Tests; + +/// +/// Marks a test that cannot run without the privilege symbolic link creation requires, and +/// reports it as skipped where that privilege is missing. A test that returns early instead +/// would report a pass for coverage it never exercised, which is how a deleted guard reaches +/// review looking tested. +/// +internal sealed class RequiresSymbolicLinkSupportFactAttribute : FactAttribute +{ + private static readonly Lazy LazySkipReason = new(Probe); + + /// + /// Initializes a new instance of the + /// class. + /// + public RequiresSymbolicLinkSupportFactAttribute() + { + Skip = LazySkipReason.Value; + } + + /// + /// Creates a link rather than inferring the privilege from the platform, so that a Windows + /// machine which does grant it still runs the test instead of being excluded by assumption. + /// Any failure counts as the privilege being absent, including one raised while preparing the + /// probe, because this runs during discovery: an exception leaving here removes the whole test + /// class from the run and reports a pass, which is the outcome the attribute exists to prevent. + /// + /// The reason to skip, or when links can be created. + private static string? Probe() + { + DirectoryInfo? probeRoot = null; + try + { + probeRoot = Directory.CreateTempSubdirectory("workerproxy-symlink-probe"); + string target = Path.Combine(probeRoot.FullName, "target"); + File.WriteAllBytes(target, []); + File.CreateSymbolicLink(Path.Combine(probeRoot.FullName, "link"), target); + + return null; + } + catch (Exception exception) + { + return $"Requires symbolic link creation, which this environment withholds ({exception.GetType().Name})."; + } + finally + { + TryDelete(probeRoot); + } + } + + /// + /// Removes the probe directory, tolerating failure so that cleanup cannot be what drops the + /// test class from discovery. + /// + /// The directory to remove, or when none was created. + private static void TryDelete(DirectoryInfo? probeRoot) + { + try + { + probeRoot?.Delete(recursive: true); + } + catch (Exception) + { + // A directory left in the temp path is not worth losing the tests over. + } + } +}