Browse Source

First commit.

jlaffaye 14 years ago
commit
bcc332e95c
5 changed files with 395 additions and 0 deletions
  1. 13 0
      LICENSE
  2. 8 0
      Makefile
  3. 224 0
      ftp.go
  4. 50 0
      parse_test.go
  5. 100 0
      status.go

+ 13 - 0
LICENSE

@@ -0,0 +1,13 @@
+Copyright (c) 2011, Julien Laffaye <jlaffaye@FreeBSD.org>
+
+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.

+ 8 - 0
Makefile

@@ -0,0 +1,8 @@
+include ${GOROOT}/src/Make.inc
+
+TARG=	ftp
+GOFILES=\
+	ftp.go\
+	status.go
+
+include ${GOROOT}/src/Make.pkg

+ 224 - 0
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()
+}

+ 50 - 0
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)
+		}
+	}
+}

+ 100 - 0
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:		"",
+}