From bcc332e95c2d8b6b2d26ed63363a1dc212e1787b Mon Sep 17 00:00:00 2001 From: jlaffaye Date: Sat, 7 May 2011 00:29:10 +0100 Subject: [PATCH] First commit. --- LICENSE | 13 +++ Makefile | 8 ++ ftp.go | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++ parse_test.go | 50 +++++++++++ status.go | 100 ++++++++++++++++++++++ 5 files changed, 395 insertions(+) create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 ftp.go create mode 100644 parse_test.go create mode 100644 status.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ccb0ba8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2011, Julien Laffaye + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..be6e448 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +include ${GOROOT}/src/Make.inc + +TARG= ftp +GOFILES=\ + ftp.go\ + status.go + +include ${GOROOT}/src/Make.pkg diff --git a/ftp.go b/ftp.go new file mode 100644 index 0000000..28de182 --- /dev/null +++ b/ftp.go @@ -0,0 +1,224 @@ +package ftp + +import ( + "bufio" + "net" + "os" + "fmt" + "strconv" + "strings" +) + +const ( + EntryTypeFile = iota + EntryTypeFolder + EntryTypeLink +) + +type ServerCon struct { + conn net.Conn + bio *bufio.Reader +} + +type Response struct { + conn net.Conn + c *ServerCon +} + +type Entry struct { + Name string + EntryType int + Size uint64 +} + +// Check if the last status code is equal to the given code +// If it is the case, err is nil +// Returns the status line for further processing +func (c *ServerCon) checkStatus(expected int) (line string, err os.Error) { + line, err = c.bio.ReadString('\n') + if err != nil { + return + } + code, err := strconv.Atoi(line[:3]) // A status is 3 digits + if err != nil { + return + } + if code != expected { + err = os.NewError(fmt.Sprintf("%d %s", code, statusText[code])) + return + } + return +} + +// Like send() but with formating. +func (c *ServerCon) sendf(str string, a ...interface{}) (os.Error) { + return c.send([]byte(fmt.Sprintf(str, a...))) +} + +// Send a raw command on the connection. +func (c *ServerCon) send(data []byte) (os.Error) { + _, err := c.conn.Write(data) + return err +} + +// Connect to a ftp server and returns a ServerCon handler. +func Connect(host, user, password string) (*ServerCon, os.Error) { + conn, err := net.Dial("tcp", host) + if err != nil { + return nil, err + } + + c := &ServerCon{conn, bufio.NewReader(conn)} + + _, err = c.checkStatus(StatusReady) + if err != nil { + c.Close() + return nil, err + } + + c.sendf("USER %v\r\n", user) + _, err = c.checkStatus(StatusUserOK) + if err != nil { + c.Close() + return nil, err + } + + c.sendf("PASS %v\r\n", password) + _, err = c.checkStatus(StatusLoggedIn) + if err != nil { + c.Close() + return nil, err + } + + return c, nil +} + +// Like Connect() but with anonymous credentials. +func ConnectAnonymous(host string) (*ServerCon, os.Error) { + return Connect(host, "anonymous", "anonymous") +} + +// Enter extended passive mode +func (c *ServerCon) epsv() (port int, err os.Error) { + c.send([]byte("EPSV\r\n")) + line, err := c.checkStatus(StatusExtendedPassiveMode) + if err != nil { + return + } + start := strings.Index(line, "|||") + end := strings.LastIndex(line, "|") + if start == -1 || end == -1 { + err = os.NewError("Invalid EPSV response format") + return + } + port, err = strconv.Atoi(line[start+3 : end]) + return +} + +// Open a new data connection using extended passive mode +func (c *ServerCon) openDataConnection() (r *Response, err os.Error) { + port, err := c.epsv() + if err != nil { + return + } + + // Build the new net address string + a := strings.Split(c.conn.RemoteAddr().String(), ":", 2) + addr := fmt.Sprintf("%v:%v", a[0], port) + + conn, err := net.Dial("tcp", addr) + if err != nil { + return + } + + r = &Response{conn, c} + return +} + +func parseListLine(line string) (*Entry, os.Error) { + fields := strings.Fields(line) + if len(fields) < 9 { + return nil, os.NewError("Unsupported LIST line") + } + + e := &Entry{} + switch fields[0][0] { + case '-': + e.EntryType = EntryTypeFile + case 'd': + e.EntryType = EntryTypeFolder + case 'l': + e.EntryType = EntryTypeLink + default: + return nil, os.NewError("Unknown entry type") + } + + e.Name = strings.Join(fields[8:], " ") + return e, nil +} + +func (c *ServerCon) List() (entries []*Entry, err os.Error) { + r, err := c.openDataConnection() + if err != nil { + return + } + defer r.Close() + + c.send([]byte("LIST\r\n")) + _, err = c.checkStatus(StatusAboutToSend) + if err != nil { + return + } + + bio := bufio.NewReader(r) + for { + line, e := bio.ReadString('\n') + if e == os.EOF { + break + } else if e != nil { + return nil, e + } + entry, err := parseListLine(line) + if err == nil { + entries = append(entries, entry) + } + } + return +} + +func (c *ServerCon) ChangeDir(path string) (err os.Error) { + c.sendf("CWD %s\r\n", path); + _, err = c.checkStatus(StatusRequestedFileActionOK) + return +} + +func (c *ServerCon) Get(path string) (r *Response, err os.Error) { + r, err = c.openDataConnection() + if err != nil { + return + } + + c.sendf("RETR %s\r\n", path) + _, err = c.checkStatus(StatusAboutToSend) + return +} + +func (c *ServerCon) Close() { + c.send([]byte("QUIT\r\n")) + c.conn.Close() +} + +func (r *Response) Read(buf []byte) (int, os.Error) { + n, err := r.conn.Read(buf) + if err == os.EOF { + _, err2 := r.c.checkStatus(StatusClosingDataConnection) + if err2 != nil { + err = err2 + } + } + return n, err +} + +func (r *Response) Close() os.Error { + return r.conn.Close() +} diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000..8fd86ff --- /dev/null +++ b/parse_test.go @@ -0,0 +1,50 @@ +package ftp + +import "testing" + +type line struct { + line string + name string + entryType int +} + +var listTests = []line { + // UNIX ls -l style + line{"drwxr-xr-x 3 110 1002 3 Dec 02 2009 pub", "pub", EntryTypeFolder}, + line{"drwxr-xr-x 3 110 1002 3 Dec 02 2009 p u b", "p u b", EntryTypeFolder}, + line{"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 fileName", "fileName", EntryTypeFile}, + line{"lrwxrwxrwx 1 root other 7 Jan 25 00:17 bin -> usr/bin", "bin -> usr/bin", EntryTypeLink}, + // Microsoft's FTP servers for Windows + line{"---------- 1 owner group 1803128 Jul 10 10:18 ls-lR.Z", "ls-lR.Z", EntryTypeFile}, + line{"d--------- 1 owner group 0 May 9 19:45 Softlib", "Softlib", EntryTypeFolder}, + // WFTPD for MSDOS + line{"-rwxrwxrwx 1 noone nogroup 322 Aug 19 1996 message.ftp", "message.ftp", EntryTypeFile}, +} + +// Not supported, at least we should properly return failure +var listTestsFail = []line { + line{"d [R----F--] supervisor 512 Jan 16 18:53 login", "login", EntryTypeFolder}, + line{"- [R----F--] rhesus 214059 Oct 20 15:27 cx.exe", "cx.exe", EntryTypeFile}, +} + +func TestParseListLine(t *testing.T) { + for _, lt := range listTests { + entry, err := parseListLine(lt.line) + if err != nil { + t.Errorf("parseListLine(%v) returned err = %v", lt.line, err) + continue + } + if entry.Name != lt.name { + t.Errorf("parseListLine(%v).Name = '%v', want '%v'", lt.line, entry.Name, lt.name) + } + if entry.EntryType != lt.entryType { + t.Errorf("parseListLine(%v).EntryType = %v, want %v", lt.line, entry.EntryType, lt.entryType,) + } + } + for _, lt := range listTestsFail { + _, err := parseListLine(lt.line) + if err == nil { + t.Errorf("parseListLine(%v) expected to fail", lt.line) + } + } +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..70b0fbe --- /dev/null +++ b/status.go @@ -0,0 +1,100 @@ +package ftp + +const ( + StatusInitiating = 100 + StatusRestartMarker = 110 + StatusReadyMinute = 120 + StatusAboutToSend = 150 + + StatusCommandOK = 200 + StatusCommandNotImplemented = 202 + StatusSystem = 211 + StatusDirectory = 212 + StatusFile = 213 + StatusHelp = 214 + StatusName = 215 + StatusReady = 220 + StatusClosing = 221 + StatusDataConnectionOpen = 225 + StatusClosingDataConnection = 226 + StatusPassiveMode = 227 + StatusLongPassiveMode = 228 + StatusExtendedPassiveMode = 229 + StatusLoggedIn = 230 + StatusLoggedOut = 231 + StatusLogoutAck = 232 + StatusRequestedFileActionOK = 250 + StatusPathCreated = 257 + + StatusUserOK = 331 + StatusLoginNeedAccount = 332 + Status350 = 350 + + StatusNotAvailable = 421 + StatusCanNotOpenDataConnection = 425 + StatusTransfertAborted = 426 + StatusInvalidCredentials = 430 + StatusHostUnavailable = 434 + StatusFileActionIgnored = 450 + StatusActionAborted = 451 + Status452 = 452 + + StatusBadCommand = 500 + StatusBadArguments = 501 + StatusNotImplemented = 502 + StatusBadSequence = 503 + StatusNotImplementedParameter = 504 + StatusNotLoggedIn = 530 + StatusStorNeedAccount = 532 + StatusFileUnavailable = 550 + StatusPageTypeUnknown = 551 + StatusExceededStorage = 552 + StatusBadFileName = 553 +) + +var statusText = map[int]string{ + StatusCommandOK: "Command okay", + StatusCommandNotImplemented: "Command not implemented, superfluous at this site", + StatusSystem: "System status, or system help reply", + StatusDirectory: "Directory status", + StatusFile: "File status", + StatusHelp: "Help message", + StatusName: "", + StatusReady: "Service ready for new user", + StatusClosing: "Service closing control connection", + StatusDataConnectionOpen: "Data connection open; no transfer in progress", + StatusClosingDataConnection: "Closing data connection. Requested file action successful", + StatusPassiveMode: "Entering Passive Mode", + StatusLongPassiveMode: "Entering Long Passive Mode", + StatusExtendedPassiveMode: "Entering Extended Passive Mode", + StatusLoggedIn: "User logged in, proceed", + StatusLoggedOut: "User logged out; service terminated", + StatusLogoutAck: "Logout command noted, will complete when transfer done", + StatusRequestedFileActionOK: "Requested file action okay, completed", + StatusPathCreated: "Path created", + + StatusUserOK: "", + StatusLoginNeedAccount: "", + Status350: "", + + StatusNotAvailable: "", + StatusCanNotOpenDataConnection: "", + StatusTransfertAborted: "", + StatusInvalidCredentials: "", + StatusHostUnavailable: "", + StatusFileActionIgnored: "", + StatusActionAborted: "", + Status452: "", + + StatusBadCommand: "", + StatusBadArguments: "", + StatusNotImplemented: "", + StatusBadSequence: "", + StatusNotImplementedParameter: "", + StatusNotLoggedIn: "", + StatusStorNeedAccount: "", + StatusFileUnavailable: "", + StatusPageTypeUnknown: "", + StatusExceededStorage: "", + StatusBadFileName: "", +}