Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ Pull requests or feature requests are welcome, but in the case of the former, yo
### Tests ###

How to run tests (windows not supported):
* do not use the "root" account, use an unprivileged user with write access to the root goftp directory
* ```./build_test_server.sh``` from root goftp directory (this downloads and compiles pure-ftpd and proftpd)
* ```go test``` from the root goftp directory
2 changes: 1 addition & 1 deletion build_test_server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ VpOorURz8ETlfAA=
-----END CERTIFICATE-----
CERT

curl -O http://download.pureftpd.org/pub/pure-ftpd/releases/obsolete/pure-ftpd-1.0.36.tar.gz
curl -k -O https://download.pureftpd.org/pub/pure-ftpd/releases/obsolete/pure-ftpd-1.0.36.tar.gz
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still not sure why why need -k. What are you developing on that doesn't have a CA bundle?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do have the CA bundle... but from experience I've also ended up working with servers that do not have it or it is outdated (not my choice). If you think it is unsafe I can remove it as it is not really need, I just added it for compatibility.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should remove it.

tar -xzf pure-ftpd-1.0.36.tar.gz
cd pure-ftpd-1.0.36

Expand Down
18 changes: 18 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,24 @@ func (c *Client) openConn(idx int, host string) (pconn *persistentConn, err erro
goto Error
}

if pconn.hasFeature("CLNT") {
if err = pconn.setClient(); err != nil {
goto Error
}
}

if pconn.hasFeature("UTF8") {
if err = pconn.setUnicode(); err != nil {
goto Error
}
}

if pconn.hasFeature("MLST") {
if err = pconn.setMLST(); err != nil {
goto Error
}
}

c.mu.Lock()
defer c.mu.Unlock()

Expand Down
142 changes: 115 additions & 27 deletions file_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,27 @@ func (c *Client) Getwd() (string, error) {
return dir, nil
}

// Setwd changes the current working directory.
func (c *Client) Setwd(path string) (error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't include this because it isn't compatible with the connection pool (would end up with connections in different directories). I considered making it a client-wide setting that gets applied whenever you get a connection (i.e. switches to the desired directory), but that could get complicated, too.

Copy link
Author

@rvicentiu rvicentiu Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know(and there is a comment on the commit with this :) ) but it seems it is used as a workaround by ftp clients when LIST or MLSD fails with path argument. I ran into the issue while testing and using CWD first before LIST (without path) worked.

I was considering the following:

  • renaming all functions that call getIdleConn() to *Ex() and replace them with a wrapper.
  • the Ex() versions would get the idx of the pconn as a param
  • the Ex() versions would call getIdleConn() only if idx was -1 otherwise use the idx pconn / fail.
  • SetWd() returns the pconn idx

some locking might be needed if the user starts several go routines using the same handle

as for me I really need this but I could move it to a separate branch if you do not

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if each connection remembers what dir it is in, and when a new connection is gotten it automatically does the cwd if it isn't in the correct dir (user's desired dir is stored on client object when they set via setwd())?

pconn, err := c.getIdleConn()
if err != nil {
return err
}

defer c.returnConn(pconn)

code, msg, err := pconn.sendCommand("CWD %s", path)
if err != nil {
return err
}

if code != replyFileActionOkay {
return ftpError{code: code, msg: msg}
}

return nil
}

