More .NET Core Apps on Linux

bobrundle

Bob Rundle

Posted on June 13, 2021

More .NET Core Apps on Linux

In my first post on this subject, I demonstrated how to develop a simple console app on windows and get it running on Linux with unit tests. In this post I will expand on the previous post and show how Linux-specific and Windows-specific code can be developed and unit tested. In addition I will take a look at xUnit test fixtures, Windows ACLs and Linux file permissions.

As in the last post, the approach is to develop code on Windows and test in Linux containers. So on your windows dev box the set up you need is Hyper-V and Docker.

image

Also needed are the dotnet CLI and VS Code for this optimum (in my view) setup.

image

All the code for this tutorial can be found at https://github.com/bobrundle/dotnettolinux2

What I want is to create an API to test read and write access of files and folders. This functionality is missing from the .NET API in my view. The only way to test that a file or folder is readable is to try to read it. If it is unreadable, an exception will be thrown. Likewise for writing an existing file. To verify that a folder is writable, that is, a file can be created in it, you must try to create a file and see if an exception is thrown. Catching exceptions might be fine for a lot of use cases but I keep running into use cases where I really want to know whether a file or folder path is readable or writable before actually doing file operations…for example for validating user entry. To do that, I need to check ACLs on Windows and File Permissions on Linux. So here we go

image

Add a set of stubs to define our API.

using System;

