From b0079ae326b256c0a5f806c14597153011629f42 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:39:37 +0000 Subject: [PATCH] Keep the SFTP v3 longname of directory entries SSH_FXP_NAME responses of SFTP protocol version 3 carry, for each file, a "longname": a line in the style of `ls -l` that servers use to show the names of the owner and the group, which the attributes give only as numbers. SftpNameResponse read it and discarded it. It is now kept with the attributes and exposed as SftpFile.LongName, null when the server sent none (protocol version 4 and later) or when the file was not obtained from a directory listing. SaveData writes it back instead of an empty string. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EyGJnsvMwUoy4JFWLgYjGm --- .../Sftp/Responses/SftpNameResponse.cs | 9 ++- src/Renci.SshNet/Sftp/SftpFile.cs | 18 +++++ src/Renci.SshNet/Sftp/SftpFileAttributes.cs | 10 +++ .../Sftp/Responses/SftpNameResponseTest.cs | 80 +++++++++++++++++++ .../Classes/Sftp/SftpFileTest.cs | 33 ++++++++ 5 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 test/Renci.SshNet.Tests/Classes/Sftp/Responses/SftpNameResponseTest.cs create mode 100644 test/Renci.SshNet.Tests/Classes/Sftp/SftpFileTest.cs diff --git a/src/Renci.SshNet/Sftp/Responses/SftpNameResponse.cs b/src/Renci.SshNet/Sftp/Responses/SftpNameResponse.cs index 0e6c9f464..d819fa26f 100644 --- a/src/Renci.SshNet/Sftp/Responses/SftpNameResponse.cs +++ b/src/Renci.SshNet/Sftp/Responses/SftpNameResponse.cs @@ -34,12 +34,15 @@ protected override void LoadData() for (var i = 0; i < Count; i++) { var fileName = ReadString(Encoding); + string longName = null; if (SupportsLongName(ProtocolVersion)) { - _ = ReadString(Encoding); // skip longname + longName = ReadString(Encoding); } - Files[i] = new KeyValuePair(fileName, ReadAttributes()); + var attributes = ReadAttributes(); + attributes.LongName = longName; + Files[i] = new KeyValuePair(fileName, attributes); } } @@ -57,7 +60,7 @@ protected override void SaveData() if (SupportsLongName(ProtocolVersion)) { - Write(0U); // longname + Write(file.Value.LongName ?? string.Empty, Encoding); // longname } Write(file.Value.GetBytes()); // attrs diff --git a/src/Renci.SshNet/Sftp/SftpFile.cs b/src/Renci.SshNet/Sftp/SftpFile.cs index 31cb00023..c1c0df8ea 100644 --- a/src/Renci.SshNet/Sftp/SftpFile.cs +++ b/src/Renci.SshNet/Sftp/SftpFile.cs @@ -19,6 +19,24 @@ public sealed class SftpFile : ISftpFile /// public SftpFileAttributes Attributes { get; private set; } + /// + /// Gets the "longname" that the server sent for this file in a directory listing. + /// + /// + /// A line in the style of the output of ls -l, such as + /// -rw-r--r-- 1 owner group 1024 Oct 9 14:31 file.txt, which servers commonly use to show + /// the names of the owner and the group; or if the server did not send one (SFTP + /// protocol version 4 and later) or if this instance was not obtained from a directory listing. + /// + /// + /// The SFTP protocol does not specify the format of this line: it is meant to be displayed to users. Use + /// for the attributes of the file. + /// + public string LongName + { + get { return Attributes.LongName; } + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Renci.SshNet/Sftp/SftpFileAttributes.cs b/src/Renci.SshNet/Sftp/SftpFileAttributes.cs index 70ca0ddca..158fbd518 100644 --- a/src/Renci.SshNet/Sftp/SftpFileAttributes.cs +++ b/src/Renci.SshNet/Sftp/SftpFileAttributes.cs @@ -570,6 +570,16 @@ public bool OthersCanExecute internal uint Permissions { get; private set; } + /// + /// Gets or sets the "longname" that the server sent with these attributes in an SSH_FXP_NAME response, or + /// if it sent none. + /// + /// + /// It is not part of the attributes on the wire: it is kept here so that it reaches the + /// created from a directory listing. + /// + internal string? LongName { get; set; } + internal SftpFileAttributes(DateTime lastAccessTimeUtc, DateTime lastWriteTimeUtc, long size, int userId, int groupId, uint permissions, Dictionary? extensions) { LastAccessTimeUtc = _originalLastAccessTimeUtc = lastAccessTimeUtc; diff --git a/test/Renci.SshNet.Tests/Classes/Sftp/Responses/SftpNameResponseTest.cs b/test/Renci.SshNet.Tests/Classes/Sftp/Responses/SftpNameResponseTest.cs new file mode 100644 index 000000000..8723acee9 --- /dev/null +++ b/test/Renci.SshNet.Tests/Classes/Sftp/Responses/SftpNameResponseTest.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Text; + +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 +{ + [TestClass] + public class SftpNameResponseTest + { + private const string LongName = "-rw-r--r-- 1 owner group 1024 Oct 9 14:31 file.txt"; + + [TestMethod] + public void Load_ProtocolVersion3_KeepsTheLongNameOfEachFile() + { + var attributesBytes = SftpFileAttributesBuilder.Empty.GetBytes(); + var sshDataStream = new SshDataStream(0); + sshDataStream.Write(1U); // response id + sshDataStream.Write(2U); // count + sshDataStream.Write("file.txt", Encoding.UTF8); + sshDataStream.Write(LongName, Encoding.UTF8); + sshDataStream.Write(attributesBytes); + sshDataStream.Write("other", Encoding.UTF8); + sshDataStream.Write(string.Empty, Encoding.UTF8); + sshDataStream.Write(attributesBytes); + + var target = new SftpNameResponse(3, Encoding.UTF8); + target.Load(sshDataStream.ToArray()); + + Assert.AreEqual(2U, target.Count); + Assert.AreEqual("file.txt", target.Files[0].Key); + Assert.AreEqual(LongName, target.Files[0].Value.LongName); + Assert.AreEqual("other", target.Files[1].Key); + Assert.AreEqual(string.Empty, target.Files[1].Value.LongName); + } + + [TestMethod] + public void Load_ProtocolVersion4_HasNoLongName() + { + var attributesBytes = SftpFileAttributesBuilder.Empty.GetBytes(); + var sshDataStream = new SshDataStream(0); + sshDataStream.Write(1U); // response id + sshDataStream.Write(1U); // count + sshDataStream.Write("file.txt", Encoding.UTF8); + sshDataStream.Write(attributesBytes); + + var target = new SftpNameResponse(4, Encoding.UTF8); + target.Load(sshDataStream.ToArray()); + + Assert.AreEqual("file.txt", target.Files[0].Key); + Assert.IsNull(target.Files[0].Value.LongName); + } + + [TestMethod] + public void GetBytes_ProtocolVersion3_WritesTheLongName() + { + var attributes = SftpFileAttributesBuilder.Empty; + attributes.LongName = LongName; + var target = new SftpNameResponse(3, Encoding.UTF8) + { + ResponseId = 7, + Files = [new KeyValuePair("file.txt", attributes)], + }; + + var bytes = target.GetBytes(); + + // 4 bytes for the length of the SFTP data, 1 byte for the SFTP message type + var loaded = new SftpNameResponse(3, Encoding.UTF8); + loaded.Load(bytes, 5, bytes.Length - 5); + Assert.AreEqual(7U, loaded.ResponseId); + Assert.AreEqual("file.txt", loaded.Files[0].Key); + Assert.AreEqual(LongName, loaded.Files[0].Value.LongName); + } + } +} diff --git a/test/Renci.SshNet.Tests/Classes/Sftp/SftpFileTest.cs b/test/Renci.SshNet.Tests/Classes/Sftp/SftpFileTest.cs new file mode 100644 index 000000000..3c2e1049c --- /dev/null +++ b/test/Renci.SshNet.Tests/Classes/Sftp/SftpFileTest.cs @@ -0,0 +1,33 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Moq; + +using Renci.SshNet.Sftp; +using Renci.SshNet.Tests.Common; + +namespace Renci.SshNet.Tests.Classes.Sftp +{ + [TestClass] + public class SftpFileTest + { + [TestMethod] + public void LongName_FileFromADirectoryListing_IsTheLineSentByTheServer() + { + const string longName = "drwxr-xr-x 3 owner group 1024 Oct 9 14:31 dir"; + var attributes = SftpFileAttributesBuilder.Empty; + attributes.LongName = longName; + + var target = new SftpFile(new Mock(MockBehavior.Strict).Object, "/home/owner/dir", attributes); + + Assert.AreEqual(longName, target.LongName); + } + + [TestMethod] + public void LongName_WithoutLongNameFromTheServer_IsNull() + { + var target = new SftpFile(new Mock(MockBehavior.Strict).Object, "/home/owner/file.txt", SftpFileAttributesBuilder.Empty); + + Assert.IsNull(target.LongName); + } + } +}