diff --git a/ftp.go b/ftp.go index 66dcf54..5226e34 100644 --- a/ftp.go +++ b/ftp.go @@ -4,7 +4,6 @@ package ftp import ( "bufio" "errors" - "fmt" "io" "net" "net/textproto" @@ -292,221 +291,6 @@ func (c *ServerConn) cmdDataConnFrom(offset uint64, format string, args ...inter 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 -} - -// parse file or folder name with multiple spaces -func parseLsListLineName(line string, fields []string, offset int) string { - if offset < 1 { - return "" - } - - match := fmt.Sprintf(" %s ", fields[offset-1]) - index := strings.Index(line, match) - if index == -1 { - return "" - } - - index += len(match) - return strings.TrimSpace(line[index:]) -} - -// 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 = parseLsListLineName(line, fields, 8) - if len(e.Name) == 0 { - 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, "") { - e.Type = EntryTypeFolder - line = strings.TrimPrefix(line, "") - } 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) diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..0232b18 --- /dev/null +++ b/parse.go @@ -0,0 +1,213 @@ +package ftp + +import ( + "errors" + "strconv" + "strings" + "time" +) + +var errUnsupportedListLine = errors.New("Unsupported LIST line") + +var listLineParsers = []func(line string) (*Entry, error){ + parseRFC3659ListLine, + parseLsListLine, + parseDirListLine, +} + +var dirTimeFormats = []string{ + "01-02-06 03:04PM", + "2006-01-02 15:04", +} + +// 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) { + + // Has the first field a length of 10 bytes? + if strings.IndexByte(line, ' ') != 10 { + return nil, errUnsupportedListLine + } + + scanner := NewScanner(line) + fields := scanner.NextFields(6) + + if len(fields) < 6 { + return nil, errUnsupportedListLine + } + + if fields[1] == "folder" && fields[2] == "0" { + e := &Entry{ + Type: EntryTypeFolder, + Name: scanner.Remaining(), + } + if err := e.setTime(fields[3:6]); err != nil { + return nil, err + } + + return e, nil + } + + if fields[1] == "0" { + fields = append(fields, scanner.Next()) + e := &Entry{ + Type: EntryTypeFile, + Name: scanner.Remaining(), + } + + 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 + } + + // Read two more fields + fields = append(fields, scanner.NextFields(2)...) + if len(fields) < 8 { + return nil, errUnsupportedListLine + } + + e := &Entry{ + Name: scanner.Remaining(), + } + 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 + } + + return e, nil +} + +// 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, "") { + e.Type = EntryTypeFolder + line = strings.TrimPrefix(line, "") + } 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 +} + +// 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 { + 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 +} diff --git a/parse_test.go b/parse_test.go index aa9dba3..ab44c38 100644 --- a/parse_test.go +++ b/parse_test.go @@ -55,6 +55,7 @@ var listTests = []line{ // dir and file names that contain multiple spaces {"drwxr-xr-x 3 110 1002 3 Dec 02 2009 spaces dir name", "spaces dir name", 0, EntryTypeFolder, time.Date(2009, time.December, 2, 0, 0, 0, 0, time.UTC)}, {"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 file name", "file name", 1234567, EntryTypeFile, time.Date(2009, time.December, 2, 0, 0, 0, 0, time.UTC)}, + {"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 foo bar ", " foo bar ", 1234567, EntryTypeFile, time.Date(2009, time.December, 2, 0, 0, 0, 0, time.UTC)}, } // Not supported, we expect a specific error message diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..3b5ffdb --- /dev/null +++ b/scanner.go @@ -0,0 +1,58 @@ +package ftp + +// A Scanner for fields delimited by one or more whitespace characters +type Scanner struct { + bytes []byte + position int +} + +// NewScanner creates a new Scanner +func NewScanner(str string) *Scanner { + return &Scanner{ + bytes: []byte(str), + } +} + +// NextFields returns the next `count` fields +func (s *Scanner) NextFields(count int) []string { + fields := make([]string, 0, count) + for i := 0; i < count; i++ { + if field := s.Next(); field != "" { + fields = append(fields, field) + } else { + break + } + } + return fields +} + +// Next returns the next field +func (s *Scanner) Next() string { + sLen := len(s.bytes) + + // skip trailing whitespace + for s.position < sLen { + if s.bytes[s.position] != ' ' { + break + } + s.position++ + } + + start := s.position + + // skip non-whitespace + for s.position < sLen { + if s.bytes[s.position] == ' ' { + s.position++ + return string(s.bytes[start : s.position-1]) + } + s.position++ + } + + return string(s.bytes[start:s.position]) +} + +// Remaining returns the remaining string +func (s *Scanner) Remaining() string { + return string(s.bytes[s.position:len(s.bytes)]) +} diff --git a/scanner_test.go b/scanner_test.go new file mode 100644 index 0000000..939f70e --- /dev/null +++ b/scanner_test.go @@ -0,0 +1,28 @@ +package ftp + +import "testing" +import "github.com/stretchr/testify/assert" + +func TestScanner(t *testing.T) { + assert := assert.New(t) + + s := NewScanner("foo bar x y") + assert.Equal("foo", s.Next()) + assert.Equal(" bar x y", s.Remaining()) + assert.Equal("bar", s.Next()) + assert.Equal("x y", s.Remaining()) + assert.Equal("x", s.Next()) + assert.Equal(" y", s.Remaining()) + assert.Equal("y", s.Next()) + assert.Equal("", s.Next()) + assert.Equal("", s.Remaining()) +} + +func TestScannerEmpty(t *testing.T) { + assert := assert.New(t) + + s := NewScanner("") + assert.Equal("", s.Next()) + assert.Equal("", s.Next()) + assert.Equal("", s.Remaining()) +}