namespace FileSupport
{
    public static class FileAccess
    {
        /// <summary>
        /// This method determines whether the file indicated by the path argument is writable. To be writable in this context
        /// means that the file can be either modified, appended to, overwritten or deleted.  If the file does not exist, it can
        /// be created.
        /// </summary>
        /// <param name="path">The path to the file to test for writability.  The path may be either relative or absolute.</param>
        /// <returns>True, if the file is writable.</returns>
        public static bool IsFileWritable(string path)
        {
            return false;
        }
        /// <summary>
        /// This method determines whether the folder indicated by the path argument is writable. To be writable in this context
        /// means that files or folders can be added or removed from the folde.
        /// </summary>
        /// <param name="path">The path to the folder to test for writability.  The path may be either relative or absolute.</param>
        /// <returns>True, if the folder is writable.</returns>
        public static bool IsFolderWritable(string path)
        {
            return false;
        }
        /// <summary>
        /// Determines whether the file indicated by the path is readable.  To be readable means that the file can be opened to an input stream and bytes can be
        /// read from the stream.
        /// </summary>
        /// <param name="path">The relative or absolute path to the file to be tested.</param>
        /// <returns>True, if the file is readable.</returns>
        public static bool IsFileReadable(string path)
        {
            return false;
        }
        /// <summary>
        /// Determines whether the folder indicated by the path is readable.  To be readable means that the folder contents can be
        /// listed.
        /// </summary>
        /// <param name="path">The relative or absolute path to the folder to be tested.</param>
        /// <returns>True, if the folder is readable.</returns>
        public static bool IsFolderReadable(string path)
        {
            return false;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

The API is very simple. Just 4 top level methods each taking a path and returning a Boolean. Note that this API has an expansive view of writability. If I can write a file, I should also be able to read and delete it. Perhaps there is a use case for being able to write a file without being able to read it, but I can't think of it and am not interested in it. This in general is the problem with working with privileges and permissions…the number of corner cases is huge. At every turn we will seek to simplify and limit the number of permission combinations we will accept.

Filling out the stubs…

using System;
using System.IO;
using System.IO.FileSystem.AccessControl;

namespace FileSupport
{
    public static class FileAccess
    {
        /// <summary>
        /// Determines whether the the file indicated by the path is readable.  To be readable means that the file can be opened to an input stream and bytes can be
        /// read from the stream.
        /// </summary>
        /// <param name="path">The relative or absolute path to the file to be tested.</param>
        /// <returns>True, if the file is readable.</returns>
        public static bool IsFileReadable(string path)
        {
            if (File.Exists(path))
            {
                return  IsNormalFile(path) && HasFilePermission(path, FileSystemRights.Read);
            }
            return false;
        }
        /// <summary>
        /// Determines whether the the folder indicated by the path is readable.  To be readable means that the folder contents can be
        /// listed.
        /// </summary>
        /// <param name="path">The relative or absolute path to the folder to be tested.</param>
        /// <returns>True, if the folder is readable.</returns>
        public static bool IsFolderReadable(string path)
        {
            if (Directory.Exists(path))
            {
                    return HasFilePermission(path, FileSystemRights.Read);
            }
            return false;
        }
        /// <summary>
        /// This method determines whether the file indicated by the path argument is writable. To be writable in this context
        /// means that the file can be either modified, appended to, overwritten or deleted.  If the file does not exist, it can
        /// be created.
        /// </summary>
        /// <param name="path">The path to the file to test for writability.  The path may be either relative or absolute.</param>
        /// <returns>True, if the file is writable.</returns>
        public static bool IsFileWritable(string path)
        {
            bool result = false;
            if (File.Exists(path))
            {
                result = IsNormalFile(path) && !IsReadOnly(path) && HasFilePermission(path, FileSystemRights.Modify);
            }
            else
            {
                result = true;
            }
            return result && IsFolderWritable(path.GetDirectoryName(path));
        }
        /// <summary>
        /// This method determines whether the folder indicated by the path argument is writable. To be writable in this context
        /// means that files or folders can be added or removed from the folde.
        /// </summary>
        /// <param name="path">The path to the folder to test for writability.  The path may be either relative or absolute.</param>
        /// <returns>True, if the folder is writable.</returns>
        public static bool IsFolderWritable(string path)
        {
            if (Directory.Exists(path))
            {
                return HasFolderPermission(path, FileSystemRights.Modify);
            }
            return false;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

I added the nuget package System.IO.FileSystem.AccessControl. This is Windows only code. I will address the Linux part of the code later.

image

Need to add code now for several utility functions we added. IsNormal() verifies that we are reading and writing only "normal" files…that is, files that are not directory, system, hidden, encrypted or temporary files. IsReadOnly() tests the read only file attribute of the file.

Finally HasPermission() checks the ACL for the file (More properly the DACL, the discretionary access control list) . ACLs are the primary means for determining whether the caller has read or write access. The read only file attribute must still be checked, because even if the ACL allows write the read only file attribute will prevent it. An ACL is a list of ACE's (access control entries), each of which denies or allows access for a particular right and a particular user or group.

ACLs are Windows only so the HasFilePermission() and HasPermission() functions are protected by the SupportedOSPlatformAttribute(). ACLs are also supported on some Linux systems, but they are not often used and there is no support in .NET for Linux ACLs.

using System;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;

namespace FileSupport
{
    public static class FileAccess
    {
        /// …
        /// <summary>
        /// Determines whether the file indicated by the path argument is a normal file.  To be a normal file in this context
        /// means that the file is not a directory and is also not encrypted, hidden, temporary or a system file.
        /// </summary>
        /// <param name="path">the path to the file to be tested.</param>
        /// <returns>True, if the indicated file is a normal file.</returns>
        [SupportedOSPlatform("windows")]
        public static bool IsNormalFile(string path)
        {
            FileAttributes fa = File.GetAttributes(path);
            FileAttributes specialFile = FileAttributes.Directory | FileAttributes.Encrypted | FileAttributes.Hidden | FileAttributes.System | FileAttributes.Temporary;
            return (fa & specialFile) == 0;
        }
        /// <summary>
        /// Determines if the read-only file attribute is set.
        /// </summary>
        /// <param name="path">The path to the file</param>
        /// <returns>True, if the file has the read only attribute set.</returns>
        public static bool IsReadOnly(string path)
        {
            FileAttributes fa = File.GetAttributes(path);
            return (fa & FileAttributes.ReadOnly) != 0;
        }
        /// <summary>
        /// Determines whether a file has the indicated file system right.
        /// </summary>
        /// <param name="path">The path to the file</param>
        /// <param name="right">The file system right.</param>
        /// <returns>True, if the file has the indicated right.</returns>
        [SupportedOSPlatform("windows")]
        public static bool HasFilePermission(string path, FileSystemRights right)
        {
            FileSecurity fs = new FileSecurity(path,AccessControlSections.Access);
            return HasPermission(fs, right);
        }
        /// <summary>
        /// Determines whether the indicated file system security object has the indicated file system right.
        /// </summary>
        /// <param name="fss">The file system security object.</param>
        /// <param name="right">The file system right.</param>
        /// <returns>True, if the indicated file system security object has the indicated file system right.</returns>
        /// <remarks>The current Windows user identity is used to search the security object's ACL for 
        /// relevent allow or deny rules.  To have permission for the indicated right, the object's ACL
        /// list must contain an explicit allow rule and no deny rules for either the user identity or a group to which
        /// the user belongs.</remarks>
        [SupportedOSPlatform("windows")]
        private static bool HasPermission(FileSystemSecurity fss, FileSystemRights right)
        {
            AuthorizationRuleCollection rules = fss.GetAccessRules(true, true, typeof(SecurityIdentifier));
            var groups = WindowsIdentity.GetCurrent().Groups;
            SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
            FileSystemRights remaining = right;
            foreach (FileSystemAccessRule rule in rules.OfType<FileSystemAccessRule>())
            {
                FileSystemRights test = rule.FileSystemRights & right;
                if (test != 0)
                {
                    if (rule.IdentityReference == user || (groups != null && groups.Contains(rule.IdentityReference)))
                    {
                        if (rule.AccessControlType == AccessControlType.Allow)
                        {
                            remaining &= ~test;
                            if (remaining == 0)return true;
                        }
                        else if (rule.AccessControlType == AccessControlType.Deny)
                        {
                            return false;
                        }
                    }
                }
            }
            return false;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Let's build what we have…

$ dotnet build -c Release
Microsoft (R) Build Engine version 16.9.0+57a23d249 for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  All projects are up-to-date for restore.
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(22,71): warning CA1416: This call site is reachable on all platforms. 'FileSystemRights.Read' is 
only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(36,28): warning CA1416: This call site is reachable on all platforms. 'FileAccess.HasFilePermission(string, FileSystemRights)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(22,47): warning CA1416: This call site is reachable on all platforms. 'FileAccess.HasFilePermission(string, FileSystemRights)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(36,52): warning CA1416: This call site is reachable on all platforms. 'FileSystemRights.Read' is 
only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(22,25): warning CA1416: This call site is reachable on all platforms. 'FileAccess.IsNormalFile(string)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(70,48): warning CA1416: This call site is reachable on all platforms. 'FileSystemRights.Modify' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(70,24): warning CA1416: This call site is reachable on all platforms. 'FileAccess.HasFilePermission(string, FileSystemRights)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(52,69): warning CA1416: This call site is reachable on all platforms. 'FileAccess.HasFilePermission(string, FileSystemRights)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(52,93): warning CA1416: This call site is reachable on all platforms. 'FileSystemRights.Modify' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(52,26): warning CA1416: This call site is reachable on all platforms. 'FileAccess.IsNormalFile(string)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
  FileSupport -> D:\rundle\dev.to\2021-06-05\FileSupport\bin\Release\net5.0\FileSupport.dll

Build succeeded.

D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(22,71): warning CA1416: This call site is reachable on all platforms. 'FileSystemRights.Read' is 
only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(36,28): warning CA1416: This call site is reachable on all platforms. 'FileAccess.HasFilePermission(string, FileSystemRights)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(22,47): warning CA1416: This call site is reachable on all platforms. 'FileAccess.HasFilePermission(string, FileSystemRights)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(36,52): warning CA1416: This call site is reachable on all platforms. 'FileSystemRights.Read' is 
only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(22,25): warning CA1416: This call site is reachable on all platforms. 'FileAccess.IsNormalFile(string)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(70,48): warning CA1416: This call site is reachable on all platforms. 'FileSystemRights.Modify' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(70,24): warning CA1416: This call site is reachable on all platforms. 'FileAccess.HasFilePermission(string, FileSystemRights)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(52,69): warning CA1416: This call site is reachable on all platforms. 'FileAccess.HasFilePermission(string, FileSystemRights)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(52,93): warning CA1416: This call site is reachable on all platforms. 'FileSystemRights.Modify' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
D:\rundle\dev.to\2021-06-05\FileSupport\FileAccess.cs(52,26): warning CA1416: This call site is reachable on all platforms. 'FileAccess.IsNormalFile(string)' is only supported on: 'windows'. [D:\rundle\dev.to\2021-06-05\FileSupport\FileSupport.csproj]
    10 Warning(s)
    0 Error(s)

Time Elapsed 00:00:01.58


Enter fullscreen mode Exit fullscreen mode

We have a number of warning related to the windows-only code. We'll address these later. First let's build some unit tests to run on Windows.
image
Add project reference..
image
For our units tests we will need a test fixture. To test the various access methods we will need files and folders set to various combinations of read and write permissions. The test fixture will create these files and folders before the unit tests run and then remove them when the unit tests are complete. The test fixture must be robust, removing any test files left over from previous runs before adding new ones. Also an environment variable is checked before removing files. This is important for a debug scenario where we want to examine the created test files.

using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using Xunit;

namespace FileSupportTests
{
    public class TestFileFixture : IDisposable
    {
        public string TestDir { get; }
        public const string InvalidFilePath = " <|>";
        public const string NullFilePath = null;
        public const string EmptyFilePath = "";
        public const string UnwritableFileName = "Unwritable.txt";
        public const string UnreadableFileName = "Unreadable.txt";
        public const string WritableFileName = "Writable.txt";
        public const string HiddenFileName = "HiddenFile.txt";
        public const string ReadOnlyFileName = "ReadOnly.txt";
        public const string NonExistentFileName = "NonExistent.txt";
        public const string UnreadableFolderName = "UnreadableFolder";
        public const string UnwritableFolderName = "UnwritableFolder";
        public const string ReadableFolderName = "ReadableFolder";
        public TestFileFixture()
        {
            TestDir = CreateTestDirectory();
            Directory.SetCurrentDirectory(TestDir);
            DeleteTestFiles();
            CreateTestFiles();
        }
        public void CreateTestFiles()
        {
            CreateUnwritableFile();
            CreateUnreadableFile();
            CreateWritableFile();
            CreateHiddenFile();
            CreateReadOnlyFile();
            CreateUnreadableFolder();
            CreateUnwritableFolder();
            CreateReadableFolder();
        }
        protected string CreateTestDirectory()
        {
            string testbasedir = Path.GetTempPath();
            string testDirPath = Path.Combine(testbasedir, "FileSupportTests");
            Directory.CreateDirectory(testDirPath);
            return testDirPath;
        }
        private void CreateReadOnlyFile()
        {
            string path = ReadOnlyFileName;
            CreateFile(path);
            FileAttributes fa = File.GetAttributes(path);
            File.SetAttributes(path, fa | FileAttributes.ReadOnly);
        }
        protected void CreateFile(string path)
        {
            using (FileStream fs = File.Create(path))
            {

            }
        }
        private void ClearReadOnlyAttribute(string path)
        {
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileAttributes fa = File.GetAttributes(path);
                File.SetAttributes(path, fa & (~FileAttributes.ReadOnly));
            }
            else
            {
                FileInfo fi = new FileInfo(path);
                fi.IsReadOnly = false;
            }
        }

        private void CreateHiddenFile()
        {
            string path = HiddenFileName;
            CreateFile(path);
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileAttributes fa = File.GetAttributes(path);
                File.SetAttributes(path, fa | FileAttributes.Hidden);
            }
        }

        private void CreateWritableFile()
        {
            string path = WritableFileName;
            CreateFile(path);
        }

        private void CreateReadableFolder()
        {
            string path = ReadableFolderName;
            Directory.CreateDirectory(path);
        }

        private void CreateUnreadableFile()
        {
            string path = UnreadableFileName;
            CreateFile(path);
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
                SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
                FileSystemAccessRule r = new FileSystemAccessRule(user, FileSystemRights.Read, AccessControlType.Deny);
                fs.AddAccessRule(r);
                FileInfo fi = new FileInfo(path);
                fi.SetAccessControl(fs);
            }
            else
            {
                var fi = new UnixFileInfo(path);
                fi.FileAccessPermissions = 0;
            }
        }

        private void CreateUnwritableFile()
        {
            string path = UnwritableFileName;
            CreateFile(path);
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
                SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
                FileSystemAccessRule r = new FileSystemAccessRule(user, FileSystemRights.Write, AccessControlType.Deny);
                fs.AddAccessRule(r);
                FileInfo fi = new FileInfo(path);
                fi.SetAccessControl(fs);
            }
            else
            {
                var fi = new UnixFileInfo(path);
                fi.FileAccessPermissions = FileAccessPermissions.OtherRead | FileAccessPermissions.GroupRead | FileAccessPermissions.UserRead;
            }
        }

        private void CreateUnwritableFolder()
        {
            string path = UnwritableFolderName;
            Directory.CreateDirectory(path);
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
                SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
                FileSystemAccessRule r = new FileSystemAccessRule(user, FileSystemRights.Write | FileSystemRights.Modify, AccessControlType.Deny);
                fs.AddAccessRule(r);
                FileInfo fi = new FileInfo(path);
                fi.SetAccessControl(fs);
            }
            else
            {
                var fi = new UnixFileInfo(path);
                fi.FileAccessPermissions = FileAccessPermissions.OtherRead | FileAccessPermissions.GroupRead | FileAccessPermissions.UserRead;
            }
        }

        private void CreateUnreadableFolder()
        {
            string path = UnreadableFolderName;
            Directory.CreateDirectory(path);
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
                SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
                FileSystemAccessRule r = new FileSystemAccessRule(user, FileSystemRights.Read, AccessControlType.Deny);
                fs.AddAccessRule(r);
                FileInfo fi = new FileInfo(path);
                fi.SetAccessControl(fs);
            }
            else
            {
                var fi = new UnixFileInfo(path);
                fi.FileAccessPermissions = 0;
            }
        }
        public void ClearDenyACEs(string path)
        {
            FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
            AuthorizationRuleCollection rules = fs.GetAccessRules(true, true, typeof(SecurityIdentifier));
            SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
            AuthorizationRuleCollection newRules = new AuthorizationRuleCollection();
            FileSystemSecurity fssNew = new FileSecurity();
            foreach (FileSystemAccessRule rule in rules.OfType<FileSystemAccessRule>())
            {
                if(rule.IdentityReference == user && rule.AccessControlType == AccessControlType.Deny)
                {
                    fs.RemoveAccessRule(rule);
                    FileInfo fi = new FileInfo(path);
                    fi.SetAccessControl(fs);

                }
            }
        }
        public void DeleteTestFiles()
        {
            if(File.Exists(UnwritableFileName))File.Delete(UnwritableFileName);
            if(File.Exists(UnreadableFileName))File.Delete(UnreadableFileName);
            if(File.Exists(WritableFileName))File.Delete(WritableFileName);
            if(File.Exists(HiddenFileName))File.Delete(HiddenFileName);
            if(File.Exists(ReadOnlyFileName))
            {
                ClearReadOnlyAttribute(ReadOnlyFileName);
                File.Delete(ReadOnlyFileName);
            }
            if(Directory.Exists(UnwritableFolderName))
            {
                if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                {
                    ClearDenyACEs(UnwritableFolderName);
                }
                Directory.Delete(UnwritableFolderName);
            }
            if(Directory.Exists(ReadableFolderName))Directory.Delete(ReadableFolderName);
            if(Directory.Exists(UnreadableFolderName))Directory.Delete(UnreadableFolderName);
        }
        public bool NeedsCleanup()
        {
            var flag = Environment.GetEnvironmentVariable("NOCLEANUP");
            return flag != "1";
        }
        public void Dispose()
        {
            if(NeedsCleanup())
            {
                DeleteTestFiles();
            }
        }

    }
}

Enter fullscreen mode Exit fullscreen mode

Now our unit tests. We not only use the API to check file access but we also try to actually access the file to ensure the fixture is creating the file properly.

using System;
using System.Security.AccessControl;
using Xunit;
using FileSupport;
using Xunit.Abstractions;
using System.Runtime.InteropServices;

namespace FileSupportTests
{
    public class FileSupportTests : IClassFixture<TestFileFixture>
    {
        private TestFileFixture _fixture;
        public FileSupportTests(TestFileFixture fixture)
        {
            _fixture = fixture;
        }
        [Fact]
        public void IsWritableTest()
        {
            Assert.False(FileAccess.IsFileWritable(TestFileFixture.UnwritableFileName));
            Assert.Throws<UnauthorizedAccessException>(() => { System.IO.File.WriteAllText(TestFileFixture.UnwritableFileName,""); });
            Assert.True(FileAccess.IsFileWritable(TestFileFixture.WritableFileName));
            Assert.True(FileAccess.IsFileWritable(TestFileFixture.NonExistentFileName));
            Assert.True(FileAccess.IsFileWritable(TestFileFixture.InvalidFilePath));
            Assert.False(FileAccess.IsFileWritable(TestFileFixture.NullFilePath));
            Assert.False(FileAccess.IsFileWritable(TestFileFixture.EmptyFilePath));
        }

        [Fact]
        public void IsFolderReadableTest()
        {
            Assert.False(FileAccess.IsFolderReadable(TestFileFixture.UnreadableFolderName));
            Assert.ThrowsAny<UnauthorizedAccessException>(() => { System.IO.Directory.GetFiles(TestFileFixture.UnreadableFolderName); });
            Assert.True(FileAccess.IsFolderReadable(TestFileFixture.ReadableFolderName));
            Assert.False(FileAccess.IsFolderReadable(TestFileFixture.InvalidFilePath));
        }
        [Fact]
        public void IsFolderWritableTest()
        {
            Assert.False(FileAccess.IsFolderWritable(TestFileFixture.UnwritableFolderName));
            Assert.Throws<UnauthorizedAccessException>(() => { System.IO.File.WriteAllText(System.IO.Path.Combine(TestFileFixture.UnwritableFolderName,"1.tmp"),""); });
            Assert.False(FileAccess.IsFolderWritable(TestFileFixture.UnreadableFolderName));
            Assert.True(FileAccess.IsFolderWritable(TestFileFixture.ReadableFolderName));
            Assert.False(FileAccess.IsFolderWritable(TestFileFixture.InvalidFilePath));
        }

        [Fact]
        public void IsNormalFileTest()
        {
            Assert.False(FileAccess.IsNormalFile(TestFileFixture.HiddenFileName));
            Assert.False(FileAccess.IsNormalFile(_fixture.TestDir));
            Assert.True(FileAccess.IsNormalFile(TestFileFixture.WritableFileName));
        }
        [Fact]
        public void IsReadableTest()
        {
            Assert.True(FileAccess.IsFileReadable(TestFileFixture.UnwritableFileName));
            Assert.True(FileAccess.IsFileReadable(TestFileFixture.WritableFileName));
            Assert.False(FileAccess.IsFileReadable(TestFileFixture.UnreadableFileName));
            Assert.Throws<UnauthorizedAccessException>(() => { System.IO.File.ReadAllText(TestFileFixture.UnreadableFileName); });
            Assert.False(FileAccess.IsFileReadable(TestFileFixture.InvalidFilePath));
        }
        [Fact]
        public void HasFilePermissionTest()
        {
             Assert.True(FileAccess.HasFilePermission(TestFileFixture.WritableFileName, FileSystemRights.Write));
             Assert.False(FileAccess.HasFilePermission(TestFileFixture.UnwritableFileName, FileSystemRights.Write));
        }

        [Fact]
        public void IsReadOnlyTest()
        {
            Assert.False(FileAccess.IsReadOnly(TestFileFixture.WritableFileName));
            Assert.True(FileAccess.IsReadOnly(TestFileFixture.ReadOnlyFileName));
        }

    }
}

Enter fullscreen mode Exit fullscreen mode

Build our unit tests

image

image

image
We get a large number of warnings related to windows-only code. These warning will get tedious and they are not telling us anything we don't know already so let's disable them by adding in every source file…

#pragma warning disable CA1416

Enter fullscreen mode Exit fullscreen mode

Let's run our unit tests.
image

We've got our windows only code well in hand. Now let's turn to the Linux side of things. On Linux we use file permissions. .NET Core does not have native support for Linux file permissions but we can use a Mono package to get these. For those unfamiliar with Mono, the short history is that Mono is a predecessor to .NET Core, the first .NET that ran on Linux.
image

We also add it to the FileSupportTests project. In FileSupport we'll add Linux specific code. So for each of our 4 top level API functions…

       public static bool IsFileReadable(string path)
        {
            if (File.Exists(path))
            {
                if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                {
                    return  IsNormalFile(path) && HasFilePermission(path, FileSystemRights.Read);
                }
                else
                {
                    return IsNormalFile(path) && HasFilePermission(path, FileAccessPermissions.UserRead | FileAccessPermissions.GroupRead | FileAccessPermissions.OtherRead);
                }
            }
            return false;
        }

        public static bool IsFolderReadable(string path)
        {
            if (Directory.Exists(path)) 
            {
                if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                {
                    return HasFilePermission(path, FileSystemRights.Read);
                }
                else
                {
                    return HasFilePermission(path, FileAccessPermissions.UserRead | FileAccessPermissions.GroupRead | FileAccessPermissions.OtherRead);
                }
            }
            return false;
        }

        public static bool IsFileWritable(string path)
        {
            bool result = false;
            if (File.Exists(path))
            {
                if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                    result = IsNormalFile(path) && !IsReadOnly(path) && HasFilePermission(path, FileSystemRights.Modify);
                else
                    result = IsNormalFile(path) && HasFilePermission(path, FileAccessPermissions.UserWrite | FileAccessPermissions.UserRead 
                        | FileAccessPermissions.GroupWrite | FileAccessPermissions.GroupRead
                        | FileAccessPermissions.OtherWrite | FileAccessPermissions.OtherRead);
            }
            else
            {
                result = true;
            }
            return result && IsFolderWritable(Path.GetDirectoryName(path));
        }

        public static bool IsFolderWritable(string path)
        {
            if (path == "") path = ".";
            if (Directory.Exists(path))
            {
                if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                {
                    return HasFilePermission(path, FileSystemRights.Modify);
                }
                else
                {
                    return HasFilePermission(path, FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite);
                }
            }
            return false;
        }


Enter fullscreen mode Exit fullscreen mode

We need Linux specific code in the IsNormalFile() method.


        public static bool IsNormalFile(string path)
        {
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileAttributes fa = File.GetAttributes(path);
                FileAttributes specialFile = FileAttributes.Directory | FileAttributes.Encrypted | FileAttributes.Hidden | FileAttributes.System | FileAttributes.Temporary;
                return (fa & specialFile) == 0;
            }
            else
            {
                var fi = new UnixFileInfo(path);
                return fi.FileType == FileTypes.RegularFile;
            }
        }

Enter fullscreen mode Exit fullscreen mode

Finally we need a new overload of HasPermission() to handle Linux file permissions. Note that I am using UnsupportedOSPlatform("windows") instead of SupportedOSPlatform("linux") because I want this code to run for macOS as well, even though this won't be tested.

