Browse Source

Fix SftpFileAttributes file type detection (#1688)

* Fix SftpFileAttributes file type detection

To get the file type, S_IFMT should be used as the mask. Instead it was using each file
type as the mask. It meant that e.g. a symbolic link would also show as a regular file
and a character device.

Also allow setting and retrieving the setuid/setgid/sticky bits

* fix build
Rob Hague 3 tuần trước cách đây
mục cha
commit
af279d206a
22 tập tin đã thay đổi với 673 bổ sung359 xóa
  1. 3 22
      src/Renci.SshNet/Sftp/Requests/SftpMkDirRequest.cs
  2. 2 15
      src/Renci.SshNet/Sftp/Requests/SftpOpenRequest.cs
  3. 4 4
      src/Renci.SshNet/Sftp/Responses/SftpVersionResponse.cs
  4. 405 213
      src/Renci.SshNet/Sftp/SftpFileAttributes.cs
  5. 1 1
      src/Renci.SshNet/Sftp/SftpSession.cs
  6. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/Requests/SftpFSetStatRequestTest.cs
  7. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/Requests/SftpMkDirRequestTest.cs
  8. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/Requests/SftpOpenRequestTest.cs
  9. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/Requests/SftpSetStatRequestTest.cs
  10. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/Responses/SftpAttrsResponseTest.cs
  11. 223 0
      test/Renci.SshNet.Tests/Classes/Sftp/SftpFileAttributesTest.cs
  12. 0 6
      test/Renci.SshNet.Tests/Classes/Sftp/SftpFileReaderTestBase.cs
  13. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_Connected_RequestRead.cs
  14. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_Connected_RequestStatVfs.cs
  15. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_DataReceived_MultipleSftpMessagesInSingleSshDataMessage.cs
  16. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_DataReceived_MultipleSftpMessagesSplitOverMultipleSshDataMessages.cs
  17. 2 1
      test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_DataReceived_SingleSftpMessageInSshDataMessage.cs
  18. 1 1
      test/Renci.SshNet.Tests/Classes/Sftp/SftpVersionResponseBuilder.cs
  19. 1 4
      test/Renci.SshNet.Tests/Classes/ShellStreamTest_Write_WriteBufferNotEmptyAndWriteMoreBytesThanBufferCanContain.cs
  20. 0 34
      test/Renci.SshNet.Tests/Common/ArrayBuilder`1.cs
  21. 1 42
      test/Renci.SshNet.Tests/Common/Extensions.cs
  22. 12 7
      test/Renci.SshNet.Tests/Common/SftpFileAttributesBuilder.cs

+ 3 - 22
src/Renci.SshNet/Sftp/Requests/SftpMkDirRequest.cs

@@ -8,7 +8,6 @@ namespace Renci.SshNet.Sftp.Requests
     internal sealed class SftpMkDirRequest : SftpRequest
     {
         private byte[] _path;
-        private byte[] _attributesBytes;
 
         public override SftpMessageTypes SftpMessageType
         {
@@ -23,18 +22,6 @@ namespace Renci.SshNet.Sftp.Requests
 
         public Encoding Encoding { get; private set; }
 
-        private SftpFileAttributes Attributes { get; set; }
-
-        private byte[] AttributesBytes
-        {
-            get
-            {
-                _attributesBytes ??= Attributes.GetBytes();
-
-                return _attributesBytes;
-            }
-        }
-
         /// <summary>
         /// Gets the size of the message in bytes.
         /// </summary>
@@ -48,36 +35,30 @@ namespace Renci.SshNet.Sftp.Requests
                 var capacity = base.BufferCapacity;
                 capacity += 4; // Path length
                 capacity += _path.Length; // Path
-                capacity += AttributesBytes.Length; // Attributes
+                capacity += 4; // Attributes
                 return capacity;
             }
         }
 
         public SftpMkDirRequest(uint protocolVersion, uint requestId, string path, Encoding encoding, Action<SftpStatusResponse> statusAction)
-            : this(protocolVersion, requestId, path, encoding, SftpFileAttributes.Empty, statusAction)
-        {
-        }
-
-        private SftpMkDirRequest(uint protocolVersion, uint requestId, string path, Encoding encoding, SftpFileAttributes attributes, Action<SftpStatusResponse> statusAction)
             : base(protocolVersion, requestId, statusAction)
         {
             Encoding = encoding;
             Path = path;
-            Attributes = attributes;
         }
 
         protected override void LoadData()
         {
             base.LoadData();
             _path = ReadBinary();
-            Attributes = ReadAttributes();
+            _ = ReadAttributes();
         }
 
         protected override void SaveData()
         {
             base.SaveData();
             WriteBinaryString(_path);
-            Write(AttributesBytes);
+            Write(0u); // empty attributes
         }
     }
 }

+ 2 - 15
src/Renci.SshNet/Sftp/Requests/SftpOpenRequest.cs

@@ -9,7 +9,6 @@ namespace Renci.SshNet.Sftp.Requests
     {
         private readonly Action<SftpHandleResponse> _handleAction;
         private byte[] _fileName;
-        private byte[] _attributes;
 
         public override SftpMessageTypes SftpMessageType
         {
@@ -24,12 +23,6 @@ namespace Renci.SshNet.Sftp.Requests
 
         public Flags Flags { get; }
 
-        public SftpFileAttributes Attributes
-        {
-            get { return SftpFileAttributes.FromBytes(_attributes); }
-            private set { _attributes = value.GetBytes(); }
-        }
-
         public Encoding Encoding { get; }
 
         /// <summary>
@@ -46,23 +39,17 @@ namespace Renci.SshNet.Sftp.Requests
                 capacity += 4; // FileName length
                 capacity += _fileName.Length; // FileName
                 capacity += 4; // Flags
-                capacity += _attributes.Length; // Attributes
+                capacity += 4; // Attributes
                 return capacity;
             }
         }
 
         public SftpOpenRequest(uint protocolVersion, uint requestId, string fileName, Encoding encoding, Flags flags, Action<SftpHandleResponse> handleAction, Action<SftpStatusResponse> statusAction)
-            : this(protocolVersion, requestId, fileName, encoding, flags, SftpFileAttributes.Empty, handleAction, statusAction)
-        {
-        }
-
-        private SftpOpenRequest(uint protocolVersion, uint requestId, string fileName, Encoding encoding, Flags flags, SftpFileAttributes attributes, Action<SftpHandleResponse> handleAction, Action<SftpStatusResponse> statusAction)
             : base(protocolVersion, requestId, statusAction)
         {
             Encoding = encoding;
             Filename = fileName;
             Flags = flags;
-            Attributes = attributes;
 
             _handleAction = handleAction;
         }
@@ -79,7 +66,7 @@ namespace Renci.SshNet.Sftp.Requests
 
             WriteBinaryString(_fileName);
             Write((uint)Flags);
-            Write(_attributes);
+            Write(0u); // empty attributes
         }
 
         public override void Complete(SftpResponse response)

+ 4 - 4
src/Renci.SshNet/Sftp/Responses/SftpVersionResponse.cs

@@ -11,14 +11,14 @@ namespace Renci.SshNet.Sftp.Responses
 
         public uint Version { get; set; }
 
-        public IDictionary<string, string> Extentions { get; set; }
+        public IDictionary<string, string> Extensions { get; set; }
 
         protected override void LoadData()
         {
             base.LoadData();
 
             Version = ReadUInt32();
-            Extentions = ReadExtensionPair();
+            Extensions = ReadExtensionPair();
         }
 
         protected override void SaveData()
@@ -27,9 +27,9 @@ namespace Renci.SshNet.Sftp.Responses
 
             Write(Version);
 
-            if (Extentions != null)
+            if (Extensions != null)
             {
-                Write(Extentions);
+                Write(Extensions);
             }
         }
     }

+ 405 - 213
src/Renci.SshNet/Sftp/SftpFileAttributes.cs

@@ -1,7 +1,10 @@
-using System;
+#nullable enable
+using System;
 using System.Collections.Generic;
-using System.Globalization;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
 using System.Linq;
+using System.Text;
 
 using Renci.SshNet.Common;
 
@@ -23,8 +26,8 @@ namespace Renci.SshNet.Sftp
         private const uint S_IFCHR = 0x2000; // character device
         private const uint S_IFIFO = 0x1000; // FIFO
         private const uint S_ISUID = 0x0800; // set UID bit
-        private const uint S_ISGID = 0x0400; // set-group-ID bit (see below)
-        private const uint S_ISVTX = 0x0200; // sticky bit (see below)
+        private const uint S_ISGID = 0x0400; // set-group-ID bit
+        private const uint S_ISVTX = 0x0200; // sticky bit
         private const uint S_IRUSR = 0x0100; // owner has read permission
         private const uint S_IWUSR = 0x0080; // owner has write permission
         private const uint S_IXUSR = 0x0040; // owner has execute permission
@@ -43,12 +46,7 @@ namespace Renci.SshNet.Sftp
         private readonly int _originalUserId;
         private readonly int _originalGroupId;
         private readonly uint _originalPermissions;
-        private readonly IDictionary<string, string> _originalExtensions;
-
-        private bool _isBitFiledsBitSet;
-        private bool _isUIDBitSet;
-        private bool _isGroupIDBitSet;
-        private bool _isStickyBitSet;
+        private readonly Dictionary<string, string>? _originalExtensions;
 
         internal bool IsLastAccessTimeChanged
         {
@@ -82,6 +80,7 @@ namespace Renci.SshNet.Sftp
 
         internal bool IsExtensionsChanged
         {
+            [MemberNotNullWhen(true, nameof(Extensions))]
             get { return _originalExtensions != null && Extensions != null && !_originalExtensions.SequenceEqual(Extensions); }
         }
 
@@ -169,7 +168,13 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if file represents a socket; otherwise, <see langword="false"/>.
         /// </value>
-        public bool IsSocket { get; private set; }
+        public bool IsSocket
+        {
+            get
+            {
+                return (Permissions & S_IFMT) == S_IFSOCK;
+            }
+        }
 
         /// <summary>
         /// Gets a value indicating whether file represents a symbolic link.
@@ -177,7 +182,13 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if file represents a symbolic link; otherwise, <see langword="false"/>.
         /// </value>
-        public bool IsSymbolicLink { get; private set; }
+        public bool IsSymbolicLink
+        {
+            get
+            {
+                return (Permissions & S_IFMT) == S_IFLNK;
+            }
+        }
 
         /// <summary>
         /// Gets a value indicating whether file represents a regular file.
@@ -185,7 +196,13 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if file represents a regular file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool IsRegularFile { get; private set; }
+        public bool IsRegularFile
+        {
+            get
+            {
+                return (Permissions & S_IFMT) == S_IFREG;
+            }
+        }
 
         /// <summary>
         /// Gets a value indicating whether file represents a block device.
@@ -193,7 +210,13 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if file represents a block device; otherwise, <see langword="false"/>.
         /// </value>
-        public bool IsBlockDevice { get; private set; }
+        public bool IsBlockDevice
+        {
+            get
+            {
+                return (Permissions & S_IFMT) == S_IFBLK;
+            }
+        }
 
         /// <summary>
         /// Gets a value indicating whether file represents a directory.
@@ -201,7 +224,13 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if file represents a directory; otherwise, <see langword="false"/>.
         /// </value>
-        public bool IsDirectory { get; private set; }
+        public bool IsDirectory
+        {
+            get
+            {
+                return (Permissions & S_IFMT) == S_IFDIR;
+            }
+        }
 
         /// <summary>
         /// Gets a value indicating whether file represents a character device.
@@ -209,7 +238,13 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if file represents a character device; otherwise, <see langword="false"/>.
         /// </value>
-        public bool IsCharacterDevice { get; private set; }
+        public bool IsCharacterDevice
+        {
+            get
+            {
+                return (Permissions & S_IFMT) == S_IFCHR;
+            }
+        }
 
         /// <summary>
         /// Gets a value indicating whether file represents a named pipe.
@@ -217,7 +252,88 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if file represents a named pipe; otherwise, <see langword="false"/>.
         /// </value>
-        public bool IsNamedPipe { get; private set; }
+        public bool IsNamedPipe
+        {
+            get
+            {
+                return (Permissions & S_IFMT) == S_IFIFO;
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the setuid bit is set.
+        /// </summary>
+        /// <value>
+        /// <see langword="true"/> if the setuid bit is set; otherwise, <see langword="false"/>.
+        /// </value>
+        public bool IsUIDBitSet
+        {
+            get
+            {
+                return (Permissions & S_ISUID) == S_ISUID;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_ISUID;
+                }
+                else
+                {
+                    Permissions &= ~S_ISUID;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the setgid bit is set.
+        /// </summary>
+        /// <value>
+        /// <see langword="true"/> if the setgid bit is set; otherwise, <see langword="false"/>.
+        /// </value>
+        public bool IsGroupIDBitSet
+        {
+            get
+            {
+                return (Permissions & S_ISGID) == S_ISGID;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_ISGID;
+                }
+                else
+                {
+                    Permissions &= ~S_ISGID;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the sticky bit is set.
+        /// </summary>
+        /// <value>
+        /// <see langword="true"/> if the sticky bit is set; otherwise, <see langword="false"/>.
+        /// </value>
+        public bool IsStickyBitSet
+        {
+            get
+            {
+                return (Permissions & S_ISVTX) == S_ISVTX;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_ISVTX;
+                }
+                else
+                {
+                    Permissions &= ~S_ISVTX;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the owner can read from this file.
@@ -225,7 +341,24 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if owner can read from this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool OwnerCanRead { get; set; }
+        public bool OwnerCanRead
+        {
+            get
+            {
+                return (Permissions & S_IRUSR) == S_IRUSR;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_IRUSR;
+                }
+                else
+                {
+                    Permissions &= ~S_IRUSR;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the owner can write into this file.
@@ -233,7 +366,24 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if owner can write into this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool OwnerCanWrite { get; set; }
+        public bool OwnerCanWrite
+        {
+            get
+            {
+                return (Permissions & S_IWUSR) == S_IWUSR;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_IWUSR;
+                }
+                else
+                {
+                    Permissions &= ~S_IWUSR;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the owner can execute this file.
@@ -241,7 +391,24 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if owner can execute this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool OwnerCanExecute { get; set; }
+        public bool OwnerCanExecute
+        {
+            get
+            {
+                return (Permissions & S_IXUSR) == S_IXUSR;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_IXUSR;
+                }
+                else
+                {
+                    Permissions &= ~S_IXUSR;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the group members can read from this file.
@@ -249,7 +416,24 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if group members can read from this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool GroupCanRead { get; set; }
+        public bool GroupCanRead
+        {
+            get
+            {
+                return (Permissions & S_IRGRP) == S_IRGRP;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_IRGRP;
+                }
+                else
+                {
+                    Permissions &= ~S_IRGRP;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the group members can write into this file.
@@ -257,7 +441,24 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if group members can write into this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool GroupCanWrite { get; set; }
+        public bool GroupCanWrite
+        {
+            get
+            {
+                return (Permissions & S_IWGRP) == S_IWGRP;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_IWGRP;
+                }
+                else
+                {
+                    Permissions &= ~S_IWGRP;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the group members can execute this file.
@@ -265,7 +466,24 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if group members can execute this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool GroupCanExecute { get; set; }
+        public bool GroupCanExecute
+        {
+            get
+            {
+                return (Permissions & S_IXGRP) == S_IXGRP;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_IXGRP;
+                }
+                else
+                {
+                    Permissions &= ~S_IXGRP;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the others can read from this file.
@@ -273,7 +491,24 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if others can read from this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool OthersCanRead { get; set; }
+        public bool OthersCanRead
+        {
+            get
+            {
+                return (Permissions & S_IROTH) == S_IROTH;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_IROTH;
+                }
+                else
+                {
+                    Permissions &= ~S_IROTH;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the others can write into this file.
@@ -281,7 +516,24 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if others can write into this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool OthersCanWrite { get; set; }
+        public bool OthersCanWrite
+        {
+            get
+            {
+                return (Permissions & S_IWOTH) == S_IWOTH;
+            }
+            set
+            {
+                if (value)
+                {
+                    Permissions |= S_IWOTH;
+                }
+                else
+                {
+                    Permissions &= ~S_IWOTH;
+                }
+            }
+        }
 
         /// <summary>
         /// Gets or sets a value indicating whether the others can execute this file.
@@ -289,209 +541,159 @@ namespace Renci.SshNet.Sftp
         /// <value>
         /// <see langword="true"/> if others can execute this file; otherwise, <see langword="false"/>.
         /// </value>
-        public bool OthersCanExecute { get; set; }
-
-        /// <summary>
-        /// Gets the extensions.
-        /// </summary>
-        /// <value>
-        /// The extensions.
-        /// </value>
-        public IDictionary<string, string> Extensions { get; private set; }
-
-        internal uint Permissions
+        public bool OthersCanExecute
         {
             get
             {
-                uint permission = 0;
-
-                if (_isBitFiledsBitSet)
-                {
-                    permission |= S_IFMT;
-                }
-
-                if (IsSocket)
-                {
-                    permission |= S_IFSOCK;
-                }
-
-                if (IsSymbolicLink)
-                {
-                    permission |= S_IFLNK;
-                }
-
-                if (IsRegularFile)
+                return (Permissions & S_IXOTH) == S_IXOTH;
+            }
+            set
+            {
+                if (value)
                 {
-                    permission |= S_IFREG;
+                    Permissions |= S_IXOTH;
                 }
-
-                if (IsBlockDevice)
+                else
                 {
-                    permission |= S_IFBLK;
+                    Permissions &= ~S_IXOTH;
                 }
+            }
+        }
 
-                if (IsDirectory)
-                {
-                    permission |= S_IFDIR;
-                }
+        /// <summary>
+        /// Gets the extensions.
+        /// </summary>
+        /// <value>
+        /// The extensions.
+        /// </value>
+        public IDictionary<string, string>? Extensions { get; }
 
-                if (IsCharacterDevice)
-                {
-                    permission |= S_IFCHR;
-                }
+        internal uint Permissions { get; private set; }
 
-                if (IsNamedPipe)
-                {
-                    permission |= S_IFIFO;
-                }
+        internal SftpFileAttributes(DateTime lastAccessTimeUtc, DateTime lastWriteTimeUtc, long size, int userId, int groupId, uint permissions, Dictionary<string, string>? extensions)
+        {
+            LastAccessTimeUtc = _originalLastAccessTimeUtc = lastAccessTimeUtc;
+            LastWriteTimeUtc = _originalLastWriteTimeUtc = lastWriteTimeUtc;
+            Size = _originalSize = size;
+            UserId = _originalUserId = userId;
+            GroupId = _originalGroupId = groupId;
+            Permissions = _originalPermissions = permissions;
+            Extensions = _originalExtensions = extensions;
+        }
 
-                if (_isUIDBitSet)
-                {
-                    permission |= S_ISUID;
-                }
+        /// <summary>
+        /// Sets the POSIX permissions for this file.
+        /// </summary>
+        /// <param name="mode">
+        /// The permission mode as an octal number (e.g., <c>755</c>, <c>644</c>, <c>1777</c>).
+        /// </param>
+        /// <exception cref="ArgumentOutOfRangeException">
+        /// <paramref name="mode"/> has more than 4 digits or cannot be interpreted as an octal number.
+        /// </exception>
+        public void SetPermissions(short mode)
+        {
+            var special = (uint)Math.DivRem(mode, 1000, out var userGroupOther);
 
-                if (_isGroupIDBitSet)
-                {
-                    permission |= S_ISGID;
-                }
+            var user = (uint)Math.DivRem(userGroupOther, 100, out var groupOther);
 
-                if (_isStickyBitSet)
-                {
-                    permission |= S_ISVTX;
-                }
+            var group = (uint)Math.DivRem(groupOther, 10, out var iOther);
 
-                if (OwnerCanRead)
-                {
-                    permission |= S_IRUSR;
-                }
+            var other = (uint)iOther;
 
-                if (OwnerCanWrite)
-                {
-                    permission |= S_IWUSR;
-                }
+            if ((special & ~7u) != 0 || (user & ~7u) != 0 || (group & ~7u) != 0 || (other & ~7u) != 0)
+            {
+                throw new ArgumentOutOfRangeException(nameof(mode));
+            }
 
-                if (OwnerCanExecute)
-                {
-                    permission |= S_IXUSR;
-                }
+            Permissions = (Permissions & ~0xFFFu) | (special << 9) | (user << 6) | (group << 3) | other;
+        }
 
-                if (GroupCanRead)
-                {
-                    permission |= S_IRGRP;
-                }
+        /// <inheritdoc/>
+        public override string? ToString()
+        {
+            var sb = new StringBuilder();
 
-                if (GroupCanWrite)
-                {
-                    permission |= S_IWGRP;
-                }
+            if (Permissions != default)
+            {
+                AppendPermissionsString(sb);
+                sb.Append(' ');
+            }
 
-                if (GroupCanExecute)
-                {
-                    permission |= S_IXGRP;
-                }
+            if (Size != -1)
+            {
+                sb.AppendFormat("Size: {0} ", Size);
+            }
 
-                if (OthersCanRead)
-                {
-                    permission |= S_IROTH;
-                }
+            if (LastWriteTime != default)
+            {
+                sb.AppendFormat("LastWriteTime: {0:s} ", LastWriteTime);
+            }
 
-                if (OthersCanWrite)
+            if (sb.Length > 0)
+            {
+                if (sb[sb.Length - 1] == ' ')
                 {
-                    permission |= S_IWOTH;
+                    sb.Length--;
                 }
 
-                if (OthersCanExecute)
-                {
-                    permission |= S_IXOTH;
-                }
+                Debug.Assert(sb.Length > 0);
+                Debug.Assert(sb[^1] != ' ');
 
-                return permission;
+                return sb.ToString();
             }
-            private set
-            {
-                _isBitFiledsBitSet = (value & S_IFMT) == S_IFMT;
-
-                IsSocket = (value & S_IFSOCK) == S_IFSOCK;
-
-                IsSymbolicLink = (value & S_IFLNK) == S_IFLNK;
-
-                IsRegularFile = (value & S_IFREG) == S_IFREG;
-
-                IsBlockDevice = (value & S_IFBLK) == S_IFBLK;
-
-                IsDirectory = (value & S_IFDIR) == S_IFDIR;
-
-                IsCharacterDevice = (value & S_IFCHR) == S_IFCHR;
-
-                IsNamedPipe = (value & S_IFIFO) == S_IFIFO;
-
-                _isUIDBitSet = (value & S_ISUID) == S_ISUID;
-
-                _isGroupIDBitSet = (value & S_ISGID) == S_ISGID;
-
-                _isStickyBitSet = (value & S_ISVTX) == S_ISVTX;
-
-                OwnerCanRead = (value & S_IRUSR) == S_IRUSR;
-
-                OwnerCanWrite = (value & S_IWUSR) == S_IWUSR;
-
-                OwnerCanExecute = (value & S_IXUSR) == S_IXUSR;
 
-                GroupCanRead = (value & S_IRGRP) == S_IRGRP;
-
-                GroupCanWrite = (value & S_IWGRP) == S_IWGRP;
-
-                GroupCanExecute = (value & S_IXGRP) == S_IXGRP;
-
-                OthersCanRead = (value & S_IROTH) == S_IROTH;
-
-                OthersCanWrite = (value & S_IWOTH) == S_IWOTH;
-
-                OthersCanExecute = (value & S_IXOTH) == S_IXOTH;
-            }
+            return base.ToString();
         }
 
-        private SftpFileAttributes()
+        private void AppendPermissionsString(StringBuilder sb)
         {
-        }
-
-        internal SftpFileAttributes(DateTime lastAccessTimeUtc, DateTime lastWriteTimeUtc, long size, int userId, int groupId, uint permissions, IDictionary<string, string> extensions)
-        {
-            LastAccessTimeUtc = _originalLastAccessTimeUtc = lastAccessTimeUtc;
-            LastWriteTimeUtc = _originalLastWriteTimeUtc = lastWriteTimeUtc;
-            Size = _originalSize = size;
-            UserId = _originalUserId = userId;
-            GroupId = _originalGroupId = groupId;
-            Permissions = _originalPermissions = permissions;
-            Extensions = _originalExtensions = extensions;
-        }
-
-        /// <summary>
-        /// Sets the permissions.
-        /// </summary>
-        /// <param name="mode">The mode.</param>
-        public void SetPermissions(short mode)
-        {
-            if (mode is < 0 or > 999)
+            // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/ls.html
+
+            sb.Append(
+                IsRegularFile ? '-' :
+                IsDirectory ? 'd' :
+                IsSymbolicLink ? 'l' :
+                IsNamedPipe ? 'p' :
+                IsSocket ? 's' :
+                IsCharacterDevice ? 'c' :
+                IsBlockDevice ? 'b' :
+                '-');
+
+            sb.Append(OwnerCanRead ? 'r' : '-');
+            sb.Append(OwnerCanWrite ? 'w' : '-');
+
+            if (OwnerCanExecute)
             {
-                throw new ArgumentOutOfRangeException(nameof(mode));
+                sb.Append(IsUIDBitSet ? 's' : 'x');
+            }
+            else
+            {
+                sb.Append(IsUIDBitSet ? 'S' : '-');
             }
 
-            var modeBytes = mode.ToString(CultureInfo.InvariantCulture).PadLeft(3, '0').ToCharArray();
-
-            var permission = ((modeBytes[0] & 0x0F) * 8 * 8) + ((modeBytes[1] & 0x0F) * 8) + (modeBytes[2] & 0x0F);
+            sb.Append(GroupCanRead ? 'r' : '-');
+            sb.Append(GroupCanWrite ? 'w' : '-');
 
-            OwnerCanRead = (permission & S_IRUSR) == S_IRUSR;
-            OwnerCanWrite = (permission & S_IWUSR) == S_IWUSR;
-            OwnerCanExecute = (permission & S_IXUSR) == S_IXUSR;
+            if (GroupCanExecute)
+            {
+                sb.Append(IsGroupIDBitSet ? 's' : 'x');
+            }
+            else
+            {
+                sb.Append(IsGroupIDBitSet ? 'S' : '-');
+            }
 
-            GroupCanRead = (permission & S_IRGRP) == S_IRGRP;
-            GroupCanWrite = (permission & S_IWGRP) == S_IWGRP;
-            GroupCanExecute = (permission & S_IXGRP) == S_IXGRP;
+            sb.Append(OthersCanRead ? 'r' : '-');
+            sb.Append(OthersCanWrite ? 'w' : '-');
 
-            OthersCanRead = (permission & S_IROTH) == S_IROTH;
-            OthersCanWrite = (permission & S_IWOTH) == S_IWOTH;
-            OthersCanExecute = (permission & S_IXOTH) == S_IXOTH;
+            if (OthersCanExecute)
+            {
+                sb.Append(IsStickyBitSet ? 't' : 'x');
+            }
+            else
+            {
+                sb.Append(IsStickyBitSet ? 'T' : '-');
+            }
         }
 
         /// <summary>
@@ -506,7 +708,7 @@ namespace Renci.SshNet.Sftp
             {
                 uint flag = 0;
 
-                if (IsSizeChanged && IsRegularFile)
+                if (IsSizeChanged)
                 {
                     flag |= 0x00000001;
                 }
@@ -533,7 +735,7 @@ namespace Renci.SshNet.Sftp
 
                 stream.Write(flag);
 
-                if (IsSizeChanged && IsRegularFile)
+                if (IsSizeChanged)
                 {
                     stream.Write((ulong)Size);
                 }
@@ -551,9 +753,9 @@ namespace Renci.SshNet.Sftp
 
                 if (IsLastAccessTimeChanged || IsLastWriteTimeChanged)
                 {
-                    var time = (uint)((LastAccessTimeUtc.ToFileTimeUtc() / 10000000) - 11644473600);
+                    var time = (uint)((DateTimeOffset)DateTime.SpecifyKind(LastAccessTimeUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
                     stream.Write(time);
-                    time = (uint)((LastWriteTimeUtc.ToFileTimeUtc() / 10000000) - 11644473600);
+                    time = (uint)((DateTimeOffset)DateTime.SpecifyKind(LastWriteTimeUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
                     stream.Write(time);
                 }
 
@@ -561,12 +763,8 @@ namespace Renci.SshNet.Sftp
                 {
                     foreach (var item in Extensions)
                     {
-                        /*
-                         * TODO: we write as ASCII but read as UTF8 !!!
-                         */
-
-                        stream.Write(item.Key, SshData.Ascii);
-                        stream.Write(item.Value, SshData.Ascii);
+                        stream.Write(item.Key, Encoding.UTF8);
+                        stream.Write(item.Value, Encoding.UTF8);
                     }
                 }
 
@@ -574,8 +772,6 @@ namespace Renci.SshNet.Sftp
             }
         }
 
-        internal static readonly SftpFileAttributes Empty = new SftpFileAttributes();
-
         internal static SftpFileAttributes FromBytes(SshDataStream stream)
         {
             const uint SSH_FILEXFER_ATTR_SIZE = 0x00000001;
@@ -592,7 +788,7 @@ namespace Renci.SshNet.Sftp
             uint permissions = 0;
             DateTime accessTime;
             DateTime modifyTime;
-            Dictionary<string, string> extensions = null;
+            Dictionary<string, string>? extensions = null;
 
             if ((flag & SSH_FILEXFER_ATTR_SIZE) == SSH_FILEXFER_ATTR_SIZE)
             {
@@ -613,12 +809,8 @@ namespace Renci.SshNet.Sftp
 
             if ((flag & SSH_FILEXFER_ATTR_ACMODTIME) == SSH_FILEXFER_ATTR_ACMODTIME)
             {
-                // The incoming times are "Unix times", so they're already in UTC.  We need to preserve that
-                // to avoid losing information in a local time conversion during the "fall back" hour in DST.
-                var time = stream.ReadUInt32();
-                accessTime = DateTime.FromFileTimeUtc((time + 11644473600) * 10000000);
-                time = stream.ReadUInt32();
-                modifyTime = DateTime.FromFileTimeUtc((time + 11644473600) * 10000000);
+                accessTime = DateTimeOffset.FromUnixTimeSeconds(stream.ReadUInt32()).UtcDateTime;
+                modifyTime = DateTimeOffset.FromUnixTimeSeconds(stream.ReadUInt32()).UtcDateTime;
             }
             else
             {
@@ -632,8 +824,8 @@ namespace Renci.SshNet.Sftp
                 extensions = new Dictionary<string, string>(extendedCount);
                 for (var i = 0; i < extendedCount; i++)
                 {
-                    var extensionName = stream.ReadString(SshData.Utf8);
-                    var extensionData = stream.ReadString(SshData.Utf8);
+                    var extensionName = stream.ReadString(Encoding.UTF8);
+                    var extensionData = stream.ReadString(Encoding.UTF8);
                     extensions.Add(extensionName, extensionData);
                 }
             }

+ 1 - 1
src/Renci.SshNet/Sftp/SftpSession.cs

@@ -379,7 +379,7 @@ namespace Renci.SshNet.Sftp
                 if (response is SftpVersionResponse versionResponse)
                 {
                     ProtocolVersion = versionResponse.Version;
-                    _supportedExtensions = versionResponse.Extentions;
+                    _supportedExtensions = versionResponse.Extensions;
 
                     _ = _sftpVersionConfirmed.Set();
                 }

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/Requests/SftpFSetStatRequestTest.cs

@@ -8,6 +8,7 @@ using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Requests;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp.Requests
 {
@@ -29,7 +30,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp.Requests
             _requestId = (uint)random.Next(0, int.MaxValue);
             _handle = new byte[random.Next(1, 10)];
             random.NextBytes(_handle);
-            _attributes = SftpFileAttributes.Empty;
+            _attributes = SftpFileAttributesBuilder.Empty;
             _attributesBytes = _attributes.GetBytes();
         }
 

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/Requests/SftpMkDirRequestTest.cs

@@ -10,6 +10,7 @@ using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Requests;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp.Requests
 {
@@ -34,7 +35,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp.Requests
             _encoding = Encoding.Unicode;
             _path = random.Next().ToString(CultureInfo.InvariantCulture);
             _pathBytes = _encoding.GetBytes(_path);
-            _attributes = SftpFileAttributes.Empty;
+            _attributes = SftpFileAttributesBuilder.Empty;
             _attributesBytes = _attributes.GetBytes();
         }
 

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/Requests/SftpOpenRequestTest.cs

@@ -10,6 +10,7 @@ using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Requests;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp.Requests
 {
@@ -36,7 +37,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp.Requests
             _filename = random.Next().ToString(CultureInfo.InvariantCulture);
             _filenameBytes = _encoding.GetBytes(_filename);
             _flags = Flags.Read;
-            _attributes = SftpFileAttributes.Empty;
+            _attributes = SftpFileAttributesBuilder.Empty;
             _attributesBytes = _attributes.GetBytes();
         }
 

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/Requests/SftpSetStatRequestTest.cs

@@ -10,6 +10,7 @@ using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Requests;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp.Requests
 {
@@ -34,7 +35,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp.Requests
             _encoding = Encoding.Unicode;
             _path = random.Next().ToString(CultureInfo.InvariantCulture);
             _pathBytes = _encoding.GetBytes(_path);
-            _attributes = SftpFileAttributes.Empty;
+            _attributes = SftpFileAttributesBuilder.Empty;
             _attributesBytes = _attributes.GetBytes();
         }
 

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/Responses/SftpAttrsResponseTest.cs

@@ -5,6 +5,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting;
 using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp.Responses
 {
@@ -61,7 +62,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp.Responses
 
         private SftpFileAttributes CreateSftpFileAttributes()
         {
-            var attributes = SftpFileAttributes.Empty;
+            var attributes = SftpFileAttributesBuilder.Empty;
             attributes.GroupId = _random.Next();
             attributes.LastAccessTime = new DateTime(2014, 8, 23, 17, 43, 50, DateTimeKind.Local);
             attributes.LastWriteTime = new DateTime(2013, 7, 22, 16, 40, 42, DateTimeKind.Local);

+ 223 - 0
test/Renci.SshNet.Tests/Classes/Sftp/SftpFileAttributesTest.cs

@@ -0,0 +1,223 @@
+using System;
+using System.Buffers.Binary;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Renci.SshNet.Common;
+using Renci.SshNet.Sftp;
+
+namespace Renci.SshNet.Tests.Classes.Sftp
+{
+    [TestClass]
+    public class SftpFileAttributesTest
+    {
+        [TestMethod]
+        [DataRow(0xC000u, true, false, false, false, false, false, false)] // Socket
+        [DataRow(0xA000u, false, true, false, false, false, false, false)] // Symbolic link
+        [DataRow(0x8000u, false, false, true, false, false, false, false)] // Regular file
+        [DataRow(0x6000u, false, false, false, true, false, false, false)] // Block device
+        [DataRow(0x4000u, false, false, false, false, true, false, false)] // Directory
+        [DataRow(0x2000u, false, false, false, false, false, true, false)] // Character device
+        [DataRow(0x1000u, false, false, false, false, false, false, true)] // Named pipe
+        public void FileTypePropertiesAreMutuallyExclusive(
+            uint permissions,
+            bool isSocket,
+            bool isSymbolicLink,
+            bool isRegularFile,
+            bool isBlockDevice,
+            bool isDirectory,
+            bool isCharacterDevice,
+            bool isNamedPipe)
+        {
+            var attributeBytes = new byte[8];
+            attributeBytes[3] = 0x4; // SSH_FILEXFER_ATTR_PERMISSIONS
+            BinaryPrimitives.WriteUInt32BigEndian(attributeBytes.AsSpan(4), permissions);
+
+            var attributes = SftpFileAttributes.FromBytes(attributeBytes);
+
+            Assert.AreEqual(isSocket, attributes.IsSocket);
+            Assert.AreEqual(isSymbolicLink, attributes.IsSymbolicLink);
+            Assert.AreEqual(isRegularFile, attributes.IsRegularFile);
+            Assert.AreEqual(isBlockDevice, attributes.IsBlockDevice);
+            Assert.AreEqual(isDirectory, attributes.IsDirectory);
+            Assert.AreEqual(isCharacterDevice, attributes.IsCharacterDevice);
+            Assert.AreEqual(isNamedPipe, attributes.IsNamedPipe);
+        }
+
+        [TestMethod]
+        public void FromBytesGetBytes()
+        {
+            // 81a4 in hex = 100644 in octal
+            var attributes = SftpFileAttributes.FromBytes([0, 0, 0, 0x4, 0, 0, 0x81, 0xa4]);
+
+            Assert.IsTrue(attributes.IsRegularFile);
+
+            Assert.IsFalse(attributes.IsUIDBitSet);
+            Assert.IsFalse(attributes.IsGroupIDBitSet);
+            Assert.IsFalse(attributes.IsStickyBitSet);
+            Assert.IsTrue(attributes.OwnerCanRead);
+            Assert.IsTrue(attributes.OwnerCanWrite);
+            Assert.IsFalse(attributes.OwnerCanExecute);
+            Assert.IsTrue(attributes.GroupCanRead);
+            Assert.IsFalse(attributes.GroupCanWrite);
+            Assert.IsFalse(attributes.GroupCanExecute);
+            Assert.IsTrue(attributes.OthersCanRead);
+            Assert.IsFalse(attributes.OthersCanWrite);
+            Assert.IsFalse(attributes.OthersCanExecute);
+
+            Assert.AreEqual(-1, attributes.Size); // Erm, OK?
+            Assert.AreEqual(-1, attributes.UserId);
+            Assert.AreEqual(-1, attributes.GroupId);
+
+            Assert.AreEqual(default, attributes.LastAccessTimeUtc);
+            Assert.AreEqual(DateTimeKind.Utc, attributes.LastAccessTimeUtc.Kind);
+
+            Assert.AreEqual(default, attributes.LastWriteTimeUtc);
+            Assert.AreEqual(DateTimeKind.Utc, attributes.LastWriteTimeUtc.Kind);
+
+            Assert.AreEqual("-rw-r--r--", attributes.ToString());
+
+            // No changes
+            CollectionAssert.AreEqual(
+                new byte[] { 0, 0, 0, 0 },
+                attributes.GetBytes());
+
+
+            // Permissions change
+            attributes.IsUIDBitSet = true;
+            attributes.OwnerCanExecute = true;
+
+            CollectionAssert.AreEqual(
+                new byte[] { 0, 0, 0, 0x4, 0, 0, 0x89, 0xe4 },
+                attributes.GetBytes());
+
+            Assert.AreEqual("-rwsr--r--", attributes.ToString());
+
+            // Size change
+            attributes.Size = 123;
+
+            CollectionAssert.AreEqual(
+                new byte[] {
+                    0, 0, 0, 0x1 | 0x4,
+                    0, 0, 0, 0, 0, 0, 0, 123,
+                    0, 0, 0x89, 0xe4 },
+                attributes.GetBytes());
+
+            Assert.IsTrue(attributes.ToString().StartsWith("-rwsr--r-- Size: ", StringComparison.Ordinal));
+
+            // Uid/gid change
+            attributes.UserId = 99;
+            attributes.GroupId = 66;
+
+            CollectionAssert.AreEqual(
+                new byte[] {
+                    0, 0, 0, 0x1 | 0x2 | 0x4,
+                    0, 0, 0, 0, 0, 0, 0, 123,
+                    0, 0, 0, 99, 0, 0, 0, 66,
+                    0, 0, 0x89, 0xe4 },
+                attributes.GetBytes());
+
+
+            // Access/mod time change
+            attributes.LastAccessTimeUtc = new DateTime(2025, 08, 10, 17, 51, 37, DateTimeKind.Unspecified);
+            attributes.LastWriteTime = new DateTimeOffset(2016, 12, 02, 13, 18, 20, TimeSpan.FromHours(3)).LocalDateTime;
+
+            var expectedTimeBytes = new byte[8];
+            BinaryPrimitives.WriteUInt32BigEndian(expectedTimeBytes, 1754848297);
+            BinaryPrimitives.WriteUInt32BigEndian(expectedTimeBytes.AsSpan(4), 1480673900);
+
+            CollectionAssert.AreEqual(
+                new byte[] {
+                    0, 0, 0, 0x1 | 0x2 | 0x4 | 0x8,
+                    0, 0, 0, 0, 0, 0, 0, 123,
+                    0, 0, 0, 99, 0, 0, 0, 66,
+                    0, 0, 0x89, 0xe4
+                }.Concat(expectedTimeBytes),
+                attributes.GetBytes());
+
+            Assert.AreEqual(new DateTime(2016, 12, 02, 10, 18, 20, DateTimeKind.Utc), attributes.LastWriteTimeUtc);
+            Assert.AreEqual(DateTimeKind.Utc, attributes.LastWriteTimeUtc.Kind);
+
+            var attributesString = attributes.ToString();
+            Assert.IsTrue(attributesString.StartsWith("-rwsr--r-- Size: ", StringComparison.Ordinal));
+            Assert.Contains(" LastWriteTime: ", attributesString, StringComparison.CurrentCulture);
+        }
+
+        [TestMethod]
+        [DataRow((short)8888)]
+        [DataRow((short)10000)]
+        [DataRow((short)8000)]
+        [DataRow((short)0080)]
+        [DataRow((short)0008)]
+        [DataRow((short)1797)]
+        [DataRow((short)-1)]
+        [DataRow(short.MaxValue)]
+        public void SetPermissions_InvalidMode_ThrowsArgumentOutOfRangeException(short mode)
+        {
+            var attributes = SftpFileAttributes.FromBytes([0, 0, 0, 0]);
+
+            var ex = Assert.Throws<ArgumentOutOfRangeException>(() => attributes.SetPermissions(mode));
+            Assert.AreEqual("mode", ex.ParamName);
+        }
+
+        [TestMethod]
+        [DataRow((short)0777, false, false, false, true, true, true, true, true, true, true, true, true)]
+        [DataRow((short)0755, false, false, false, true, true, true, true, false, true, true, false, true)]
+        [DataRow((short)0644, false, false, false, true, true, false, true, false, false, true, false, false)]
+        [DataRow((short)0444, false, false, false, true, false, false, true, false, false, true, false, false)]
+        [DataRow((short)0000, false, false, false, false, false, false, false, false, false, false, false, false)]
+        [DataRow((short)4700, true, false, false, true, true, true, false, false, false, false, false, false)]
+        [DataRow((short)3001, false, true, true, false, false, false, false, false, false, false, false, true)]
+        [DataRow((short)7777, true, true, true, true, true, true, true, true, true, true, true, true)]
+        public void SetPermissions_ValidMode(
+            short mode,
+            bool setUid, bool setGid, bool sticky,
+            bool ownerRead, bool ownerWrite, bool ownerExec,
+            bool groupRead, bool groupWrite, bool groupExec,
+            bool othersRead, bool othersWrite, bool othersExec)
+        {
+            var attributes = SftpFileAttributes.FromBytes([0, 0, 0, 0]);
+
+            attributes.SetPermissions(mode);
+
+            Assert.AreEqual(setUid, attributes.IsUIDBitSet);
+            Assert.AreEqual(setGid, attributes.IsGroupIDBitSet);
+            Assert.AreEqual(sticky, attributes.IsStickyBitSet);
+            Assert.AreEqual(ownerRead, attributes.OwnerCanRead);
+            Assert.AreEqual(ownerWrite, attributes.OwnerCanWrite);
+            Assert.AreEqual(ownerExec, attributes.OwnerCanExecute);
+            Assert.AreEqual(groupRead, attributes.GroupCanRead);
+            Assert.AreEqual(groupWrite, attributes.GroupCanWrite);
+            Assert.AreEqual(groupExec, attributes.GroupCanExecute);
+            Assert.AreEqual(othersRead, attributes.OthersCanRead);
+            Assert.AreEqual(othersWrite, attributes.OthersCanWrite);
+            Assert.AreEqual(othersExec, attributes.OthersCanExecute);
+        }
+
+        [TestMethod]
+        [DataRow(0xC000u, (short)1770, "srwxrwx--T")] // Socket
+        [DataRow(0xA000u, (short)2707, "lrwx--Srwx")] // Symbolic link
+        [DataRow(0x8000u, (short)4755, "-rwsr-xr-x")] // Regular file
+        [DataRow(0x8000u, (short)4644, "-rwSr--r--")] // Regular file
+        [DataRow(0x6000u, (short)2711, "brwx--s--x")] // Block device
+        [DataRow(0x4000u, (short)1777, "drwxrwxrwt")] // Directory
+        [DataRow(0x4000u, (short)1776, "drwxrwxrwT")] // Directory
+        [DataRow(0x2000u, (short)0660, "crw-rw----")] // Character device
+        [DataRow(0x1000u, (short)0022, "p----w--w-")] // Named pipe
+        public void ToStringWithPermissions(
+            uint fileType,
+            short permissions,
+            string expected)
+        {
+            var attributeBytes = new byte[8];
+            attributeBytes[3] = 0x4; // SSH_FILEXFER_ATTR_PERMISSIONS
+            BinaryPrimitives.WriteUInt32BigEndian(attributeBytes.AsSpan(4), fileType);
+
+            var attributes = SftpFileAttributes.FromBytes(attributeBytes);
+
+            attributes.SetPermissions(permissions);
+
+            Assert.AreEqual(expected, attributes.ToString());
+        }
+    }
+}

+ 0 - 6
test/Renci.SshNet.Tests/Classes/Sftp/SftpFileReaderTestBase.cs

@@ -41,12 +41,6 @@ namespace Renci.SshNet.Tests.Classes.Sftp
 
         protected abstract void Act();
 
-        protected static SftpFileAttributes CreateSftpFileAttributes(long size)
-        {
-            var utcDefault = DateTime.SpecifyKind(default, DateTimeKind.Utc);
-            return new SftpFileAttributes(utcDefault, utcDefault, size, default, default, default, null);
-        }
-
         protected static byte[] CreateByteArray(Random random, int length)
         {
             var chunk = new byte[length];

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_Connected_RequestRead.cs

@@ -12,6 +12,7 @@ using Renci.SshNet.Channels;
 using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp
 {
@@ -73,7 +74,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp
             _sftpNameResponse = new SftpNameResponseBuilder().WithProtocolVersion(_protocolVersion)
                                                              .WithResponseId(1)
                                                              .WithEncoding(_encoding)
-                                                             .WithFile("XYZ", SftpFileAttributes.Empty)
+                                                             .WithFile("XYZ", SftpFileAttributesBuilder.Empty)
                                                              .Build();
 
             #endregion SftpSession.Connect()

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_Connected_RequestStatVfs.cs

@@ -10,6 +10,7 @@ using Renci.SshNet.Channels;
 using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp
 {
@@ -70,7 +71,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp
             _sftpNameResponse = new SftpNameResponseBuilder().WithProtocolVersion(_protocolVersion)
                                                              .WithResponseId(1U)
                                                              .WithEncoding(_encoding)
-                                                             .WithFile("ABC", SftpFileAttributes.Empty)
+                                                             .WithFile("ABC", SftpFileAttributesBuilder.Empty)
                                                              .Build();
 
             #endregion SftpSession.Connect()

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_DataReceived_MultipleSftpMessagesInSingleSshDataMessage.cs

@@ -11,6 +11,7 @@ using Renci.SshNet.Channels;
 using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp
 {
@@ -75,7 +76,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp
             _sftpNameResponse = new SftpNameResponseBuilder().WithProtocolVersion(_protocolVersion)
                                                              .WithResponseId(1)
                                                              .WithEncoding(_encoding)
-                                                             .WithFile("/ABC", SftpFileAttributes.Empty)
+                                                             .WithFile("/ABC", SftpFileAttributesBuilder.Empty)
                                                              .Build();
 
             #endregion SftpSession.Connect()

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_DataReceived_MultipleSftpMessagesSplitOverMultipleSshDataMessages.cs

@@ -11,6 +11,7 @@ using Renci.SshNet.Channels;
 using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp
 {
@@ -75,7 +76,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp
             _sftpNameResponse = new SftpNameResponseBuilder().WithProtocolVersion(_protocolVersion)
                                                              .WithResponseId(1)
                                                              .WithEncoding(_encoding)
-                                                             .WithFile("/ABC", SftpFileAttributes.Empty)
+                                                             .WithFile("/ABC", SftpFileAttributesBuilder.Empty)
                                                              .Build();
 
             #endregion SftpSession.Connect()

+ 2 - 1
test/Renci.SshNet.Tests/Classes/Sftp/SftpSessionTest_DataReceived_SingleSftpMessageInSshDataMessage.cs

@@ -11,6 +11,7 @@ using Renci.SshNet.Channels;
 using Renci.SshNet.Common;
 using Renci.SshNet.Sftp;
 using Renci.SshNet.Sftp.Responses;
+using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes.Sftp
 {
@@ -71,7 +72,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp
             _sftpNameResponse = new SftpNameResponseBuilder().WithProtocolVersion(_protocolVersion)
                                                              .WithResponseId(1)
                                                              .WithEncoding(_encoding)
-                                                             .WithFile("/ABC", SftpFileAttributes.Empty)
+                                                             .WithFile("/ABC", SftpFileAttributesBuilder.Empty)
                                                              .Build();
 
             #endregion SftpSession.Connect()

+ 1 - 1
test/Renci.SshNet.Tests/Classes/Sftp/SftpVersionResponseBuilder.cs

@@ -31,7 +31,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp
             var sftpVersionResponse = new SftpVersionResponse()
             {
                 Version = _version,
-                Extentions = _extensions
+                Extensions = _extensions
             };
             return sftpVersionResponse;
         }

+ 1 - 4
test/Renci.SshNet.Tests/Classes/ShellStreamTest_Write_WriteBufferNotEmptyAndWriteMoreBytesThanBufferCanContain.cs

@@ -10,7 +10,6 @@ using Moq;
 using Renci.SshNet.Abstractions;
 using Renci.SshNet.Channels;
 using Renci.SshNet.Common;
-using Renci.SshNet.Tests.Common;
 
 namespace Renci.SshNet.Tests.Classes
 {
@@ -60,9 +59,7 @@ namespace Renci.SshNet.Tests.Classes
             _offset = 0;
             _count = _data.Length;
 
-            _expectedBytesSent = new ArrayBuilder<byte>().Add(_bufferData)
-                                                         .Add(_data, 0, _bufferSize - _bufferData.Length)
-                                                         .Build();
+            _expectedBytesSent = [.. _bufferData, .. _data.Take(0, _bufferSize - _bufferData.Length)];
         }
 
         private void CreateMocks()

+ 0 - 34
test/Renci.SshNet.Tests/Common/ArrayBuilder`1.cs

@@ -1,34 +0,0 @@
-using System.Collections.Generic;
-
-namespace Renci.SshNet.Tests.Common
-{
-    public class ArrayBuilder<T>
-    {
-        private readonly List<T> _buffer;
-
-        public ArrayBuilder()
-        {
-            _buffer = new List<T>();
-        }
-
-        public ArrayBuilder<T> Add(T[] array)
-        {
-            return Add(array, 0, array.Length);
-        }
-
-        public ArrayBuilder<T> Add(T[] array, int index, int length)
-        {
-            for (var i = 0; i < length; i++)
-            {
-                _buffer.Add(array[index + i]);
-            }
-
-            return this;
-        }
-
-        public T[] Build()
-        {
-            return _buffer.ToArray();
-        }
-    }
-}

+ 1 - 42
test/Renci.SshNet.Tests/Common/Extensions.cs

@@ -1,8 +1,6 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
 
 using Renci.SshNet.Common;
-using Renci.SshNet.Sftp;
 
 namespace Renci.SshNet.Tests.Common
 {
@@ -23,44 +21,5 @@ namespace Renci.SshNet.Tests.Common
 
             return reportedExceptions;
         }
-
-        public static byte[] Copy(this byte[] buffer)
-        {
-            var copy = new byte[buffer.Length];
-            Buffer.BlockCopy(buffer, 0, copy, 0, buffer.Length);
-            return copy;
-        }
-
-        /// <summary>
-        /// Creates a deep clone of the current instance.
-        /// </summary>
-        /// <returns>
-        /// A deep clone of the current instance.
-        /// </returns>
-        internal static SftpFileAttributes Clone(this SftpFileAttributes value)
-        {
-            Dictionary<string, string> clonedExtensions;
-
-            if (value.Extensions != null)
-            {
-                clonedExtensions = new Dictionary<string, string>(value.Extensions.Count);
-                foreach (var entry in value.Extensions)
-                {
-                    clonedExtensions.Add(entry.Key, entry.Value);
-                }
-            }
-            else
-            {
-                clonedExtensions = null;
-            }
-
-            return new SftpFileAttributes(value.LastAccessTimeUtc,
-                                          value.LastWriteTimeUtc,
-                                          value.Size,
-                                          value.UserId,
-                                          value.GroupId,
-                                          value.Permissions,
-                                          clonedExtensions);
-        }
     }
 }

