├── testroot ├── subdir │ └── 1234.bin ├── lorem.txt ├── email%40mail.com.txt └── git-ignored │ └── .gitignore ├── .gitignore ├── .travis.yml ├── README.md ├── LICENSE ├── raw_conn_test.go ├── parallel_walk_test.go ├── examples_test.go ├── goftp.go ├── reply_codes.go ├── client_test.go ├── main_test.go ├── transfer.go ├── transfer_test.go ├── file_system_test.go ├── client.go ├── file_system.go └── persistent_connection.go /testroot/subdir/1234.bin: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /testroot/lorem.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum 2 | -------------------------------------------------------------------------------- /testroot/email%40mail.com.txt: -------------------------------------------------------------------------------- 1 | Sample data here -------------------------------------------------------------------------------- /testroot/git-ignored/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # contains ftp server used for tests 2 | ftpd -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.10" 5 | - tip 6 | 7 | install: 8 | - ./build_test_server.sh 9 | 10 | before_script: 11 | - echo 0 | sudo tee /proc/sys/net/ipv6/conf/all/disable_ipv6 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # goftp - an FTP client for golang 2 | 3 | [![Build Status](https://travis-ci.org/secsy/goftp.svg)](https://travis-ci.org/secsy/goftp) [![GoDoc](https://godoc.org/github.com/secsy/goftp?status.svg)](https://godoc.org/github.com/secsy/goftp) 4 | 5 | goftp aims to be a high-level FTP client that takes advantage of useful FTP features when supported by the server. 6 | 7 | Here are some notable package highlights: 8 | 9 | * Connection pooling for parallel transfers/traversal. 10 | * Automatic resumption of interruped file transfers. 11 | * Explicit and implicit FTPS support (TLS only, no SSL). 12 | * IPv6 support. 13 | * Reasonably good automated tests that run against pure-ftpd and proftpd. 14 | 15 | Please see the godocs for details and examples. 16 | 17 | Pull requests or feature requests are welcome, but in the case of the former, you better add tests. 18 | 19 | ### Tests ### 20 | 21 | How to run tests (windows not supported): 22 | * ```./build_test_server.sh``` from root goftp directory (this downloads and compiles pure-ftpd and proftpd) 23 | * ```go test``` from the root goftp directory 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Muir Manders 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /raw_conn_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "io/ioutil" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestRawConn(t *testing.T) { 14 | for _, addr := range ftpdAddrs { 15 | c, err := DialConfig(goftpConfig, addr) 16 | 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | rawConn, err := c.OpenRawConn() 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | code, msg, err := rawConn.SendCommand("FEAT") 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | if code != 211 { 32 | t.Errorf("got %d", code) 33 | } 34 | 35 | if !strings.Contains(msg, "REST") { 36 | t.Errorf("got %s", msg) 37 | } 38 | 39 | dcGetter, err := rawConn.PrepareDataConn() 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | _, _, err = rawConn.SendCommand("LIST") 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | dc, err := dcGetter() 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | got, err := ioutil.ReadAll(dc) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | 59 | if !strings.Contains(string(got), "lorem.txt") { 60 | t.Errorf("got %s", got) 61 | } 62 | 63 | dc.Close() 64 | 65 | code, _, err = rawConn.ReadResponse() 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | if code != 226 { 70 | t.Errorf("got: %d", code) 71 | } 72 | 73 | if err := rawConn.Close(); err != nil { 74 | t.Error(err) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /parallel_walk_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp_test 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "sync/atomic" 13 | 14 | "github.com/secsy/goftp" 15 | ) 16 | 17 | // Just for fun, walk an ftp server in parallel. I make no claim that this is 18 | // correct or a good idea. 19 | func ExampleClient_ReadDir_parallelWalk() { 20 | client, err := goftp.Dial("ftp.hq.nasa.gov") 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | Walk(client, "", func(fullPath string, info os.FileInfo, err error) error { 26 | if err != nil { 27 | // no permissions is okay, keep walking 28 | if err.(goftp.Error).Code() == 550 { 29 | return nil 30 | } 31 | return err 32 | } 33 | 34 | fmt.Println(fullPath) 35 | 36 | return nil 37 | }) 38 | } 39 | 40 | // Walk a FTP file tree in parallel with prunability and error handling. 41 | // See http://golang.org/pkg/path/filepath/#Walk for interface details. 42 | func Walk(client *goftp.Client, root string, walkFn filepath.WalkFunc) (ret error) { 43 | dirsToCheck := make(chan string, 100) 44 | 45 | var workCount int32 = 1 46 | dirsToCheck <- root 47 | 48 | for dir := range dirsToCheck { 49 | go func(dir string) { 50 | files, err := client.ReadDir(dir) 51 | 52 | if err != nil { 53 | if err = walkFn(dir, nil, err); err != nil && err != filepath.SkipDir { 54 | ret = err 55 | close(dirsToCheck) 56 | return 57 | } 58 | } 59 | 60 | for _, file := range files { 61 | if err = walkFn(path.Join(dir, file.Name()), file, nil); err != nil { 62 | if file.IsDir() && err == filepath.SkipDir { 63 | continue 64 | } 65 | ret = err 66 | close(dirsToCheck) 67 | return 68 | } 69 | 70 | if file.IsDir() { 71 | atomic.AddInt32(&workCount, 1) 72 | dirsToCheck <- path.Join(dir, file.Name()) 73 | } 74 | } 75 | 76 | atomic.AddInt32(&workCount, -1) 77 | if workCount == 0 { 78 | close(dirsToCheck) 79 | } 80 | }(dir) 81 | } 82 | 83 | return ret 84 | } 85 | -------------------------------------------------------------------------------- /examples_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp_test 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "io/ioutil" 11 | "os" 12 | "time" 13 | 14 | "github.com/secsy/goftp" 15 | ) 16 | 17 | func Example() { 18 | // Create client object with default config 19 | client, err := goftp.Dial("ftp.example.com") 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | // Download a file to disk 25 | readme, err := os.Create("readme") 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | err = client.Retrieve("README", readme) 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | // Upload a file from disk 36 | bigFile, err := os.Open("big_file") 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | err = client.Store("big_file", bigFile) 42 | if err != nil { 43 | panic(err) 44 | } 45 | } 46 | 47 | func Example_config() { 48 | config := goftp.Config{ 49 | User: "jlpicard", 50 | Password: "beverly123", 51 | ConnectionsPerHost: 10, 52 | Timeout: 10 * time.Second, 53 | Logger: os.Stderr, 54 | } 55 | 56 | client, err := goftp.DialConfig(config, "ftp.example.com") 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | // download to a buffer instead of file 62 | buf := new(bytes.Buffer) 63 | err = client.Retrieve("pub/interesting_file.txt", buf) 64 | if err != nil { 65 | panic(err) 66 | } 67 | } 68 | 69 | func ExampleClient_OpenRawConn() { 70 | // ignore errors for brevity 71 | 72 | client, _ := goftp.Dial("ftp.hq.nasa.gov") 73 | 74 | rawConn, _ := client.OpenRawConn() 75 | 76 | code, msg, _ := rawConn.SendCommand("FEAT") 77 | fmt.Printf("FEAT: %d-%s\n", code, msg) 78 | 79 | // prepare data connection 80 | dcGetter, _ := rawConn.PrepareDataConn() 81 | 82 | // cause server to initiate data connection 83 | rawConn.SendCommand("LIST") 84 | 85 | // get actual data connection 86 | dc, _ := dcGetter() 87 | 88 | data, _ := ioutil.ReadAll(dc) 89 | fmt.Printf("LIST response: %s\n", data) 90 | 91 | // close data connection 92 | dc.Close() 93 | 94 | // read final response from server after data transfer 95 | code, msg, _ = rawConn.ReadResponse() 96 | fmt.Printf("Final response: %d-%s\n", code, msg) 97 | } 98 | -------------------------------------------------------------------------------- /goftp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | /* 6 | Package goftp provides a high-level FTP client for go. 7 | */ 8 | package goftp 9 | 10 | import ( 11 | "errors" 12 | "fmt" 13 | "net" 14 | "regexp" 15 | ) 16 | 17 | // Dial creates an FTP client using the default config. See DialConfig for 18 | // information about "hosts". 19 | func Dial(hosts ...string) (*Client, error) { 20 | return DialConfig(Config{}, hosts...) 21 | } 22 | 23 | // DialConfig creates an FTP client using the given config. "hosts" is a list 24 | // of IP addresses or hostnames with an optional port (defaults to 21). 25 | // Hostnames will be expanded to all the IP addresses they resolve to. The 26 | // client's connection pool will pick from all the addresses in a round-robin 27 | // fashion. If you specify multiple hosts, they should be identical mirrors of 28 | // each other. 29 | func DialConfig(config Config, hosts ...string) (*Client, error) { 30 | expandedHosts, err := lookupHosts(hosts, config.IPv6Lookup) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return newClient(config, expandedHosts), nil 36 | } 37 | 38 | var hasPort = regexp.MustCompile(`^[^:]+:\d+$|\]:\d+$`) 39 | 40 | func lookupHosts(hosts []string, ipv6Lookup bool) ([]string, error) { 41 | if len(hosts) == 0 { 42 | return nil, errors.New("must specify at least one host") 43 | } 44 | 45 | var ( 46 | ret []string 47 | ipv6 []string 48 | ) 49 | 50 | for i, host := range hosts { 51 | if !hasPort.MatchString(host) { 52 | host = fmt.Sprintf("[%s]:21", host) 53 | } 54 | hostnameOrIP, port, err := net.SplitHostPort(host) 55 | if err != nil { 56 | return nil, fmt.Errorf(`invalid host "%s"`, hosts[i]) 57 | } 58 | 59 | if net.ParseIP(hostnameOrIP) != nil { 60 | // is IP, add to list 61 | ret = append(ret, host) 62 | } else { 63 | // not an IP, must be hostname 64 | ips, err := net.LookupIP(hostnameOrIP) 65 | 66 | // consider not returning error if other hosts in the list work 67 | if err != nil { 68 | return nil, fmt.Errorf(`error resolving host "%s": %s`, hostnameOrIP, err) 69 | } 70 | 71 | for _, ip := range ips { 72 | ipAndPort := fmt.Sprintf("[%s]:%s", ip.String(), port) 73 | if ip.To4() == nil && !ipv6Lookup { 74 | ipv6 = append(ipv6, ipAndPort) 75 | } else { 76 | ret = append(ret, ipAndPort) 77 | } 78 | } 79 | } 80 | } 81 | 82 | // if you only found IPv6 addresses and IPv6Lookup was off, try them anyway 83 | // just for kicks 84 | if len(ret) == 0 && len(ipv6) > 0 { 85 | return ipv6, nil 86 | } 87 | 88 | return ret, nil 89 | } 90 | -------------------------------------------------------------------------------- /reply_codes.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | // Taken from https://www.ietf.org/rfc/rfc959.txt 8 | 9 | const ( 10 | replyGroupPreliminaryReply = 1 11 | replyGroupPositiveCompletion = 2 12 | 13 | // positive preliminary replies 14 | replyRestartMarker = 110 // Restart marker reply 15 | replyReadyInNMinutes = 120 // Service ready in nnn minutes 16 | replyDataConnectionAlreadyOpen = 125 // (transfer starting) 17 | replyFileStatusOkay = 150 // (about to open data connection) 18 | 19 | // positive completion replies 20 | replyCommandOkay = 200 21 | replyCommandOkayNotImplemented = 202 22 | replySystemStatus = 211 // or system help reply 23 | replyDirectoryStatus = 212 24 | replyFileStatus = 213 25 | replyHelpMessage = 214 26 | replySystemType = 215 27 | replyServiceReady = 220 28 | replyClosingControlConnection = 221 29 | replyDataConnectionOpen = 225 // (no transfer in progress) 30 | replyClosingDataConnection = 226 // requested file action successful 31 | replyEnteringPassiveMode = 227 32 | replyEnteringExtendedPassiveMode = 229 33 | replyUserLoggedIn = 230 34 | replyAuthOkayNoDataNeeded = 234 35 | replyFileActionOkay = 250 // (completed) 36 | replyDirCreated = 257 37 | 38 | // positive intermediate replies 39 | replyNeedPassword = 331 40 | replyNeedAccount = 332 41 | replyFileActionPending = 350 // pending further information 42 | 43 | // transient negative completion replies 44 | replyServiceNotAvailable = 421 // (service shutting down) 45 | replyCantOpenDataConnection = 425 46 | replyConnectionClosed = 426 // (transfer aborted) 47 | replyTransientFileError = 450 // (file unavailable) 48 | replyLocalError = 451 // action aborted 49 | replyOutOfSpace = 452 // action not taken 50 | 51 | // permanenet negative completion replies 52 | replyCommandSyntaxError = 500 53 | replyParameterSyntaxError = 501 54 | replyCommandNotImplemented = 502 55 | replyBadCommandSequence = 503 56 | replyCommandNotImplementedForParameter = 504 57 | replyNotLoggedIn = 530 58 | replyNeedAccountToStore = 532 59 | replyFileError = 550 // file not found, no access 60 | replyPageTypeUnknown = 551 61 | replyExceededStorageAllocation = 552 // for current directory/dataset 62 | replyBadFileName = 553 63 | ) 64 | 65 | func positiveCompletionReply(code int) bool { 66 | return code/100 == 2 67 | } 68 | 69 | func positivePreliminaryReply(code int) bool { 70 | return code/100 == 1 71 | } 72 | 73 | func transientNegativeCompletionReply(code int) bool { 74 | return code/100 == 4 75 | } 76 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "bytes" 9 | "crypto/tls" 10 | "sync" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestTimeoutConnect(t *testing.T) { 16 | config := Config{Timeout: 100 * time.Millisecond} 17 | 18 | c, err := DialConfig(config, "168.254.111.222:2121") 19 | 20 | t0 := time.Now() 21 | _, err = c.ReadDir("") 22 | delta := time.Now().Sub(t0) 23 | 24 | if err == nil || !err.(Error).Temporary() { 25 | t.Error("Expected a timeout error") 26 | } 27 | 28 | offBy := delta - config.Timeout 29 | if offBy < 0 { 30 | offBy = -offBy 31 | } 32 | if offBy > 50*time.Millisecond { 33 | t.Errorf("Timeout of 100ms was off by %s", offBy) 34 | } 35 | 36 | if c.numOpenConns() != len(c.freeConnCh) { 37 | t.Error("Leaked a connection") 38 | } 39 | } 40 | 41 | func TestExplicitTLS(t *testing.T) { 42 | for _, addr := range ftpdAddrs { 43 | config := Config{ 44 | User: "goftp", 45 | Password: "rocks", 46 | TLSConfig: &tls.Config{ 47 | InsecureSkipVerify: true, 48 | }, 49 | TLSMode: TLSExplicit, 50 | } 51 | 52 | c, err := DialConfig(config, addr) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | buf := new(bytes.Buffer) 58 | err = c.Retrieve("subdir/1234.bin", buf) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | if !bytes.Equal([]byte{1, 2, 3, 4}, buf.Bytes()) { 64 | t.Errorf("Got %v", buf.Bytes()) 65 | } 66 | 67 | if c.numOpenConns() != len(c.freeConnCh) { 68 | t.Error("Leaked a connection") 69 | } 70 | } 71 | } 72 | 73 | func TestImplicitTLS(t *testing.T) { 74 | closer, err := startPureFTPD(implicitTLSAddrs, "ftpd/pure-ftpd-implicittls") 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | defer closer() 80 | 81 | for _, addr := range implicitTLSAddrs { 82 | config := Config{ 83 | TLSConfig: &tls.Config{ 84 | InsecureSkipVerify: true, 85 | }, 86 | TLSMode: TLSImplicit, 87 | } 88 | 89 | c, err := DialConfig(config, addr) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | buf := new(bytes.Buffer) 95 | err = c.Retrieve("subdir/1234.bin", buf) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | if !bytes.Equal([]byte{1, 2, 3, 4}, buf.Bytes()) { 101 | t.Errorf("Got %v", buf.Bytes()) 102 | } 103 | 104 | if c.numOpenConns() != len(c.freeConnCh) { 105 | t.Error("Leaked a connection") 106 | } 107 | } 108 | } 109 | 110 | func TestPooling(t *testing.T) { 111 | config := Config{ 112 | ConnectionsPerHost: 2, 113 | User: "goftp", 114 | Password: "rocks", 115 | } 116 | c, err := DialConfig(config, ftpdAddrs...) 117 | if err != nil { 118 | t.Fatal(err) 119 | } 120 | 121 | wg := sync.WaitGroup{} 122 | ok := true 123 | numConns := config.ConnectionsPerHost * len(ftpdAddrs) 124 | 125 | for i := 0; i < numConns; i++ { 126 | wg.Add(1) 127 | go func() { 128 | buf := new(bytes.Buffer) 129 | err := c.Retrieve("subdir/1234.bin", buf) 130 | if err != nil || !bytes.Equal(buf.Bytes(), []byte{1, 2, 3, 4}) { 131 | ok = false 132 | } 133 | wg.Done() 134 | }() 135 | } 136 | 137 | wg.Wait() 138 | 139 | if !ok { 140 | t.Error("something went wrong") 141 | } 142 | 143 | if len(c.freeConnCh) != numConns { 144 | t.Errorf("Expected %d conns, was %d", numConns, len(c.freeConnCh)) 145 | } 146 | 147 | if c.numOpenConns() != len(c.freeConnCh) { 148 | t.Error("Leaked a connection") 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "log" 11 | "net" 12 | "os" 13 | "os/exec" 14 | "path" 15 | "testing" 16 | "time" 17 | ) 18 | 19 | var goftpConfig = Config{ 20 | User: "goftp", 21 | Password: "rocks", 22 | } 23 | 24 | // list of addresses for tests to connect to 25 | var ftpdAddrs []string 26 | 27 | var ( 28 | // used for implicit tls test 29 | implicitTLSAddrs = []string{"127.0.0.1:2122", "[::1]:2122"} 30 | pureAddrs = []string{"127.0.0.1:2121", "[::1]:2121"} 31 | proAddrs = []string{"127.0.0.1:2124"} 32 | ) 33 | 34 | func TestMain(m *testing.M) { 35 | pureCloser, err := startPureFTPD(pureAddrs, "ftpd/pure-ftpd") 36 | ftpdAddrs = append(ftpdAddrs, pureAddrs...) 37 | 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | proCloser, err := startProFTPD() 43 | // this port is hard coded in its config 44 | ftpdAddrs = append(ftpdAddrs, proAddrs...) 45 | 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | var ret int 51 | func() { 52 | defer pureCloser() 53 | defer proCloser() 54 | ret = m.Run() 55 | }() 56 | 57 | os.Exit(ret) 58 | } 59 | 60 | func startPureFTPD(addrs []string, binary string) (func(), error) { 61 | if _, err := os.Open("client_test.go"); os.IsNotExist(err) { 62 | return nil, errors.New("must run tests in goftp/ directory") 63 | } 64 | 65 | if _, err := os.Stat(binary); os.IsNotExist(err) { 66 | return nil, fmt.Errorf("%s not found - you need to run ./build_test_server.sh from the goftp directory", binary) 67 | } 68 | 69 | cwd, err := os.Getwd() 70 | if err != nil { 71 | return nil, fmt.Errorf("couldn't determine cwd: %s", err) 72 | } 73 | 74 | var ftpdProcs []*os.Process 75 | for _, addr := range addrs { 76 | host, port, err := net.SplitHostPort(addr) 77 | if err != nil { 78 | panic(err) 79 | } 80 | 81 | cmd := exec.Command(binary, 82 | "--bind", host+","+port, 83 | "--login", "puredb:ftpd/users.pdb", 84 | "--tls", "1", 85 | ) 86 | 87 | cmd.Env = []string{fmt.Sprintf("FTP_ANON_DIR=%s/testroot", cwd)} 88 | 89 | err = cmd.Start() 90 | if err != nil { 91 | return nil, fmt.Errorf("error starting pure-ftpd on %s: %s", addr, err) 92 | } 93 | 94 | ftpdProcs = append(ftpdProcs, cmd.Process) 95 | } 96 | 97 | closer := func() { 98 | for _, proc := range ftpdProcs { 99 | proc.Kill() 100 | } 101 | } 102 | 103 | // give them a bit to get started 104 | time.Sleep(100 * time.Millisecond) 105 | 106 | return closer, nil 107 | } 108 | 109 | // ./proftpd --nodaemon --config `pwd`/proftpd.conf 110 | func startProFTPD() (func(), error) { 111 | if _, err := os.Open("client_test.go"); os.IsNotExist(err) { 112 | return nil, errors.New("must run tests in goftp/ directory") 113 | } 114 | 115 | binary := "ftpd/proftpd" 116 | 117 | if _, err := os.Stat(binary); os.IsNotExist(err) { 118 | return nil, fmt.Errorf("%s not found - you need to run ./build_test_server.sh from the goftp directory", binary) 119 | } 120 | 121 | cwd, err := os.Getwd() 122 | if err != nil { 123 | return nil, fmt.Errorf("couldn't determine cwd: %s", err) 124 | } 125 | 126 | cmd := exec.Command(binary, 127 | "--nodaemon", 128 | "--config", path.Join(cwd, "ftpd", "proftpd.conf"), 129 | // "--debug", "10", 130 | ) 131 | 132 | // cmd.Stdout = os.Stdout 133 | // cmd.Stderr = os.Stderr 134 | 135 | err = cmd.Start() 136 | if err != nil { 137 | return nil, fmt.Errorf("error starting proftpd on: %s", err) 138 | } 139 | 140 | closer := func() { 141 | cmd.Process.Kill() 142 | } 143 | 144 | // give it a bit to get started 145 | time.Sleep(100 * time.Millisecond) 146 | 147 | return closer, nil 148 | } 149 | -------------------------------------------------------------------------------- /transfer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | "os" 11 | "strconv" 12 | ) 13 | 14 | // Retrieve file "path" from server and write bytes to "dest". If the 15 | // server supports resuming stream transfers, Retrieve will continue 16 | // resuming a failed download as long as it continues making progress. 17 | // Retrieve will also verify the file's size after the transfer if the 18 | // server supports the SIZE command. 19 | func (c *Client) Retrieve(path string, dest io.Writer) error { 20 | // fetch file size to check against how much we transferred 21 | size, err := c.size(path) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | canResume := c.canResume() 27 | 28 | var bytesSoFar int64 29 | for { 30 | n, err := c.transferFromOffset(path, dest, nil, bytesSoFar) 31 | 32 | bytesSoFar += n 33 | 34 | if err == nil { 35 | break 36 | } else if n == 0 { 37 | return err 38 | } else if !canResume { 39 | return ftpError{ 40 | err: fmt.Errorf("%s (can't resume)", err), 41 | temporary: true, 42 | } 43 | } 44 | } 45 | 46 | if size != -1 && bytesSoFar != size { 47 | return ftpError{ 48 | err: fmt.Errorf("expected %d bytes, got %d", size, bytesSoFar), 49 | temporary: true, 50 | } 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // Store bytes read from "src" into file "path" on the server. If the 57 | // server supports resuming stream transfers and "src" is an io.Seeker 58 | // (*os.File is an io.Seeker), Store will continue resuming a failed upload 59 | // as long as it continues making progress. Store will not attempt to 60 | // resume an upload if the client is connected to multiple servers. Store 61 | // will also verify the remote file's size after the transfer if the server 62 | // supports the SIZE command. 63 | func (c *Client) Store(path string, src io.Reader) error { 64 | 65 | canResume := len(c.hosts) == 1 && c.canResume() 66 | 67 | seeker, ok := src.(io.Seeker) 68 | if !ok { 69 | canResume = false 70 | } 71 | 72 | var ( 73 | bytesSoFar int64 74 | err error 75 | n int64 76 | ) 77 | for { 78 | if bytesSoFar > 0 { 79 | size, sizeErr := c.size(path) 80 | if sizeErr != nil { 81 | return ftpError{ 82 | err: sizeErr, 83 | temporary: true, 84 | } 85 | } 86 | if size == -1 { 87 | return ftpError{ 88 | err: fmt.Errorf("%s (resume failed)", err), 89 | temporary: true, 90 | } 91 | } 92 | 93 | _, seekErr := seeker.Seek(size, os.SEEK_SET) 94 | if seekErr != nil { 95 | c.debug("failed seeking to %d while resuming upload to %s: %s", 96 | size, 97 | path, 98 | err, 99 | ) 100 | return ftpError{ 101 | err: fmt.Errorf("%s (resume failed)", err), 102 | temporary: true, 103 | } 104 | } 105 | bytesSoFar = size 106 | } 107 | 108 | n, err = c.transferFromOffset(path, nil, src, bytesSoFar) 109 | 110 | bytesSoFar += n 111 | 112 | if err == nil { 113 | break 114 | } else if n == 0 { 115 | return ftpError{ 116 | err: err, 117 | temporary: true, 118 | } 119 | } else if !canResume { 120 | return ftpError{ 121 | err: fmt.Errorf("%s (can't resume)", err), 122 | temporary: true, 123 | } 124 | } 125 | } 126 | 127 | // fetch file size to check against how much we transferred 128 | size, err := c.size(path) 129 | if err != nil { 130 | return err 131 | } 132 | if size != -1 && size != bytesSoFar { 133 | return ftpError{ 134 | err: fmt.Errorf("sent %d bytes, but size is %d", bytesSoFar, size), 135 | temporary: true, 136 | } 137 | } 138 | 139 | return nil 140 | } 141 | 142 | func (c *Client) transferFromOffset(path string, dest io.Writer, src io.Reader, offset int64) (int64, error) { 143 | pconn, err := c.getIdleConn() 144 | if err != nil { 145 | return 0, err 146 | } 147 | 148 | defer c.returnConn(pconn) 149 | 150 | if err = pconn.setType("I"); err != nil { 151 | return 0, err 152 | } 153 | 154 | if offset > 0 { 155 | err := pconn.sendCommandExpected(replyFileActionPending, "REST %d", offset) 156 | if err != nil { 157 | return 0, err 158 | } 159 | } 160 | 161 | connGetter, err := pconn.prepareDataConn() 162 | if err != nil { 163 | pconn.debug("error preparing data connection: %s", err) 164 | return 0, err 165 | } 166 | 167 | var cmd string 168 | if dest == nil && src != nil { 169 | cmd = "STOR" 170 | } else if dest != nil && src == nil { 171 | cmd = "RETR" 172 | } else { 173 | panic("this shouldn't happen") 174 | } 175 | 176 | err = pconn.sendCommandExpected(replyGroupPreliminaryReply, "%s %s", cmd, path) 177 | if err != nil { 178 | return 0, err 179 | } 180 | 181 | dc, err := connGetter() 182 | if err != nil { 183 | pconn.debug("error getting data connection: %s", err) 184 | return 0, err 185 | } 186 | 187 | // to catch early returns 188 | defer dc.Close() 189 | 190 | if dest == nil { 191 | dest = dc 192 | } else { 193 | src = dc 194 | } 195 | 196 | n, err := io.Copy(dest, src) 197 | 198 | if err != nil { 199 | pconn.broken = true 200 | return n, err 201 | } 202 | 203 | err = dc.Close() 204 | if err != nil { 205 | pconn.debug("error closing data connection: %s", err) 206 | } 207 | 208 | code, msg, err := pconn.readResponse() 209 | if err != nil { 210 | pconn.debug("error reading response after %s: %s", cmd, err) 211 | return n, err 212 | } 213 | 214 | if !positiveCompletionReply(code) { 215 | pconn.debug("unexpected response after %s: %d (%s)", cmd, code, msg) 216 | return n, ftpError{code: code, msg: msg} 217 | } 218 | 219 | return n, nil 220 | } 221 | 222 | // Fetch SIZE of file. Returns error only on underlying connection error. 223 | // If the server doesn't support size, it returns -1 and no error. 224 | func (c *Client) size(path string) (int64, error) { 225 | pconn, err := c.getIdleConn() 226 | if err != nil { 227 | return -1, err 228 | } 229 | 230 | defer c.returnConn(pconn) 231 | 232 | if !pconn.hasFeature("SIZE") { 233 | pconn.debug("server doesn't support SIZE") 234 | return -1, nil 235 | } 236 | 237 | if err = pconn.setType("I"); err != nil { 238 | return 0, err 239 | } 240 | 241 | code, msg, err := pconn.sendCommand("SIZE %s", path) 242 | if err != nil { 243 | return -1, err 244 | } 245 | 246 | if code != replyFileStatus { 247 | pconn.debug("unexpected SIZE response: %d (%s)", code, msg) 248 | return -1, nil 249 | } 250 | 251 | size, err := strconv.ParseInt(msg, 10, 64) 252 | if err != nil { 253 | pconn.debug(`failed parsing SIZE response "%s": %s`, msg, err) 254 | return -1, nil 255 | } 256 | 257 | return size, nil 258 | } 259 | 260 | func (c *Client) canResume() bool { 261 | pconn, err := c.getIdleConn() 262 | if err != nil { 263 | return false 264 | } 265 | 266 | defer c.returnConn(pconn) 267 | 268 | return pconn.hasFeatureWithArg("REST", "STREAM") 269 | } 270 | -------------------------------------------------------------------------------- /transfer_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "io/ioutil" 11 | "math/rand" 12 | "os" 13 | "reflect" 14 | "strings" 15 | "testing" 16 | "time" 17 | ) 18 | 19 | func TestRetrieve(t *testing.T) { 20 | for _, addr := range ftpdAddrs { 21 | c, err := DialConfig(goftpConfig, addr) 22 | 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | buf := new(bytes.Buffer) 28 | 29 | // first try a file that doesn't exit to make sure we get an error and our 30 | // connection is still okay 31 | err = c.Retrieve("doesnt-exist", buf) 32 | 33 | if err == nil { 34 | t.Errorf("Expected error about not existing") 35 | } 36 | 37 | err = c.Retrieve("subdir/1234.bin", buf) 38 | 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | if !bytes.Equal([]byte{1, 2, 3, 4}, buf.Bytes()) { 44 | t.Errorf("Got %v", buf.Bytes()) 45 | } 46 | 47 | if c.numOpenConns() != len(c.freeConnCh) { 48 | t.Error("Leaked a connection") 49 | } 50 | 51 | urlencoded := new(bytes.Buffer) 52 | err = c.Retrieve("email%40mail.com.txt", urlencoded) 53 | 54 | if err != nil { 55 | t.Errorf("%s", "Expected to successfully fetch files with % char in the name") 56 | } 57 | } 58 | } 59 | 60 | func TestRetrievePASV(t *testing.T) { 61 | for _, addr := range ftpdAddrs { 62 | if strings.HasPrefix(addr, "[::1]") { 63 | // PASV can't work with IPv6 64 | continue 65 | } 66 | 67 | c, err := DialConfig(goftpConfig, addr) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | // server doesn't support EPSV 73 | c.config.stubResponses = map[string]stubResponse{ 74 | "EPSV": stubResponse{500, `'EPSV': command not understood.`}, 75 | } 76 | 77 | buf := new(bytes.Buffer) 78 | err = c.Retrieve("subdir/1234.bin", buf) 79 | 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | if !bytes.Equal([]byte{1, 2, 3, 4}, buf.Bytes()) { 85 | t.Errorf("Got %v", buf.Bytes()) 86 | } 87 | 88 | if c.numOpenConns() != len(c.freeConnCh) { 89 | t.Error("Leaked a connection") 90 | } 91 | } 92 | } 93 | 94 | func TestRetrieveActive(t *testing.T) { 95 | for _, addr := range ftpdAddrs { 96 | activeConfig := goftpConfig 97 | activeConfig.ActiveTransfers = true 98 | 99 | // pretend server doesn't support passive mode to make sure we aren't 100 | // still using it 101 | activeConfig.stubResponses = map[string]stubResponse{ 102 | "EPSV": stubResponse{500, `'EPSV': command not understood.`}, 103 | "PASV": stubResponse{500, `'PASV': command not understood.`}, 104 | } 105 | 106 | c, err := DialConfig(activeConfig, addr) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | buf := new(bytes.Buffer) 112 | err = c.Retrieve("subdir/1234.bin", buf) 113 | 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | 118 | if !bytes.Equal([]byte{1, 2, 3, 4}, buf.Bytes()) { 119 | t.Errorf("Got %v", buf.Bytes()) 120 | } 121 | 122 | if c.numOpenConns() != len(c.freeConnCh) { 123 | t.Error("Leaked a connection") 124 | } 125 | } 126 | } 127 | 128 | // io.Writer used to simulate various exceptional cases during 129 | // file downloads 130 | type testWriter struct { 131 | writes [][]byte 132 | cb func([]byte) (int, error) 133 | } 134 | 135 | func (tb *testWriter) Write(p []byte) (int, error) { 136 | n, err := tb.cb(p) 137 | if n > 0 { 138 | tb.writes = append(tb.writes, p[0:n]) 139 | } 140 | return n, err 141 | } 142 | 143 | // pure-ftpd sups "REST STREAM", so make sure we can resume downloads. 144 | // In this test we are simulating a client write error. 145 | func TestResumeRetrieveOnWriteError(t *testing.T) { 146 | for _, addr := range ftpdAddrs { 147 | c, err := DialConfig(goftpConfig, addr) 148 | 149 | if err != nil { 150 | t.Fatal(err) 151 | } 152 | 153 | buf := new(testWriter) 154 | 155 | buf.cb = func(p []byte) (int, error) { 156 | if len(p) <= 2 { 157 | return len(p), nil 158 | } 159 | return 2, errors.New("too many bytes to handle") 160 | } 161 | 162 | err = c.Retrieve("subdir/1234.bin", buf) 163 | 164 | if err != nil { 165 | t.Fatal(err) 166 | } 167 | 168 | if !reflect.DeepEqual([][]byte{[]byte{1, 2}, []byte{3, 4}}, buf.writes) { 169 | t.Errorf("Got %v", buf.writes) 170 | } 171 | 172 | if c.numOpenConns() != len(c.freeConnCh) { 173 | t.Error("Leaked a connection") 174 | } 175 | } 176 | } 177 | 178 | // In this test we simulate a read error by closing all connections 179 | // part way through the download. 180 | func TestResumeRetrieveOnReadError(t *testing.T) { 181 | for _, addr := range ftpdAddrs { 182 | c, err := DialConfig(goftpConfig, addr) 183 | 184 | if err != nil { 185 | t.Fatal(err) 186 | } 187 | 188 | buf := new(testWriter) 189 | 190 | buf.cb = func(p []byte) (int, error) { 191 | if len(p) <= 2 { 192 | return len(p), nil 193 | } 194 | // close all the connections, then reset closed so we 195 | // can keep using this client 196 | c.Close() 197 | c.closed = false 198 | return 2, errors.New("too many bytes to handle") 199 | } 200 | 201 | err = c.Retrieve("subdir/1234.bin", buf) 202 | 203 | if err != nil { 204 | t.Fatal(err) 205 | } 206 | 207 | if !reflect.DeepEqual([][]byte{[]byte{1, 2}, []byte{3, 4}}, buf.writes) { 208 | t.Errorf("Got %v", buf.writes) 209 | } 210 | 211 | if c.numOpenConns() != len(c.freeConnCh) { 212 | t.Error("Leaked a connection") 213 | } 214 | } 215 | } 216 | 217 | func TestStore(t *testing.T) { 218 | for _, addr := range ftpdAddrs { 219 | c, err := DialConfig(goftpConfig, addr) 220 | 221 | if err != nil { 222 | t.Fatal(err) 223 | } 224 | 225 | toSend, err := os.Open("testroot/subdir/1234.bin") 226 | if err != nil { 227 | t.Fatal(err) 228 | } 229 | 230 | os.Remove("testroot/git-ignored/foo") 231 | 232 | err = c.Store("git-ignored/foo", toSend) 233 | 234 | if err != nil { 235 | t.Fatal(err) 236 | } 237 | 238 | stored, err := ioutil.ReadFile("testroot/git-ignored/foo") 239 | if err != nil { 240 | t.Fatal(err) 241 | } 242 | 243 | if !bytes.Equal([]byte{1, 2, 3, 4}, stored) { 244 | t.Errorf("Got %v", stored) 245 | } 246 | 247 | if c.numOpenConns() != len(c.freeConnCh) { 248 | t.Error("Leaked a connection") 249 | } 250 | } 251 | } 252 | 253 | func TestStoreActive(t *testing.T) { 254 | for _, addr := range ftpdAddrs { 255 | activeConfig := goftpConfig 256 | activeConfig.ActiveTransfers = true 257 | 258 | // pretend server doesn't support passive mode to make sure we aren't 259 | // still using it 260 | activeConfig.stubResponses = map[string]stubResponse{ 261 | "EPSV": stubResponse{500, `'EPSV': command not understood.`}, 262 | "PASV": stubResponse{500, `'PASV': command not understood.`}, 263 | } 264 | 265 | c, err := DialConfig(activeConfig, addr) 266 | 267 | if err != nil { 268 | t.Fatal(err) 269 | } 270 | 271 | toSend, err := os.Open("testroot/subdir/1234.bin") 272 | if err != nil { 273 | t.Fatal(err) 274 | } 275 | 276 | os.Remove("testroot/git-ignored/foo") 277 | 278 | err = c.Store("git-ignored/foo", toSend) 279 | 280 | if err != nil { 281 | t.Fatal(err) 282 | } 283 | 284 | stored, err := ioutil.ReadFile("testroot/git-ignored/foo") 285 | if err != nil { 286 | t.Fatal(err) 287 | } 288 | 289 | if !bytes.Equal([]byte{1, 2, 3, 4}, stored) { 290 | t.Errorf("Got %v", stored) 291 | } 292 | 293 | if c.numOpenConns() != len(c.freeConnCh) { 294 | t.Error("Leaked a connection") 295 | } 296 | } 297 | } 298 | 299 | func TestStoreError(t *testing.T) { 300 | for _, addr := range ftpdAddrs { 301 | c, err := DialConfig(goftpConfig, addr) 302 | 303 | if err != nil { 304 | t.Fatal(err) 305 | } 306 | 307 | toSend, err := os.Open("testroot/subdir/1234.bin") 308 | if err != nil { 309 | t.Fatal(err) 310 | } 311 | 312 | err = c.Store("does/not/exist", toSend) 313 | 314 | if err == nil { 315 | t.Error("no error?") 316 | } 317 | 318 | fe, ok := err.(Error) 319 | if !ok { 320 | t.Fatalf("Store error wasn't an Error: %+v", err) 321 | } 322 | 323 | if fe.Code() == 0 || fe.Message() == "" { 324 | t.Errorf("code: %d, message: %q", fe.Code(), fe.Message()) 325 | } 326 | 327 | if c.numOpenConns() != len(c.freeConnCh) { 328 | t.Error("Leaked a connection") 329 | } 330 | } 331 | } 332 | 333 | // io.Reader that also implements io.Seeker interface like 334 | // *os.File (used to test resuming uploads) 335 | type testSeeker struct { 336 | buf *bytes.Reader 337 | soFar int 338 | cb func(int) 339 | } 340 | 341 | func (ts *testSeeker) Read(p []byte) (int, error) { 342 | n, err := ts.buf.Read(p) 343 | ts.soFar += n 344 | ts.cb(ts.soFar) 345 | return n, err 346 | } 347 | 348 | func (ts *testSeeker) Seek(offset int64, whence int) (int64, error) { 349 | return ts.buf.Seek(offset, whence) 350 | } 351 | 352 | func randomBytes(b []byte) { 353 | for i := 0; i < len(b); i++ { 354 | b[i] = byte(rand.Int31n(256)) 355 | } 356 | } 357 | 358 | // kill connections part way through upload - show we can restart if src 359 | // is an io.Seeker 360 | func TestResumeStoreOnWriteError(t *testing.T) { 361 | for _, addr := range ftpdAddrs { 362 | c, err := DialConfig(goftpConfig, addr) 363 | 364 | if err != nil { 365 | t.Fatal(err) 366 | } 367 | 368 | // 10MB of random data 369 | buf := make([]byte, 10*1024*1024) 370 | randomBytes(buf) 371 | 372 | closed := false 373 | 374 | seeker := &testSeeker{ 375 | buf: bytes.NewReader(buf), 376 | cb: func(readSoFar int) { 377 | if readSoFar > 5*1024*1024 && !closed { 378 | // close all connections half way through upload 379 | 380 | // if you don't wait a bit here, proftpd deletes the 381 | // partially uploaded file for some reason 382 | time.Sleep(100 * time.Millisecond) 383 | 384 | c.Close() 385 | c.closed = false 386 | closed = true 387 | } 388 | }, 389 | } 390 | 391 | os.Remove("testroot/git-ignored/big") 392 | 393 | err = c.Store("git-ignored/big", seeker) 394 | 395 | if err != nil { 396 | t.Fatal(err) 397 | } 398 | 399 | stored, err := ioutil.ReadFile("testroot/git-ignored/big") 400 | if err != nil { 401 | t.Fatal(err) 402 | } 403 | 404 | if !bytes.Equal(buf, stored) { 405 | t.Errorf("buf was %d, stored was %d", len(buf), len(stored)) 406 | } 407 | 408 | if c.numOpenConns() != len(c.freeConnCh) { 409 | t.Error("Leaked a connection") 410 | } 411 | } 412 | } 413 | 414 | func TestEmptyLinesFeat(t *testing.T) { 415 | for _, addr := range ftpdAddrs { 416 | c, err := DialConfig(goftpConfig, addr) 417 | 418 | if err != nil { 419 | t.Fatal(err) 420 | } 421 | 422 | c.config.stubResponses = map[string]stubResponse{ 423 | "FEAT": {code: 211, msg: "Extensions supported:\n EPRT\n EPSV\n\nEND"}, 424 | } 425 | 426 | // stat the file so the client asks for features 427 | _, err = c.Stat("subdir/1234.bin") 428 | if err != nil { 429 | t.Fatal(err) 430 | } 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /file_system_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "io/ioutil" 11 | "os" 12 | "path" 13 | "reflect" 14 | "sort" 15 | "testing" 16 | "time" 17 | ) 18 | 19 | func TestDelete(t *testing.T) { 20 | for _, addr := range ftpdAddrs { 21 | c, err := DialConfig(Config{User: "goftp", Password: "rocks"}, addr) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | os.Remove("testroot/git-ignored/foo") 27 | 28 | err = c.Store("git-ignored/foo", bytes.NewReader([]byte{1, 2, 3, 4})) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | _, err = os.Open("testroot/git-ignored/foo") 34 | if err != nil { 35 | t.Fatal("file is not there?", err) 36 | } 37 | 38 | if err := c.Delete("git-ignored/foo"); err != nil { 39 | t.Error(err) 40 | } 41 | 42 | if err := c.Delete("git-ignored/foo"); err == nil { 43 | t.Error("should be some sort of errorg") 44 | } 45 | 46 | if c.numOpenConns() != len(c.freeConnCh) { 47 | t.Error("Leaked a connection") 48 | } 49 | } 50 | } 51 | 52 | func TestRename(t *testing.T) { 53 | for _, addr := range ftpdAddrs { 54 | c, err := DialConfig(Config{User: "goftp", Password: "rocks"}, addr) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | 59 | os.Remove("testroot/git-ignored/foo") 60 | 61 | err = c.Store("git-ignored/foo", bytes.NewReader([]byte{1, 2, 3, 4})) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | _, err = os.Open("testroot/git-ignored/foo") 67 | if err != nil { 68 | t.Fatal("file is not there?", err) 69 | } 70 | 71 | if err := c.Rename("git-ignored/foo", "git-ignored/bar"); err != nil { 72 | t.Error(err) 73 | } 74 | 75 | newContents, err := ioutil.ReadFile("testroot/git-ignored/bar") 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | 80 | if !bytes.Equal(newContents, []byte{1, 2, 3, 4}) { 81 | t.Error("file contents wrong", newContents) 82 | } 83 | 84 | if c.numOpenConns() != len(c.freeConnCh) { 85 | t.Error("Leaked a connection") 86 | } 87 | } 88 | } 89 | 90 | func TestMkdirRmdir(t *testing.T) { 91 | for _, addr := range ftpdAddrs { 92 | c, err := DialConfig(Config{User: "goftp", Password: "rocks"}, addr) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | os.Remove("testroot/git-ignored/foodir") 98 | 99 | _, err = c.Mkdir("git-ignored/foodir") 100 | if err != nil { 101 | t.Fatal(err) 102 | } 103 | 104 | stat, err := os.Stat("testroot/git-ignored/foodir") 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | 109 | if !stat.IsDir() { 110 | t.Error("should be a dir") 111 | } 112 | 113 | err = c.Rmdir("git-ignored/foodir") 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | 118 | _, err = os.Stat("testroot/git-ignored/foodir") 119 | if !os.IsNotExist(err) { 120 | t.Error("directory should be gone") 121 | } 122 | 123 | cwd, err := c.Getwd() 124 | if err != nil { 125 | t.Fatal(err) 126 | } 127 | 128 | os.Remove(`testroot/git-ignored/dir-with-"`) 129 | dir, err := c.Mkdir(`git-ignored/dir-with-"`) 130 | if dir != `git-ignored/dir-with-"` && dir != path.Join(cwd, `git-ignored/dir-with-"`) { 131 | t.Errorf("Unexpected dir-with-quote value: %s", dir) 132 | } 133 | 134 | if c.numOpenConns() != len(c.freeConnCh) { 135 | t.Error("Leaked a connection") 136 | } 137 | } 138 | } 139 | 140 | func mustParseTime(f, s string) time.Time { 141 | t, err := time.Parse(timeFormat, s) 142 | if err != nil { 143 | panic(err) 144 | } 145 | return t 146 | } 147 | 148 | func TestParseMLST(t *testing.T) { 149 | cases := []struct { 150 | raw string 151 | exp *ftpFile 152 | }{ 153 | { 154 | // dirs dont necessarily have size 155 | "modify=19991014192630;perm=fle;type=dir;unique=806U246E0B1;UNIX.group=1;UNIX.mode=0755;UNIX.owner=0; files", 156 | &ftpFile{ 157 | name: "files", 158 | mtime: mustParseTime(timeFormat, "19991014192630"), 159 | mode: os.FileMode(0755) | os.ModeDir, 160 | }, 161 | }, 162 | { 163 | // xlightftp (windows ftp server) mlsd output I found 164 | "size=1089207168;type=file;modify=20090426141232; adsl TV 2009-04-22 23-55-05 Jazz Icons Lionel Hampton Live in 1958 [Mezzo].avi", 165 | &ftpFile{ 166 | name: "adsl TV 2009-04-22 23-55-05 Jazz Icons Lionel Hampton Live in 1958 [Mezzo].avi", 167 | mtime: mustParseTime(timeFormat, "20090426141232"), 168 | mode: os.FileMode(0400), 169 | size: 1089207168, 170 | }, 171 | }, 172 | { 173 | // test "type=OS.unix=slink" 174 | "type=OS.unix=slink:;size=32;modify=20140728100902;UNIX.mode=0777;UNIX.uid=647;UNIX.gid=649;unique=fd01g1220c04; access-logs", 175 | &ftpFile{ 176 | name: "access-logs", 177 | mtime: mustParseTime(timeFormat, "20140728100902"), 178 | mode: os.FileMode(0777) | os.ModeSymlink, 179 | size: 32, 180 | }, 181 | }, 182 | { 183 | // test "type=OS.unix=symlink" 184 | "modify=20150928140340;perm=adfrw;size=6;type=OS.unix=symlink;unique=801U5AA227;UNIX.group=1000;UNIX.mode=0777;UNIX.owner=1000; slinkdir", 185 | &ftpFile{ 186 | name: "slinkdir", 187 | mtime: mustParseTime(timeFormat, "20150928140340"), 188 | mode: os.FileMode(0777) | os.ModeSymlink, 189 | size: 6, 190 | }, 191 | }, 192 | } 193 | 194 | for _, c := range cases { 195 | c.exp.raw = c.raw 196 | 197 | got, err := parseMLST(c.raw, false) 198 | if err != nil { 199 | t.Fatal(err) 200 | } 201 | gotFile := got.(*ftpFile) 202 | if !reflect.DeepEqual(gotFile, c.exp) { 203 | t.Errorf("exp %+v\n got %+v", c.exp, gotFile) 204 | } 205 | } 206 | } 207 | 208 | func compareFileInfos(a, b os.FileInfo) error { 209 | if a.Name() != b.Name() { 210 | return fmt.Errorf("Name(): %s != %s", a.Name(), b.Name()) 211 | } 212 | 213 | // reporting of size for directories is inconsistent 214 | if !a.IsDir() { 215 | if a.Size() != b.Size() { 216 | return fmt.Errorf("Size(): %d != %d", a.Size(), b.Size()) 217 | } 218 | } 219 | 220 | if a.Mode() != b.Mode() { 221 | return fmt.Errorf("Mode(): %s != %s", a.Mode(), b.Mode()) 222 | } 223 | 224 | if !a.ModTime().Truncate(time.Minute).Equal(b.ModTime().Truncate(time.Minute)) { 225 | return fmt.Errorf("ModTime() %s != %s", a.ModTime(), b.ModTime()) 226 | } 227 | 228 | if a.IsDir() != b.IsDir() { 229 | return fmt.Errorf("IsDir(): %v != %v", a.IsDir(), b.IsDir()) 230 | } 231 | 232 | return nil 233 | } 234 | 235 | func TestReadDir(t *testing.T) { 236 | for _, addr := range ftpdAddrs { 237 | c, err := DialConfig(goftpConfig, addr) 238 | 239 | if err != nil { 240 | t.Fatal(err) 241 | } 242 | 243 | list, err := c.ReadDir("") 244 | 245 | if err != nil { 246 | t.Fatal(err) 247 | } 248 | 249 | if len(list) != 4 { 250 | t.Errorf("expected 3 items, got %d", len(list)) 251 | } 252 | 253 | var names []string 254 | 255 | for _, item := range list { 256 | expected, err := os.Stat("testroot/" + item.Name()) 257 | if err != nil { 258 | t.Fatal(err) 259 | } 260 | 261 | if err := compareFileInfos(item, expected); err != nil { 262 | t.Errorf("mismatch on %s: %s (%s)", item.Name(), err, item.Sys().(string)) 263 | } 264 | 265 | names = append(names, item.Name()) 266 | } 267 | 268 | // sanity check names are what we expected 269 | sort.Strings(names) 270 | if !reflect.DeepEqual(names, []string{"email%40mail.com.txt", "git-ignored", "lorem.txt", "subdir"}) { 271 | t.Errorf("got: %v", names) 272 | } 273 | 274 | if c.numOpenConns() != len(c.freeConnCh) { 275 | t.Error("Leaked a connection") 276 | } 277 | } 278 | } 279 | 280 | func TestReadDirNoMLSD(t *testing.T) { 281 | // pureFTPD seems to have some issues with timestamps in LIST output 282 | for _, addr := range proAddrs { 283 | config := goftpConfig 284 | config.stubResponses = map[string]stubResponse{ 285 | "MLSD ": {500, "'MLSD ': command not understood."}, 286 | } 287 | 288 | c, err := DialConfig(config, addr) 289 | 290 | if err != nil { 291 | t.Fatal(err) 292 | } 293 | 294 | list, err := c.ReadDir("") 295 | 296 | if err != nil { 297 | t.Fatal(err) 298 | } 299 | 300 | if len(list) != 4 { 301 | t.Errorf("expected 3 items, got %d", len(list)) 302 | } 303 | 304 | var names []string 305 | 306 | for _, item := range list { 307 | expected, err := os.Stat("testroot/" + item.Name()) 308 | if err != nil { 309 | t.Fatal(err) 310 | } 311 | 312 | if err := compareFileInfos(item, expected); err != nil { 313 | t.Errorf("mismatch on %s: %s (%s)", item.Name(), err, item.Sys().(string)) 314 | } 315 | 316 | names = append(names, item.Name()) 317 | } 318 | 319 | // sanity check names are what we expected 320 | sort.Strings(names) 321 | if !reflect.DeepEqual(names, []string{"email%40mail.com.txt", "git-ignored", "lorem.txt", "subdir"}) { 322 | t.Errorf("got: %v", names) 323 | } 324 | 325 | if c.numOpenConns() != len(c.freeConnCh) { 326 | t.Error("Leaked a connection") 327 | } 328 | } 329 | } 330 | 331 | func TestStat(t *testing.T) { 332 | for _, addr := range ftpdAddrs { 333 | c, err := DialConfig(goftpConfig, addr) 334 | 335 | if err != nil { 336 | t.Fatal(err) 337 | } 338 | 339 | // check root 340 | info, err := c.Stat("") 341 | if err != nil { 342 | t.Fatal(err) 343 | } 344 | 345 | // work around inconsistency between pure-ftpd and proftpd 346 | var realStat os.FileInfo 347 | if info.Name() == "testroot" { 348 | realStat, err = os.Stat("testroot") 349 | } else { 350 | realStat, err = os.Stat("testroot/.") 351 | } 352 | if err != nil { 353 | t.Fatal(err) 354 | } 355 | 356 | if err := compareFileInfos(info, realStat); err != nil { 357 | t.Error(err) 358 | } 359 | 360 | // check a file 361 | info, err = c.Stat("subdir/1234.bin") 362 | if err != nil { 363 | t.Fatal(err) 364 | } 365 | 366 | realStat, err = os.Stat("testroot/subdir/1234.bin") 367 | if err != nil { 368 | t.Fatal(err) 369 | } 370 | 371 | if err := compareFileInfos(info, realStat); err != nil { 372 | t.Error(err) 373 | } 374 | 375 | // check a directory 376 | info, err = c.Stat("subdir") 377 | if err != nil { 378 | t.Fatal(err) 379 | } 380 | 381 | realStat, err = os.Stat("testroot/subdir") 382 | if err != nil { 383 | t.Fatal(err) 384 | } 385 | 386 | if err := compareFileInfos(info, realStat); err != nil { 387 | t.Error(err) 388 | } 389 | 390 | if c.numOpenConns() != len(c.freeConnCh) { 391 | t.Error("Leaked a connection") 392 | } 393 | } 394 | } 395 | 396 | func TestStatNoMLST(t *testing.T) { 397 | // pureFTPD seems to have some issues with timestamps in LIST output 398 | for _, addr := range proAddrs { 399 | config := goftpConfig 400 | config.stubResponses = map[string]stubResponse{ 401 | "MLST ": {500, "'MLST ': command not understood."}, 402 | "MLST subdir/1234.bin": {500, "'MLST ': command not understood."}, 403 | "MLST subdir": {500, "'MLST ': command not understood."}, 404 | } 405 | 406 | c, err := DialConfig(config, addr) 407 | 408 | if err != nil { 409 | t.Fatal(err) 410 | } 411 | 412 | // check a file 413 | info, err := c.Stat("subdir/1234.bin") 414 | if err != nil { 415 | t.Fatal(err) 416 | } 417 | 418 | realStat, err := os.Stat("testroot/subdir/1234.bin") 419 | if err != nil { 420 | t.Fatal(err) 421 | } 422 | 423 | if err := compareFileInfos(info, realStat); err != nil { 424 | t.Error(err) 425 | } 426 | 427 | if c.numOpenConns() != len(c.freeConnCh) { 428 | t.Error("Leaked a connection") 429 | } 430 | } 431 | } 432 | func TestGetwd(t *testing.T) { 433 | for _, addr := range ftpdAddrs { 434 | c, err := DialConfig(goftpConfig, addr) 435 | 436 | if err != nil { 437 | t.Fatal(err) 438 | } 439 | 440 | cwd, err := c.Getwd() 441 | if err != nil { 442 | t.Fatal(err) 443 | } 444 | 445 | realCwd, err := os.Getwd() 446 | if err != nil { 447 | t.Fatal(err) 448 | } 449 | 450 | if cwd != "/" && cwd != path.Join(realCwd, "testroot") { 451 | t.Errorf("Unexpected cwd: %s", cwd) 452 | } 453 | 454 | // cd into quote directory so we can test Getwd's quote handling 455 | os.Remove(`testroot/git-ignored/dir-with-"`) 456 | dir, err := c.Mkdir(`git-ignored/dir-with-"`) 457 | if err != nil { 458 | t.Fatal(err) 459 | } 460 | 461 | pconn, err := c.getIdleConn() 462 | if err != nil { 463 | t.Fatal(err) 464 | } 465 | 466 | err = pconn.sendCommandExpected(replyFileActionOkay, "CWD %s", dir) 467 | c.returnConn(pconn) 468 | 469 | if err != nil { 470 | t.Fatal(err) 471 | } 472 | 473 | dir, err = c.Getwd() 474 | if dir != `git-ignored/dir-with-"` && dir != path.Join(cwd, `git-ignored/dir-with-"`) { 475 | t.Errorf("Unexpected dir-with-quote value: %s", dir) 476 | } 477 | 478 | if c.numOpenConns() != len(c.freeConnCh) { 479 | t.Error("Leaked a connection") 480 | } 481 | } 482 | } 483 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "crypto/tls" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | // Error is an expanded error interface returned by all Client methods. 18 | // It allows discerning callers to discover potentially actionable qualities 19 | // of the error. 20 | type Error interface { 21 | error 22 | 23 | // Whether the error was transient and attempting the same operation 24 | // again may be succesful. This includes timeouts. 25 | Temporary() bool 26 | 27 | // If the error originated from an unexpected response from the server, this 28 | // will return the FTP response code. Otherwise it will return 0. 29 | Code() int 30 | 31 | // Similarly, this will return the text response from the server, or empty 32 | // string. 33 | Message() string 34 | } 35 | 36 | type ftpError struct { 37 | err error 38 | code int 39 | msg string 40 | timeout bool 41 | temporary bool 42 | } 43 | 44 | func (e ftpError) Error() string { 45 | if e.err != nil { 46 | return e.err.Error() 47 | } else { 48 | return fmt.Sprintf("unexpected response: %d-%s", e.code, e.msg) 49 | } 50 | } 51 | 52 | func (e ftpError) Temporary() bool { 53 | return e.temporary || transientNegativeCompletionReply(e.code) 54 | } 55 | 56 | func (e ftpError) Timeout() bool { 57 | return e.timeout 58 | } 59 | 60 | func (e ftpError) Code() int { 61 | if fe, _ := e.err.(Error); fe != nil { 62 | return fe.Code() 63 | } 64 | return e.code 65 | } 66 | 67 | func (e ftpError) Message() string { 68 | if fe, _ := e.err.(Error); fe != nil { 69 | return fe.Message() 70 | } 71 | return e.msg 72 | } 73 | 74 | // TLSMode represents the FTPS connection strategy. Servers cannot support 75 | // both modes on the same port. 76 | type TLSMode int 77 | 78 | const ( 79 | // TLSExplicit means the client first runs an explicit command ("AUTH TLS") 80 | // before switching to TLS. 81 | TLSExplicit TLSMode = 0 82 | 83 | // TLSImplicit means both sides already implicitly agree to use TLS, and the 84 | // client connects directly using TLS. 85 | TLSImplicit TLSMode = 1 86 | ) 87 | 88 | // for testing 89 | type stubResponse struct { 90 | code int 91 | msg string 92 | } 93 | 94 | // Config contains configuration for a Client object. 95 | type Config struct { 96 | // User name. Defaults to "anonymous". 97 | User string 98 | 99 | // User password. Defaults to "anonymous" if required. 100 | Password string 101 | 102 | // Maximum number of FTP connections to open per-host. Defaults to 5. Keep in 103 | // mind that FTP servers typically limit how many connections a single user 104 | // may have open at once, so you may need to lower this if you are doing 105 | // concurrent transfers. 106 | ConnectionsPerHost int 107 | 108 | // Timeout for opening connections, sending control commands, and each read/write 109 | // of data transfers. Defaults to 5 seconds. 110 | Timeout time.Duration 111 | 112 | // TLS Config used for FTPS. If provided, it will be an error if the server 113 | // does not support TLS. Both the control and data connection will use TLS. 114 | TLSConfig *tls.Config 115 | 116 | // FTPS mode. TLSExplicit means connect non-TLS, then upgrade connection to 117 | // TLS via "AUTH TLS" command. TLSImplicit means open the connection using 118 | // TLS. Defaults to TLSExplicit. 119 | TLSMode TLSMode 120 | 121 | // This flag controls whether to use IPv6 addresses found when resolving 122 | // hostnames. Defaults to false to prevent failures when your computer can't 123 | // IPv6. If the hostname(s) only resolve to IPv6 addresses, Dial() will still 124 | // try to use them as a last ditch effort. You can still directly give an 125 | // IPv6 address to Dial() even with this flag off. 126 | IPv6Lookup bool 127 | 128 | // Logging destination for debugging messages. Set to os.Stderr to log to stderr. 129 | // Password value will not be logged. 130 | Logger io.Writer 131 | 132 | // Time zone of the FTP server. Used when parsing mtime from "LIST" output if 133 | // server does not support "MLST"/"MLSD". Defaults to UTC. 134 | ServerLocation *time.Location 135 | 136 | // Enable "active" FTP data connections where the server connects to the client to 137 | // establish data connections (does not work if client is behind NAT). If TLSConfig 138 | // is specified, it will be used when listening for active connections. 139 | ActiveTransfers bool 140 | 141 | // Set the host:port to listen on for active data connections. If the host and/or 142 | // port is empty, the local address/port of the control connection will be used. A 143 | // port of 0 will listen on a random port. If not specified, the default behavior is 144 | // ":0", i.e. listen on the local control connection host and a random port. 145 | ActiveListenAddr string 146 | 147 | // Disables EPSV in favour of PASV. This is useful in cases where EPSV connections 148 | // neither complete nor downgrade to PASV successfully by themselves, resulting in 149 | // hung connections. 150 | DisableEPSV bool 151 | 152 | // For testing convenience. 153 | stubResponses map[string]stubResponse 154 | } 155 | 156 | // Client maintains a connection pool to the FTP server(s), so you typically only 157 | // need one Client object. Client methods are safe to call concurrently from 158 | // different goroutines, but once you are using all ConnectionsPerHost connections 159 | // per host, methods will block waiting for a free connection. 160 | type Client struct { 161 | config Config 162 | hosts []string 163 | freeConnCh chan *persistentConn 164 | numConnsPerHost map[string]int 165 | allCons map[int]*persistentConn 166 | connIdx int 167 | rawConnIdx int 168 | mu sync.Mutex 169 | t0 time.Time 170 | closed bool 171 | } 172 | 173 | // Construct and return a new client Conn, setting default config 174 | // values as necessary. 175 | func newClient(config Config, hosts []string) *Client { 176 | 177 | if config.ConnectionsPerHost <= 0 { 178 | config.ConnectionsPerHost = 5 179 | } 180 | 181 | if config.Timeout <= 0 { 182 | config.Timeout = 5 * time.Second 183 | } 184 | 185 | if config.User == "" { 186 | config.User = "anonymous" 187 | } 188 | 189 | if config.Password == "" { 190 | config.Password = "anonymous" 191 | } 192 | 193 | if config.ServerLocation == nil { 194 | config.ServerLocation = time.UTC 195 | } 196 | 197 | if config.ActiveListenAddr == "" { 198 | config.ActiveListenAddr = ":0" 199 | } 200 | 201 | return &Client{ 202 | config: config, 203 | freeConnCh: make(chan *persistentConn, len(hosts)*config.ConnectionsPerHost), 204 | t0: time.Now(), 205 | hosts: hosts, 206 | allCons: make(map[int]*persistentConn), 207 | numConnsPerHost: make(map[string]int), 208 | } 209 | } 210 | 211 | // Close closes all open server connections. Currently this does not attempt 212 | // to do any kind of polite FTP connection termination. It will interrupt 213 | // all transfers in progress. 214 | func (c *Client) Close() error { 215 | c.mu.Lock() 216 | if c.closed { 217 | c.mu.Unlock() 218 | return ftpError{err: errors.New("already closed")} 219 | } 220 | c.closed = true 221 | 222 | var conns []*persistentConn 223 | for _, conn := range c.allCons { 224 | conns = append(conns, conn) 225 | } 226 | c.mu.Unlock() 227 | 228 | for _, pconn := range conns { 229 | c.removeConn(pconn) 230 | } 231 | 232 | return nil 233 | } 234 | 235 | // Log a debug message in the context of the client (i.e. not for a 236 | // particular connection). 237 | func (c *Client) debug(f string, args ...interface{}) { 238 | if c.config.Logger == nil { 239 | return 240 | } 241 | 242 | fmt.Fprintf(c.config.Logger, "goftp: %.3f %s\n", 243 | time.Now().Sub(c.t0).Seconds(), 244 | fmt.Sprintf(f, args...), 245 | ) 246 | } 247 | 248 | func (c *Client) numOpenConns() int { 249 | var numOpen int 250 | for _, num := range c.numConnsPerHost { 251 | numOpen += int(num) 252 | } 253 | return numOpen 254 | } 255 | 256 | // Get an idle connection. 257 | func (c *Client) getIdleConn() (*persistentConn, error) { 258 | 259 | // First check for available connections in the channel. 260 | Loop: 261 | for { 262 | select { 263 | case pconn := <-c.freeConnCh: 264 | if pconn.broken { 265 | c.debug("#%d was ready (broken)", pconn.idx) 266 | c.mu.Lock() 267 | c.numConnsPerHost[pconn.host]-- 268 | c.mu.Unlock() 269 | c.removeConn(pconn) 270 | } else { 271 | c.debug("#%d was ready", pconn.idx) 272 | return pconn, nil 273 | } 274 | default: 275 | break Loop 276 | } 277 | } 278 | 279 | // No available connections. Loop until we can open a new one, or 280 | // one becomes available. 281 | for { 282 | c.mu.Lock() 283 | 284 | // can we open a connection to some host 285 | if c.numOpenConns() < len(c.hosts)*c.config.ConnectionsPerHost { 286 | c.connIdx++ 287 | idx := c.connIdx 288 | 289 | // find the next host with less than ConnectionsPerHost connections 290 | var host string 291 | for i := idx; i < idx+len(c.hosts); i++ { 292 | if c.numConnsPerHost[c.hosts[i%len(c.hosts)]] < c.config.ConnectionsPerHost { 293 | host = c.hosts[i%len(c.hosts)] 294 | break 295 | } 296 | } 297 | 298 | if host == "" { 299 | panic("this shouldn't be possible") 300 | } 301 | 302 | c.numConnsPerHost[host]++ 303 | 304 | c.mu.Unlock() 305 | 306 | pconn, err := c.openConn(idx, host) 307 | if err != nil { 308 | c.mu.Lock() 309 | c.numConnsPerHost[host]-- 310 | c.mu.Unlock() 311 | c.debug("#%d error connecting: %s", idx, err) 312 | } 313 | return pconn, err 314 | } 315 | 316 | c.mu.Unlock() 317 | 318 | // block waiting for a free connection 319 | pconn := <-c.freeConnCh 320 | 321 | if pconn.broken { 322 | c.debug("waited and got #%d (broken)", pconn.idx) 323 | c.mu.Lock() 324 | c.numConnsPerHost[pconn.host]-- 325 | c.mu.Unlock() 326 | c.removeConn(pconn) 327 | } else { 328 | c.debug("waited and got #%d", pconn.idx) 329 | return pconn, nil 330 | 331 | } 332 | } 333 | } 334 | 335 | func (c *Client) removeConn(pconn *persistentConn) { 336 | c.mu.Lock() 337 | delete(c.allCons, pconn.idx) 338 | c.mu.Unlock() 339 | pconn.close() 340 | } 341 | 342 | func (c *Client) returnConn(pconn *persistentConn) { 343 | c.freeConnCh <- pconn 344 | } 345 | 346 | // OpenRawConn opens a "raw" connection to the server which allows you to run any control 347 | // or data command you want. See the RawConn interface for more details. The RawConn will 348 | // not participate in the Client's pool (i.e. does not count against ConnectionsPerHost). 349 | func (c *Client) OpenRawConn() (RawConn, error) { 350 | c.mu.Lock() 351 | idx := c.rawConnIdx 352 | host := c.hosts[idx%len(c.hosts)] 353 | c.rawConnIdx++ 354 | c.mu.Unlock() 355 | return c.openConn(-(idx + 1), host) 356 | } 357 | 358 | // Open and set up a control connection. 359 | func (c *Client) openConn(idx int, host string) (pconn *persistentConn, err error) { 360 | pconn = &persistentConn{ 361 | idx: idx, 362 | features: make(map[string]string), 363 | config: c.config, 364 | t0: c.t0, 365 | currentType: "A", 366 | host: host, 367 | epsvNotSupported: c.config.DisableEPSV, 368 | } 369 | 370 | var conn net.Conn 371 | 372 | if c.config.TLSConfig != nil && c.config.TLSMode == TLSImplicit { 373 | pconn.debug("opening TLS control connection to %s", host) 374 | dialer := &net.Dialer{ 375 | Timeout: c.config.Timeout, 376 | } 377 | conn, err = tls.DialWithDialer(dialer, "tcp", host, pconn.config.TLSConfig) 378 | } else { 379 | pconn.debug("opening control connection to %s", host) 380 | conn, err = net.DialTimeout("tcp", host, c.config.Timeout) 381 | } 382 | 383 | var ( 384 | code int 385 | msg string 386 | ) 387 | 388 | if err != nil { 389 | var isTemporary bool 390 | if ne, ok := err.(net.Error); ok { 391 | isTemporary = ne.Temporary() 392 | } 393 | err = ftpError{ 394 | err: err, 395 | temporary: isTemporary, 396 | } 397 | goto Error 398 | } 399 | 400 | pconn.setControlConn(conn) 401 | 402 | code, msg, err = pconn.readResponse() 403 | if err != nil { 404 | goto Error 405 | } 406 | 407 | if code != replyServiceReady { 408 | err = ftpError{code: code, msg: msg} 409 | goto Error 410 | } 411 | 412 | if c.config.TLSConfig != nil && c.config.TLSMode == TLSExplicit { 413 | err = pconn.logInTLS() 414 | } else { 415 | err = pconn.logIn() 416 | } 417 | 418 | if err != nil { 419 | goto Error 420 | } 421 | 422 | if err = pconn.fetchFeatures(); err != nil { 423 | goto Error 424 | } 425 | 426 | c.mu.Lock() 427 | defer c.mu.Unlock() 428 | 429 | if c.closed { 430 | err = ftpError{err: errors.New("client closed")} 431 | goto Error 432 | } 433 | 434 | if idx >= 0 { 435 | c.allCons[idx] = pconn 436 | } 437 | return pconn, nil 438 | 439 | Error: 440 | pconn.close() 441 | return nil, err 442 | } 443 | -------------------------------------------------------------------------------- /file_system.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "bufio" 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | // time.Parse format string for parsing file mtimes. 19 | const timeFormat = "20060102150405" 20 | 21 | // Delete deletes the file "path". 22 | func (c *Client) Delete(path string) error { 23 | pconn, err := c.getIdleConn() 24 | if err != nil { 25 | return err 26 | } 27 | 28 | defer c.returnConn(pconn) 29 | 30 | return pconn.sendCommandExpected(replyFileActionOkay, "DELE %s", path) 31 | } 32 | 33 | // Rename renames file "from" to "to". 34 | func (c *Client) Rename(from, to string) error { 35 | pconn, err := c.getIdleConn() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | defer c.returnConn(pconn) 41 | 42 | err = pconn.sendCommandExpected(replyFileActionPending, "RNFR %s", from) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | return pconn.sendCommandExpected(replyFileActionOkay, "RNTO %s", to) 48 | } 49 | 50 | // Mkdir creates directory "path". The returned string is how the client 51 | // should refer to the created directory. 52 | func (c *Client) Mkdir(path string) (string, error) { 53 | pconn, err := c.getIdleConn() 54 | if err != nil { 55 | return "", err 56 | } 57 | 58 | defer c.returnConn(pconn) 59 | 60 | code, msg, err := pconn.sendCommand("MKD %s", path) 61 | if err != nil { 62 | return "", err 63 | } 64 | 65 | if code != replyDirCreated { 66 | return "", ftpError{code: code, msg: msg} 67 | } 68 | 69 | dir, err := extractDirName(msg) 70 | if err != nil { 71 | return "", err 72 | } 73 | 74 | return dir, nil 75 | } 76 | 77 | // Rmdir removes directory "path". 78 | func (c *Client) Rmdir(path string) error { 79 | pconn, err := c.getIdleConn() 80 | if err != nil { 81 | return err 82 | } 83 | 84 | defer c.returnConn(pconn) 85 | 86 | return pconn.sendCommandExpected(replyFileActionOkay, "RMD %s", path) 87 | } 88 | 89 | // Getwd returns the current working directory. 90 | func (c *Client) Getwd() (string, error) { 91 | pconn, err := c.getIdleConn() 92 | if err != nil { 93 | return "", err 94 | } 95 | 96 | defer c.returnConn(pconn) 97 | 98 | code, msg, err := pconn.sendCommand("PWD") 99 | if err != nil { 100 | return "", err 101 | } 102 | 103 | if code != replyDirCreated { 104 | return "", ftpError{code: code, msg: msg} 105 | } 106 | 107 | dir, err := extractDirName(msg) 108 | if err != nil { 109 | return "", err 110 | } 111 | 112 | return dir, nil 113 | } 114 | 115 | func commandNotSupporterdError(err error) bool { 116 | respCode := err.(ftpError).Code() 117 | return respCode == replyCommandSyntaxError || respCode == replyCommandNotImplemented 118 | } 119 | 120 | // ReadDir fetches the contents of a directory, returning a list of 121 | // os.FileInfo's which are relatively easy to work with programatically. It 122 | // will not return entries corresponding to the current directory or parent 123 | // directories. The os.FileInfo's fields may be incomplete depending on what 124 | // the server supports. If the server does not support "MLSD", "LIST" will 125 | // be used. You may have to set ServerLocation in your config to get (more) 126 | // accurate ModTimes in this case. 127 | func (c *Client) ReadDir(path string) ([]os.FileInfo, error) { 128 | entries, err := c.dataStringList("MLSD %s", path) 129 | 130 | parser := parseMLST 131 | 132 | if err != nil { 133 | if !commandNotSupporterdError(err) { 134 | return nil, err 135 | } 136 | 137 | entries, err = c.dataStringList("LIST %s", path) 138 | if err != nil { 139 | return nil, err 140 | } 141 | parser = func(entry string, skipSelfParent bool) (os.FileInfo, error) { 142 | return parseLIST(entry, c.config.ServerLocation, skipSelfParent) 143 | } 144 | } 145 | 146 | var ret []os.FileInfo 147 | for _, entry := range entries { 148 | info, err := parser(entry, true) 149 | if err != nil { 150 | c.debug("error in ReadDir: %s", err) 151 | return nil, err 152 | } 153 | 154 | if info == nil { 155 | continue 156 | } 157 | 158 | ret = append(ret, info) 159 | } 160 | 161 | return ret, nil 162 | } 163 | 164 | // Stat fetches details for a particular file. The os.FileInfo's fields may 165 | // be incomplete depending on what the server supports. If the server doesn't 166 | // support "MLST", "LIST" will be attempted, but "LIST" will not work if path 167 | // is a directory. You may have to set ServerLocation in your config to get 168 | // (more) accurate ModTimes when using "LIST". 169 | func (c *Client) Stat(path string) (os.FileInfo, error) { 170 | lines, err := c.controlStringList("MLST %s", path) 171 | if err != nil { 172 | if commandNotSupporterdError(err) { 173 | lines, err = c.dataStringList("LIST %s", path) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | if len(lines) != 1 { 179 | return nil, ftpError{err: fmt.Errorf("unexpected LIST response: %v", lines)} 180 | } 181 | 182 | return parseLIST(lines[0], c.config.ServerLocation, false) 183 | } 184 | return nil, err 185 | } 186 | 187 | if len(lines) != 3 { 188 | return nil, ftpError{err: fmt.Errorf("unexpected MLST response: %v", lines)} 189 | } 190 | 191 | return parseMLST(strings.TrimLeft(lines[1], " "), false) 192 | } 193 | 194 | func extractDirName(msg string) (string, error) { 195 | openQuote := strings.Index(msg, "\"") 196 | closeQuote := strings.LastIndex(msg, "\"") 197 | if openQuote == -1 || len(msg) == openQuote+1 || closeQuote <= openQuote { 198 | return "", ftpError{ 199 | err: fmt.Errorf("failed parsing directory name: %s", msg), 200 | } 201 | } 202 | return strings.Replace(msg[openQuote+1:closeQuote], `""`, `"`, -1), nil 203 | } 204 | 205 | func (c *Client) controlStringList(f string, args ...interface{}) ([]string, error) { 206 | pconn, err := c.getIdleConn() 207 | if err != nil { 208 | return nil, err 209 | } 210 | 211 | defer c.returnConn(pconn) 212 | 213 | cmd := fmt.Sprintf(f, args...) 214 | 215 | code, msg, err := pconn.sendCommand(cmd) 216 | 217 | if !positiveCompletionReply(code) { 218 | pconn.debug("unexpected response to %s: %d-%s", cmd, code, msg) 219 | return nil, ftpError{code: code, msg: msg} 220 | } 221 | 222 | return strings.Split(msg, "\n"), nil 223 | } 224 | 225 | func (c *Client) dataStringList(f string, args ...interface{}) ([]string, error) { 226 | pconn, err := c.getIdleConn() 227 | if err != nil { 228 | return nil, err 229 | } 230 | 231 | defer c.returnConn(pconn) 232 | 233 | dcGetter, err := pconn.prepareDataConn() 234 | if err != nil { 235 | return nil, err 236 | } 237 | 238 | cmd := fmt.Sprintf(f, args...) 239 | 240 | err = pconn.sendCommandExpected(replyGroupPreliminaryReply, cmd) 241 | if err != nil { 242 | return nil, err 243 | } 244 | 245 | dc, err := dcGetter() 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | // to catch early returns 251 | defer dc.Close() 252 | 253 | scanner := bufio.NewScanner(dc) 254 | scanner.Split(bufio.ScanLines) 255 | 256 | var res []string 257 | for scanner.Scan() { 258 | res = append(res, scanner.Text()) 259 | } 260 | 261 | var dataError error 262 | if err = scanner.Err(); err != nil { 263 | pconn.debug("error reading %s data: %s", cmd, err) 264 | dataError = ftpError{ 265 | err: fmt.Errorf("error reading %s data: %s", cmd, err), 266 | temporary: true, 267 | } 268 | } 269 | 270 | err = dc.Close() 271 | if err != nil { 272 | pconn.debug("error closing data connection: %s", err) 273 | } 274 | 275 | code, msg, err := pconn.readResponse() 276 | if err != nil { 277 | return nil, err 278 | } 279 | 280 | if !positiveCompletionReply(code) { 281 | pconn.debug("unexpected result: %d-%s", code, msg) 282 | return nil, ftpError{code: code, msg: msg} 283 | } 284 | 285 | if dataError != nil { 286 | return nil, dataError 287 | } 288 | 289 | return res, nil 290 | } 291 | 292 | type ftpFile struct { 293 | name string 294 | size int64 295 | mode os.FileMode 296 | mtime time.Time 297 | raw string 298 | } 299 | 300 | func (f *ftpFile) Name() string { 301 | return f.name 302 | } 303 | 304 | func (f *ftpFile) Size() int64 { 305 | return f.size 306 | } 307 | 308 | func (f *ftpFile) Mode() os.FileMode { 309 | return f.mode 310 | } 311 | 312 | func (f *ftpFile) ModTime() time.Time { 313 | return f.mtime 314 | } 315 | 316 | func (f *ftpFile) IsDir() bool { 317 | return f.mode.IsDir() 318 | } 319 | 320 | func (f *ftpFile) Sys() interface{} { 321 | return f.raw 322 | } 323 | 324 | var lsRegex = regexp.MustCompile(`^\s*(\S)(\S{3})(\S{3})(\S{3})(?:\s+\S+){3}\s+(\d+)\s+(\w+\s+\d+)\s+([\d:]+)\s+(.+)$`) 325 | 326 | // total 404456 327 | // drwxr-xr-x 8 goftp 20 272 Jul 28 05:03 git-ignored 328 | func parseLIST(entry string, loc *time.Location, skipSelfParent bool) (os.FileInfo, error) { 329 | if strings.HasPrefix(entry, "total ") { 330 | return nil, nil 331 | } 332 | 333 | matches := lsRegex.FindStringSubmatch(entry) 334 | if len(matches) == 0 { 335 | return nil, ftpError{err: fmt.Errorf(`failed parsing LIST entry: %s`, entry)} 336 | } 337 | 338 | if skipSelfParent && (matches[8] == "." || matches[8] == "..") { 339 | return nil, nil 340 | } 341 | 342 | var mode os.FileMode 343 | switch matches[1] { 344 | case "d": 345 | mode |= os.ModeDir 346 | case "l": 347 | mode |= os.ModeSymlink 348 | } 349 | 350 | for i := 0; i < 3; i++ { 351 | if matches[i+2][0] == 'r' { 352 | mode |= os.FileMode(04 << (3 * uint(2-i))) 353 | } 354 | if matches[i+2][1] == 'w' { 355 | mode |= os.FileMode(02 << (3 * uint(2-i))) 356 | } 357 | if matches[i+2][2] == 'x' || matches[i+2][2] == 's' { 358 | mode |= os.FileMode(01 << (3 * uint(2-i))) 359 | } 360 | } 361 | 362 | size, err := strconv.ParseUint(matches[5], 10, 64) 363 | if err != nil { 364 | return nil, ftpError{err: fmt.Errorf(`failed parsing LIST entry's size: %s (%s)`, err, entry)} 365 | } 366 | 367 | var mtime time.Time 368 | if strings.Contains(matches[7], ":") { 369 | mtime, err = time.ParseInLocation("Jan _2 15:04", matches[6]+" "+matches[7], loc) 370 | if err == nil { 371 | now := time.Now() 372 | year := now.Year() 373 | if mtime.Month() > now.Month() { 374 | year-- 375 | } 376 | mtime, err = time.ParseInLocation("Jan _2 15:04 2006", matches[6]+" "+matches[7]+" "+strconv.Itoa(year), loc) 377 | } 378 | } else { 379 | mtime, err = time.ParseInLocation("Jan _2 2006", matches[6]+" "+matches[7], loc) 380 | } 381 | 382 | if err != nil { 383 | return nil, ftpError{err: fmt.Errorf(`failed parsing LIST entry's mtime: %s (%s)`, err, entry)} 384 | } 385 | 386 | info := &ftpFile{ 387 | name: filepath.Base(matches[8]), 388 | mode: mode, 389 | mtime: mtime, 390 | raw: entry, 391 | size: int64(size), 392 | } 393 | 394 | return info, nil 395 | } 396 | 397 | // an entry looks something like this: 398 | // type=file;size=12;modify=20150216084148;UNIX.mode=0644;unique=1000004g1187ec7; lorem.txt 399 | func parseMLST(entry string, skipSelfParent bool) (os.FileInfo, error) { 400 | parseError := ftpError{err: fmt.Errorf(`failed parsing MLST entry: %s`, entry)} 401 | incompleteError := ftpError{err: fmt.Errorf(`MLST entry incomplete: %s`, entry)} 402 | 403 | parts := strings.Split(entry, "; ") 404 | if len(parts) != 2 { 405 | return nil, parseError 406 | } 407 | 408 | facts := make(map[string]string) 409 | for _, factPair := range strings.Split(parts[0], ";") { 410 | factParts := strings.SplitN(factPair, "=", 2) 411 | if len(factParts) != 2 { 412 | return nil, parseError 413 | } 414 | facts[strings.ToLower(factParts[0])] = strings.ToLower(factParts[1]) 415 | } 416 | 417 | typ := facts["type"] 418 | 419 | if typ == "" { 420 | return nil, incompleteError 421 | } 422 | 423 | if skipSelfParent && (typ == "cdir" || typ == "pdir" || typ == "." || typ == "..") { 424 | return nil, nil 425 | } 426 | 427 | var mode os.FileMode 428 | if facts["unix.mode"] != "" { 429 | m, err := strconv.ParseInt(facts["unix.mode"], 8, 32) 430 | if err != nil { 431 | return nil, parseError 432 | } 433 | mode = os.FileMode(m) 434 | } else if facts["perm"] != "" { 435 | // see http://tools.ietf.org/html/rfc3659#section-7.5.5 436 | for _, c := range facts["perm"] { 437 | switch c { 438 | case 'a', 'd', 'c', 'f', 'm', 'p', 'w': 439 | // these suggest you have write permissions 440 | mode |= 0200 441 | case 'l': 442 | // can list dir entries means readable and executable 443 | mode |= 0500 444 | case 'r': 445 | // readable file 446 | mode |= 0400 447 | } 448 | } 449 | } else { 450 | // no mode info, just say it's readable to us 451 | mode = 0400 452 | } 453 | 454 | if typ == "dir" || typ == "cdir" || typ == "pdir" { 455 | mode |= os.ModeDir 456 | } else if strings.HasPrefix(typ, "os.unix=slink") || strings.HasPrefix(typ, "os.unix=symlink") { 457 | // note: there is no general way to determine whether a symlink points to a dir or a file 458 | mode |= os.ModeSymlink 459 | } 460 | 461 | var ( 462 | size int64 463 | err error 464 | ) 465 | 466 | if facts["size"] != "" { 467 | size, err = strconv.ParseInt(facts["size"], 10, 64) 468 | } else if mode.IsDir() && facts["sizd"] != "" { 469 | size, err = strconv.ParseInt(facts["sizd"], 10, 64) 470 | } else if facts["type"] == "file" { 471 | return nil, incompleteError 472 | } 473 | 474 | if err != nil { 475 | return nil, parseError 476 | } 477 | 478 | if facts["modify"] == "" { 479 | return nil, incompleteError 480 | } 481 | 482 | mtime, err := time.ParseInLocation(timeFormat, facts["modify"], time.UTC) 483 | if err != nil { 484 | return nil, incompleteError 485 | } 486 | 487 | info := &ftpFile{ 488 | name: filepath.Base(parts[1]), 489 | size: size, 490 | mtime: mtime, 491 | raw: entry, 492 | mode: mode, 493 | } 494 | 495 | return info, nil 496 | } 497 | -------------------------------------------------------------------------------- /persistent_connection.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Muir Manders. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package goftp 6 | 7 | import ( 8 | "bufio" 9 | "crypto/tls" 10 | "fmt" 11 | "net" 12 | "net/textproto" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type RawConn interface { 19 | // Sends command fmt.Sprintf(f, args...) to the server, returning the response code, 20 | // response message, and error if any. 21 | SendCommand(f string, args ...interface{}) (int, string, error) 22 | 23 | // Prepares a data connection to the server. PrepareDataConn returns a getter function 24 | // because in active transfer mode you must first call PrepareDataConn (to tell server 25 | // what port to connect to), then send a control command to tell the server to initiate 26 | // a connection, then finally you invoke the getter function to get the actual 27 | // net.Conn. 28 | PrepareDataConn() (func() (net.Conn, error), error) 29 | 30 | // Read a pending response from the server. This is necessary after completing a 31 | // data command since the server sends an unsolicited response you must read. 32 | ReadResponse() (int, string, error) 33 | 34 | // Close the control and data connection, if open. 35 | Close() error 36 | } 37 | 38 | // Represents a single connection to an FTP server. 39 | type persistentConn struct { 40 | // control socket 41 | controlConn net.Conn 42 | 43 | // data socket (tracked so we can close it on client.Close()) 44 | dataConn net.Conn 45 | 46 | // control socket read/write helpers 47 | reader *textproto.Reader 48 | writer *textproto.Writer 49 | 50 | config Config 51 | t0 time.Time 52 | 53 | // has this connection encountered an unrecoverable error 54 | broken bool 55 | 56 | // index of this connection (used for logging context and 57 | // round-roubin host selection) 58 | idx int 59 | 60 | // map of ftp features available on server 61 | features map[string]string 62 | 63 | // remember EPSV support 64 | epsvNotSupported bool 65 | 66 | // tracks the current type (e.g. ASCII/Image) of connection 67 | currentType string 68 | 69 | host string 70 | } 71 | 72 | func (pconn *persistentConn) SendCommand(f string, args ...interface{}) (int, string, error) { 73 | return pconn.sendCommand(f, args...) 74 | } 75 | 76 | func (pconn *persistentConn) PrepareDataConn() (func() (net.Conn, error), error) { 77 | return pconn.prepareDataConn() 78 | } 79 | 80 | func (pconn *persistentConn) ReadResponse() (int, string, error) { 81 | return pconn.readResponse() 82 | } 83 | 84 | func (pconn *persistentConn) Close() error { 85 | return pconn.close() 86 | } 87 | 88 | func (pconn *persistentConn) setControlConn(conn net.Conn) { 89 | pconn.controlConn = conn 90 | pconn.reader = textproto.NewReader(bufio.NewReader(conn)) 91 | pconn.writer = textproto.NewWriter(bufio.NewWriter(conn)) 92 | } 93 | 94 | func (pconn *persistentConn) close() error { 95 | pconn.debug("closing") 96 | 97 | if pconn.dataConn != nil { 98 | // ignore "already closed" error since typically the user of dataConn will 99 | // close it, but we still want to make sure it's closed here 100 | pconn.dataConn.Close() 101 | } 102 | 103 | if pconn.controlConn != nil { 104 | return pconn.controlConn.Close() 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func (pconn *persistentConn) sendCommandExpected(expected int, f string, args ...interface{}) error { 111 | code, msg, err := pconn.sendCommand(f, args...) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | var ok bool 117 | switch expected { 118 | case replyGroupPositiveCompletion, replyGroupPreliminaryReply: 119 | ok = code/100 == expected 120 | default: 121 | ok = code == expected 122 | } 123 | 124 | if !ok { 125 | return ftpError{code: code, msg: msg} 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (pconn *persistentConn) sendCommand(f string, args ...interface{}) (int, string, error) { 132 | cmd := fmt.Sprintf(f, args...) 133 | 134 | logName := cmd 135 | if strings.HasPrefix(cmd, "PASS") { 136 | logName = "PASS ******" 137 | } 138 | 139 | pconn.debug("sending command %s", logName) 140 | 141 | if pconn.config.stubResponses != nil { 142 | if stub, found := pconn.config.stubResponses[cmd]; found { 143 | pconn.debug("got stub response %d-%s", stub.code, stub.msg) 144 | return stub.code, stub.msg, nil 145 | } 146 | } 147 | 148 | pconn.controlConn.SetWriteDeadline(time.Now().Add(pconn.config.Timeout)) 149 | err := pconn.writer.PrintfLine("%s", cmd) 150 | 151 | if err != nil { 152 | pconn.broken = true 153 | pconn.debug(`error sending command "%s": %s`, logName, err) 154 | return 0, "", ftpError{ 155 | err: fmt.Errorf("error writing command: %s", err), 156 | temporary: true, 157 | } 158 | } 159 | 160 | code, msg, err := pconn.readResponse() 161 | if err != nil { 162 | return 0, "", err 163 | } 164 | 165 | pconn.debug("got %d-%s", code, msg) 166 | 167 | return code, msg, err 168 | } 169 | 170 | func (pconn *persistentConn) readResponse() (int, string, error) { 171 | pconn.controlConn.SetReadDeadline(time.Now().Add(pconn.config.Timeout)) 172 | code, msg, err := pconn.reader.ReadResponse(0) 173 | if err != nil { 174 | pconn.broken = true 175 | pconn.debug("error reading response: %s", err) 176 | err = ftpError{ 177 | err: fmt.Errorf("error reading response: %s", err), 178 | temporary: true, 179 | } 180 | } 181 | return code, msg, err 182 | } 183 | 184 | func (pconn *persistentConn) debug(f string, args ...interface{}) { 185 | if pconn.config.Logger == nil { 186 | return 187 | } 188 | 189 | fmt.Fprintf(pconn.config.Logger, "goftp: %.3f #%d %s\n", 190 | time.Now().Sub(pconn.t0).Seconds(), 191 | pconn.idx, 192 | fmt.Sprintf(f, args...), 193 | ) 194 | } 195 | 196 | func (pconn *persistentConn) fetchFeatures() error { 197 | code, msg, err := pconn.sendCommand("FEAT") 198 | if err != nil { 199 | return err 200 | } 201 | 202 | if !positiveCompletionReply(code) { 203 | pconn.debug("server doesn't support FEAT: %d-%s", code, msg) 204 | return nil 205 | } 206 | 207 | for _, line := range strings.Split(msg, "\n") { 208 | if len(line) > 0 && line[0] == ' ' { 209 | parts := strings.SplitN(strings.TrimSpace(line), " ", 2) 210 | if len(parts) == 1 { 211 | pconn.features[strings.ToUpper(parts[0])] = "" 212 | } else if len(parts) == 2 { 213 | pconn.features[strings.ToUpper(parts[0])] = parts[1] 214 | } 215 | } 216 | } 217 | 218 | return nil 219 | } 220 | 221 | func (pconn *persistentConn) hasFeature(name string) bool { 222 | _, found := pconn.features[name] 223 | return found 224 | } 225 | 226 | func (pconn *persistentConn) hasFeatureWithArg(name, arg string) bool { 227 | val, found := pconn.features[name] 228 | return found && strings.ToUpper(arg) == val 229 | } 230 | 231 | func (pconn *persistentConn) logIn() error { 232 | if pconn.config.User == "" { 233 | return nil 234 | } 235 | 236 | code, msg, err := pconn.sendCommand("USER %s", pconn.config.User) 237 | if err != nil { 238 | pconn.broken = true 239 | return err 240 | } 241 | 242 | if code == replyNeedPassword { 243 | code, msg, err = pconn.sendCommand("PASS %s", pconn.config.Password) 244 | if err != nil { 245 | return err 246 | } 247 | } 248 | 249 | if !positiveCompletionReply(code) { 250 | return ftpError{code: code, msg: msg} 251 | } 252 | 253 | if pconn.config.TLSConfig != nil && pconn.config.TLSMode == TLSImplicit { 254 | 255 | err = pconn.sendCommandExpected(replyGroupPositiveCompletion, "PBSZ 0") 256 | if err != nil { 257 | return err 258 | } 259 | 260 | err = pconn.sendCommandExpected(replyGroupPositiveCompletion, "PROT P") 261 | if err != nil { 262 | return err 263 | } 264 | } 265 | 266 | return nil 267 | } 268 | 269 | // Request that the server enters passive mode, allowing us to connect to it. 270 | // This lets transfers work with the client behind NAT, so you almost always 271 | // want it. First try EPSV, then fall back to PASV. 272 | func (pconn *persistentConn) requestPassive() (string, error) { 273 | var ( 274 | startIdx int 275 | endIdx int 276 | port int 277 | remoteHost string 278 | code int 279 | msg string 280 | err error 281 | ) 282 | 283 | if pconn.epsvNotSupported { 284 | goto PASV 285 | } 286 | 287 | // Extended PaSsiVe (same idea as PASV, but works with IPv6). 288 | // See http://tools.ietf.org/html/rfc2428. 289 | code, msg, err = pconn.sendCommand("EPSV") 290 | if err != nil { 291 | return "", err 292 | } 293 | 294 | if code != replyEnteringExtendedPassiveMode { 295 | pconn.debug("server doesn't support EPSV: %d-%s", code, msg) 296 | pconn.epsvNotSupported = true 297 | goto PASV 298 | } 299 | 300 | startIdx = strings.Index(msg, "|||") 301 | endIdx = strings.LastIndex(msg, "|") 302 | if startIdx == -1 || endIdx == -1 || startIdx+3 > endIdx { 303 | pconn.debug("failed parsing EPSV response: %s", msg) 304 | goto PASV 305 | } 306 | 307 | port, err = strconv.Atoi(msg[startIdx+3 : endIdx]) 308 | if err != nil { 309 | pconn.debug("EPSV response didn't contain port: %s", msg) 310 | goto PASV 311 | } 312 | 313 | remoteHost, _, err = net.SplitHostPort(pconn.controlConn.RemoteAddr().String()) 314 | if err != nil { 315 | pconn.debug("failed determining remote host: %s", err) 316 | goto PASV 317 | } 318 | 319 | return fmt.Sprintf("[%s]:%d", remoteHost, port), nil 320 | 321 | PASV: 322 | code, msg, err = pconn.sendCommand("PASV") 323 | if err != nil { 324 | return "", err 325 | } 326 | 327 | if code != replyEnteringPassiveMode { 328 | return "", ftpError{code: code, msg: msg} 329 | } 330 | 331 | parseError := ftpError{ 332 | err: fmt.Errorf("error parsing PASV response (%s)", msg), 333 | } 334 | 335 | // "Entering Passive Mode (162,138,208,11,223,57)." 336 | startIdx = strings.Index(msg, "(") 337 | endIdx = strings.LastIndex(msg, ")") 338 | if startIdx == -1 || endIdx == -1 || startIdx > endIdx { 339 | return "", parseError 340 | } 341 | 342 | addrParts := strings.Split(msg[startIdx+1:endIdx], ",") 343 | if len(addrParts) != 6 { 344 | return "", parseError 345 | } 346 | 347 | ip := net.ParseIP(strings.Join(addrParts[0:4], ".")) 348 | if ip == nil { 349 | return "", parseError 350 | } 351 | 352 | port = 0 353 | for i, part := range addrParts[4:6] { 354 | portOctet, err := strconv.Atoi(part) 355 | if err != nil { 356 | return "", parseError 357 | } 358 | port |= portOctet << (byte(1-i) * 8) 359 | } 360 | 361 | return net.JoinHostPort(ip.String(), strconv.Itoa(port)), nil 362 | } 363 | 364 | type dataConn struct { 365 | net.Conn 366 | Timeout time.Duration 367 | } 368 | 369 | func (c *dataConn) Read(buf []byte) (int, error) { 370 | c.Conn.SetReadDeadline(time.Now().Add(c.Timeout)) 371 | return c.Conn.Read(buf) 372 | } 373 | 374 | func (c *dataConn) Write(buf []byte) (int, error) { 375 | c.Conn.SetWriteDeadline(time.Now().Add(c.Timeout)) 376 | return c.Conn.Write(buf) 377 | } 378 | 379 | func (pconn *persistentConn) prepareDataConn() (func() (net.Conn, error), error) { 380 | if pconn.config.ActiveTransfers { 381 | listener, err := pconn.listenActive() 382 | if err != nil { 383 | return nil, err 384 | } 385 | 386 | return func() (net.Conn, error) { 387 | defer func() { 388 | if err := listener.Close(); err != nil { 389 | pconn.debug("error closing data connection listener: %s", err) 390 | } 391 | }() 392 | 393 | listener.SetDeadline(time.Now().Add(pconn.config.Timeout)) 394 | dc, netErr := listener.Accept() 395 | 396 | if netErr != nil { 397 | var isTemporary bool 398 | if ne, ok := netErr.(net.Error); ok { 399 | isTemporary = ne.Temporary() 400 | } 401 | return nil, ftpError{err: netErr, temporary: isTemporary} 402 | } 403 | 404 | if pconn.config.TLSConfig != nil { 405 | dc = tls.Server(dc, pconn.config.TLSConfig) 406 | pconn.debug("upgraded active connection to TLS") 407 | } 408 | 409 | pconn.dataConn = &dataConn{ 410 | Conn: dc, 411 | Timeout: pconn.config.Timeout, 412 | } 413 | return pconn.dataConn, nil 414 | }, nil 415 | } else { 416 | host, err := pconn.requestPassive() 417 | if err != nil { 418 | return nil, err 419 | } 420 | 421 | pconn.debug("opening data connection to %s", host) 422 | dc, netErr := net.DialTimeout("tcp", host, pconn.config.Timeout) 423 | 424 | if netErr != nil { 425 | var isTemporary bool 426 | if ne, ok := netErr.(net.Error); ok { 427 | isTemporary = ne.Temporary() 428 | } 429 | return nil, ftpError{err: netErr, temporary: isTemporary} 430 | } 431 | 432 | if pconn.config.TLSConfig != nil { 433 | pconn.debug("upgrading data connection to TLS") 434 | dc = tls.Client(dc, pconn.config.TLSConfig) 435 | } 436 | 437 | return func() (net.Conn, error) { 438 | pconn.dataConn = &dataConn{ 439 | Conn: dc, 440 | Timeout: pconn.config.Timeout, 441 | } 442 | return pconn.dataConn, nil 443 | }, nil 444 | } 445 | } 446 | 447 | func (pconn *persistentConn) listenActive() (*net.TCPListener, error) { 448 | listenAddr := pconn.config.ActiveListenAddr 449 | 450 | localAddr := pconn.controlConn.LocalAddr().String() 451 | localHost, localPort, err := net.SplitHostPort(localAddr) 452 | if err != nil { 453 | return nil, ftpError{err: fmt.Errorf("error splitting local address: %s (%s)", err, localAddr)} 454 | } 455 | 456 | if listenAddr == ":" { 457 | listenAddr = localAddr 458 | } else if listenAddr[len(listenAddr)-1] == ':' { 459 | listenAddr = net.JoinHostPort(listenAddr[0:len(listenAddr)-1], localPort) 460 | } else if listenAddr[0] == ':' { 461 | listenAddr = net.JoinHostPort(localHost, listenAddr[1:]) 462 | } 463 | 464 | tcpAddr, err := net.ResolveTCPAddr("tcp", listenAddr) 465 | if err != nil { 466 | return nil, ftpError{err: fmt.Errorf("error parsing active listen addr: %s (%s)", err, listenAddr)} 467 | } 468 | 469 | listener, err := net.ListenTCP("tcp", tcpAddr) 470 | if err != nil { 471 | return nil, ftpError{err: fmt.Errorf("error listening on %s for active transfer: %s", listenAddr, err)} 472 | } 473 | pconn.debug("listening on %s for active connection", listener.Addr().String()) 474 | 475 | listenHost, listenPortStr, err := net.SplitHostPort(listener.Addr().String()) 476 | if err != nil { 477 | return nil, ftpError{err: fmt.Errorf("error splitting listener addr: %s (%s)", err, listener.Addr().String())} 478 | } 479 | 480 | listenPort, err := strconv.Atoi(listenPortStr) 481 | if err != nil { 482 | return nil, ftpError{err: fmt.Errorf("error parsing listen port: %s (%s)", err, listenPortStr)} 483 | } 484 | 485 | hostIP := net.ParseIP(listenHost) 486 | if hostIP == nil { 487 | return nil, ftpError{err: fmt.Errorf("failed parsing host IP %s", listenHost)} 488 | } 489 | 490 | hostIPv4 := hostIP.To4() 491 | if hostIPv4 == nil { 492 | if err := pconn.sendCommandExpected(200, "EPRT |%d|%s|%d|", 2, listenHost, listenPort); err != nil { 493 | return nil, err 494 | } 495 | } else { 496 | err := pconn.sendCommandExpected(200, "PORT %d,%d,%d,%d,%d,%d", 497 | hostIPv4[0], hostIPv4[1], hostIPv4[2], hostIPv4[3], 498 | listenPort>>8, listenPort&0xFF, 499 | ) 500 | if err != nil { 501 | return nil, err 502 | } 503 | } 504 | 505 | return listener, nil 506 | } 507 | 508 | func (pconn *persistentConn) setType(t string) error { 509 | if pconn.currentType == t { 510 | pconn.debug("type already set to %s", t) 511 | return nil 512 | } 513 | err := pconn.sendCommandExpected(replyCommandOkay, "TYPE %s", t) 514 | if err != nil { 515 | pconn.currentType = t 516 | } 517 | return err 518 | } 519 | 520 | func (pconn *persistentConn) logInTLS() error { 521 | err := pconn.sendCommandExpected(replyAuthOkayNoDataNeeded, "AUTH TLS") 522 | if err != nil { 523 | return err 524 | } 525 | 526 | pconn.setControlConn(tls.Client(pconn.controlConn, pconn.config.TLSConfig)) 527 | 528 | err = pconn.logIn() 529 | if err != nil { 530 | return err 531 | } 532 | 533 | err = pconn.sendCommandExpected(replyGroupPositiveCompletion, "PBSZ 0") 534 | if err != nil { 535 | return err 536 | } 537 | 538 | err = pconn.sendCommandExpected(replyGroupPositiveCompletion, "PROT P") 539 | if err != nil { 540 | return err 541 | } 542 | 543 | pconn.debug("successfully upgraded to TLS") 544 | 545 | return nil 546 | } 547 | --------------------------------------------------------------------------------