From 427245209b3f4e4a2f83b651249ff82d94e3ae87 Mon Sep 17 00:00:00 2001 From: Elinor Fung Date: Fri, 12 Jun 2026 11:09:29 -0700 Subject: [PATCH] Fix apphost/bundle creation failure on chmod-hostile filesystems CreateAppHost and single-file bundle generation failed inside rootless podman devcontainers because File.SetUnixFileMode (chmod 755) returns EPERM on bind-mounted Windows folders that appear root-owned to the non-root container user, surfacing as: System.UnauthorizedAccessException: Access to the path '.../apphost' is denied. ---> System.IO.IOException: Operation not permitted at System.IO.File.SetUnixFileMode(...) at Microsoft.NET.HostModel.AppHost.HostWriter.CreateAppHost(...) Set the executable permissions when the file is created (via the open syscall using FileStreamOptions.UnixCreateMode) instead of relying on a separate chmod, which is not permitted on such filesystems. The post-write chmod is now only attempted when the file is missing the required permissions, so it is skipped on filesystems where the file was already created executable and still enforces exact permissions (e.g. under a restrictive umask) where chmod is supported. Both HostWriter (apphost) and Bundler (single-file) now share the CreateFileStreamForHost and SetPermissionsForHost helpers. The net472 libc chmod shim (UnixUtils.cs) is removed: net472 only runs on Windows in the modern SDK, where Unix permissions do not apply. Fixes dotnet/runtime#129040 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AppHost/HostWriter.cs | 14 +--- .../Microsoft.NET.HostModel/Bundle/Bundler.cs | 22 +++--- .../Microsoft.NET.HostModel/HostModelUtils.cs | 67 ++++++++++++++++-- .../Utils/UnixUtils.cs | 69 ------------------- .../AppHost/CreateAppHost.cs | 32 +++++++++ 5 files changed, 108 insertions(+), 96 deletions(-) delete mode 100644 src/installer/managed/Microsoft.NET.HostModel/Utils/UnixUtils.cs diff --git a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs index 777fab812578b0..3aa8b097a22cfb 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/AppHost/HostWriter.cs @@ -2,10 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.ComponentModel; using System.IO; using System.IO.MemoryMappedFiles; -using System.Runtime.InteropServices; using System.Text; using Microsoft.NET.HostModel.MachO; @@ -162,7 +160,7 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access } } } - using (FileStream appHostDestinationStream = new FileStream(appHostDestinationFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize: 1)) + using (FileStream appHostDestinationStream = HostModelUtils.CreateFileStreamForHost(appHostDestinationFilePath, FileAccess.ReadWrite, FileShare.None, bufferSize: 1)) using (MemoryMappedViewAccessor appHostAccessor = appHostDestinationMap.CreateViewAccessor(0, appHostDestinationLength, MemoryMappedFileAccess.Read)) { // Write the final content to the destination file, only up to the total length of the host, not the entire mapped file. @@ -179,14 +177,8 @@ void RewriteAppHost(MemoryMappedFile mappedFile, MemoryMappedViewAccessor access } } }); - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // chmod +755 - File.SetUnixFileMode(appHostDestinationFilePath, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherExecute); - } + + HostModelUtils.SetPermissionsForHost(appHostDestinationFilePath); } catch (Exception ex) { diff --git a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs index 17263c70d651f7..30ef7285fd1e5e 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/Bundle/Bundler.cs @@ -399,26 +399,24 @@ public string GenerateBundle(BundleContents bundleContents) } } - // MacOS keeps a cache of file signatures, so we must create a new inode to ensure the file signature is properly updated. - if (_macosCodesign && File.Exists(bundlePath)) + // On non-Windows, delete any existing bundle so the output is written to a new inode. + // FileStreamOptions.UnixCreateMode only applies when a file is created, not when an + // existing file is truncated in place, so without this an existing non-executable + // bundle would keep its permissions and require a chmod (which can fail on some + // filesystems, e.g. bind-mounted volumes in rootless containers). On macOS this also + // ensures the kernel's code signature cache is not reused for the new contents. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && File.Exists(bundlePath)) { - _tracer.Log($"Removing existing bundle file to clear signature cache: {bundlePath}"); + _tracer.Log($"Removing existing bundle file: {bundlePath}"); File.Delete(bundlePath); } - using (FileStream bundleOutputStream = File.Open(bundlePath, FileMode.Create, FileAccess.Write, FileShare.None)) + using (FileStream bundleOutputStream = HostModelUtils.CreateFileStreamForHost(bundlePath, FileAccess.Write, FileShare.None)) { BinaryUtils.WriteToStream(accessor, bundleOutputStream, (long)endOfBundle); } } } - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // chmod +755 - File.SetUnixFileMode(bundlePath, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherExecute); - } + HostModelUtils.SetPermissionsForHost(bundlePath); return bundlePath; } diff --git a/src/installer/managed/Microsoft.NET.HostModel/HostModelUtils.cs b/src/installer/managed/Microsoft.NET.HostModel/HostModelUtils.cs index 8fcb58da9b899a..0d6c60423540ae 100644 --- a/src/installer/managed/Microsoft.NET.HostModel/HostModelUtils.cs +++ b/src/installer/managed/Microsoft.NET.HostModel/HostModelUtils.cs @@ -2,18 +2,77 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -#if NETFRAMEWORK -using Microsoft.IO; -#else using System.IO; -#endif using System.Runtime.InteropServices; +#if NETFRAMEWORK +using FileInfo = Microsoft.IO.FileInfo; +#endif namespace Microsoft.NET.HostModel { internal static class HostModelUtils { +#if NET + // -rwxr-xr-x: the permissions an apphost or single-file bundle should have. + private const UnixFileMode AppHostFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; +#endif + + /// + /// Creates a for writing an apphost or bundle, requesting the desired + /// Unix permissions at creation time on platforms that support them. + /// When is , the default buffer size is used. + /// + public static FileStream CreateFileStreamForHost( + string path, + FileAccess access, + FileShare share, + int? bufferSize = null) + { +#if NET + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + FileStreamOptions options = new() + { + Mode = FileMode.Create, + Access = access, + Share = share, + UnixCreateMode = AppHostFileMode, + }; + + if (bufferSize is int size) + { + options.BufferSize = size; + } + + return new FileStream(path, options); + } +#endif + return bufferSize.HasValue + ? new FileStream(path, FileMode.Create, access, share, bufferSize.Value) + : new FileStream(path, FileMode.Create, access, share); + } + + /// + /// Ensures a host file has the required permissions. + /// + public static void SetPermissionsForHost(string filePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + +#if NET + // File already has required permissions + if ((File.GetUnixFileMode(filePath) & AppHostFileMode) == AppHostFileMode) + return; + + File.SetUnixFileMode(filePath, AppHostFileMode); +#endif + } + private const string CodesignPath = @"/usr/bin/codesign"; public static bool IsCodesignAvailable() => File.Exists(CodesignPath); diff --git a/src/installer/managed/Microsoft.NET.HostModel/Utils/UnixUtils.cs b/src/installer/managed/Microsoft.NET.HostModel/Utils/UnixUtils.cs deleted file mode 100644 index 58306e0bb846ac..00000000000000 --- a/src/installer/managed/Microsoft.NET.HostModel/Utils/UnixUtils.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; - -namespace System.IO; - -#if NETFRAMEWORK - -[Flags] -internal enum UnixFileMode -{ - None = 0, - OtherExecute = 1, - OtherWrite = 2, - OtherRead = 4, - GroupExecute = 8, - GroupWrite = 16, - GroupRead = 32, - UserExecute = 64, - UserWrite = 128, - UserRead = 256, - StickyBit = 512, - SetGroup = 1024, - SetUser = 2048, -} - -internal static class FileExtensions -{ - extension(File) - { - public static void SetUnixFileMode(string path, UnixFileMode mode) - { - int user = ((mode & UnixFileMode.UserRead) != 0 ? 4 : 0) - | ((mode & UnixFileMode.UserWrite) != 0 ? 2 : 0) - | ((mode & UnixFileMode.UserExecute) != 0 ? 1 : 0); - int group = ((mode & UnixFileMode.GroupRead) != 0 ? 4 : 0) - | ((mode & UnixFileMode.GroupWrite) != 0 ? 2 : 0) - | ((mode & UnixFileMode.GroupExecute) != 0 ? 1 : 0); - int other = ((mode & UnixFileMode.OtherRead) != 0 ? 4 : 0) - | ((mode & UnixFileMode.OtherWrite) != 0 ? 2 : 0) - | ((mode & UnixFileMode.OtherExecute) != 0 ? 1 : 0); - int octal = (user << 6) | (group << 3) | other; - - const int EINTR = 4; - int res; - int iterations = 0; - do - { - res = chmod(path, octal); - } while (res == -1 - && Marshal.GetLastWin32Error() == EINTR - && iterations++ < 8); - if (res == -1) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), $"Could not set file permission {Convert.ToString(octal, 8)} for {path}."); - } - } - } - - [DllImport("libc", SetLastError = true)] - private static extern int chmod([MarshalAs(UnmanagedType.LPStr)] string pathname, int mode); -} -#endif diff --git a/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs b/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs index dc69ddb8adc80a..89da4cd09a5f64 100644 --- a/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs +++ b/src/installer/tests/Microsoft.NET.HostModel.Tests/AppHost/CreateAppHost.cs @@ -261,6 +261,38 @@ public void ExecutableImage() .Be(expectedPermissions); } + [Fact] + [PlatformSpecific(TestPlatforms.AnyUnix)] + public void ExecutableImage_ExistingNonExecutableFile() + { + using TestArtifact artifact = CreateTestDirectory(); + string sourceAppHostMock = PrepareAppHostMockFile(artifact.Location); + string destinationFilePath = Path.Combine(artifact.Location, "DestinationAppHost.exe.mock"); + string appBinaryFilePath = "Test/App/Binary/Path.dll"; + + // A non-executable file already exists at the destination (-rw-r--r--). + File.WriteAllText(destinationFilePath, "pre-existing content"); + File.SetUnixFileMode(destinationFilePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + + // -rwxr-xr-x + const UnixFileMode expectedPermissions = UnixFileMode.UserRead | UnixFileMode.UserExecute | UnixFileMode.UserWrite | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + + HostWriter.CreateAppHost( + sourceAppHostMock, + destinationFilePath, + appBinaryFilePath, + windowsGraphicalUserInterface: true); + + // assert that the generated app has executable permissions + // despite the non-executable permissions on the pre-existing destination file. + File.GetUnixFileMode(destinationFilePath) + .Should() + .Be(expectedPermissions); + } + [Theory] [PlatformSpecific(TestPlatforms.OSX)] [InlineData("")]