        [UnsupportedOSPlatform("windows")]
        public static bool HasPermission(UnixFileSystemInfo fi, FileAccessPermissions fap)
        {

            var effective = fi.FileAccessPermissions & fap;
            var user = UnixUserInfo.GetRealUser();
            if(user.UserId == fi.OwnerUserId)
            {
                return (effective & FileAccessPermissions.UserReadWriteExecute) == (fap & FileAccessPermissions.UserReadWriteExecute);
            }
            else if(user.GroupId == fi.OwnerGroupId)
            {
                return (effective & FileAccessPermissions.GroupReadWriteExecute) == (fap & FileAccessPermissions.GroupReadWriteExecute);
            }
            else
            {
                return (effective & FileAccessPermissions.OtherReadWriteExecute) == (fap & FileAccessPermissions.OtherReadWriteExecute);
            }
        }


Enter fullscreen mode Exit fullscreen mode

We'll need some Linux specific code in the test fixture.

        private void ClearReadOnlyAttribute(string path)
        {
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileAttributes fa = File.GetAttributes(path);
                File.SetAttributes(path, fa & (~FileAttributes.ReadOnly));
            }
            else
            {
                FileInfo fi = new FileInfo(path);
                fi.IsReadOnly = false;
            }
        }

        private string CreateHiddenFile()
        {
            string path = HiddenFileName;
            CreateFile(path);
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileAttributes fa = File.GetAttributes(path);
                File.SetAttributes(path, fa | FileAttributes.Hidden);
            }
            return path;
        }

        private string CreateUnreadableFile()
        {
            string path = UnreadableFileName;
            CreateFile(path);
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
                SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
                FileSystemAccessRule r = new FileSystemAccessRule(user, FileSystemRights.Read, AccessControlType.Deny);
                fs.AddAccessRule(r);
                FileInfo fi = new FileInfo(path);
                fi.SetAccessControl(fs);
            }
            else
            {
                var fi = new UnixFileInfo(path);
                fi.FileAccessPermissions = 0;
            }
            return path;
        }

        private string CreateUnwritableFile()
        {
            string path = UnwritableFileName;
            CreateFile(path);
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
                SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
                FileSystemAccessRule r = new FileSystemAccessRule(user, FileSystemRights.Write, AccessControlType.Deny);
                fs.AddAccessRule(r);
                FileInfo fi = new FileInfo(path);
                fi.SetAccessControl(fs);
            }
            else
            {
                var fi = new UnixFileInfo(path);
                fi.FileAccessPermissions = FileAccessPermissions.OtherRead | FileAccessPermissions.GroupRead | FileAccessPermissions.UserRead;
            }
            return path;
        }

        private string CreateUnwritableFolder()
        {
            string path = UnwritableFolderName;
            Directory.CreateDirectory(path);
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
                SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
                FileSystemAccessRule r = new FileSystemAccessRule(user, FileSystemRights.Write | FileSystemRights.Modify, AccessControlType.Deny);
                fs.AddAccessRule(r);
                FileInfo fi = new FileInfo(path);
                fi.SetAccessControl(fs);
            }
            else
            {
                var fi = new UnixFileInfo(path);
                fi.FileAccessPermissions = FileAccessPermissions.OtherRead | FileAccessPermissions.GroupRead | FileAccessPermissions.UserRead;
            }
            return path;
        }

        private string CreateUnreadableFolder()
        {
            string path = UnreadableFolderName;
            Directory.CreateDirectory(path);
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                FileSecurity fs = new FileSecurity(path, AccessControlSections.Access);
                SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
                FileSystemAccessRule r = new FileSystemAccessRule(user, FileSystemRights.Read, AccessControlType.Deny);
                fs.AddAccessRule(r);
                FileInfo fi = new FileInfo(path);
                fi.SetAccessControl(fs);
            }
            else
            {
                var fi = new UnixFileInfo(path);
                fi.FileAccessPermissions = 0;
            }
            return path;
        }


