36e873b513
Some servers seem to send a blank line at the end of an MLST response. MLST Download 250- Size=53248;Modify=20230327134339.000;Type=dir; Download 250 Requested file action okay, completed. Before this change this would cause the GetEntry method to return this error. unsupported LIST line This patch ignores blank lines in the MLST response.
445 lines
11 KiB
Go
445 lines
11 KiB
Go
package ftp
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/textproto"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type ftpMock struct {
|
|
t *testing.T
|
|
address string
|
|
modtime string // no-time, std-time, vsftpd
|
|
listener *net.TCPListener
|
|
proto *textproto.Conn
|
|
commands []string // list of received commands
|
|
lastFull string // full last command
|
|
rest int
|
|
fileCont *bytes.Buffer
|
|
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) {
|
|
return newFtpMockExt(t, address, "no-time")
|
|
}
|
|
|
|
func newFtpMockExt(t *testing.T, address, modtime string) (*ftpMock, error) {
|
|
var err error
|
|
mock := &ftpMock{
|
|
t: t,
|
|
address: address,
|
|
modtime: modtime,
|
|
}
|
|
|
|
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()
|
|
|
|
return mock, nil
|
|
}
|
|
|
|
func (mock *ftpMock) listen() {
|
|
// Listen for an incoming connection.
|
|
conn, err := mock.listener.Accept()
|
|
if err != nil {
|
|
mock.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.printfLine("220 FTP Server ready.")
|
|
|
|
for {
|
|
fullCommand, _ := mock.proto.ReadLine()
|
|
mock.lastFull = fullCommand
|
|
|
|
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":
|
|
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"
|
|
case "vsftpd":
|
|
features += " MDTM\r\n"
|
|
}
|
|
features += "211 End"
|
|
mock.printfLine(features)
|
|
case "USER":
|
|
if cmdParts[1] == "anonymous" {
|
|
mock.printfLine("331 Please send your password")
|
|
} else {
|
|
mock.printfLine("530 This FTP server is anonymous only")
|
|
}
|
|
case "PASS":
|
|
mock.printfLine("230-Hey,\r\nWelcome to my FTP\r\n230 Access granted")
|
|
case "TYPE":
|
|
mock.printfLine("200 Type set ok")
|
|
case "CWD":
|
|
if cmdParts[1] == "missing-dir" {
|
|
mock.printfLine("550 %s: No such file or directory", cmdParts[1])
|
|
} else {
|
|
mock.printfLine("250 Directory successfully changed.")
|
|
}
|
|
case "DELE":
|
|
mock.printfLine("250 File successfully removed.")
|
|
case "MKD":
|
|
mock.printfLine("257 Directory successfully created.")
|
|
case "RMD":
|
|
if cmdParts[1] == "missing-dir" {
|
|
mock.printfLine("550 No such file or directory")
|
|
} else {
|
|
mock.printfLine("250 Directory successfully removed.")
|
|
}
|
|
case "PWD":
|
|
mock.printfLine("257 \"/incoming\"")
|
|
case "CDUP":
|
|
mock.printfLine("250 CDUP command successful")
|
|
case "SIZE":
|
|
if cmdParts[1] == "magic-file" {
|
|
mock.printfLine("213 42")
|
|
} else {
|
|
mock.printfLine("550 Could not get file size.")
|
|
}
|
|
case "PASV":
|
|
p, err := mock.listenDataConn()
|
|
if err != nil {
|
|
mock.printfLine("451 %s.", err)
|
|
break
|
|
}
|
|
|
|
p1 := int(p / 256)
|
|
p2 := p % 256
|
|
|
|
mock.printfLine("227 Entering Passive Mode (127,0,0,1,%d,%d).", p1, p2)
|
|
case "EPSV":
|
|
p, err := mock.listenDataConn()
|
|
if err != nil {
|
|
mock.printfLine("451 %s.", err)
|
|
break
|
|
}
|
|
mock.printfLine("229 Entering Extended Passive Mode (|||%d|)", p)
|
|
case "STOR":
|
|
if mock.dataConn == nil {
|
|
mock.printfLine("425 Unable to build data connection: Connection refused")
|
|
break
|
|
}
|
|
mock.printfLine("150 please send")
|
|
mock.recvDataConn(false)
|
|
case "APPE":
|
|
if mock.dataConn == nil {
|
|
mock.printfLine("425 Unable to build data connection: Connection refused")
|
|
break
|
|
}
|
|
mock.printfLine("150 please send")
|
|
mock.recvDataConn(true)
|
|
case "LIST":
|
|
if mock.dataConn == nil {
|
|
mock.printfLine("425 Unable to build data connection: Connection refused")
|
|
break
|
|
}
|
|
|
|
mock.dataConn.Wait()
|
|
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\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\n \r\n250 End")
|
|
}
|
|
case "NLST":
|
|
if mock.dataConn == nil {
|
|
mock.printfLine("425 Unable to build data connection: Connection refused")
|
|
break
|
|
}
|
|
|
|
mock.dataConn.Wait()
|
|
mock.printfLine("150 Opening ASCII mode data connection for file list")
|
|
mock.dataConn.write([]byte("/incoming"))
|
|
mock.printfLine("226 Transfer complete")
|
|
mock.closeDataConn()
|
|
case "RETR":
|
|
if mock.dataConn == nil {
|
|
mock.printfLine("425 Unable to build data connection: Connection refused")
|
|
break
|
|
}
|
|
|
|
mock.dataConn.Wait()
|
|
mock.printfLine("150 Opening ASCII mode data connection for file list")
|
|
mock.dataConn.write(mock.fileCont.Bytes()[mock.rest:])
|
|
mock.rest = 0
|
|
mock.printfLine("226 Transfer complete")
|
|
mock.closeDataConn()
|
|
case "RNFR":
|
|
mock.printfLine("350 File or directory exists, ready for destination name")
|
|
case "RNTO":
|
|
mock.printfLine("250 Rename successful")
|
|
case "REST":
|
|
if len(cmdParts) != 2 {
|
|
mock.printfLine("500 wrong number of arguments")
|
|
break
|
|
}
|
|
rest, err := strconv.Atoi(cmdParts[1])
|
|
if err != nil {
|
|
mock.printfLine("500 REST: %s", err)
|
|
break
|
|
}
|
|
mock.rest = rest
|
|
mock.printfLine("350 Restarting at %s. Send STORE or RETRIEVE to initiate transfer", cmdParts[1])
|
|
case "MDTM":
|
|
var answer string
|
|
switch {
|
|
case mock.modtime == "no-time":
|
|
answer = "500 Unknown command MDTM"
|
|
case len(cmdParts) == 3 && mock.modtime == "vsftpd":
|
|
answer = "213 UTIME OK"
|
|
_, err := time.ParseInLocation(timeFormat, cmdParts[1], time.UTC)
|
|
if err != nil {
|
|
answer = "501 Can't get a time stamp"
|
|
}
|
|
case len(cmdParts) == 2:
|
|
answer = "213 20201213202400"
|
|
default:
|
|
answer = "500 wrong number of arguments"
|
|
}
|
|
mock.printfLine(answer)
|
|
case "MFMT":
|
|
var answer string
|
|
switch {
|
|
case mock.modtime == "std-time" && len(cmdParts) == 3:
|
|
answer = "213 UTIME OK"
|
|
_, err := time.ParseInLocation(timeFormat, cmdParts[1], time.UTC)
|
|
if err != nil {
|
|
answer = "501 Can't get a time stamp"
|
|
}
|
|
default:
|
|
answer = "500 Unknown command MFMT"
|
|
}
|
|
mock.printfLine(answer)
|
|
case "NOOP":
|
|
mock.printfLine("200 NOOP ok.")
|
|
case "OPTS":
|
|
if len(cmdParts) != 3 {
|
|
mock.printfLine("500 wrong number of arguments")
|
|
break
|
|
}
|
|
if (strings.Join(cmdParts[1:], " ")) == "UTF8 ON" {
|
|
mock.printfLine("200 OK, UTF-8 enabled")
|
|
}
|
|
case "REIN":
|
|
mock.printfLine("220 Logged out")
|
|
case "QUIT":
|
|
mock.printfLine("221 Goodbye.")
|
|
return
|
|
default:
|
|
mock.printfLine("500 Unknown command %s.", cmdParts[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
func (mock *ftpMock) printfLine(format string, args ...interface{}) {
|
|
if err := mock.proto.Writer.PrintfLine(format, args...); err != nil {
|
|
mock.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (mock *ftpMock) closeDataConn() {
|
|
if mock.dataConn != nil {
|
|
if err := mock.dataConn.Close(); err != nil {
|
|
mock.t.Fatal(err)
|
|
}
|
|
mock.dataConn = nil
|
|
}
|
|
}
|
|
|
|
type mockDataConn struct {
|
|
t *testing.T
|
|
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 (d *mockDataConn) write(b []byte) {
|
|
if d.conn == nil {
|
|
d.t.Fatal("data conn is not opened")
|
|
}
|
|
|
|
if _, err := d.conn.Write(b); err != nil {
|
|
d.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
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{
|
|
t: mock.t,
|
|
listener: tcpListener,
|
|
}
|
|
dataConn.Add(1)
|
|
|
|
go func() {
|
|
// Listen for an incoming connection.
|
|
conn, err := dataConn.listener.Accept()
|
|
if err != nil {
|
|
// mock.t.Fatalf("can not accept data conn: %s", err)
|
|
return
|
|
}
|
|
|
|
dataConn.conn = conn
|
|
dataConn.Done()
|
|
}()
|
|
|
|
mock.dataConn = dataConn
|
|
return p, nil
|
|
}
|
|
|
|
func (mock *ftpMock) recvDataConn(append bool) {
|
|
mock.dataConn.Wait()
|
|
if !append {
|
|
mock.fileCont = new(bytes.Buffer)
|
|
}
|
|
|
|
if _, err := io.Copy(mock.fileCont, mock.dataConn.conn); err != nil {
|
|
mock.t.Fatal(err)
|
|
}
|
|
|
|
mock.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, options ...DialOption) (*ftpMock, *ServerConn) {
|
|
return openConnExt(t, addr, "no-time", options...)
|
|
}
|
|
|
|
func openConnExt(t *testing.T, addr, modtime string, options ...DialOption) (*ftpMock, *ServerConn) {
|
|
mock, err := newFtpMockExt(t, addr, modtime)
|
|
require.NoError(t, err)
|
|
defer mock.Close()
|
|
|
|
c, err := Dial(mock.Addr(), options...)
|
|
require.NoError(t, err)
|
|
|
|
err = c.Login("anonymous", "anonymous")
|
|
require.NoError(t, 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{"USER", "PASS", "FEAT", "TYPE", "OPTS"}
|
|
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()
|
|
|
|
assert.Equal(t, expected, mock.commands, "unexpected sequence of commands")
|
|
}
|
|
|
|
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)
|
|
}
|