func commandNotSupporterdError(err error) bool {
respCode := err.(ftpError).Code()
return respCode == replyCommandSyntaxError || respCode == replyCommandNotImplemented
Expand All @@ -125,16 +146,27 @@ func commandNotSupporterdError(err error) bool {
// be used. You may have to set ServerLocation in your config to get (more)
// accurate ModTimes in this case.
func (c *Client) ReadDir(path string) ([]os.FileInfo, error) {
entries, err := c.dataStringList("MLSD %s", path)
pconn, err := c.getIdleConn()
if err != nil {
return nil, err
}

defer c.returnConn(pconn)

var entries []string

if !pconn.mlsdNotSupported {
entries, err = c.dataStringList(pconn, "MLSD %s", path)
}
parser := parseMLST

if err != nil {
if !commandNotSupporterdError(err) {
if pconn.mlsdNotSupported || err != nil {
if !pconn.mlsdNotSupported && !commandNotSupporterdError(err) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this commandNotSupported check be moved up directly after the dataStringList() call? It's a little weird having the nested checks if mlsdNotSupported.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right... missed that, I'll commit a fix

return nil, err
}
pconn.mlsdNotSupported = true

entries, err = c.dataStringList("LIST %s", path)
entries, err = c.dataStringList(pconn, "LIST %s", path)
if err != nil {
return nil, err
}
Expand All @@ -148,7 +180,9 @@ func (c *Client) ReadDir(path string) ([]os.FileInfo, error) {
info, err := parser(entry, true)
if err != nil {
c.debug("error in ReadDir: %s", err)
return nil, err
if !strings.Contains(err.Error(), "incomplete") {
Copy link
Contributor

@muirmanders muirmanders Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have examples of incomplete MLSD/LIST output? Is it safe to give the user back incomplete results (i.e. maybe we just failed parsing)?

Copy link
Author

@rvicentiu rvicentiu Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"............" is text utf8, this is a wireshark dump

220 Serv-U FTP Server v10.4 ready...
....
Type=dir;Modify=20160910211543.432;Perm=el; music
Type=dir;Modify=20160917224516.936;Perm=el; ............
....
Type=dir;Modify=20151128035821.241;Perm=el; video
Type=dir;Perm=el; ..........
Type=dir;Modify=20160911053801.535;Perm=el; www
...

"Type=dir;Perm=el; .........." is a directory that is no longer accesible and the server does not send any other information about it but we can still use the received list

return nil, err
}
}

if info == nil {
Expand All @@ -167,10 +201,17 @@ func (c *Client) ReadDir(path string) ([]os.FileInfo, error) {
// is a directory. You may have to set ServerLocation in your config to get
// (more) accurate ModTimes when using "LIST".
func (c *Client) Stat(path string) (os.FileInfo, error) {
lines, err := c.controlStringList("MLST %s", path)
pconn, err := c.getIdleConn()
if err != nil {
return nil, err
}

defer c.returnConn(pconn)

lines, err := c.controlStringList(pconn, "MLST %s", path)
if err != nil {
if commandNotSupporterdError(err) {
lines, err = c.dataStringList("LIST %s", path)
lines, err = c.dataStringList(pconn, "LIST %s", path)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -202,17 +243,10 @@ func extractDirName(msg string) (string, error) {
return strings.Replace(msg[openQuote+1:closeQuote], `""`, `"`, -1), nil
}

func (c *Client) controlStringList(f string, args ...interface{}) ([]string, error) {
pconn, err := c.getIdleConn()
if err != nil {
return nil, err
}

defer c.returnConn(pconn)

func (c *Client) controlStringList(pconn *persistentConn, f string, args ...interface{}) ([]string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you change these methods to take the pconn? Was it to avoid calling getIdleConn() multiple times?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly because I needed to access pconn.mlsdNotSupported in ReadDir for the connection.
Once I did getIdleConn() in ReadDir() I should also pass it to the underlying functions.

cmd := fmt.Sprintf(f, args...)

code, msg, err := pconn.sendCommand(cmd)
code, msg, _ := pconn.sendCommand(cmd)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error should probably be checked (oversight in existing code).


if !positiveCompletionReply(code) {
pconn.debug("unexpected response to %s: %d-%s", cmd, code, msg)
Expand All @@ -222,14 +256,7 @@ func (c *Client) controlStringList(f string, args ...interface{}) ([]string, err
return strings.Split(msg, "\n"), nil
}

func (c *Client) dataStringList(f string, args ...interface{}) ([]string, error) {
pconn, err := c.getIdleConn()
if err != nil {
return nil, err
}

defer c.returnConn(pconn)

func (c *Client) dataStringList(pconn *persistentConn, f string, args ...interface{}) ([]string, error) {
dcGetter, err := pconn.prepareDataConn()
if err != nil {
return nil, err
Expand Down Expand Up @@ -321,6 +348,66 @@ func (f *ftpFile) Sys() interface{} {
return f.raw
}

var dirRegex = regexp.MustCompile(`^\s*(\S+\s+\S+\s{0,1}\S{2})\s+(\S+)\s+(.*)`)
var dirTimeFormats = []string{
"01-02-06 03:04",
Copy link
Contributor

@muirmanders muirmanders Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be 15:04 for parsing of 24hr clock value without am/pm?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes...

"01-02-06 03:04PM",
"2006-01-02 15:04",
}

// 08/03/2016 17:13 <DIR> directory-name
// 08/07/2016 17:20 1,135 file-name
// or
// 02-10-16 12:10PM 1067784 file.exe
func parseDirLIST(entry string, loc *time.Location, skipSelfParent bool) (os.FileInfo, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What server returns this format?

Copy link
Author

@rvicentiu rvicentiu Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

220 Microsoft FTP Service
...
STAT
211-Microsoft FTP Service status:
Logged in user: anonymous
TYPE: ASCII; FORM: NONPRINT; STRUcture: FILE; transfer MODE: STREAM
Data connection: none
211 End of status.
SYST
215 Windows_NT`

LIST output looks like this:
02-10-16 12:10PM 8067784 OneDriveSetup.exe
03-03-16 05:52PM

OS
06-20-16 03:43PM 164559 print.pdf

the main issue is the date format so I tried to support the most common formats I have found

matches := dirRegex.FindStringSubmatch(entry)

if len(matches) == 0 {
return nil, ftpError{err: fmt.Errorf(`failed parsing LIST entry: %s`, entry)}
}

if skipSelfParent && (matches[3] == "." || matches[3] == "..") {
return nil, nil
}

var err error

var size uint64
var mode os.FileMode = 0400
if strings.Contains(matches[2], "DIR") {
mode |= os.ModeDir
} else {
size, err = strconv.ParseUint(matches[2], 10, 64)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ParseUint going to work on sizes with a comma like in your example above?

Copy link
Author

@rvicentiu rvicentiu Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it will fail with err (size will be 0) but I have not found a single server that returns commas yet. Only running "dir" in local "command prompt" does that.

if err != nil {
return nil, ftpError{err: fmt.Errorf(`failed parsing LIST entry's size: %s (%s)`, err, entry)}
}
}

var mtime time.Time
for _, format := range dirTimeFormats {
if len(entry) >= len(format) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check needed? Are you certain time strings can never be shorter than the formats (eg. "2006-01-02 2:04" is shorter than "2006-01-02 15:04")?

Copy link
Author

@rvicentiu rvicentiu Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I needed a way to speed up processing large lists (thousands of files) and avoid false matches.
I have not yet found a server that doesn't pad with zero's but it might be possible.

mtime, err = time.ParseInLocation(format, matches[1], loc)
if err == nil {
break
}
}
}

if err != nil {
return nil, ftpError{err: fmt.Errorf(`failed parsing LIST entry's mtime: %s (%s)`, err, entry)}
}

info := &ftpFile{
name: filepath.Base(matches[3]),
mode: mode,
mtime: mtime,
raw: entry,
size: int64(size),
}

return info, nil
}

var lsRegex = regexp.MustCompile(`^\s*(\S)(\S{3})(\S{3})(\S{3})(?:\s+\S+){3}\s+(\d+)\s+(\w+\s+\d+)\s+([\d:]+)\s+(.+)$`)

// total 404456
Expand All @@ -332,7 +419,8 @@ func parseLIST(entry string, loc *time.Location, skipSelfParent bool) (os.FileIn

matches := lsRegex.FindStringSubmatch(entry)
if len(matches) == 0 {
return nil, ftpError{err: fmt.Errorf(`failed parsing LIST entry: %s`, entry)}
info, err := parseDirLIST(entry, loc, skipSelfParent)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add tests for this additional parsing? Maybe some unit tests similar to TestParseMLST() ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I will look into it.

return info, err
}

if skipSelfParent && (matches[8] == "." || matches[8] == "..") {
Expand Down Expand Up @@ -400,7 +488,7 @@ func parseMLST(entry string, skipSelfParent bool) (os.FileInfo, error) {
parseError := ftpError{err: fmt.Errorf(`failed parsing MLST entry: %s`, entry)}
incompleteError := ftpError{err: fmt.Errorf(`MLST entry incomplete: %s`, entry)}

parts := strings.Split(entry, "; ")
parts := strings.SplitN(entry, "; ", 2)
Copy link
Contributor

@muirmanders muirmanders Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test case?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

if len(parts) != 2 {
return nil, parseError
}
Expand All @@ -420,7 +508,7 @@ func parseMLST(entry string, skipSelfParent bool) (os.FileInfo, error) {
return nil, incompleteError
}

if skipSelfParent && (typ == "cdir" || typ == "pdir" || typ == "." || typ == "..") {
if skipSelfParent && (typ == "cdir" || typ == "pdir" || typ == "." || typ == ".."|| parts[1] == "." || parts[1] == "..") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test case?

Copy link
Author

@rvicentiu rvicentiu Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No sane server should return "." or ".." as type "dir"...
I noticed this as my walk function entered a infinite loop, tcpdump helped identify the problem.

How would you test for this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unit test for parseList is fine.

return nil, nil
}

Expand Down
58 changes: 57 additions & 1 deletion persistent_connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ type persistentConn struct {
// remember EPSV support
epsvNotSupported bool

// remember MLSD support
mlsdNotSupported bool

// tracks the current type (e.g. ASCII/Image) of connection
currentType string

Expand Down Expand Up @@ -177,6 +180,17 @@ func (pconn *persistentConn) readResponse() (int, string, error) {
err: fmt.Errorf("error reading response: %s", err),
temporary: true,
}
} else if code == replyConnectionClosed {
pconn.controlConn.SetReadDeadline(time.Now().Add(pconn.config.Timeout))
code, msg, err = pconn.reader.ReadResponse(0)
Copy link
Contributor

@muirmanders muirmanders Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What case is this handling? If this is for reading the final control response after a failed data transfer, it seems like the transferFromOffset() function should be checking for this error and reading the response (i.e. if prepareDataConn() fails with replyConnectionClosed, then change it so it still does a pconn.readResponse() to read the pending response). Or alternatively, just set pconn.broken = true if we are not confident we know the state of the connection and/or the connection is safe to continue using (next command will open a fresh connection).

Copy link
Author

@rvicentiu rvicentiu Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was hard to reproduce as it happened randomly on a high latency server (probably because of some packet loss or timeout...). Looks like a bug in the server as it should not send a 426 code as a preliminary reply. In my tests it happened while walking with ReadDir() .

It could be handled in dataStringList & transferFromOffset if I had more data but this seems like a more generic fix.
I have found no way of checking the socket buffer for data in go in a non-blocking manner (timeout is not a real option as it does not handle sub-second timeouts in my tests). Also does bufio Reader treat the connection break as EOF or returns an error?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is the user of the data connection's responsibility to read the extra control connection response (even in the case of data connection being closed early). I think a sign this doesn't belong here is that it is basically duplicates all of the readResponse() method itself.

if err != nil {
pconn.broken = true
pconn.debug("error reading second response: %s", err)
err = ftpError{
err: fmt.Errorf("error reading response: %s", err),
temporary: true,
}
}
}
return code, msg, err
}
Expand Down Expand Up @@ -205,7 +219,7 @@ func (pconn *persistentConn) fetchFeatures() error {
}

for _, line := range strings.Split(msg, "\n") {
if line[0] == ' ' {
if len(line) > 0 && line[0] == ' ' {
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
if len(parts) == 1 {
pconn.features[strings.ToUpper(parts[0])] = ""
Expand All @@ -228,6 +242,48 @@ func (pconn *persistentConn) hasFeatureWithArg(name, arg string) bool {
return found && strings.ToUpper(arg) == val
}

func (pconn *persistentConn) setClient() error {
code, msg, err := pconn.sendCommand("CLNT GoFTP")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of the CLNT command?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the RFCs - it has no real purpose, just telling the server what client you are using.
But as I was testing I found a few servers that would not allow "OPTS UTF8 ON" unless CLNT is issued before...

For example on some version of CGFTP:
OPTS UTF8 ON
500 Send 'CLNT client_type' before enabling UTF8

if err != nil {
return err
}

if !positiveCompletionReply(code) {
pconn.debug("server doesn't support CLNT: %d-%s", code, msg)
return nil
}

return nil
}

func (pconn *persistentConn) setUnicode() error {
code, msg, err := pconn.sendCommand("OPTS UTF8 ON")
Copy link
Contributor

@muirmanders muirmanders Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specifying UTF8 when available makes sense, but for my own edification, when is this required? Are there servers that won't send UTF-8 path names without this command?

Copy link
Author

@rvicentiu rvicentiu Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had an issue with several servers that treat UTF8 in client commands (like path in LIST / MLSD) as ASCII, thus replying with "not found", although they sent proper UTF-8 paths in replies

Again this is a feature not required in RFCs as UTF8 should be enabled by default if listed in FEAT but needed to work around broken servers (and there are quite a few of them).

if err != nil {
return err
}

if !positiveCompletionReply(code) {
pconn.debug("server doesn't support UTF8: %d-%s", code, msg)
return nil
}

return nil
}

func (pconn *persistentConn) setMLST() error {
code, msg, err := pconn.sendCommand("OPTS MLST type;size;modify;perm;unix.mode;")
Copy link
Contributor

@muirmanders muirmanders Sep 20, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be UNIX.mode or does case not matter?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from RFC 3659:
"Fact names are case-insensitive. Size, size, SIZE, and SiZe are the same fact."

I've made some protocol dumps from a few ftp clients and they seem to use lower case so I kept it as such, though the RFC continues with:
"Further operating system specific keywords could be specified by
using the IANA operating system name as a prefix (examples only):

  OS/2.ea   -- OS/2 extended attributes
  MACOS.rf  -- MacIntosh resource forks
  UNIX.mode -- Unix file modes (permissions)"

if err != nil {
return err
}

if !positiveCompletionReply(code) {
pconn.debug("server doesn't support UTF8: %d-%s", code, msg)
return nil
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check the response message to see if server supports requested options?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought of it but it seems like it would just add complexity. If the commands fail the client would still behave as before.

OPTS UTF8 ON is optional and may fail but the server could still use UTF8 according to FEAT reply.

return nil
}

func (pconn *persistentConn) logIn() error {
if pconn.config.User == "" {
return nil
Expand Down