Enter fullscreen mode Exit fullscreen mode

Also needed is a little bit of Linux specific code in the unit tests since there is no such thing as a hidden file on Linux. Also we are using a different HasPermission() overload on Linux.

       [Fact]
        public void IsNormalFileTest()
        {
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                Assert.False(FileAccess.IsNormalFile(_fixture.HiddenFilePath));
            else
                Assert.False(FileAccess.IsNormalFile("/dev/null"));
            Assert.False(FileAccess.IsNormalFile(_fixture.TestDir));
            Assert.True(FileAccess.IsNormalFile(_fixture.WritableFilePath));
        }

        [Fact]
        public void HasFilePermissionTest()
        {
            if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                Assert.True(FileAccess.HasFilePermission(TestFileFixture.WritableFileName, FileSystemRights.Write));
                Assert.False(FileAccess.HasFilePermission(TestFileFixture.UnwritableFileName, FileSystemRights.Write));
            }
            else
            {
                Assert.True(FileAccess.HasFilePermission(TestFileFixture.WritableFileName, FileAccessPermissions.UserWrite
                        | FileAccessPermissions.GroupWrite
                        | FileAccessPermissions.OtherWrite));   
                Assert.False(FileAccess.HasFilePermission(TestFileFixture.UnwritableFileName, FileAccessPermissions.UserWrite
                        | FileAccessPermissions.GroupWrite
                        | FileAccessPermissions.OtherWrite));
            }
        }


