diff --git a/client_test.go b/client_test.go index 01843d0..b4faae5 100644 --- a/client_test.go +++ b/client_test.go @@ -110,6 +110,40 @@ func testConn(t *testing.T, disableEPSV bool) { _, err = c.FileSize("not-found") assert.Error(err) + entry, err := c.GetEntry("magic-file") + if err != nil { + t.Error(err) + } + if entry == nil { + t.Fatal("expected entry, got nil") + } + if entry.Size != 42 { + t.Errorf("entry size %q, expected %q", entry.Size, 42) + } + if entry.Type != EntryTypeFile { + t.Errorf("entry type %q, expected %q", entry.Type, EntryTypeFile) + } + if entry.Name != "magic-file" { + t.Errorf("entry name %q, expected %q", entry.Name, "magic-file") + } + + entry, err = c.GetEntry("multiline-dir") + if err != nil { + t.Error(err) + } + if entry == nil { + t.Fatal("expected entry, got nil") + } + if entry.Size != 0 { + t.Errorf("entry size %q, expected %q", entry.Size, 0) + } + if entry.Type != EntryTypeFolder { + t.Errorf("entry type %q, expected %q", entry.Type, EntryTypeFolder) + } + if entry.Name != "multiline-dir" { + t.Errorf("entry name %q, expected %q", entry.Name, "multiline-dir") + } + err = c.Delete("tset") assert.NoError(err) @@ -245,7 +279,7 @@ func TestMissingFolderDeleteDirRecur(t *testing.T) { } func TestListCurrentDir(t *testing.T) { - mock, c := openConn(t, "127.0.0.1") + mock, c := openConnExt(t, "127.0.0.1", "no-time", DialWithDisabledMLSD(true)) _, err := c.List("") assert.NoError(t, err) diff --git a/conn_test.go b/conn_test.go index cbe4b74..7d3c299 100644 --- a/conn_test.go +++ b/conn_test.go @@ -90,7 +90,7 @@ func (mock *ftpMock) listen() { // At least one command must have a multiline response switch cmdParts[0] { case "FEAT": - features := "211-Features:\r\n FEAT\r\n PASV\r\n EPSV\r\n UTF8\r\n SIZE\r\n" + features := "211-Features:\r\n FEAT\r\n PASV\r\n EPSV\r\n UTF8\r\n SIZE\r\n MLST\r\n" switch mock.modtime { case "std-time": features += " MDTM\r\n MFMT\r\n" @@ -178,6 +178,23 @@ func (mock *ftpMock) listen() { mock.dataConn.write([]byte("-rw-r--r-- 1 ftp wheel 0 Jan 29 10:29 lo\r\ntotal 1")) mock.printfLine("226 Transfer complete") mock.closeDataConn() + case "MLSD": + if mock.dataConn == nil { + mock.printfLine("425 Unable to build data connection: Connection refused") + break + } + + mock.dataConn.Wait() + mock.printfLine("150 Opening data connection for file list") + mock.dataConn.write([]byte("Type=file;Size=0;Modify=20201213202400; lo\r\n")) + mock.printfLine("226 Transfer complete") + mock.closeDataConn() + case "MLST": + if cmdParts[1] == "multiline-dir" { + mock.printfLine("250-File data\r\n Type=dir;Size=0; multiline-dir\r\n Modify=20201213202400; multiline-dir\r\n250 End") + } else { + mock.printfLine("250-File data\r\n Type=file;Size=42;Modify=20201213202400; magic-file\r\n250 End") + } case "NLST": if mock.dataConn == nil { mock.printfLine("425 Unable to build data connection: Connection refused") diff --git a/ftp.go b/ftp.go index 9ba5651..80975e8 100644 --- a/ftp.go +++ b/ftp.go @@ -678,6 +678,58 @@ func (c *ServerConn) List(path string) (entries []*Entry, err error) { return entries, errs.ErrorOrNil() } +// GetEntry issues a MLST FTP command which retrieves one single Entry using the +// control connection. The returnedEntry will describe the current directory +// when no path is given. +func (c *ServerConn) GetEntry(path string) (entry *Entry, err error) { + if !c.mlstSupported { + return nil, &textproto.Error{Code: StatusNotImplemented, Msg: StatusText(StatusNotImplemented)} + } + space := " " + if path == "" { + space = "" + } + _, msg, err := c.cmd(StatusRequestedFileActionOK, "%s%s%s", "MLST", space, path) + if err != nil { + return nil, err + } + + // The expected reply will look something like: + // + // 250-File details + // Type=file;Size=1024;Modify=20220813133357; path + // 250 End + // + // Multiple lines are allowed though, so it can also be in the form: + // + // 250-File details + // Type=file;Size=1024; path + // Modify=20220813133357; path + // 250 End + lines := strings.Split(msg, "\n") + lc := len(lines) + + // lines must be a multi-line message with a length of 3 or more, and we + // don't care about the first and last line + if lc < 3 { + return nil, errors.New("invalid response") + } + + e := &Entry{} + for _, l := range lines[1 : lc-1] { + // According to RFC 3659, the entry lines must start with a space when passed over the + // control connection. Some servers don't seem to add that space though. Both forms are + // accepted here. + if len(l) > 0 && l[0] == ' ' { + l = l[1:] + } + if e, err = parseNextRFC3659ListLine(l, c.options.location, e); err != nil { + return nil, err + } + } + return e, nil +} + // IsTimePreciseInList returns true if client and server support the MLSD // command so List can return time with 1-second precision for all files. func (c *ServerConn) IsTimePreciseInList() bool { diff --git a/parse.go b/parse.go index dbe0324..2decf1e 100644 --- a/parse.go +++ b/parse.go @@ -27,7 +27,11 @@ var dirTimeFormats = []string{ } // parseRFC3659ListLine parses the style of directory line defined in RFC 3659. -func parseRFC3659ListLine(line string, now time.Time, loc *time.Location) (*Entry, error) { +func parseRFC3659ListLine(line string, _ time.Time, loc *time.Location) (*Entry, error) { + return parseNextRFC3659ListLine(line, loc, &Entry{}) +} + +func parseNextRFC3659ListLine(line string, loc *time.Location, e *Entry) (*Entry, error) { iSemicolon := strings.Index(line, ";") iWhitespace := strings.Index(line, " ") @@ -35,8 +39,12 @@ func parseRFC3659ListLine(line string, now time.Time, loc *time.Location) (*Entr return nil, errUnsupportedListLine } - e := &Entry{ - Name: line[iWhitespace+1:], + name := line[iWhitespace+1:] + if e.Name == "" { + e.Name = name + } else if e.Name != name { + // All lines must have the same name + return nil, errUnsupportedListLine } for _, field := range strings.Split(line[:iWhitespace-1], ";") {