+ 12 - 7
test/Renci.SshNet.Tests/Common/SftpFileAttributesBuilder.cs

@@ -1,4 +1,5 @@
-using System;
+#nullable enable
+using System;
 using System.Collections.Generic;
 
 using Renci.SshNet.Sftp;
@@ -7,18 +8,21 @@ namespace Renci.SshNet.Tests.Common
 {
     public class SftpFileAttributesBuilder
     {
+        public static SftpFileAttributes Empty
+        {
+            get
+            {
+                return new SftpFileAttributesBuilder().Build();
+            }
+        }
+
         private DateTime? _lastAccessTime;
         private DateTime? _lastWriteTime;
         private long? _size;
         private int? _userId;
         private int? _groupId;
         private uint? _permissions;
-        private readonly IDictionary<string, string> _extensions;
-
-        public SftpFileAttributesBuilder()
-        {
-            _extensions = new Dictionary<string, string>();
-        }
+        private Dictionary<string, string>? _extensions;
 
         public SftpFileAttributesBuilder WithLastAccessTime(DateTime lastAccessTime)
         {
@@ -58,6 +62,7 @@ namespace Renci.SshNet.Tests.Common
 
         public SftpFileAttributesBuilder WithExtension(string name, string value)
         {
+            _extensions ??= [];
             _extensions.Add(name, value);
             return this;
         }