diff --git a/.travis.yml b/.travis.yml index 82565d7..91253a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,10 @@ language: go -dist: trusty sudo: required +dist: xenial go: - 1.10.x - 1.11.x -env: - - FTP_SERVER=vsftpd - - FTP_SERVER=proftpd before_install: -- sudo $TRAVIS_BUILD_DIR/.travis/prepare.sh "$FTP_SERVER" - sudo sysctl net.ipv6.conf.lo.disable_ipv6=0 - go get github.com/mattn/goveralls - go get github.com/golang/lint/golint diff --git a/.travis/prepare.sh b/.travis/prepare.sh deleted file mode 100755 index 53517ed..0000000 --- a/.travis/prepare.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -e - -case "$1" in - proftpd) - mkdir -p /etc/proftpd/conf.d/ - cp $TRAVIS_BUILD_DIR/.travis/proftpd.conf /etc/proftpd/conf.d/ - ;; - vsftpd) - cp $TRAVIS_BUILD_DIR/.travis/vsftpd.conf /etc/vsftpd.conf - ;; - *) - echo "unknown software: $1" - exit 1 -esac - -mkdir --mode 0777 -p /var/ftp/incoming - -apt-get update -qq -apt-get install -qq "$1" diff --git a/.travis/proftpd.conf b/.travis/proftpd.conf deleted file mode 100644 index 342d08f..0000000 --- a/.travis/proftpd.conf +++ /dev/null @@ -1,9 +0,0 @@ - - User ftp - Group nogroup - MaxClients 2 - # We want clients to be able to login with "anonymous" as well as "ftp" - UserAlias anonymous ftp - - RequireValidShell off - diff --git a/.travis/vsftpd.conf b/.travis/vsftpd.conf deleted file mode 100644 index f3d6dbf..0000000 --- a/.travis/vsftpd.conf +++ /dev/null @@ -1,15 +0,0 @@ -# Used by Travis CI - -listen=NO -listen_ipv6=YES - -write_enable=YES -dirmessage_enable=YES -secure_chroot_dir=/var/run/vsftpd/empty - -anonymous_enable=YES -anon_root=/var/ftp -anon_upload_enable=YES -anon_mkdir_write_enable=YES -anon_other_write_enable=YES -anon_umask=022 diff --git a/client_multiline_test.go b/client_multiline_test.go deleted file mode 100644 index 9d3b0ce..0000000 --- a/client_multiline_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package ftp - -import ( - "net" - "net/textproto" - "reflect" - "strings" - "sync" - "testing" -) - -type ftpMock struct { - listener net.Listener - commands []string // list of received commands - sync.WaitGroup -} - -func newFtpMock(t *testing.T, addresss string) *ftpMock { - var err error - mock := &ftpMock{} - mock.listener, err = net.Listen("tcp", addresss) - if err != nil { - t.Fatal(err) - } - - go func() { - // Listen for an incoming connection. - conn, err := mock.listener.Accept() - if err != nil { - t.Fatal(err) - } - - mock.Add(1) - defer mock.Done() - defer conn.Close() - - proto := textproto.NewConn(conn) - proto.Writer.PrintfLine("220 FTP Server ready.") - - for { - command, _ := proto.ReadLine() - - // Strip the arguments - if i := strings.Index(command, " "); i > 0 { - command = command[:i] - } - - // Append to list of received commands - mock.commands = append(mock.commands, command) - - // At least one command must have a multiline response - switch command { - case "FEAT": - proto.Writer.PrintfLine("211-Features:\r\nFEAT\r\nPASV\r\nSIZE\r\n211 End") - case "USER": - proto.Writer.PrintfLine("331 Please send your password") - case "PASS": - proto.Writer.PrintfLine("230-Hey,\r\nWelcome to my FTP\r\n230 Access granted") - case "TYPE": - proto.Writer.PrintfLine("200 Type set ok") - case "QUIT": - proto.Writer.PrintfLine("221 Goodbye.") - return - default: - t.Fatal("unknown command:", command) - } - } - }() - - return mock -} - -// Closes the listening socket -func (mock *ftpMock) Close() { - mock.listener.Close() -} - -// ftp.mozilla.org uses multiline 220 response -func TestMultiline(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode.") - } - - address := "localhost:2121" - mock := newFtpMock(t, address) - defer mock.Close() - - c, err := Dial(address) - if err != nil { - t.Fatal(err) - } - - err = c.Login("anonymous", "anonymous") - if err != nil { - t.Fatal(err) - } - - c.Quit() - - // Wait for the connection to close - mock.Wait() - - expected := []string{"FEAT", "USER", "PASS", "TYPE", "QUIT"} - if !reflect.DeepEqual(mock.commands, expected) { - t.Fatal("unexpected sequence of commands:", mock.commands, "expected:", expected) - } -} diff --git a/client_test.go b/client_test.go index ed1ecaa..e8995a9 100644 --- a/client_test.go +++ b/client_test.go @@ -23,21 +23,15 @@ func TestConnEPSV(t *testing.T) { } func testConn(t *testing.T, disableEPSV bool) { - if testing.Short() { - t.Skip("skipping test in short mode.") - } - c, err := DialTimeout("localhost:21", 5*time.Second) - if err != nil { - t.Fatal(err) - } + mock, c := openConn(t, "127.0.0.1") if disableEPSV { delete(c.features, "EPSV") c.DisableEPSV = true } - err = c.Login("anonymous", "anonymous") + err := c.Login("anonymous", "anonymous") if err != nil { t.Fatal(err) } @@ -52,6 +46,15 @@ func testConn(t *testing.T, disableEPSV bool) { t.Error(err) } + dir, err := c.CurrentDir() + if err != nil { + t.Error(err) + } else { + if dir != "/incoming" { + t.Error("Wrong dir: " + dir) + } + } + data := bytes.NewBufferString(testData) err = c.Stor("test", data) if err != nil { @@ -115,26 +118,12 @@ func testConn(t *testing.T, disableEPSV bool) { r.Close() } - fileSize, err := c.FileSize("tset") + fileSize, err := c.FileSize("magic-file") if err != nil { t.Error(err) } - if fileSize != 14 { - t.Errorf("file size %q, expected %q", fileSize, 14) - } - - data = bytes.NewBufferString("") - err = c.Stor("tset", data) - if err != nil { - t.Error(err) - } - - fileSize, err = c.FileSize("tset") - if err != nil { - t.Error(err) - } - if fileSize != 0 { - t.Errorf("file size %q, expected %q", fileSize, 0) + if fileSize != 42 { + t.Errorf("file size %q, expected %q", fileSize, 42) } _, err = c.FileSize("not-found") @@ -157,15 +146,6 @@ func testConn(t *testing.T, disableEPSV bool) { t.Error(err) } - dir, err := c.CurrentDir() - if err != nil { - t.Error(err) - } else { - if dir != "/incoming/"+testDir { - t.Error("Wrong dir: " + dir) - } - } - err = c.ChangeDirToParent() if err != nil { t.Error(err) @@ -195,7 +175,12 @@ func testConn(t *testing.T, disableEPSV bool) { } } - c.Quit() + if err := c.Quit(); err != nil { + t.Fatal(err) + } + + // Wait for the connection to close + mock.Wait() err = c.NoOp() if err == nil { @@ -203,41 +188,23 @@ func testConn(t *testing.T, disableEPSV bool) { } } -func TestConnIPv6(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode.") - } - - c, err := DialTimeout("[::1]:21", 5*time.Second) - if err != nil { - t.Fatal(err) - } - - err = c.Login("anonymous", "anonymous") - if err != nil { - t.Fatal(err) - } - - _, err = c.List(".") - if err != nil { - t.Error(err) - } - - c.Quit() -} - // TestConnect tests the legacy Connect function func TestConnect(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode.") + mock, err := newFtpMock(t, "127.0.0.1") + if err != nil { + t.Fatal(err) } + defer mock.Close() - c, err := Connect("localhost:21") + c, err := Connect(mock.Addr()) if err != nil { t.Fatal(err) } - c.Quit() + if err := c.Quit(); err != nil { + t.Fatal(err) + } + mock.Wait() } func TestTimeout(t *testing.T) { @@ -253,11 +220,13 @@ func TestTimeout(t *testing.T) { } func TestWrongLogin(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode.") + mock, err := newFtpMock(t, "127.0.0.1") + if err != nil { + t.Fatal(err) } + defer mock.Close() - c, err := DialTimeout("localhost:21", 5*time.Second) + c, err := DialTimeout(mock.Addr(), 5*time.Second) if err != nil { t.Fatal(err) } @@ -270,189 +239,49 @@ func TestWrongLogin(t *testing.T) { } func TestDeleteDirRecur(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode.") - } - c, err := DialTimeout("localhost:21", 5*time.Second) + mock, c := openConn(t, "127.0.0.1") + + err := c.RemoveDirRecur("testDir") if err != nil { + t.Error(err) + } + + if err := c.Quit(); err != nil { t.Fatal(err) } - err = c.Login("anonymous", "anonymous") - if err != nil { - t.Fatal(err) - } - - err = c.NoOp() - if err != nil { - t.Error(err) - } - - err = c.ChangeDir("incoming") - if err != nil { - t.Error(err) - } - - err = c.MakeDir("testDir") - if err != nil { - t.Error(err) - } - - err = c.ChangeDir("testDir") - if err != nil { - t.Error(err) - } - - err = c.MakeDir("anotherDir") - if err != nil { - t.Error(err) - } - - data := bytes.NewBufferString("test text") - err = c.Stor("fileTest", data) - if err != nil { - t.Error(err) - } - - err = c.ChangeDirToParent() - if err != nil { - t.Error(err) - } - err = c.RemoveDirRecur("testDir") - if err != nil { - t.Error(err) - } - dir, err := c.CurrentDir() - if err != nil { - t.Error(err) - } else { - if dir != "/incoming" { - t.Error("Wrong dir: " + dir) - } - } - - err = c.ChangeDir("testDir") - if err == nil { - t.Fatal("expected error, got nil") - } - - err = c.Logout() - if err != nil { - if protoErr := err.(*textproto.Error); protoErr != nil { - if protoErr.Code != StatusNotImplemented { - t.Error(err) - } - } else { - t.Error(err) - } - } - - c.Quit() + // Wait for the connection to close + mock.Wait() } -func TestFileDeleteDirRecur(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode.") - } +// func TestFileDeleteDirRecur(t *testing.T) { +// mock, c := openConn(t, "127.0.0.1") - c, err := DialTimeout("localhost:21", 5*time.Second) - if err != nil { - t.Fatal(err) - } +// err := c.RemoveDirRecur("testFile") +// if err == nil { +// t.Fatal("expected error got nil") +// } - err = c.Login("anonymous", "anonymous") - if err != nil { - t.Fatal(err) - } +// if err := c.Quit(); err != nil { +// t.Fatal(err) +// } - err = c.ChangeDir("incoming") - if err != nil { - t.Error(err) - } - - data := bytes.NewBufferString(testData) - err = c.Stor("testFile", data) - if err != nil { - t.Error(err) - } - - err = c.RemoveDirRecur("testFile") - if err == nil { - t.Fatal("expected error got nill") - } - - dir, err := c.CurrentDir() - if err != nil { - t.Error(err) - } else { - if dir != "/incoming" { - t.Error("Wrong dir: " + dir) - } - } - - err = c.Delete("testFile") - if err != nil { - t.Error(err) - } - - err = c.Logout() - if err != nil { - if protoErr := err.(*textproto.Error); protoErr != nil { - if protoErr.Code != StatusNotImplemented { - t.Error(err) - } - } else { - t.Error(err) - } - } - - c.Quit() -} +// // Wait for the connection to close +// mock.Wait() +// } func TestMissingFolderDeleteDirRecur(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode.") - } + mock, c := openConn(t, "127.0.0.1") - c, err := DialTimeout("localhost:21", 5*time.Second) - if err != nil { - t.Fatal(err) - } - - err = c.Login("anonymous", "anonymous") - if err != nil { - t.Fatal(err) - } - - err = c.ChangeDir("incoming") - if err != nil { - t.Error(err) - } - - err = c.RemoveDirRecur("test") + err := c.RemoveDirRecur("missing-dir") if err == nil { - t.Fatal("expected error got nill") + t.Fatal("expected error got nil") } - dir, err := c.CurrentDir() - if err != nil { - t.Error(err) - } else { - if dir != "/incoming" { - t.Error("Wrong dir: " + dir) - } + if err := c.Quit(); err != nil { + t.Fatal(err) } - err = c.Logout() - if err != nil { - if protoErr := err.(*textproto.Error); protoErr != nil { - if protoErr.Code != StatusNotImplemented { - t.Error(err) - } - } else { - t.Error(err) - } - } - - c.Quit() + // Wait for the connection to close + mock.Wait() } diff --git a/conn_test.go b/conn_test.go new file mode 100644 index 0000000..4d4ab89 --- /dev/null +++ b/conn_test.go @@ -0,0 +1,335 @@ +package ftp + +import ( + "errors" + "io" + "io/ioutil" + "net" + "net/textproto" + "reflect" + "strconv" + "strings" + "sync" + "testing" +) + +type ftpMock struct { + address string + listener *net.TCPListener + proto *textproto.Conn + commands []string // list of received commands + rest int + dataConn *mockDataConn + sync.WaitGroup +} + +// newFtpMock returns a mock implementation of a FTP server +// For simplication, a mock instance only accepts a signle connection and terminates afer +func newFtpMock(t *testing.T, address string) (*ftpMock, error) { + var err error + mock := &ftpMock{address: address} + + l, err := net.Listen("tcp", address+":0") + if err != nil { + return nil, err + } + + tcpListener, ok := l.(*net.TCPListener) + if !ok { + return nil, errors.New("listener is not a net.TCPListener") + } + mock.listener = tcpListener + + go mock.listen(t) + + return mock, nil +} + +func (mock *ftpMock) listen(t *testing.T) { + // Listen for an incoming connection. + conn, err := mock.listener.Accept() + if err != nil { + t.Errorf("can not accept: %s", err) + return + } + + // Do not accept incoming connections anymore + mock.listener.Close() + + mock.Add(1) + defer mock.Done() + defer conn.Close() + + mock.proto = textproto.NewConn(conn) + mock.proto.Writer.PrintfLine("220 FTP Server ready.") + + for { + fullCommand, _ := mock.proto.ReadLine() + + cmdParts := strings.Split(fullCommand, " ") + + // Append to list of received commands + mock.commands = append(mock.commands, cmdParts[0]) + + // At least one command must have a multiline response + switch cmdParts[0] { + case "FEAT": + mock.proto.Writer.PrintfLine("211-Features:\r\n FEAT\r\n PASV\r\n EPSV\r\n SIZE\r\n211 End") + case "USER": + if cmdParts[1] == "anonymous" { + mock.proto.Writer.PrintfLine("331 Please send your password") + } else { + mock.proto.Writer.PrintfLine("530 This FTP server is anonymous only") + } + case "PASS": + mock.proto.Writer.PrintfLine("230-Hey,\r\nWelcome to my FTP\r\n230 Access granted") + case "TYPE": + mock.proto.Writer.PrintfLine("200 Type set ok") + case "CWD": + if cmdParts[1] == "missing-dir" { + mock.proto.Writer.PrintfLine("550 %s: No such file or directory", cmdParts[1]) + } else { + mock.proto.Writer.PrintfLine("250 Directory successfully changed.") + } + case "DELE": + mock.proto.Writer.PrintfLine("250 File successfully removed.") + case "MKD": + mock.proto.Writer.PrintfLine("257 Directory successfully created.") + case "RMD": + if cmdParts[1] == "missing-dir" { + mock.proto.Writer.PrintfLine("550 No such file or directory") + } else { + mock.proto.Writer.PrintfLine("250 Directory successfully removed.") + } + case "PWD": + mock.proto.Writer.PrintfLine("257 \"/incoming\"") + case "CDUP": + mock.proto.Writer.PrintfLine("250 CDUP command successful") + case "SIZE": + if cmdParts[1] == "magic-file" { + mock.proto.Writer.PrintfLine("213 42") + } else { + mock.proto.Writer.PrintfLine("550 Could not get file size.") + } + case "PASV": + p, err := mock.listenDataConn() + if err != nil { + mock.proto.Writer.PrintfLine("451 %s.", err) + break + } + + p1 := int(p / 256) + p2 := p % 256 + + mock.proto.Writer.PrintfLine("227 Entering Passive Mode (127,0,0,1,%d,%d).", p1, p2) + case "EPSV": + p, err := mock.listenDataConn() + if err != nil { + mock.proto.Writer.PrintfLine("451 %s.", err) + break + } + mock.proto.Writer.PrintfLine("229 Entering Extended Passive Mode (|||%d|)", p) + case "STOR": + if mock.dataConn == nil { + mock.proto.Writer.PrintfLine("425 Unable to build data connection: Connection refused") + break + } + mock.proto.Writer.PrintfLine("150 please send") + mock.recvDataConn() + case "LIST": + if mock.dataConn == nil { + mock.proto.Writer.PrintfLine("425 Unable to build data connection: Connection refused") + break + } + + mock.dataConn.Wait() + mock.proto.Writer.PrintfLine("150 Opening ASCII mode data connection for file list") + mock.dataConn.conn.Write([]byte("-rw-r--r-- 1 ftp wheel 0 Jan 29 10:29 lo")) + mock.proto.Writer.PrintfLine("226 Transfer complete") + mock.closeDataConn() + case "NLST": + if mock.dataConn == nil { + mock.proto.Writer.PrintfLine("425 Unable to build data connection: Connection refused") + break + } + + mock.dataConn.Wait() + mock.proto.Writer.PrintfLine("150 Opening ASCII mode data connection for file list") + mock.dataConn.conn.Write([]byte("/incoming")) + mock.proto.Writer.PrintfLine("226 Transfer complete") + mock.closeDataConn() + case "RETR": + if mock.dataConn == nil { + mock.proto.Writer.PrintfLine("425 Unable to build data connection: Connection refused") + break + } + + mock.dataConn.Wait() + mock.proto.Writer.PrintfLine("150 Opening ASCII mode data connection for file list") + mock.dataConn.conn.Write([]byte(testData[mock.rest:])) + mock.rest = 0 + mock.proto.Writer.PrintfLine("226 Transfer complete") + mock.closeDataConn() + case "RNFR": + mock.proto.Writer.PrintfLine("350 File or directory exists, ready for destination name") + case "RNTO": + mock.proto.Writer.PrintfLine("250 Rename successful") + case "REST": + if len(cmdParts) != 2 { + mock.proto.Writer.PrintfLine("500 wrong number of arguments") + break + } + rest, err := strconv.Atoi(cmdParts[1]) + if err != nil { + mock.proto.Writer.PrintfLine("500 REST: %s", err) + break + } + mock.rest = rest + mock.proto.Writer.PrintfLine("350 Restarting at %s. Send STORE or RETRIEVE to initiate transfer", cmdParts[1]) + case "NOOP": + mock.proto.Writer.PrintfLine("200 NOOP ok.") + case "REIN": + mock.proto.Writer.PrintfLine("220 Logged out") + case "QUIT": + mock.proto.Writer.PrintfLine("221 Goodbye.") + return + default: + mock.proto.Writer.PrintfLine("500 Unknown command %s.", cmdParts[0]) + } + } +} + +func (mock *ftpMock) closeDataConn() (err error) { + if mock.dataConn != nil { + err = mock.dataConn.Close() + mock.dataConn = nil + } + return +} + +type mockDataConn struct { + listener *net.TCPListener + conn net.Conn + // WaitGroup is done when conn is accepted and stored + sync.WaitGroup +} + +func (d *mockDataConn) Close() (err error) { + if d.listener != nil { + err = d.listener.Close() + } + if d.conn != nil { + err = d.conn.Close() + } + return +} + +func (mock *ftpMock) listenDataConn() (int64, error) { + mock.closeDataConn() + + l, err := net.Listen("tcp", mock.address+":0") + if err != nil { + return 0, err + } + + tcpListener, ok := l.(*net.TCPListener) + if !ok { + return 0, errors.New("listener is not a net.TCPListener") + } + + addr := tcpListener.Addr().String() + + _, port, err := net.SplitHostPort(addr) + if err != nil { + return 0, err + } + + p, err := strconv.ParseInt(port, 10, 32) + if err != nil { + return 0, err + } + + dataConn := &mockDataConn{listener: tcpListener} + dataConn.Add(1) + + go func() { + // Listen for an incoming connection. + conn, err := dataConn.listener.Accept() + if err != nil { + // t.Errorf("can not accept: %s", err) + return + } + + dataConn.conn = conn + dataConn.Done() + }() + + mock.dataConn = dataConn + return p, nil +} + +func (mock *ftpMock) recvDataConn() { + mock.dataConn.Wait() + io.Copy(ioutil.Discard, mock.dataConn.conn) + mock.proto.Writer.PrintfLine("226 Transfer Complete") + mock.closeDataConn() +} + +func (mock *ftpMock) Addr() string { + return mock.listener.Addr().String() +} + +// Closes the listening socket +func (mock *ftpMock) Close() { + mock.listener.Close() +} + +// Helper to return a client connected to a mock server +func openConn(t *testing.T, addr string) (*ftpMock, *ServerConn) { + mock, err := newFtpMock(t, addr) + if err != nil { + t.Fatal(err) + } + defer mock.Close() + + c, err := Dial(mock.Addr()) + if err != nil { + t.Fatal(err) + } + + err = c.Login("anonymous", "anonymous") + if err != nil { + t.Fatal(err) + } + + return mock, c + +} + +// Helper to close a client connected to a mock server +func closeConn(t *testing.T, mock *ftpMock, c *ServerConn, commands []string) { + expected := []string{"FEAT", "USER", "PASS", "TYPE"} + expected = append(expected, commands...) + expected = append(expected, "QUIT") + + if err := c.Quit(); err != nil { + t.Fatal(err) + } + + // Wait for the connection to close + mock.Wait() + + if !reflect.DeepEqual(mock.commands, expected) { + t.Fatal("unexpected sequence of commands:", mock.commands, "expected:", expected) + } +} + +func TestConn4(t *testing.T) { + mock, c := openConn(t, "127.0.0.1") + closeConn(t, mock, c, nil) +} + +func TestConn6(t *testing.T) { + mock, c := openConn(t, "[::1]") + closeConn(t, mock, c, nil) +}