Add MLST command in the form of a Get method (#269)

* Add MLST command in the form of a Get method

The `LIST` and `MLSD` commands are inefficient when the objective
is to retrieve one single `Entry` for a known path, because the only way
to get such an entry is to list the parent directory using a data
connection. The `MLST` fixes this by allowing one single `Entry` to be
returned using the control connection.

The name `Get` was chosen because it is often used in conjunction with
`List` as a mean to get one single entry.

Signed-off-by: Thomas Hallgren <thomas@datawire.io>

* Changes in response to code review:

- Rename `Get` to `GetEntry` (because it returns an `*Entry`)
- Add test-case for multiline response on the control connection
- Fix issues with parsing the multiline response.

Signed-off-by: Thomas Hallgren <thomas@datawire.io>

* Add sample output from MLST to GetEntry comment.

Signed-off-by: Thomas Hallgren <thomas@datawire.io>

* Changes in response to code review:

- Remove unused `time.Time` argument
- Add struct labels to make `govet` happy

Signed-off-by: Thomas Hallgren <thomas@datawire.io>

* Remove time arg when calling parseNextRFC3659ListLine

Signed-off-by: Thomas Hallgren <thomas@datawire.io>

Signed-off-by: Thomas Hallgren <thomas@datawire.io>
This commit is contained in:
Thomas Hallgren 2022-08-21 23:25:29 +02:00 committed by GitHub
parent 4d1d644cf1
commit 0aeb8660a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 116 additions and 5 deletions

View File

@ -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)

View File

@ -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")

52
ftp.go
View File

@ -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 {

View File

@ -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], ";") {