Enter fullscreen mode Exit fullscreen mode

Build for Linux…
image

As in the 1st .NET Core to Linux post, create a dockerfile to run the tests on Linux

FROM mcr.microsoft.com/dotnet/sdk:5.0

WORKDIR /bin
COPY bin/Release/net5.0/linux-x64 .

CMD ["dotnet","vstest","FileSupportTests.dll"]

Enter fullscreen mode Exit fullscreen mode

Build docker…
image
Running our unit test on Linux…
image
image
This is surprising. Our unit tests are failing on Linux. What is going on? Let's run an interactive docker session to find out. Turn on our NOCLEANUP flag and examine the files created by the test fixture.
image
image
image
So here is the problem. Our API for IsFileReadable() properly returns false for Unreadable.txt, but when we open it, no exception is thrown. This is because, by default, we are root in the docker container. There is no stopping root from reading everything. So clearly to get our unit tests working we need to run the tests as an ordinary user. In the docker file we add a new user "tester"…

FROM mcr.microsoft.com/dotnet/sdk:5.0

SHELL ["/bin/bash","-c"]
RUN adduser --gid 100 tester
WORKDIR /home/tester
COPY bin/Release/net5.0/linux-x64 .

CMD ["dotnet","vstest","FileSupportTests.dll"]

