ftp/ftp.go
Brian Foshee 025459f901 Make ServerConn safe for concurrent use
ServerConn's textproto.Conn is safe for concurrent access. This adds
calls to StartResponse() and EndResponse() to put that to use wherever
Cmd() is called.
https://golang.org/pkg/net/textproto/#Conn.Cmd

Additionally, this adds a sync.Mutex on ServerConn to provide
concurrency protection for data commands such as STOR and RETR that need
to run through synchronous steps.
2016-03-03 14:30:28 -05:00

708 lines
17 KiB
Go

// Package ftp implements a FTP client as described in RFC 959.
package ftp
import (
"bufio"
"errors"
"io"
"net"
"net/textproto"
"strconv"
"strings"
"sync"
"time"
)
// EntryType describes the different types of an Entry.
type EntryType int
// The differents types of an Entry
const (
EntryTypeFile EntryType = iota
EntryTypeFolder
EntryTypeLink
)
// ServerConn represents the connection to a remote FTP server.
type ServerConn struct {
conn *textproto.Conn
host string
timeout time.Duration
features map[string]string
// mu provides concurrent-safe use of a single instance of this type. It is
// locked and unlocked around data actions such as STOR and RETR.
mu sync.Mutex
// id is the current conn Pipeline id in use. This is set and used in every
// case that mu is locked and unlocked.
id uint
}
// Entry describes a file and is returned by List().
type Entry struct {
Name string
Type EntryType
Size uint64
Time time.Time
}
// response represent a data-connection
type response struct {
conn net.Conn
c *ServerConn
}
// Connect is an alias to Dial, for backward compatibility
func Connect(addr string) (*ServerConn, error) {
return Dial(addr)
}
// Dial is like DialTimeout with no timeout
func Dial(addr string) (*ServerConn, error) {
return DialTimeout(addr, 0)
}
// DialTimeout initializes the connection to the specified ftp server address.
//
// It is generally followed by a call to Login() as most FTP commands require
// an authenticated user.
func DialTimeout(addr string, timeout time.Duration) (*ServerConn, error) {
tconn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return nil, err
}
// Use the resolved IP address in case addr contains a domain name
// If we use the domain name, we might not resolve to the same IP.
remoteAddr := tconn.RemoteAddr().String()
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return nil, err
}
conn := textproto.NewConn(tconn)
c := &ServerConn{
conn: conn,
host: host,
timeout: timeout,
features: make(map[string]string),
}
_, _, err = c.conn.ReadResponse(StatusReady)
if err != nil {
c.Quit()
return nil, err
}
err = c.feat()
if err != nil {
c.Quit()
return nil, err
}
return c, nil
}
// Login authenticates the client with specified user and password.
//
// "anonymous"/"anonymous" is a common user/password scheme for FTP servers
// that allows anonymous read-only accounts.
func (c *ServerConn) Login(user, password string) error {
code, message, err := c.cmd(-1, "USER %s", user)
if err != nil {
return err
}
switch code {
case StatusLoggedIn:
case StatusUserOK:
_, _, err = c.cmd(StatusLoggedIn, "PASS %s", password)
if err != nil {
return err
}
default:
return errors.New(message)
}
// Switch to binary mode
_, _, err = c.cmd(StatusCommandOK, "TYPE I")
if err != nil {
return err
}
return nil
}
// feat issues a FEAT FTP command to list the additional commands supported by
// the remote FTP server.
// FEAT is described in RFC 2389
func (c *ServerConn) feat() error {
code, message, err := c.cmd(-1, "FEAT")
if err != nil {
return err
}
if code != StatusSystem {
// The server does not support the FEAT command. This is not an
// error: we consider that there is no additional feature.
return nil
}
lines := strings.Split(message, "\n")
for _, line := range lines {
if !strings.HasPrefix(line, " ") {
continue
}
line = strings.TrimSpace(line)
featureElements := strings.SplitN(line, " ", 2)
command := featureElements[0]
var commandDesc string
if len(featureElements) == 2 {
commandDesc = featureElements[1]
}
c.features[command] = commandDesc
}
return nil
}
// epsv issues an "EPSV" command to get a port number for a data connection.
func (c *ServerConn) epsv() (port int, err error) {
_, line, err := c.cmd(StatusExtendedPassiveMode, "EPSV")
if err != nil {
return
}
start := strings.Index(line, "|||")
end := strings.LastIndex(line, "|")
if start == -1 || end == -1 {
err = errors.New("Invalid EPSV response format")
return
}
port, err = strconv.Atoi(line[start+3 : end])
return
}
// pasv issues a "PASV" command to get a port number for a data connection.
func (c *ServerConn) pasv() (port int, err error) {
_, line, err := c.cmd(StatusPassiveMode, "PASV")
if err != nil {
return
}
// PASV response format : 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2).
start := strings.Index(line, "(")
end := strings.LastIndex(line, ")")
if start == -1 || end == -1 {
return 0, errors.New("Invalid PASV response format")
}
// We have to split the response string
pasvData := strings.Split(line[start+1:end], ",")
if len(pasvData) < 6 {
return 0, errors.New("Invalid PASV response format")
}
// Let's compute the port number
portPart1, err1 := strconv.Atoi(pasvData[4])
if err1 != nil {
err = err1
return
}
portPart2, err2 := strconv.Atoi(pasvData[5])
if err2 != nil {
err = err2
return
}
// Recompose port
port = portPart1*256 + portPart2
return
}
// openDataConn creates a new FTP data connection.
func (c *ServerConn) openDataConn() (net.Conn, error) {
var (
port int
err error
)
if port, err = c.epsv(); err != nil {
if port, err = c.pasv(); err != nil {
return nil, err
}
}
return net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(port)), c.timeout)
}
// cmd is a helper function to execute a command and check for the expected FTP
// return code
func (c *ServerConn) cmd(expected int, format string, args ...interface{}) (int, string, error) {
id, err := c.conn.Cmd(format, args...)
if err != nil {
return 0, "", err
}
// Utilize the Pipeline on c.conn to be safe for concurrency.
c.conn.StartResponse(id)
defer c.conn.EndResponse(id)
return c.conn.ReadResponse(expected)
}
// cmdDataConnFrom executes a command which require a FTP data connection.
// Issues a REST FTP command to specify the number of bytes to skip for the transfer.
//
// Caller MUST call c.conn.EndResponse(c.id) AND c.mu.Unlock() once the returned
// connection is closed.
func (c *ServerConn) cmdDataConnFrom(offset uint64, format string, args ...interface{}) (net.Conn, error) {
// Lock ServerConn so that the data action can run through its synchronous
// sequence of events. Must be Unlock()'d before any other data action can
// proceed.
c.mu.Lock()
conn, err := c.openDataConn()
if err != nil {
return nil, err
}
if offset != 0 {
_, _, err := c.cmd(StatusRequestFilePending, "REST %d", offset)
if err != nil {
return nil, err
}
}
id, err := c.conn.Cmd(format, args...)
if err != nil {
conn.Close()
return nil, err
}
// Set ServerConn's Pipeline id and start the response. Must call
// c.conn.EndResponse(c.id) once this data action is complete.
c.id = id
c.conn.StartResponse(c.id)
code, msg, err := c.conn.ReadResponse(-1)
if err != nil {
conn.Close()
c.conn.EndResponse(c.id)
c.mu.Unlock()
return nil, err
}
if code != StatusAlreadyOpen && code != StatusAboutToSend {
conn.Close()
c.conn.EndResponse(c.id)
c.mu.Unlock()
return nil, &textproto.Error{Code: code, Msg: msg}
}
return conn, nil
}
var errUnsupportedListLine = errors.New("Unsupported LIST line")
// parseRFC3659ListLine parses the style of directory line defined in RFC 3659.
func parseRFC3659ListLine(line string) (*Entry, error) {
iSemicolon := strings.Index(line, ";")
iWhitespace := strings.Index(line, " ")
if iSemicolon < 0 || iSemicolon > iWhitespace {
return nil, errUnsupportedListLine
}
e := &Entry{
Name: line[iWhitespace+1:],
}
for _, field := range strings.Split(line[:iWhitespace-1], ";") {
i := strings.Index(field, "=")
if i < 1 {
return nil, errUnsupportedListLine
}
key := field[:i]
value := field[i+1:]
switch key {
case "modify":
var err error
e.Time, err = time.Parse("20060102150405", value)
if err != nil {
return nil, err
}
case "type":
switch value {
case "dir", "cdir", "pdir":
e.Type = EntryTypeFolder
case "file":
e.Type = EntryTypeFile
}
case "size":
e.setSize(value)
}
}
return e, nil
}
// parseLsListLine parses a directory line in a format based on the output of
// the UNIX ls command.
func parseLsListLine(line string) (*Entry, error) {
fields := strings.Fields(line)
if len(fields) >= 7 && fields[1] == "folder" && fields[2] == "0" {
e := &Entry{
Type: EntryTypeFolder,
Name: strings.Join(fields[6:], " "),
}
if err := e.setTime(fields[3:6]); err != nil {
return nil, err
}
return e, nil
}
if len(fields) < 8 {
return nil, errUnsupportedListLine
}
if fields[1] == "0" {
e := &Entry{
Type: EntryTypeFile,
Name: strings.Join(fields[7:], " "),
}
if err := e.setSize(fields[2]); err != nil {
return nil, err
}
if err := e.setTime(fields[4:7]); err != nil {
return nil, err
}
return e, nil
}
if len(fields) < 9 {
return nil, errUnsupportedListLine
}
e := &Entry{}
switch fields[0][0] {
case '-':
e.Type = EntryTypeFile
if err := e.setSize(fields[4]); err != nil {
return nil, err
}
case 'd':
e.Type = EntryTypeFolder
case 'l':
e.Type = EntryTypeLink
default:
return nil, errors.New("Unknown entry type")
}
if err := e.setTime(fields[5:8]); err != nil {
return nil, err
}
e.Name = strings.Join(fields[8:], " ")
return e, nil
}
var dirTimeFormats = []string{
"01-02-06 03:04PM",
"2006-01-02 15:04",
}
// parseDirListLine parses a directory line in a format based on the output of
// the MS-DOS DIR command.
func parseDirListLine(line string) (*Entry, error) {
e := &Entry{}
var err error
// Try various time formats that DIR might use, and stop when one works.
for _, format := range dirTimeFormats {
if len(line) > len(format) {
e.Time, err = time.Parse(format, line[:len(format)])
if err == nil {
line = line[len(format):]
break
}
}
}
if err != nil {
// None of the time formats worked.
return nil, errUnsupportedListLine
}
line = strings.TrimLeft(line, " ")
if strings.HasPrefix(line, "<DIR>") {
e.Type = EntryTypeFolder
line = strings.TrimPrefix(line, "<DIR>")
} else {
space := strings.Index(line, " ")
if space == -1 {
return nil, errUnsupportedListLine
}
e.Size, err = strconv.ParseUint(line[:space], 10, 64)
if err != nil {
return nil, errUnsupportedListLine
}
e.Type = EntryTypeFile
line = line[space:]
}
e.Name = strings.TrimLeft(line, " ")
return e, nil
}
var listLineParsers = []func(line string) (*Entry, error){
parseRFC3659ListLine,
parseLsListLine,
parseDirListLine,
}
// parseListLine parses the various non-standard format returned by the LIST
// FTP command.
func parseListLine(line string) (*Entry, error) {
for _, f := range listLineParsers {
e, err := f(line)
if err == errUnsupportedListLine {
// Try another format.
continue
}
return e, err
}
return nil, errUnsupportedListLine
}
func (e *Entry) setSize(str string) (err error) {
e.Size, err = strconv.ParseUint(str, 0, 64)
return
}
func (e *Entry) setTime(fields []string) (err error) {
var timeStr string
if strings.Contains(fields[2], ":") { // this year
thisYear, _, _ := time.Now().Date()
timeStr = fields[1] + " " + fields[0] + " " + strconv.Itoa(thisYear)[2:4] + " " + fields[2] + " GMT"
} else { // not this year
if len(fields[2]) != 4 {
return errors.New("Invalid year format in time string")
}
timeStr = fields[1] + " " + fields[0] + " " + fields[2][2:4] + " 00:00 GMT"
}
e.Time, err = time.Parse("_2 Jan 06 15:04 MST", timeStr)
return
}
// NameList issues an NLST FTP command.
func (c *ServerConn) NameList(path string) (entries []string, err error) {
conn, err := c.cmdDataConnFrom(0, "NLST %s", path)
if err != nil {
return
}
r := &response{conn, c}
defer r.Close()
scanner := bufio.NewScanner(r)
for scanner.Scan() {
entries = append(entries, scanner.Text())
}
if err = scanner.Err(); err != nil {
return entries, err
}
return
}
// List issues a LIST FTP command.
func (c *ServerConn) List(path string) (entries []*Entry, err error) {
conn, err := c.cmdDataConnFrom(0, "LIST %s", path)
if err != nil {
return
}
r := &response{conn, c}
defer r.Close()
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
entry, err := parseListLine(line)
if err == nil {
entries = append(entries, entry)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return
}
// ChangeDir issues a CWD FTP command, which changes the current directory to
// the specified path.
func (c *ServerConn) ChangeDir(path string) error {
_, _, err := c.cmd(StatusRequestedFileActionOK, "CWD %s", path)
return err
}
// ChangeDirToParent issues a CDUP FTP command, which changes the current
// directory to the parent directory. This is similar to a call to ChangeDir
// with a path set to "..".
func (c *ServerConn) ChangeDirToParent() error {
_, _, err := c.cmd(StatusRequestedFileActionOK, "CDUP")
return err
}
// CurrentDir issues a PWD FTP command, which Returns the path of the current
// directory.
func (c *ServerConn) CurrentDir() (string, error) {
_, msg, err := c.cmd(StatusPathCreated, "PWD")
if err != nil {
return "", err
}
start := strings.Index(msg, "\"")
end := strings.LastIndex(msg, "\"")
if start == -1 || end == -1 {
return "", errors.New("Unsuported PWD response format")
}
return msg[start+1 : end], nil
}
// Retr issues a RETR FTP command to fetch the specified file from the remote
// FTP server.
//
// The returned ReadCloser must be closed to cleanup the FTP data connection.
func (c *ServerConn) Retr(path string) (io.ReadCloser, error) {
return c.RetrFrom(path, 0)
}
// RetrFrom issues a RETR FTP command to fetch the specified file from the remote
// FTP server, the server will not send the offset first bytes of the file.
//
// The returned ReadCloser must be closed to cleanup the FTP data connection.
func (c *ServerConn) RetrFrom(path string, offset uint64) (io.ReadCloser, error) {
conn, err := c.cmdDataConnFrom(offset, "RETR %s", path)
if err != nil {
return nil, err
}
return &response{conn, c}, nil
}
// Stor issues a STOR FTP command to store a file to the remote FTP server.
// Stor creates the specified file with the content of the io.Reader.
//
// Hint: io.Pipe() can be used if an io.Writer is required.
func (c *ServerConn) Stor(path string, r io.Reader) error {
return c.StorFrom(path, r, 0)
}
// StorFrom issues a STOR FTP command to store a file to the remote FTP server.
// Stor creates the specified file with the content of the io.Reader, writing
// on the server will start at the given file offset.
//
// Hint: io.Pipe() can be used if an io.Writer is required.
func (c *ServerConn) StorFrom(path string, r io.Reader, offset uint64) error {
conn, err := c.cmdDataConnFrom(offset, "STOR %s", path)
if err != nil {
return err
}
defer c.mu.Unlock()
defer c.conn.EndResponse(c.id)
_, err = io.Copy(conn, r)
conn.Close()
if err != nil {
return err
}
_, _, err = c.conn.ReadResponse(StatusClosingDataConnection)
return err
}
// Rename renames a file on the remote FTP server.
func (c *ServerConn) Rename(from, to string) error {
_, _, err := c.cmd(StatusRequestFilePending, "RNFR %s", from)
if err != nil {
return err
}
_, _, err = c.cmd(StatusRequestedFileActionOK, "RNTO %s", to)
return err
}
// Delete issues a DELE FTP command to delete the specified file from the
// remote FTP server.
func (c *ServerConn) Delete(path string) error {
_, _, err := c.cmd(StatusRequestedFileActionOK, "DELE %s", path)
return err
}
// MakeDir issues a MKD FTP command to create the specified directory on the
// remote FTP server.
func (c *ServerConn) MakeDir(path string) error {
_, _, err := c.cmd(StatusPathCreated, "MKD %s", path)
return err
}
// RemoveDir issues a RMD FTP command to remove the specified directory from
// the remote FTP server.
func (c *ServerConn) RemoveDir(path string) error {
_, _, err := c.cmd(StatusRequestedFileActionOK, "RMD %s", path)
return err
}
// NoOp issues a NOOP FTP command.
// NOOP has no effects and is usually used to prevent the remote FTP server to
// close the otherwise idle connection.
func (c *ServerConn) NoOp() error {
_, _, err := c.cmd(StatusCommandOK, "NOOP")
return err
}
// Logout issues a REIN FTP command to logout the current user.
func (c *ServerConn) Logout() error {
_, _, err := c.cmd(StatusReady, "REIN")
return err
}
// Quit issues a QUIT FTP command to properly close the connection from the
// remote FTP server.
func (c *ServerConn) Quit() error {
c.conn.Cmd("QUIT")
return c.conn.Close()
}
// Read implements the io.Reader interface on a FTP data connection.
func (r *response) Read(buf []byte) (int, error) {
return r.conn.Read(buf)
}
// Close implements the io.Closer interface on a FTP data connection.
func (r *response) Close() error {
defer r.c.mu.Unlock()
defer r.c.conn.EndResponse(r.c.id)
if err := r.conn.Close(); err != nil {
return err
}
_, _, err := r.c.conn.ReadResponse(StatusClosingDataConnection)
if err != nil {
return err
}
return nil
}