19 Commits

Author SHA1 Message Date
Øyvind Heddeland Instefjord
99be0634ab Add forceListHidden dial option to force the use of 'LIST -a' command (#271)
This is useful for servers that do not offer up
hidden folders/files by default when using LIST/MLSD
commands.
Setting forceListHidden to true will force the use of
the 'LIST -a' command even when MLST support has
been detected.
2022-09-04 14:43:06 -04:00
Julien Laffaye
b85cf1edcc Add default timeout to instantiate connection 2022-08-28 21:58:25 -04:00
Thomas Hallgren
0aeb8660a7 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>
2022-08-21 17:25:29 -04:00
Julien Laffaye
4d1d644cf1 Mark Connect() and DialWithTimeout() as deprecated 2022-08-18 12:44:22 -04:00
Julien Laffaye
5a2fd50da8 ioutil is deprecated, use io package instead 2022-08-18 11:18:54 -04:00
Julien Laffaye
9cda78131d Fix transferring spelling. 2022-08-18 10:17:32 -04:00
Julien Laffaye
39592b91e4 Assign type to TransferType constants 2022-08-17 19:43:45 -04:00
Julien Laffaye
fa83b53d0e Use tls.Dialer to be able to use DialContext 2022-08-17 19:35:55 -04:00
Julien Laffaye
45482d097e Use assert package to simplify tests 2022-08-17 19:24:40 -04:00
Julien Laffaye
560423fa8a DialWithNetConn is a special case of DialWithDialFunc 2022-08-17 18:41:24 -04:00
Julien Laffaye
11536801d1 Merge pull request #264 from jlaffaye/dependabot/go_modules/github.com/stretchr/testify-1.8.0
Bump github.com/stretchr/testify from 1.7.2 to 1.8.0
2022-06-30 12:50:35 -04:00
dependabot[bot]
6c74f63d3c Bump github.com/stretchr/testify from 1.7.2 to 1.8.0
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.7.2 to 1.8.0.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.7.2...v1.8.0)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-29 15:39:38 +00:00
Julien Laffaye
60a941566c Merge pull request #259 from jlaffaye/dependabot/go_modules/github.com/stretchr/testify-1.7.2
Bump github.com/stretchr/testify from 1.6.1 to 1.7.2
2022-06-12 11:18:34 -04:00
dependabot[bot]
712e6cb8bc Bump github.com/stretchr/testify from 1.6.1 to 1.7.2
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.6.1 to 1.7.2.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.6.1...v1.7.2)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-10 16:37:09 +00:00
Julien Laffaye
6696131620 Create dependabot.yml 2022-06-10 12:36:43 -04:00
Julien Laffaye
dfa1e758f3 Merge pull request #257 from tauu/cmd-type
support for the TYPE command
2022-05-23 20:19:17 -04:00
Georg Wechslberger
b29e1f6c62 refactor: rename TransferTypeImage to TransferTypeBinary 2022-05-23 17:21:05 +02:00
Georg Wechslberger
190f39e8b2 feat: support TYPE command 2022-05-23 12:36:17 +02:00
Julien Laffaye
d2c44e311e Restore previous behavior for List
Fixes #251
2022-03-10 15:20:11 -05:00
8 changed files with 288 additions and 172 deletions

13
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "gomod" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
assignees:
- "jlaffaye"

View File