Enter fullscreen mode Exit fullscreen mode

Then when we run it we use the "-u" switch to specify tester as the current user. Note that no passwords are needed to do this.
image
Much better! Let's go interactive and see how the environment has changed.
image
Summary and discussion

To recap:

  1. A simple file access API library was built.
  2. Unit tests were written for the library which included a test fixture that created and deleted test files.
  3. The library was tested on Windows.
  4. Linux-specific code was added to the library and unit tests.
  5. The Linux code was tested in docker Linux containers.

This is my 2nd post on .NET Core Apps for Linux. There have been some improvements over my 1st post. In particular I figured out how to unit test in Linux without building source. The key to this is the dotnet cli command vstest

$ dotnet vstest FileSupportTests.dll
Enter fullscreen mode Exit fullscreen mode

Now I can simply move the cross compiled binaries to the container and run the tests. This is a big improvement over the 1st post where I moved source and compiled the source. The problem with compiling source in the containers is that it gets harder as the code gets bigger, more complex with more dependencies. Binaries are much easier. All the dependencies are in one place. This is still not ideal however, because I still need the .NET SDK to run the tests. I would prefer to run in plain vanilla Ubuntu to ensure that I have all the needed dependencies. Note that the library built in this example would fail in plain vanilla Ubuntu because, as discussed in the first post, .NET 5.0 requires ICU by default and it is not available by default on Linux. The 1st post indicates how to address this problem.

