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
This commit is contained in:
Rob Hague
2025-10-01 22:19:42 +02:00
committed by GitHub
parent c335ce2f20
commit af279d206a
22 changed files with 678 additions and 364 deletions
@@ -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
}
}
}
@@ -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)
@@ -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);
}
}
}
+410 -218
View File
@@ -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,7 +541,24 @@ namespace Renci.SshNet.Sftp
/// <value>
/// <see langword="true"/> if others can execute this file; otherwise, <see langword="false"/>.
/// </value>
public bool OthersCanExecute { get; set; }
public bool OthersCanExecute
{
get
{
return (Permissions & S_IXOTH) == S_IXOTH;
}
set
{
if (value)
{
Permissions |= S_IXOTH;
}
else
{
Permissions &= ~S_IXOTH;
}
}
}
/// <summary>
/// Gets the extensions.
@@ -297,165 +566,11 @@ namespace Renci.SshNet.Sftp
/// <value>
/// The extensions.
/// </value>
public IDictionary<string, string> Extensions { get; private set; }
public IDictionary<string, string>? Extensions { get; }
internal uint Permissions
{
get
{
uint permission = 0;
internal uint Permissions { get; private set; }
if (_isBitFiledsBitSet)
{
permission |= S_IFMT;
}
if (IsSocket)
{
permission |= S_IFSOCK;
}
if (IsSymbolicLink)
{
permission |= S_IFLNK;
}
if (IsRegularFile)
{
permission |= S_IFREG;
}
if (IsBlockDevice)
{
permission |= S_IFBLK;
}
if (IsDirectory)
{
permission |= S_IFDIR;
}
if (IsCharacterDevice)
{
permission |= S_IFCHR;
}
if (IsNamedPipe)
{
permission |= S_IFIFO;
}
if (_isUIDBitSet)
{
permission |= S_ISUID;
}
if (_isGroupIDBitSet)
{
permission |= S_ISGID;
}
if (_isStickyBitSet)
{
permission |= S_ISVTX;
}
if (OwnerCanRead)
{
permission |= S_IRUSR;
}
if (OwnerCanWrite)
{
permission |= S_IWUSR;
}
if (OwnerCanExecute)
{
permission |= S_IXUSR;
}
if (GroupCanRead)
{
permission |= S_IRGRP;
}
if (GroupCanWrite)
{
permission |= S_IWGRP;
}
if (GroupCanExecute)
{
permission |= S_IXGRP;
}
if (OthersCanRead)
{
permission |= S_IROTH;
}
if (OthersCanWrite)
{
permission |= S_IWOTH;
}
if (OthersCanExecute)
{
permission |= S_IXOTH;
}
return permission;
}
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;
}
}
private SftpFileAttributes()
{
}
internal SftpFileAttributes(DateTime lastAccessTimeUtc, DateTime lastWriteTimeUtc, long size, int userId, int groupId, uint permissions, IDictionary<string, string> extensions)
internal SftpFileAttributes(DateTime lastAccessTimeUtc, DateTime lastWriteTimeUtc, long size, int userId, int groupId, uint permissions, Dictionary<string, string>? extensions)
{
LastAccessTimeUtc = _originalLastAccessTimeUtc = lastAccessTimeUtc;
LastWriteTimeUtc = _originalLastWriteTimeUtc = lastWriteTimeUtc;
@@ -467,31 +582,118 @@ namespace Renci.SshNet.Sftp
}
/// <summary>
/// Sets the permissions.
/// Sets the POSIX permissions for this file.
/// </summary>
/// <param name="mode">The mode.</param>
/// <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)
{
if (mode is < 0 or > 999)
var special = (uint)Math.DivRem(mode, 1000, out var userGroupOther);
var user = (uint)Math.DivRem(userGroupOther, 100, out var groupOther);
var group = (uint)Math.DivRem(groupOther, 10, out var iOther);
var other = (uint)iOther;
if ((special & ~7u) != 0 || (user & ~7u) != 0 || (group & ~7u) != 0 || (other & ~7u) != 0)
{
throw new ArgumentOutOfRangeException(nameof(mode));
}
var modeBytes = mode.ToString(CultureInfo.InvariantCulture).PadLeft(3, '0').ToCharArray();
Permissions = (Permissions & ~0xFFFu) | (special << 9) | (user << 6) | (group << 3) | other;
}
var permission = ((modeBytes[0] & 0x0F) * 8 * 8) + ((modeBytes[1] & 0x0F) * 8) + (modeBytes[2] & 0x0F);
/// <inheritdoc/>
public override string? ToString()
{
var sb = new StringBuilder();
OwnerCanRead = (permission & S_IRUSR) == S_IRUSR;
OwnerCanWrite = (permission & S_IWUSR) == S_IWUSR;
OwnerCanExecute = (permission & S_IXUSR) == S_IXUSR;
if (Permissions != default)
{
AppendPermissionsString(sb);
sb.Append(' ');
}
GroupCanRead = (permission & S_IRGRP) == S_IRGRP;
GroupCanWrite = (permission & S_IWGRP) == S_IWGRP;
GroupCanExecute = (permission & S_IXGRP) == S_IXGRP;
if (Size != -1)
{
sb.AppendFormat("Size: {0} ", Size);
}
OthersCanRead = (permission & S_IROTH) == S_IROTH;
OthersCanWrite = (permission & S_IWOTH) == S_IWOTH;
OthersCanExecute = (permission & S_IXOTH) == S_IXOTH;
if (LastWriteTime != default)
{
sb.AppendFormat("LastWriteTime: {0:s} ", LastWriteTime);
}
if (sb.Length > 0)
{
if (sb[sb.Length - 1] == ' ')
{
sb.Length--;
}
Debug.Assert(sb.Length > 0);
Debug.Assert(sb[^1] != ' ');
return sb.ToString();
}
return base.ToString();
}
private void AppendPermissionsString(StringBuilder sb)
{
// 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)
{
sb.Append(IsUIDBitSet ? 's' : 'x');
}
else
{
sb.Append(IsUIDBitSet ? 'S' : '-');
}
sb.Append(GroupCanRead ? 'r' : '-');
sb.Append(GroupCanWrite ? 'w' : '-');
if (GroupCanExecute)
{
sb.Append(IsGroupIDBitSet ? 's' : 'x');
}
else
{
sb.Append(IsGroupIDBitSet ? 'S' : '-');
}
sb.Append(OthersCanRead ? 'r' : '-');
sb.Append(OthersCanWrite ? 'w' : '-');
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
View File
@@ -379,7 +379,7 @@ namespace Renci.SshNet.Sftp
if (response is SftpVersionResponse versionResponse)
{
ProtocolVersion = versionResponse.Version;
_supportedExtensions = versionResponse.Extentions;
_supportedExtensions = versionResponse.Extensions;
_ = _sftpVersionConfirmed.Set();
}
@@ -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();
}
@@ -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();
}
@@ -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();
}
@@ -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();
}
@@ -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);
@@ -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());
}
}
}
@@ -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];
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -31,7 +31,7 @@ namespace Renci.SshNet.Tests.Classes.Sftp
var sftpVersionResponse = new SftpVersionResponse()
{
Version = _version,
Extentions = _extensions
Extensions = _extensions
};
return sftpVersionResponse;
}
@@ -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()
@@ -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
View File
@@ -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);
}
}
}
@@ -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;
}