@@ -3,10 +3,8 @@ package ftp
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io/ioutil" "io"
"net" "net"
"net/textproto"
"strings"
"syscall" "syscall"
"testing" "testing"
"time" "time"
@@ -28,174 +26,145 @@ func TestConnEPSV(t *testing.T) {
} }
func testConn(t *testing.T, disableEPSV bool) { func testConn(t *testing.T, disableEPSV bool) {
assert := assert.New(t)
mock, c := openConn(t, "127.0.0.1", DialWithTimeout(5*time.Second), DialWithDisabledEPSV(disableEPSV)) mock, c := openConn(t, "127.0.0.1", DialWithTimeout(5*time.Second), DialWithDisabledEPSV(disableEPSV))
err := c.Login("anonymous", "anonymous") err := c.Login("anonymous", "anonymous")
if err != nil { assert.NoError(err)
t.Fatal(err)
}
err = c.NoOp() err = c.NoOp()
if err != nil { assert.NoError(err)
t.Error(err)
}
err = c.ChangeDir("incoming") err = c.ChangeDir("incoming")
if err != nil { assert.NoError(err)
t.Error(err)
}
dir, err := c.CurrentDir() dir, err := c.CurrentDir()
if err != nil { if assert.NoError(err) {
t.Error(err) assert.Equal("/incoming", dir)
} else {
if dir != "/incoming" {
t.Error("Wrong dir: " + dir)
}
} }
data := bytes.NewBufferString(testData) data := bytes.NewBufferString(testData)
err = c.Stor("test", data) err = c.Stor("test", data)
if err != nil { assert.NoError(err)
t.Error(err)
}
_, err = c.List(".") _, err = c.List(".")
if err != nil { assert.NoError(err)
t.Error(err)
}
err = c.Rename("test", "tset") err = c.Rename("test", "tset")
if err != nil { assert.NoError(err)
t.Error(err)
}
// Read without deadline // Read without deadline
r, err := c.Retr("tset") r, err := c.Retr("tset")
if err != nil { if assert.NoError(err) {
t.Error(err) buf, err := io.ReadAll(r)
} else { if assert.NoError(err) {
buf, errRead := ioutil.ReadAll(r) assert.Equal(testData, string(buf))
if err != nil {
t.Error(errRead)
}
if string(buf) != testData {
t.Errorf("'%s'", buf)
} }
r.Close() r.Close()
r.Close() // test we can close two times r.Close() // test we can close two times
} }
// Read with deadline // Read with deadline
r, err = c.Retr("tset") r, err = c.Retr("tset")
if err != nil { if assert.NoError(err) {
t.Error(err)
} else {
if err := r.SetDeadline(time.Now()); err != nil { if err := r.SetDeadline(time.Now()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err = ioutil.ReadAll(r) _, err = io.ReadAll(r)
if err == nil { assert.ErrorContains(err, "i/o timeout")
t.Error("deadline should have caused error")
} else if !strings.HasSuffix(err.Error(), "i/o timeout") {
t.Error(err)
}
r.Close() r.Close()
} }
// Read with offset // Read with offset
r, err = c.RetrFrom("tset", 5) r, err = c.RetrFrom("tset", 5)
if err != nil { if assert.NoError(err) {
t.Error(err) buf, err := io.ReadAll(r)
} else { if assert.NoError(err) {
buf, errRead := ioutil.ReadAll(r) expected := testData[5:]
if errRead != nil { assert.Equal(expected, string(buf))
t.Error(errRead)
}
expected := testData[5:]
if string(buf) != expected {
t.Errorf("read %q, expected %q", buf, expected)
} }
r.Close() r.Close()
} }
data2 := bytes.NewBufferString(testData) data2 := bytes.NewBufferString(testData)
err = c.Append("tset", data2) err = c.Append("tset", data2)
if err != nil { assert.NoError(err)
t.Error(err)
}
// Read without deadline, after append // Read without deadline, after append
r, err = c.Retr("tset") r, err = c.Retr("tset")
if err != nil { if assert.NoError(err) {
t.Error(err) buf, err := io.ReadAll(r)
} else { if assert.NoError(err) {
buf, errRead := ioutil.ReadAll(r) assert.Equal(testData+testData, string(buf))
if err != nil {
t.Error(errRead)
}
if string(buf) != testData+testData {
t.Errorf("'%s'", buf)
} }
r.Close() r.Close()
} }
fileSize, err := c.FileSize("magic-file") fileSize, err := c.FileSize("magic-file")
assert.NoError(err)
assert.Equal(int64(42), fileSize)
_, err = c.FileSize("not-found")
assert.Error(err)
entry, err := c.GetEntry("magic-file")
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
if fileSize != 42 { if entry == nil {
t.Errorf("file size %q, expected %q", fileSize, 42) 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")
} }
_, err = c.FileSize("not-found") entry, err = c.GetEntry("multiline-dir")
if err == nil { if err != nil {
t.Fatal("expected error, got 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") err = c.Delete("tset")
if err != nil { assert.NoError(err)
t.Error(err)
}
err = c.MakeDir(testDir) err = c.MakeDir(testDir)
if err != nil { assert.NoError(err)
t.Error(err)
}
err = c.ChangeDir(testDir) err = c.ChangeDir(testDir)
if err != nil { assert.NoError(err)
t.Error(err)
}
err = c.ChangeDirToParent() err = c.ChangeDirToParent()
if err != nil { assert.NoError(err)
t.Error(err)
}
entries, err := c.NameList("/") entries, err := c.NameList("/")
if err != nil { assert.NoError(err)
t.Error(err) assert.Equal([]string{"/incoming"}, entries)
}
if len(entries) != 1 || entries[0] != "/incoming" {
t.Errorf("Unexpected entries: %v", entries)
}
err = c.RemoveDir(testDir) err = c.RemoveDir(testDir)
if err != nil { assert.NoError(err)
t.Error(err)
}
err = c.Logout() err = c.Logout()
if err != nil { assert.NoError(err)
if protoErr := err.(*textproto.Error); protoErr != nil {
if protoErr.Code != StatusNotImplemented {
t.Error(err)
}
} else {
t.Error(err)
}
}
if err = c.Quit(); err != nil { if err = c.Quit(); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -205,9 +174,7 @@ func testConn(t *testing.T, disableEPSV bool) {
mock.Wait() mock.Wait()
err = c.NoOp() err = c.NoOp()
if err == nil { assert.Error(err, "should error on closed conn")
t.Error("Expected error")
}
} }
// TestConnect tests the legacy Connect function // TestConnect tests the legacy Connect function
@@ -312,7 +279,7 @@ func TestMissingFolderDeleteDirRecur(t *testing.T) {
} }
func TestListCurrentDir(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("") _, err := c.List("")
assert.NoError(t, err) assert.NoError(t, err)
@@ -328,6 +295,20 @@ func TestListCurrentDir(t *testing.T) {
mock.Wait() mock.Wait()
} }
func TestListCurrentDirWithForceListHidden(t *testing.T) {
mock, c := openConnExt(t, "127.0.0.1", "no-time", DialWithDisabledMLSD(true), DialWithForceListHidden(true))
assert.True(t, c.options.forceListHidden)
_, err := c.List("")
assert.NoError(t, err)
assert.Equal(t, "LIST -a", mock.lastFull, "LIST -a must not have a trailing whitespace")
err = c.Quit()
assert.NoError(t, err)
mock.Wait()
}
func TestTimeUnsupported(t *testing.T) { func TestTimeUnsupported(t *testing.T) {
mock, c := openConnExt(t, "127.0.0.1", "no-time") mock, c := openConnExt(t, "127.0.0.1", "no-time")

View File

@@ -6,12 +6,14 @@ import (
"io" "io"
"net" "net"
"net/textproto" "net/textproto"
"reflect"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type ftpMock struct { type ftpMock struct {
@@ -88,7 +90,7 @@ func (mock *ftpMock) listen() {
// At least one command must have a multiline response // At least one command must have a multiline response
switch cmdParts[0] { switch cmdParts[0] {
case "FEAT": 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 { switch mock.modtime {
case "std-time": case "std-time":
features += " MDTM\r\n MFMT\r\n" features += " MDTM\r\n MFMT\r\n"
@@ -173,9 +175,26 @@ func (mock *ftpMock) listen() {
mock.dataConn.Wait() mock.dataConn.Wait()
mock.printfLine("150 Opening ASCII mode data connection for file list") mock.printfLine("150 Opening ASCII mode data connection for file list")
mock.dataConn.write([]byte("-rw-r--r-- 1 ftp wheel 0 Jan 29 10:29 lo")) 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.printfLine("226 Transfer complete")
mock.closeDataConn() 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": case "NLST":
if mock.dataConn == nil { if mock.dataConn == nil {
mock.printfLine("425 Unable to build data connection: Connection refused") mock.printfLine("425 Unable to build data connection: Connection refused")
@@ -386,20 +405,14 @@ func openConn(t *testing.T, addr string, options ...DialOption) (*ftpMock, *Serv
func openConnExt(t *testing.T, addr, modtime string, options ...DialOption) (*ftpMock, *ServerConn) { func openConnExt(t *testing.T, addr, modtime string, options ...DialOption) (*ftpMock, *ServerConn) {
mock, err := newFtpMockExt(t, addr, modtime) mock, err := newFtpMockExt(t, addr, modtime)
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
defer mock.Close() defer mock.Close()
c, err := Dial(mock.Addr(), options...) c, err := Dial(mock.Addr(), options...)
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
err = c.Login("anonymous", "anonymous") err = c.Login("anonymous", "anonymous")
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
return mock, c return mock, c
} }
@@ -417,9 +430,7 @@ func closeConn(t *testing.T, mock *ftpMock, c *ServerConn, commands []string) {
// Wait for the connection to close // Wait for the connection to close
mock.Wait() mock.Wait()
if !reflect.DeepEqual(mock.commands, expected) { assert.Equal(t, expected, mock.commands, "unexpected sequence of commands")
t.Fatal("unexpected sequence of commands:", mock.commands, "expected:", expected)
}
} }
func TestConn4(t *testing.T) { func TestConn4(t *testing.T) {

179
ftp.go
View File

@@ -18,6 +18,12 @@ import (
"github.com/hashicorp/go-multierror" "github.com/hashicorp/go-multierror"
) )
const (
// 30 seconds was chosen as it's the
// same duration as http.DefaultTransport's timeout.
DefaultDialTimeout = 30 * time.Second
)
// EntryType describes the different types of an Entry. // EntryType describes the different types of an Entry.
type EntryType int type EntryType int
@@ -28,6 +34,15 @@ const (
EntryTypeLink EntryTypeLink
) )
// TransferType denotes the formats for transferring Entries.
type TransferType string
// The different transfer types
const (
TransferTypeBinary = TransferType("I")
TransferTypeASCII = TransferType("A")
)
// Time format used by the MDTM and MFMT commands // Time format used by the MDTM and MFMT commands
const timeFormat = "20060102150405" const timeFormat = "20060102150405"
@@ -57,19 +72,19 @@ type DialOption struct {
// dialOptions contains all the options set by DialOption.setup // dialOptions contains all the options set by DialOption.setup
type dialOptions struct { type dialOptions struct {
context context.Context context context.Context
dialer net.Dialer dialer net.Dialer
tlsConfig *tls.Config tlsConfig *tls.Config
explicitTLS bool explicitTLS bool
conn net.Conn disableEPSV bool
disableEPSV bool disableUTF8 bool
disableUTF8 bool disableMLSD bool
disableMLSD bool writingMDTM bool
writingMDTM bool forceListHidden bool
location *time.Location location *time.Location
debugOutput io.Writer debugOutput io.Writer
dialFunc func(network, address string) (net.Conn, error) dialFunc func(network, address string) (net.Conn, error)
shutTimeout time.Duration // time to wait for data connection closing status shutTimeout time.Duration // time to wait for data connection closing status
} }
// Entry describes a file and is returned by List(). // Entry describes a file and is returned by List().
@@ -99,27 +114,39 @@ func Dial(addr string, options ...DialOption) (*ServerConn, error) {
do.location = time.UTC do.location = time.UTC
} }
tconn := do.conn dialFunc := do.dialFunc
if tconn == nil {
var err error
if do.dialFunc != nil { if dialFunc == nil {
tconn, err = do.dialFunc("tcp", addr) ctx := do.context
} else if do.tlsConfig != nil && !do.explicitTLS {
tconn, err = tls.DialWithDialer(&do.dialer, "tcp", addr, do.tlsConfig)
} else {
ctx := do.context
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
}
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, DefaultDialTimeout)
defer cancel()
}
if do.tlsConfig != nil && !do.explicitTLS {
dialFunc = func(network, address string) (net.Conn, error) {
tlsDialer := &tls.Dialer{
NetDialer: &do.dialer,
Config: do.tlsConfig,
}
return tlsDialer.DialContext(ctx, network, addr)
} }
} else {
tconn, err = do.dialer.DialContext(ctx, "tcp", addr) dialFunc = func(network, address string) (net.Conn, error) {
return do.dialer.DialContext(ctx, network, addr)
}
} }
}
if err != nil { tconn, err := dialFunc("tcp", addr)
return nil, err if err != nil {
} return nil, err
} }
// Use the resolved IP address in case addr contains a domain name // Use the resolved IP address in case addr contains a domain name
@@ -134,7 +161,7 @@ func Dial(addr string, options ...DialOption) (*ServerConn, error) {
host: remoteAddr.IP.String(), host: remoteAddr.IP.String(),
} }
_, _, err := c.conn.ReadResponse(StatusReady) _, _, err = c.conn.ReadResponse(StatusReady)
if err != nil { if err != nil {
_ = c.Quit() _ = c.Quit()
return nil, err return nil, err
@@ -176,10 +203,12 @@ func DialWithDialer(dialer net.Dialer) DialOption {
} }
// DialWithNetConn returns a DialOption that configures the ServerConn with the underlying net.Conn // DialWithNetConn returns a DialOption that configures the ServerConn with the underlying net.Conn
//
// Deprecated: Use [DialWithDialFunc] instead
func DialWithNetConn(conn net.Conn) DialOption { func DialWithNetConn(conn net.Conn) DialOption {
return DialOption{func(do *dialOptions) { return DialWithDialFunc(func(network, address string) (net.Conn, error) {
do.conn = conn return conn, nil
}} })
} }
// DialWithDisabledEPSV returns a DialOption that configures the ServerConn with EPSV disabled // DialWithDisabledEPSV returns a DialOption that configures the ServerConn with EPSV disabled
@@ -219,6 +248,16 @@ func DialWithWritingMDTM(enabled bool) DialOption {
}} }}
} }
// DialWithForceListHidden returns a DialOption making ServerConn use LIST -a to include hidden files and folders in directory listings
//
// This is useful for servers that do not do this by default, but it forces the use of the LIST command
// even if the server supports MLST.
func DialWithForceListHidden(enabled bool) DialOption {
return DialOption{func(do *dialOptions) {
do.forceListHidden = enabled
}}
}
// DialWithLocation returns a DialOption that configures the ServerConn with specified time.Location // DialWithLocation returns a DialOption that configures the ServerConn with specified time.Location
// The location is used to parse the dates sent by the server which are in server's timezone // The location is used to parse the dates sent by the server which are in server's timezone
func DialWithLocation(location *time.Location) DialOption { func DialWithLocation(location *time.Location) DialOption {
@@ -292,14 +331,15 @@ func (o *dialOptions) wrapStream(rd io.ReadCloser) io.ReadCloser {
} }
// Connect is an alias to Dial, for backward compatibility // Connect is an alias to Dial, for backward compatibility
//
// Deprecated: Use [Dial] instead
func Connect(addr string) (*ServerConn, error) { func Connect(addr string) (*ServerConn, error) {
return Dial(addr) return Dial(addr)
} }
// DialTimeout initializes the connection to the specified ftp server address. // DialTimeout initializes the connection to the specified ftp server address.
// //
// It is generally followed by a call to Login() as most FTP commands require // Deprecated: Use [Dial] with [DialWithTimeout] option instead
// an authenticated user.
func DialTimeout(addr string, timeout time.Duration) (*ServerConn, error) { func DialTimeout(addr string, timeout time.Duration) (*ServerConn, error) {
return Dial(addr, DialWithTimeout(timeout)) return Dial(addr, DialWithTimeout(timeout))
} }
@@ -340,7 +380,7 @@ func (c *ServerConn) Login(user, password string) error {
c.mdtmCanWrite = c.mdtmSupported && c.options.writingMDTM c.mdtmCanWrite = c.mdtmSupported && c.options.writingMDTM
// Switch to binary mode // Switch to binary mode
if _, _, err = c.cmd(StatusCommandOK, "TYPE I"); err != nil { if err = c.Type(TransferTypeBinary); err != nil {
return err return err
} }
@@ -580,6 +620,12 @@ func (c *ServerConn) cmdDataConnFrom(offset uint64, format string, args ...inter
return conn, nil return conn, nil
} }
// Type switches the transfer mode for the connection.
func (c *ServerConn) Type(transferType TransferType) (err error) {
_, _, err = c.cmd(StatusCommandOK, "TYPE "+string(transferType))
return err
}
// NameList issues an NLST FTP command. // NameList issues an NLST FTP command.
func (c *ServerConn) NameList(path string) (entries []string, err error) { func (c *ServerConn) NameList(path string) (entries []string, err error) {
space := " " space := " "
@@ -615,11 +661,14 @@ func (c *ServerConn) List(path string) (entries []*Entry, err error) {
var cmd string var cmd string
var parser parseFunc var parser parseFunc
if c.mlstSupported { if c.mlstSupported && !c.options.forceListHidden {
cmd = "MLSD" cmd = "MLSD"
parser = parseRFC3659ListLine parser = parseRFC3659ListLine
} else { } else {
cmd = "LIST" cmd = "LIST"
if c.options.forceListHidden {
cmd += " -a"
}
parser = parseListLine parser = parseListLine
} }
@@ -640,9 +689,7 @@ func (c *ServerConn) List(path string) (entries []*Entry, err error) {
now := time.Now() now := time.Now()
for scanner.Scan() { for scanner.Scan() {
entry, errParse := parser(scanner.Text(), now, c.options.location) entry, errParse := parser(scanner.Text(), now, c.options.location)
if errParse != nil { if errParse == nil {
errs = multierror.Append(errs, errParse)
} else {
entries = append(entries, entry) entries = append(entries, entry)
} }
} }
@@ -657,6 +704,58 @@ func (c *ServerConn) List(path string) (entries []*Entry, err error) {
return entries, errs.ErrorOrNil() 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 // IsTimePreciseInList returns true if client and server support the MLSD
// command so List can return time with 1-second precision for all files. // command so List can return time with 1-second precision for all files.
func (c *ServerConn) IsTimePreciseInList() bool { func (c *ServerConn) IsTimePreciseInList() bool {
@@ -928,7 +1027,7 @@ func (c *ServerConn) RemoveDir(path string) error {
return err return err
} }
//Walk prepares the internal walk function so that the caller can begin traversing the directory // Walk prepares the internal walk function so that the caller can begin traversing the directory
func (c *ServerConn) Walk(root string) *Walker { func (c *ServerConn) Walk(root string) *Walker {
w := new(Walker) w := new(Walker)
w.serverConn = c w.serverConn = c

6
go.mod
View File

@@ -4,12 +4,12 @@ go 1.17
require ( require (
github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-multierror v1.1.1
github.com/stretchr/testify v1.6.1 github.com/stretchr/testify v1.8.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

12
go.sum
View File

@@ -1,5 +1,6 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
@@ -7,9 +8,12 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -27,7 +27,11 @@ var dirTimeFormats = []string{
} }
// parseRFC3659ListLine parses the style of directory line defined in RFC 3659. // 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, ";") iSemicolon := strings.Index(line, ";")
iWhitespace := 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 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], ";") { for _, field := range strings.Split(line[:iWhitespace-1], ";") {

View File

@@ -4,7 +4,7 @@ import (
"path" "path"
) )
//Walker traverses the directory tree of a remote FTP server // Walker traverses the directory tree of a remote FTP server
type Walker struct { type Walker struct {
serverConn *ServerConn serverConn *ServerConn
root string root string