The following discussion points come to mind…

100% code coverage is not a panacea. In the 1st post I claimed that 100% code coverage was the "gold standard" of unit testing. Well…let's not get carried away. The Windows specific code in this example was covered 100% by the unit tests run created for the Windows-only code. However 100% coverage does not begin to address all the possible test cases for Windows ACLs. Even with this very limited set of test cases, the unit test code, including the test fixture, has 3 times the number of lines of code as the code under test. I have the idea that people think that amount of unit test code you write is much less than the actual code. This example shows how the reverse can be true. Certain functionality, like Windows ACLs, have a depth of complexity that causes the number of possible test cases to explode.

Just good enough unit testing. The Linux specific code in this example does not have 100% code coverage. To fully cover the HasPermission() method would need two more users added to the docker container and files in the test fixture created by these two other users in order to test the group and everyone file permissions. Not sure how to do this in the container. The point is that writing unit test code does not have a uniform level of difficulty as most people seem to think. Getting to 100% code coverage sometimes requires very challenging approaches that in turn suck up a lot of developer time. This is sometimes not worth it. I have manually tested the uncovered code sections and then declared the units tests "good enough". People want perfection in unit tests only when they are not paying the bills.

More corners than sides. The term "corner case" is curious because it implies there can be no more than 4. Yet it turns out that for certain problem spaces, corners dominate. File access is one of these. Look at the huge corner case we found during Linux testing. The API we wrote indicated a file as unreadable when in fact the file could be read because we were acting as root. Note that we simply ignored this corner case and changed the test to use a normal user. Is this right? Shouldn't the code be changed to return true when the user is root? After some thought, I decided no. Our API is evaluating the permissions of the file and not considering special privileges of the user. In the same way the "ls -l" command reports file access permissions independent whether user is root. On a different day I might have decided yes. This is the nature of many corner cases…they are subtle with no clear cut solutions. There is another corner case in the Linux version of HasPermission(). I am checking user permissions before group and before everyone. This is means if user has no access, then HasPermission() will return false even if everyone has access. This must be wrong. But no! Test it for yourself…
image
So it seems that the Linux file permission 044 implies "everyone but owner can read." Find a description of Linux file permissions that explains this subtlety. You can't do it. A better name for corner case might be "between the lines case" because the situation is one where the documentation utterly fails you. So there are at least two big corner cases in the Linux code in this example. Linux file access permissions, however, are much simpler to understand than Windows ACLs. The Window ACL code in this example has even more corner cases and I don't want to spend the 5 paragraphs it would take to enumerate them. The point is that I believe people underestimate the importance of corner cases. They are thought to be a small part of the problem of writing working code when in fact for certain problem spaces the corner cases form what might be called a Super Pareto Principle…where 2% of the problem requires 98% of the effort.

I hope people find this post useful. You don't see a lot of examples devoted to ACLs, file permissions, Linux mixed with Windows specific code so I thought I would add to this oeuvre.

💖 💪 🙅 🚩
bobrundle
Bob Rundle

Posted on June 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related