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.
+ }
+ }
+}