pax_global_header00006660000000000000000000000064144332301040014504gustar00rootroot0000000000000052 comment=2c2aa379fd3e5e6b6126576a19fd19da9e091c19 ftp-0.2.0/000077500000000000000000000000001443323010400122745ustar00rootroot00000000000000ftp-0.2.0/.github/000077500000000000000000000000001443323010400136345ustar00rootroot00000000000000ftp-0.2.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001443323010400160175ustar00rootroot00000000000000ftp-0.2.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000010521443323010400205070ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: defect assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** ``` Please include a mininal test case as code ``` **Expected behavior** A clear and concise description of what you expected to happen. **FTP server** - Name and version: - Public URL if applicable **Debug output** ``` Please include the ouput generated via DialWithDebugOuput ``` **Additional context** Add any other context about the problem here. ftp-0.2.0/.github/dependabot.yml000066400000000000000000000011721443323010400164650ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "gomod" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "daily" assignees: - "jlaffaye" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" ftp-0.2.0/.github/stale.yml000066400000000000000000000012401443323010400154640ustar00rootroot00000000000000# Number of days of inactivity before an issue becomes stale daysUntilStale: 30 # Number of days of inactivity before a stale issue is closed daysUntilClose: 14 # Issues with these labels will never be considered stale exemptLabels: - accepted # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false ftp-0.2.0/.github/workflows/000077500000000000000000000000001443323010400156715ustar00rootroot00000000000000ftp-0.2.0/.github/workflows/codeql-analysis.yml000066400000000000000000000047641443323010400215170ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ "master" ] pull_request: # The branches below must be a subset of the branches above branches: [ "master" ] schedule: - cron: '20 19 * * 2' permissions: contents: read jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@b398f525a5587552e573b247ac661067fafa920b ftp-0.2.0/.github/workflows/golangci-lint.yaml000066400000000000000000000010031443323010400212760ustar00rootroot00000000000000name: golangci-lint on: [push, pull_request] jobs: golangci-lint: name: lint runs-on: ubuntu-latest permissions: contents: read # for actions/checkout to fetch code pull-requests: read # for golangci/golangci-lint-action to fetch pull requests steps: - uses: actions/checkout@7884fcad6b5d53d10323aee724dc68d8b9096a2e - name: golangci-lint uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 with: only-new-issues: true ftp-0.2.0/.github/workflows/unit_tests.yaml000066400000000000000000000017241443323010400207620ustar00rootroot00000000000000name: Units tests on: [push, pull_request] jobs: checks: name: test runs-on: ubuntu-latest steps: - uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 - name: Setup go uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 with: go-version: 1.19 - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 with: path: | ~/go/pkg/mod ~/.cache/go-build key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - name: Run tests run: go test -v -covermode=count -coverprofile=coverage.out - name: Convert coverage to lcov uses: jandelgado/gcov2lcov-action@c680c0f7c7442485f1749eb2a13e54a686e76eb5 - name: Coveralls uses: coverallsapp/github-action@f350da2c033043742f89e8c0b7b5145a1616da6d with: github-token: ${{ secrets.github_token }} path-to-lcov: coverage.lcov ftp-0.2.0/LICENSE000066400000000000000000000013711443323010400133030ustar00rootroot00000000000000Copyright (c) 2011-2013, Julien Laffaye Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ftp-0.2.0/README.md000066400000000000000000000032031443323010400135510ustar00rootroot00000000000000# goftp # [![Units tests](https://github.com/jlaffaye/ftp/actions/workflows/unit_tests.yaml/badge.svg)](https://github.com/jlaffaye/ftp/actions/workflows/unit_tests.yaml) [![Coverage Status](https://coveralls.io/repos/jlaffaye/ftp/badge.svg?branch=master&service=github)](https://coveralls.io/github/jlaffaye/ftp?branch=master) [![golangci-lint](https://github.com/jlaffaye/ftp/actions/workflows/golangci-lint.yaml/badge.svg)](https://github.com/jlaffaye/ftp/actions/workflows/golangci-lint.yaml) [![CodeQL](https://github.com/jlaffaye/ftp/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/jlaffaye/ftp/actions/workflows/codeql-analysis.yml) [![Go ReportCard](https://goreportcard.com/badge/jlaffaye/ftp)](http://goreportcard.com/report/jlaffaye/ftp) [![Go Reference](https://pkg.go.dev/badge/github.com/jlaffaye/ftp.svg)](https://pkg.go.dev/github.com/jlaffaye/ftp) A FTP client package for Go ## Install ## ``` go get -u github.com/jlaffaye/ftp ``` ## Documentation ## https://pkg.go.dev/github.com/jlaffaye/ftp ## Example ## ```go c, err := ftp.Dial("ftp.example.org:21", ftp.DialWithTimeout(5*time.Second)) if err != nil { log.Fatal(err) } err = c.Login("anonymous", "anonymous") if err != nil { log.Fatal(err) } // Do something with the FTP conn if err := c.Quit(); err != nil { log.Fatal(err) } ``` ## Store a file example ## ```go data := bytes.NewBufferString("Hello World") err = c.Stor("test-file.txt", data) if err != nil { panic(err) } ``` ## Read a file example ## ```go r, err := c.Retr("test-file.txt") if err != nil { panic(err) } defer r.Close() buf, err := ioutil.ReadAll(r) println(string(buf)) ``` ftp-0.2.0/client_test.go000066400000000000000000000217221443323010400151440ustar00rootroot00000000000000package ftp import ( "bytes" "fmt" "io" "net" "syscall" "testing" "time" "github.com/stretchr/testify/assert" ) const ( testData = "Just some text" testDir = "mydir" ) func TestConnPASV(t *testing.T) { testConn(t, true) } func TestConnEPSV(t *testing.T) { testConn(t, false) } func testConn(t *testing.T, disableEPSV bool) { assert := assert.New(t) mock, c := openConn(t, "127.0.0.1", DialWithTimeout(5*time.Second), DialWithDisabledEPSV(disableEPSV)) err := c.Login("anonymous", "anonymous") assert.NoError(err) err = c.NoOp() assert.NoError(err) err = c.ChangeDir("incoming") assert.NoError(err) dir, err := c.CurrentDir() if assert.NoError(err) { assert.Equal("/incoming", dir) } data := bytes.NewBufferString(testData) err = c.Stor("test", data) assert.NoError(err) _, err = c.List(".") assert.NoError(err) err = c.Rename("test", "tset") assert.NoError(err) // Read without deadline r, err := c.Retr("tset") if assert.NoError(err) { buf, err := io.ReadAll(r) if assert.NoError(err) { assert.Equal(testData, string(buf)) } r.Close() r.Close() // test we can close two times } // Read with deadline r, err = c.Retr("tset") if assert.NoError(err) { if err := r.SetDeadline(time.Now()); err != nil { t.Fatal(err) } _, err = io.ReadAll(r) assert.ErrorContains(err, "i/o timeout") r.Close() } // Read with offset r, err = c.RetrFrom("tset", 5) if assert.NoError(err) { buf, err := io.ReadAll(r) if assert.NoError(err) { expected := testData[5:] assert.Equal(expected, string(buf)) } r.Close() } data2 := bytes.NewBufferString(testData) err = c.Append("tset", data2) assert.NoError(err) // Read without deadline, after append r, err = c.Retr("tset") if assert.NoError(err) { buf, err := io.ReadAll(r) if assert.NoError(err) { assert.Equal(testData+testData, string(buf)) } r.Close() } fileSize, err := c.FileSize("magic-file") assert.NoError(err) assert.Equal(int64(42), fileSize) _, err = c.FileSize("not-found") assert.Error(err) entry, err := c.GetEntry("magic-file") if err != nil { t.Error(err) } if entry == nil { t.Fatal("expected entry, got nil") } if entry.Size != 42 { t.Errorf("entry size %q, expected %q", entry.Size, 42) } if entry.Type != EntryTypeFile { t.Errorf("entry type %q, expected %q", entry.Type, EntryTypeFile) } if entry.Name != "magic-file" { t.Errorf("entry name %q, expected %q", entry.Name, "magic-file") } entry, err = c.GetEntry("multiline-dir") if err != nil { t.Error(err) } if entry == nil { t.Fatal("expected entry, got nil") } if entry.Size != 0 { t.Errorf("entry size %q, expected %q", entry.Size, 0) } if entry.Type != EntryTypeFolder { t.Errorf("entry type %q, expected %q", entry.Type, EntryTypeFolder) } if entry.Name != "multiline-dir" { t.Errorf("entry name %q, expected %q", entry.Name, "multiline-dir") } err = c.Delete("tset") assert.NoError(err) err = c.MakeDir(testDir) assert.NoError(err) err = c.ChangeDir(testDir) assert.NoError(err) err = c.ChangeDirToParent() assert.NoError(err) entries, err := c.NameList("/") assert.NoError(err) assert.Equal([]string{"/incoming"}, entries) err = c.RemoveDir(testDir) assert.NoError(err) err = c.Logout() assert.NoError(err) if err = c.Quit(); err != nil { t.Fatal(err) } // Wait for the connection to close mock.Wait() err = c.NoOp() assert.Error(err, "should error on closed conn") } // TestConnect tests the legacy Connect function func TestConnect(t *testing.T) { mock, err := newFtpMock(t, "127.0.0.1") if err != nil { t.Fatal(err) } defer mock.Close() c, err := Connect(mock.Addr()) if err != nil { t.Fatal(err) } if err := c.Quit(); err != nil { t.Fatal(err) } mock.Wait() } func TestTimeout(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } if c, err := DialTimeout("localhost:2121", 1*time.Second); err == nil { _ = c.Quit() t.Fatal("expected timeout, got nil error") } } func TestWrongLogin(t *testing.T) { mock, err := newFtpMock(t, "127.0.0.1") if err != nil { t.Fatal(err) } defer mock.Close() c, err := DialTimeout(mock.Addr(), 5*time.Second) if err != nil { t.Fatal(err) } defer func() { if err := c.Quit(); err != nil { t.Errorf("can not quit: %s", err) } }() err = c.Login("zoo2Shia", "fei5Yix9") if err == nil { t.Fatal("expected error, got nil") } } func TestDeleteDirRecur(t *testing.T) { mock, c := openConn(t, "127.0.0.1") err := c.RemoveDirRecur("testDir") if err != nil { t.Error(err) } if err := c.Quit(); err != nil { t.Fatal(err) } // Wait for the connection to close mock.Wait() } // func TestFileDeleteDirRecur(t *testing.T) { // mock, c := openConn(t, "127.0.0.1") // err := c.RemoveDirRecur("testFile") // if err == nil { // t.Fatal("expected error got nil") // } // if err := c.Quit(); err != nil { // t.Fatal(err) // } // // Wait for the connection to close // mock.Wait() // } func TestMissingFolderDeleteDirRecur(t *testing.T) { mock, c := openConn(t, "127.0.0.1") err := c.RemoveDirRecur("missing-dir") if err == nil { t.Fatal("expected error got nil") } if err := c.Quit(); err != nil { t.Fatal(err) } // Wait for the connection to close mock.Wait() } func TestListCurrentDir(t *testing.T) { mock, c := openConnExt(t, "127.0.0.1", "no-time", DialWithDisabledMLSD(true)) _, err := c.List("") assert.NoError(t, err) assert.Equal(t, "LIST", mock.lastFull, "LIST must not have a trailing whitespace") _, err = c.NameList("") assert.NoError(t, err) assert.Equal(t, "NLST", mock.lastFull, "NLST must not have a trailing whitespace") err = c.Quit() assert.NoError(t, err) mock.Wait() } func TestListCurrentDirWithForceListHidden(t *testing.T) { mock, c := openConnExt(t, "127.0.0.1", "no-time", DialWithDisabledMLSD(true), DialWithForceListHidden(true)) assert.True(t, c.options.forceListHidden) _, err := c.List("") assert.NoError(t, err) assert.Equal(t, "LIST -a", mock.lastFull, "LIST -a must not have a trailing whitespace") err = c.Quit() assert.NoError(t, err) mock.Wait() } func TestTimeUnsupported(t *testing.T) { mock, c := openConnExt(t, "127.0.0.1", "no-time") assert.False(t, c.mdtmSupported, "MDTM must NOT be supported") assert.False(t, c.mfmtSupported, "MFMT must NOT be supported") assert.False(t, c.IsGetTimeSupported(), "GetTime must NOT be supported") assert.False(t, c.IsSetTimeSupported(), "SetTime must NOT be supported") _, err := c.GetTime("file1") assert.NotNil(t, err) err = c.SetTime("file1", time.Now()) assert.NotNil(t, err) assert.NoError(t, c.Quit()) mock.Wait() } func TestTimeStandard(t *testing.T) { mock, c := openConnExt(t, "127.0.0.1", "std-time") assert.True(t, c.mdtmSupported, "MDTM must be supported") assert.True(t, c.mfmtSupported, "MFMT must be supported") assert.True(t, c.IsGetTimeSupported(), "GetTime must be supported") assert.True(t, c.IsSetTimeSupported(), "SetTime must be supported") tm, err := c.GetTime("file1") assert.NoError(t, err) assert.False(t, tm.IsZero(), "GetTime must return valid time") err = c.SetTime("file1", time.Now()) assert.NoError(t, err) assert.NoError(t, c.Quit()) mock.Wait() } func TestTimeVsftpdPartial(t *testing.T) { mock, c := openConnExt(t, "127.0.0.1", "vsftpd") assert.True(t, c.mdtmSupported, "MDTM must be supported") assert.False(t, c.mfmtSupported, "MFMT must NOT be supported") assert.True(t, c.IsGetTimeSupported(), "GetTime must be supported") assert.False(t, c.IsSetTimeSupported(), "SetTime must NOT be supported") tm, err := c.GetTime("file1") assert.NoError(t, err) assert.False(t, tm.IsZero(), "GetTime must return valid time") err = c.SetTime("file1", time.Now()) assert.NotNil(t, err) assert.NoError(t, c.Quit()) mock.Wait() } func TestTimeVsftpdFull(t *testing.T) { mock, c := openConnExt(t, "127.0.0.1", "vsftpd", DialWithWritingMDTM(true)) assert.True(t, c.mdtmSupported, "MDTM must be supported") assert.False(t, c.mfmtSupported, "MFMT must NOT be supported") assert.True(t, c.IsGetTimeSupported(), "GetTime must be supported") assert.True(t, c.IsSetTimeSupported(), "SetTime must be supported") tm, err := c.GetTime("file1") assert.NoError(t, err) assert.False(t, tm.IsZero(), "GetTime must return valid time") err = c.SetTime("file1", time.Now()) assert.NoError(t, err) assert.NoError(t, c.Quit()) mock.Wait() } func TestDialWithDialFunc(t *testing.T) { dialErr := fmt.Errorf("this is proof that dial function was called") f := func(network, address string) (net.Conn, error) { return nil, dialErr } _, err := Dial("bogus-address", DialWithDialFunc(f)) assert.Equal(t, dialErr, err) } func TestDialWithDialer(t *testing.T) { dialerCalled := false dialer := net.Dialer{ Control: func(network, address string, c syscall.RawConn) error { dialerCalled = true return nil }, } mock, err := newFtpMock(t, "127.0.0.1") assert.NoError(t, err) c, err := Dial(mock.Addr(), DialWithDialer(dialer)) assert.NoError(t, err) assert.NoError(t, c.Quit()) assert.Equal(t, true, dialerCalled) } ftp-0.2.0/conn_test.go000066400000000000000000000255461443323010400146330ustar00rootroot00000000000000package ftp import ( "bytes" "errors" "io" "net" "net/textproto" "strconv" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type ftpMock struct { t *testing.T address string modtime string // no-time, std-time, vsftpd listener *net.TCPListener proto *textproto.Conn commands []string // list of received commands lastFull string // full last command rest int fileCont *bytes.Buffer dataConn *mockDataConn sync.WaitGroup } // newFtpMock returns a mock implementation of a FTP server // For simplication, a mock instance only accepts a signle connection and terminates afer func newFtpMock(t *testing.T, address string) (*ftpMock, error) { return newFtpMockExt(t, address, "no-time") } func newFtpMockExt(t *testing.T, address, modtime string) (*ftpMock, error) { var err error mock := &ftpMock{ t: t, address: address, modtime: modtime, } l, err := net.Listen("tcp", address+":0") if err != nil { return nil, err } tcpListener, ok := l.(*net.TCPListener) if !ok { return nil, errors.New("listener is not a net.TCPListener") } mock.listener = tcpListener go mock.listen() return mock, nil } func (mock *ftpMock) listen() { // Listen for an incoming connection. conn, err := mock.listener.Accept() if err != nil { mock.t.Errorf("can not accept: %s", err) return } // Do not accept incoming connections anymore mock.listener.Close() mock.Add(1) defer mock.Done() defer conn.Close() mock.proto = textproto.NewConn(conn) mock.printfLine("220 FTP Server ready.") for { fullCommand, _ := mock.proto.ReadLine() mock.lastFull = fullCommand cmdParts := strings.Split(fullCommand, " ") // Append to list of received commands mock.commands = append(mock.commands, cmdParts[0]) // At least one command must have a multiline response switch cmdParts[0] { case "FEAT": features := "211-Features:\r\n FEAT\r\n PASV\r\n EPSV\r\n UTF8\r\n SIZE\r\n MLST\r\n" switch mock.modtime { case "std-time": features += " MDTM\r\n MFMT\r\n" case "vsftpd": features += " MDTM\r\n" } features += "211 End" mock.printfLine(features) case "USER": if cmdParts[1] == "anonymous" { mock.printfLine("331 Please send your password") } else { mock.printfLine("530 This FTP server is anonymous only") } case "PASS": mock.printfLine("230-Hey,\r\nWelcome to my FTP\r\n230 Access granted") case "TYPE": mock.printfLine("200 Type set ok") case "CWD": if cmdParts[1] == "missing-dir" { mock.printfLine("550 %s: No such file or directory", cmdParts[1]) } else { mock.printfLine("250 Directory successfully changed.") } case "DELE": mock.printfLine("250 File successfully removed.") case "MKD": mock.printfLine("257 Directory successfully created.") case "RMD": if cmdParts[1] == "missing-dir" { mock.printfLine("550 No such file or directory") } else { mock.printfLine("250 Directory successfully removed.") } case "PWD": mock.printfLine("257 \"/incoming\"") case "CDUP": mock.printfLine("250 CDUP command successful") case "SIZE": if cmdParts[1] == "magic-file" { mock.printfLine("213 42") } else { mock.printfLine("550 Could not get file size.") } case "PASV": p, err := mock.listenDataConn() if err != nil { mock.printfLine("451 %s.", err) break } p1 := int(p / 256) p2 := p % 256 mock.printfLine("227 Entering Passive Mode (127,0,0,1,%d,%d).", p1, p2) case "EPSV": p, err := mock.listenDataConn() if err != nil { mock.printfLine("451 %s.", err) break } mock.printfLine("229 Entering Extended Passive Mode (|||%d|)", p) case "STOR": if mock.dataConn == nil { mock.printfLine("425 Unable to build data connection: Connection refused") break } mock.printfLine("150 please send") mock.recvDataConn(false) case "APPE": if mock.dataConn == nil { mock.printfLine("425 Unable to build data connection: Connection refused") break } mock.printfLine("150 please send") mock.recvDataConn(true) case "LIST": if mock.dataConn == nil { mock.printfLine("425 Unable to build data connection: Connection refused") break } mock.dataConn.Wait() mock.printfLine("150 Opening ASCII mode data connection for file list") mock.dataConn.write([]byte("-rw-r--r-- 1 ftp wheel 0 Jan 29 10:29 lo\r\ntotal 1")) mock.printfLine("226 Transfer complete") mock.closeDataConn() case "MLSD": if mock.dataConn == nil { mock.printfLine("425 Unable to build data connection: Connection refused") break } mock.dataConn.Wait() mock.printfLine("150 Opening data connection for file list") mock.dataConn.write([]byte("Type=file;Size=0;Modify=20201213202400; lo\r\n")) mock.printfLine("226 Transfer complete") mock.closeDataConn() case "MLST": if cmdParts[1] == "multiline-dir" { mock.printfLine("250-File data\r\n Type=dir;Size=0; multiline-dir\r\n Modify=20201213202400; multiline-dir\r\n250 End") } else { mock.printfLine("250-File data\r\n Type=file;Size=42;Modify=20201213202400; magic-file\r\n \r\n250 End") } case "NLST": if mock.dataConn == nil { mock.printfLine("425 Unable to build data connection: Connection refused") break } mock.dataConn.Wait() mock.printfLine("150 Opening ASCII mode data connection for file list") mock.dataConn.write([]byte("/incoming")) mock.printfLine("226 Transfer complete") mock.closeDataConn() case "RETR": if mock.dataConn == nil { mock.printfLine("425 Unable to build data connection: Connection refused") break } mock.dataConn.Wait() mock.printfLine("150 Opening ASCII mode data connection for file list") mock.dataConn.write(mock.fileCont.Bytes()[mock.rest:]) mock.rest = 0 mock.printfLine("226 Transfer complete") mock.closeDataConn() case "RNFR": mock.printfLine("350 File or directory exists, ready for destination name") case "RNTO": mock.printfLine("250 Rename successful") case "REST": if len(cmdParts) != 2 { mock.printfLine("500 wrong number of arguments") break } rest, err := strconv.Atoi(cmdParts[1]) if err != nil { mock.printfLine("500 REST: %s", err) break } mock.rest = rest mock.printfLine("350 Restarting at %s. Send STORE or RETRIEVE to initiate transfer", cmdParts[1]) case "MDTM": var answer string switch { case mock.modtime == "no-time": answer = "500 Unknown command MDTM" case len(cmdParts) == 3 && mock.modtime == "vsftpd": answer = "213 UTIME OK" _, err := time.ParseInLocation(timeFormat, cmdParts[1], time.UTC) if err != nil { answer = "501 Can't get a time stamp" } case len(cmdParts) == 2: answer = "213 20201213202400" default: answer = "500 wrong number of arguments" } mock.printfLine(answer) case "MFMT": var answer string switch { case mock.modtime == "std-time" && len(cmdParts) == 3: answer = "213 UTIME OK" _, err := time.ParseInLocation(timeFormat, cmdParts[1], time.UTC) if err != nil { answer = "501 Can't get a time stamp" } default: answer = "500 Unknown command MFMT" } mock.printfLine(answer) case "NOOP": mock.printfLine("200 NOOP ok.") case "OPTS": if len(cmdParts) != 3 { mock.printfLine("500 wrong number of arguments") break } if (strings.Join(cmdParts[1:], " ")) == "UTF8 ON" { mock.printfLine("200 OK, UTF-8 enabled") } case "REIN": mock.printfLine("220 Logged out") case "QUIT": mock.printfLine("221 Goodbye.") return default: mock.printfLine("500 Unknown command %s.", cmdParts[0]) } } } func (mock *ftpMock) printfLine(format string, args ...interface{}) { if err := mock.proto.Writer.PrintfLine(format, args...); err != nil { mock.t.Fatal(err) } } func (mock *ftpMock) closeDataConn() { if mock.dataConn != nil { if err := mock.dataConn.Close(); err != nil { mock.t.Fatal(err) } mock.dataConn = nil } } type mockDataConn struct { t *testing.T listener *net.TCPListener conn net.Conn // WaitGroup is done when conn is accepted and stored sync.WaitGroup } func (d *mockDataConn) Close() (err error) { if d.listener != nil { err = d.listener.Close() } if d.conn != nil { err = d.conn.Close() } return } func (d *mockDataConn) write(b []byte) { if d.conn == nil { d.t.Fatal("data conn is not opened") } if _, err := d.conn.Write(b); err != nil { d.t.Fatal(err) } } func (mock *ftpMock) listenDataConn() (int64, error) { mock.closeDataConn() l, err := net.Listen("tcp", mock.address+":0") if err != nil { return 0, err } tcpListener, ok := l.(*net.TCPListener) if !ok { return 0, errors.New("listener is not a net.TCPListener") } addr := tcpListener.Addr().String() _, port, err := net.SplitHostPort(addr) if err != nil { return 0, err } p, err := strconv.ParseInt(port, 10, 32) if err != nil { return 0, err } dataConn := &mockDataConn{ t: mock.t, listener: tcpListener, } dataConn.Add(1) go func() { // Listen for an incoming connection. conn, err := dataConn.listener.Accept() if err != nil { // mock.t.Fatalf("can not accept data conn: %s", err) return } dataConn.conn = conn dataConn.Done() }() mock.dataConn = dataConn return p, nil } func (mock *ftpMock) recvDataConn(append bool) { mock.dataConn.Wait() if !append { mock.fileCont = new(bytes.Buffer) } if _, err := io.Copy(mock.fileCont, mock.dataConn.conn); err != nil { mock.t.Fatal(err) } mock.printfLine("226 Transfer Complete") mock.closeDataConn() } func (mock *ftpMock) Addr() string { return mock.listener.Addr().String() } // Closes the listening socket func (mock *ftpMock) Close() { mock.listener.Close() } // Helper to return a client connected to a mock server func openConn(t *testing.T, addr string, options ...DialOption) (*ftpMock, *ServerConn) { return openConnExt(t, addr, "no-time", options...) } func openConnExt(t *testing.T, addr, modtime string, options ...DialOption) (*ftpMock, *ServerConn) { mock, err := newFtpMockExt(t, addr, modtime) require.NoError(t, err) defer mock.Close() c, err := Dial(mock.Addr(), options...) require.NoError(t, err) err = c.Login("anonymous", "anonymous") require.NoError(t, err) return mock, c } // Helper to close a client connected to a mock server func closeConn(t *testing.T, mock *ftpMock, c *ServerConn, commands []string) { expected := []string{"USER", "PASS", "FEAT", "TYPE", "OPTS"} expected = append(expected, commands...) expected = append(expected, "QUIT") if err := c.Quit(); err != nil { t.Fatal(err) } // Wait for the connection to close mock.Wait() assert.Equal(t, expected, mock.commands, "unexpected sequence of commands") } func TestConn4(t *testing.T) { mock, c := openConn(t, "127.0.0.1") closeConn(t, mock, c, nil) } func TestConn6(t *testing.T) { mock, c := openConn(t, "[::1]") closeConn(t, mock, c, nil) } ftp-0.2.0/constants_test.go000066400000000000000000000007061443323010400157010ustar00rootroot00000000000000package ftp import ( "testing" "github.com/stretchr/testify/assert" ) func TestStatusText(t *testing.T) { assert.Equal(t, "Unknown status code: 0", StatusText(0)) assert.Equal(t, "Invalid username or password.", StatusText(StatusInvalidCredentials)) } func TestEntryTypeString(t *testing.T) { assert.Equal(t, "file", EntryTypeFile.String()) assert.Equal(t, "folder", EntryTypeFolder.String()) assert.Equal(t, "link", EntryTypeLink.String()) } ftp-0.2.0/debug.go000066400000000000000000000012201443323010400137040ustar00rootroot00000000000000package ftp import "io" type debugWrapper struct { conn io.ReadWriteCloser io.Reader io.Writer } func newDebugWrapper(conn io.ReadWriteCloser, w io.Writer) io.ReadWriteCloser { return &debugWrapper{ Reader: io.TeeReader(conn, w), Writer: io.MultiWriter(w, conn), conn: conn, } } func (w *debugWrapper) Close() error { return w.conn.Close() } type streamDebugWrapper struct { io.Reader closer io.ReadCloser } func newStreamDebugWrapper(rd io.ReadCloser, w io.Writer) io.ReadCloser { return &streamDebugWrapper{ Reader: io.TeeReader(rd, w), closer: rd, } } func (w *streamDebugWrapper) Close() error { return w.closer.Close() } ftp-0.2.0/ftp.go000066400000000000000000000754221443323010400134260ustar00rootroot00000000000000// Package ftp implements a FTP client as described in RFC 959. // // A textproto.Error is returned for errors at the protocol level. package ftp import ( "bufio" "context" "crypto/tls" "errors" "io" "net" "net/textproto" "strconv" "strings" "time" "github.com/hashicorp/go-multierror" ) const ( // 30 seconds was chosen as it's the // same duration as http.DefaultTransport's timeout. DefaultDialTimeout = 30 * time.Second ) // EntryType describes the different types of an Entry. type EntryType int // The differents types of an Entry const ( EntryTypeFile EntryType = iota EntryTypeFolder EntryTypeLink ) // TransferType denotes the formats for transferring Entries. type TransferType string // The different transfer types const ( TransferTypeBinary = TransferType("I") TransferTypeASCII = TransferType("A") ) // Time format used by the MDTM and MFMT commands const timeFormat = "20060102150405" // ServerConn represents the connection to a remote FTP server. // A single connection only supports one in-flight data connection. // It is not safe to be called concurrently. type ServerConn struct { options *dialOptions conn *textproto.Conn // connection wrapper for text protocol netConn net.Conn // underlying network connection host string // Server capabilities discovered at runtime features map[string]string skipEPSV bool mlstSupported bool mfmtSupported bool mdtmSupported bool mdtmCanWrite bool usePRET bool } // DialOption represents an option to start a new connection with Dial type DialOption struct { setup func(do *dialOptions) } // dialOptions contains all the options set by DialOption.setup type dialOptions struct { context context.Context dialer net.Dialer tlsConfig *tls.Config explicitTLS bool disableEPSV bool disableUTF8 bool disableMLSD bool writingMDTM bool forceListHidden bool location *time.Location debugOutput io.Writer dialFunc func(network, address string) (net.Conn, error) shutTimeout time.Duration // time to wait for data connection closing status } // Entry describes a file and is returned by List(). type Entry struct { Name string Target string // target of symbolic link Type EntryType Size uint64 Time time.Time } // Response represents a data-connection type Response struct { conn net.Conn c *ServerConn closed bool } // Dial connects to the specified address with optional options func Dial(addr string, options ...DialOption) (*ServerConn, error) { do := &dialOptions{} for _, option := range options { option.setup(do) } if do.location == nil { do.location = time.UTC } dialFunc := do.dialFunc if dialFunc == nil { ctx := do.context if ctx == nil { ctx = context.Background() } if _, ok := ctx.Deadline(); !ok { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, DefaultDialTimeout) defer cancel() } if do.tlsConfig != nil && !do.explicitTLS { dialFunc = func(network, address string) (net.Conn, error) { tlsDialer := &tls.Dialer{ NetDialer: &do.dialer, Config: do.tlsConfig, } return tlsDialer.DialContext(ctx, network, addr) } } else { dialFunc = func(network, address string) (net.Conn, error) { return do.dialer.DialContext(ctx, network, addr) } } } tconn, err := dialFunc("tcp", addr) if err != nil { return nil, err } // Use the resolved IP address in case addr contains a domain name // If we use the domain name, we might not resolve to the same IP. remoteAddr := tconn.RemoteAddr().(*net.TCPAddr) c := &ServerConn{ options: do, features: make(map[string]string), conn: textproto.NewConn(do.wrapConn(tconn)), netConn: tconn, host: remoteAddr.IP.String(), } _, _, err = c.conn.ReadResponse(StatusReady) if err != nil { _ = c.Quit() return nil, err } if do.explicitTLS { if err := c.authTLS(); err != nil { _ = c.Quit() return nil, err } tconn = tls.Client(tconn, do.tlsConfig) c.conn = textproto.NewConn(do.wrapConn(tconn)) } return c, nil } // DialWithTimeout returns a DialOption that configures the ServerConn with specified timeout func DialWithTimeout(timeout time.Duration) DialOption { return DialOption{func(do *dialOptions) { do.dialer.Timeout = timeout }} } // DialWithShutTimeout returns a DialOption that configures the ServerConn with // maximum time to wait for the data closing status on control connection // and nudging the control connection deadline before reading status. func DialWithShutTimeout(shutTimeout time.Duration) DialOption { return DialOption{func(do *dialOptions) { do.shutTimeout = shutTimeout }} } // DialWithDialer returns a DialOption that configures the ServerConn with specified net.Dialer func DialWithDialer(dialer net.Dialer) DialOption { return DialOption{func(do *dialOptions) { do.dialer = dialer }} } // DialWithNetConn returns a DialOption that configures the ServerConn with the underlying net.Conn // // Deprecated: Use [DialWithDialFunc] instead func DialWithNetConn(conn net.Conn) DialOption { return DialWithDialFunc(func(network, address string) (net.Conn, error) { return conn, nil }) } // DialWithDisabledEPSV returns a DialOption that configures the ServerConn with EPSV disabled // Note that EPSV is only used when advertised in the server features. func DialWithDisabledEPSV(disabled bool) DialOption { return DialOption{func(do *dialOptions) { do.disableEPSV = disabled }} } // DialWithDisabledUTF8 returns a DialOption that configures the ServerConn with UTF8 option disabled func DialWithDisabledUTF8(disabled bool) DialOption { return DialOption{func(do *dialOptions) { do.disableUTF8 = disabled }} } // DialWithDisabledMLSD returns a DialOption that configures the ServerConn with MLSD option disabled // // This is useful for servers which advertise MLSD (eg some versions // of Serv-U) but don't support it properly. func DialWithDisabledMLSD(disabled bool) DialOption { return DialOption{func(do *dialOptions) { do.disableMLSD = disabled }} } // DialWithWritingMDTM returns a DialOption making ServerConn use MDTM to set file time // // This option addresses a quirk in the VsFtpd server which doesn't support // the MFMT command for setting file time like other servers but by default // uses the MDTM command with non-standard arguments for that. // See "mdtm_write" in https://security.appspot.com/vsftpd/vsftpd_conf.html func DialWithWritingMDTM(enabled bool) DialOption { return DialOption{func(do *dialOptions) { do.writingMDTM = enabled }} } // DialWithForceListHidden returns a DialOption making ServerConn use LIST -a to include hidden files and folders in directory listings // // This is useful for servers that do not do this by default, but it forces the use of the LIST command // even if the server supports MLST. func DialWithForceListHidden(enabled bool) DialOption { return DialOption{func(do *dialOptions) { do.forceListHidden = enabled }} } // DialWithLocation returns a DialOption that configures the ServerConn with specified time.Location // The location is used to parse the dates sent by the server which are in server's timezone func DialWithLocation(location *time.Location) DialOption { return DialOption{func(do *dialOptions) { do.location = location }} } // DialWithContext returns a DialOption that configures the ServerConn with specified context // The context will be used for the initial connection setup func DialWithContext(ctx context.Context) DialOption { return DialOption{func(do *dialOptions) { do.context = ctx }} } // DialWithTLS returns a DialOption that configures the ServerConn with specified TLS config // // If called together with the DialWithDialFunc option, the DialWithDialFunc function // will be used when dialing new connections but regardless of the function, // the connection will be treated as a TLS connection. func DialWithTLS(tlsConfig *tls.Config) DialOption { return DialOption{func(do *dialOptions) { do.tlsConfig = tlsConfig }} } // DialWithExplicitTLS returns a DialOption that configures the ServerConn to be upgraded to TLS // See DialWithTLS for general TLS documentation func DialWithExplicitTLS(tlsConfig *tls.Config) DialOption { return DialOption{func(do *dialOptions) { do.explicitTLS = true do.tlsConfig = tlsConfig }} } // DialWithDebugOutput returns a DialOption that configures the ServerConn to write to the Writer // everything it reads from the server func DialWithDebugOutput(w io.Writer) DialOption { return DialOption{func(do *dialOptions) { do.debugOutput = w }} } // DialWithDialFunc returns a DialOption that configures the ServerConn to use the // specified function to establish both control and data connections // // If used together with the DialWithNetConn option, the DialWithNetConn // takes precedence for the control connection, while data connections will // be established using function specified with the DialWithDialFunc option func DialWithDialFunc(f func(network, address string) (net.Conn, error)) DialOption { return DialOption{func(do *dialOptions) { do.dialFunc = f }} } func (o *dialOptions) wrapConn(netConn net.Conn) io.ReadWriteCloser { if o.debugOutput == nil { return netConn } return newDebugWrapper(netConn, o.debugOutput) } func (o *dialOptions) wrapStream(rd io.ReadCloser) io.ReadCloser { if o.debugOutput == nil { return rd } return newStreamDebugWrapper(rd, o.debugOutput) } // Connect is an alias to Dial, for backward compatibility // // Deprecated: Use [Dial] instead func Connect(addr string) (*ServerConn, error) { return Dial(addr) } // DialTimeout initializes the connection to the specified ftp server address. // // Deprecated: Use [Dial] with [DialWithTimeout] option instead func DialTimeout(addr string, timeout time.Duration) (*ServerConn, error) { return Dial(addr, DialWithTimeout(timeout)) } // Login authenticates the client with specified user and password. // // "anonymous"/"anonymous" is a common user/password scheme for FTP servers // that allows anonymous read-only accounts. func (c *ServerConn) Login(user, password string) error { code, message, err := c.cmd(-1, "USER %s", user) if err != nil { return err } switch code { case StatusLoggedIn: case StatusUserOK: _, _, err = c.cmd(StatusLoggedIn, "PASS %s", password) if err != nil { return err } default: return errors.New(message) } // Probe features err = c.feat() if err != nil { return err } if _, mlstSupported := c.features["MLST"]; mlstSupported && !c.options.disableMLSD { c.mlstSupported = true } _, c.usePRET = c.features["PRET"] _, c.mfmtSupported = c.features["MFMT"] _, c.mdtmSupported = c.features["MDTM"] c.mdtmCanWrite = c.mdtmSupported && c.options.writingMDTM // Switch to binary mode if err = c.Type(TransferTypeBinary); err != nil { return err } // Switch to UTF-8 if !c.options.disableUTF8 { err = c.setUTF8() } // If using implicit TLS, make data connections also use TLS if c.options.tlsConfig != nil { if _, _, err = c.cmd(StatusCommandOK, "PBSZ 0"); err != nil { return err } if _, _, err = c.cmd(StatusCommandOK, "PROT P"); err != nil { return err } } return err } // authTLS upgrades the connection to use TLS func (c *ServerConn) authTLS() error { _, _, err := c.cmd(StatusAuthOK, "AUTH TLS") return err } // feat issues a FEAT FTP command to list the additional commands supported by // the remote FTP server. // FEAT is described in RFC 2389 func (c *ServerConn) feat() error { code, message, err := c.cmd(-1, "FEAT") if err != nil { return err } if code != StatusSystem { // The server does not support the FEAT command. This is not an // error: we consider that there is no additional feature. return nil } lines := strings.Split(message, "\n") for _, line := range lines { if !strings.HasPrefix(line, " ") { continue } line = strings.TrimSpace(line) featureElements := strings.SplitN(line, " ", 2) command := featureElements[0] var commandDesc string if len(featureElements) == 2 { commandDesc = featureElements[1] } c.features[command] = commandDesc } return nil } // setUTF8 issues an "OPTS UTF8 ON" command. func (c *ServerConn) setUTF8() error { if _, ok := c.features["UTF8"]; !ok { return nil } code, message, err := c.cmd(-1, "OPTS UTF8 ON") if err != nil { return err } // Workaround for FTP servers, that does not support this option. if code == StatusBadArguments || code == StatusNotImplementedParameter { return nil } // The ftpd "filezilla-server" has FEAT support for UTF8, but always returns // "202 UTF8 mode is always enabled. No need to send this command." when // trying to use it. That's OK if code == StatusCommandNotImplemented { return nil } if code != StatusCommandOK { return errors.New(message) } return nil } // epsv issues an "EPSV" command to get a port number for a data connection. func (c *ServerConn) epsv() (port int, err error) { _, line, err := c.cmd(StatusExtendedPassiveMode, "EPSV") if err != nil { return 0, err } start := strings.Index(line, "|||") end := strings.LastIndex(line, "|") if start == -1 || end == -1 { return 0, errors.New("invalid EPSV response format") } port, err = strconv.Atoi(line[start+3 : end]) return port, err } // pasv issues a "PASV" command to get a port number for a data connection. func (c *ServerConn) pasv() (host string, port int, err error) { _, line, err := c.cmd(StatusPassiveMode, "PASV") if err != nil { return "", 0, err } // PASV response format : 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2). start := strings.Index(line, "(") end := strings.LastIndex(line, ")") if start == -1 || end == -1 { return "", 0, errors.New("invalid PASV response format") } // We have to split the response string pasvData := strings.Split(line[start+1:end], ",") if len(pasvData) < 6 { return "", 0, errors.New("invalid PASV response format") } // Let's compute the port number portPart1, err := strconv.Atoi(pasvData[4]) if err != nil { return "", 0, err } portPart2, err := strconv.Atoi(pasvData[5]) if err != nil { return "", 0, err } // Recompose port port = portPart1*256 + portPart2 // Make the IP address to connect to host = strings.Join(pasvData[0:4], ".") return host, port, nil } // getDataConnPort returns a host, port for a new data connection // it uses the best available method to do so func (c *ServerConn) getDataConnPort() (string, int, error) { if !c.options.disableEPSV && !c.skipEPSV { if port, err := c.epsv(); err == nil { return c.host, port, nil } // if there is an error, skip EPSV for the next attempts c.skipEPSV = true } return c.pasv() } // openDataConn creates a new FTP data connection. func (c *ServerConn) openDataConn() (net.Conn, error) { host, port, err := c.getDataConnPort() if err != nil { return nil, err } addr := net.JoinHostPort(host, strconv.Itoa(port)) if c.options.dialFunc != nil { return c.options.dialFunc("tcp", addr) } if c.options.tlsConfig != nil { // We don't use tls.DialWithDialer here (which does Dial, create // the Client and then do the Handshake) because it seems to // hang with some FTP servers, namely proftpd and pureftpd. // // Instead we do Dial, create the Client and wait for the first // Read or Write to trigger the Handshake. // // This means that if we are uploading a zero sized file, we // need to make sure we do the Handshake explicitly as Write // won't have been called. This is done in StorFrom(). // // See: https://github.com/jlaffaye/ftp/issues/282 conn, err := c.options.dialer.Dial("tcp", addr) if err != nil { return nil, err } tlsConn := tls.Client(conn, c.options.tlsConfig) return tlsConn, nil } return c.options.dialer.Dial("tcp", addr) } // cmd is a helper function to execute a command and check for the expected FTP // return code func (c *ServerConn) cmd(expected int, format string, args ...interface{}) (int, string, error) { _, err := c.conn.Cmd(format, args...) if err != nil { return 0, "", err } return c.conn.ReadResponse(expected) } // cmdDataConnFrom executes a command which require a FTP data connection. // Issues a REST FTP command to specify the number of bytes to skip for the transfer. func (c *ServerConn) cmdDataConnFrom(offset uint64, format string, args ...interface{}) (net.Conn, error) { // If server requires PRET send the PRET command to warm it up // See: https://tools.ietf.org/html/draft-dd-pret-00 if c.usePRET { _, _, err := c.cmd(-1, "PRET "+format, args...) if err != nil { return nil, err } } conn, err := c.openDataConn() if err != nil { return nil, err } if offset != 0 { _, _, err = c.cmd(StatusRequestFilePending, "REST %d", offset) if err != nil { _ = conn.Close() return nil, err } } _, err = c.conn.Cmd(format, args...) if err != nil { _ = conn.Close() return nil, err } code, msg, err := c.conn.ReadResponse(-1) if err != nil { _ = conn.Close() return nil, err } if code != StatusAlreadyOpen && code != StatusAboutToSend { _ = conn.Close() return nil, &textproto.Error{Code: code, Msg: msg} } return conn, nil } // Type switches the transfer mode for the connection. func (c *ServerConn) Type(transferType TransferType) (err error) { _, _, err = c.cmd(StatusCommandOK, "TYPE "+string(transferType)) return err } // NameList issues an NLST FTP command. func (c *ServerConn) NameList(path string) (entries []string, err error) { space := " " if path == "" { space = "" } conn, err := c.cmdDataConnFrom(0, "NLST%s%s", space, path) if err != nil { return nil, err } var errs *multierror.Error r := &Response{conn: conn, c: c} scanner := bufio.NewScanner(c.options.wrapStream(r)) for scanner.Scan() { entries = append(entries, scanner.Text()) } if err := scanner.Err(); err != nil { errs = multierror.Append(errs, err) } if err := r.Close(); err != nil { errs = multierror.Append(errs, err) } return entries, errs.ErrorOrNil() } // List issues a LIST FTP command. func (c *ServerConn) List(path string) (entries []*Entry, err error) { var cmd string var parser parseFunc if c.mlstSupported && !c.options.forceListHidden { cmd = "MLSD" parser = parseRFC3659ListLine } else { cmd = "LIST" if c.options.forceListHidden { cmd += " -a" } parser = parseListLine } space := " " if path == "" { space = "" } conn, err := c.cmdDataConnFrom(0, "%s%s%s", cmd, space, path) if err != nil { return nil, err } var errs *multierror.Error r := &Response{conn: conn, c: c} scanner := bufio.NewScanner(c.options.wrapStream(r)) now := time.Now() for scanner.Scan() { entry, errParse := parser(scanner.Text(), now, c.options.location) if errParse == nil { entries = append(entries, entry) } } if err := scanner.Err(); err != nil { errs = multierror.Append(errs, err) } if err := r.Close(); err != nil { errs = multierror.Append(errs, err) } return entries, errs.ErrorOrNil() } // GetEntry issues a MLST FTP command which retrieves one single Entry using the // control connection. The returnedEntry will describe the current directory // when no path is given. func (c *ServerConn) GetEntry(path string) (entry *Entry, err error) { if !c.mlstSupported { return nil, &textproto.Error{Code: StatusNotImplemented, Msg: StatusText(StatusNotImplemented)} } space := " " if path == "" { space = "" } _, msg, err := c.cmd(StatusRequestedFileActionOK, "%s%s%s", "MLST", space, path) if err != nil { return nil, err } // The expected reply will look something like: // // 250-File details // Type=file;Size=1024;Modify=20220813133357; path // 250 End // // Multiple lines are allowed though, so it can also be in the form: // // 250-File details // Type=file;Size=1024; path // Modify=20220813133357; path // 250 End lines := strings.Split(msg, "\n") lc := len(lines) // lines must be a multi-line message with a length of 3 or more, and we // don't care about the first and last line if lc < 3 { return nil, errors.New("invalid response") } e := &Entry{} for _, l := range lines[1 : lc-1] { // According to RFC 3659, the entry lines must start with a space when passed over the // control connection. Some servers don't seem to add that space though. Both forms are // accepted here. if len(l) > 0 && l[0] == ' ' { l = l[1:] } // Some severs seem to send a blank line at the end which we ignore if l == "" { continue } if e, err = parseNextRFC3659ListLine(l, c.options.location, e); err != nil { return nil, err } } return e, nil } // IsTimePreciseInList returns true if client and server support the MLSD // command so List can return time with 1-second precision for all files. func (c *ServerConn) IsTimePreciseInList() bool { return c.mlstSupported } // ChangeDir issues a CWD FTP command, which changes the current directory to // the specified path. func (c *ServerConn) ChangeDir(path string) error { _, _, err := c.cmd(StatusRequestedFileActionOK, "CWD %s", path) return err } // ChangeDirToParent issues a CDUP FTP command, which changes the current // directory to the parent directory. This is similar to a call to ChangeDir // with a path set to "..". func (c *ServerConn) ChangeDirToParent() error { _, _, err := c.cmd(StatusRequestedFileActionOK, "CDUP") return err } // CurrentDir issues a PWD FTP command, which Returns the path of the current // directory. func (c *ServerConn) CurrentDir() (string, error) { _, msg, err := c.cmd(StatusPathCreated, "PWD") if err != nil { return "", err } start := strings.Index(msg, "\"") end := strings.LastIndex(msg, "\"") if start == -1 || end == -1 { return "", errors.New("unsuported PWD response format") } return msg[start+1 : end], nil } // FileSize issues a SIZE FTP command, which Returns the size of the file func (c *ServerConn) FileSize(path string) (int64, error) { _, msg, err := c.cmd(StatusFile, "SIZE %s", path) if err != nil { return 0, err } return strconv.ParseInt(msg, 10, 64) } // GetTime issues the MDTM FTP command to obtain the file modification time. // It returns a UTC time. func (c *ServerConn) GetTime(path string) (time.Time, error) { var t time.Time if !c.mdtmSupported { return t, errors.New("GetTime is not supported") } _, msg, err := c.cmd(StatusFile, "MDTM %s", path) if err != nil { return t, err } return time.ParseInLocation(timeFormat, msg, time.UTC) } // IsGetTimeSupported allows library callers to check in advance that they // can use GetTime to get file time. func (c *ServerConn) IsGetTimeSupported() bool { return c.mdtmSupported } // SetTime issues the MFMT FTP command to set the file modification time. // Also it can use a non-standard form of the MDTM command supported by // the VsFtpd server instead of MFMT for the same purpose. // See "mdtm_write" in https://security.appspot.com/vsftpd/vsftpd_conf.html func (c *ServerConn) SetTime(path string, t time.Time) (err error) { utime := t.In(time.UTC).Format(timeFormat) switch { case c.mfmtSupported: _, _, err = c.cmd(StatusFile, "MFMT %s %s", utime, path) case c.mdtmCanWrite: _, _, err = c.cmd(StatusFile, "MDTM %s %s", utime, path) default: err = errors.New("SetTime is not supported") } return } // IsSetTimeSupported allows library callers to check in advance that they // can use SetTime to set file time. func (c *ServerConn) IsSetTimeSupported() bool { return c.mfmtSupported || c.mdtmCanWrite } // Retr issues a RETR FTP command to fetch the specified file from the remote // FTP server. // // The returned ReadCloser must be closed to cleanup the FTP data connection. func (c *ServerConn) Retr(path string) (*Response, error) { return c.RetrFrom(path, 0) } // RetrFrom issues a RETR FTP command to fetch the specified file from the remote // FTP server, the server will not send the offset first bytes of the file. // // The returned ReadCloser must be closed to cleanup the FTP data connection. func (c *ServerConn) RetrFrom(path string, offset uint64) (*Response, error) { conn, err := c.cmdDataConnFrom(offset, "RETR %s", path) if err != nil { return nil, err } return &Response{conn: conn, c: c}, nil } // Stor issues a STOR FTP command to store a file to the remote FTP server. // Stor creates the specified file with the content of the io.Reader. // // Hint: io.Pipe() can be used if an io.Writer is required. func (c *ServerConn) Stor(path string, r io.Reader) error { return c.StorFrom(path, r, 0) } // checkDataShut reads the "closing data connection" status from the // control connection. It is called after transferring a piece of data // on the data connection during which the control connection was idle. // This may result in the idle timeout triggering on the control connection // right when we try to read the response. // The ShutTimeout dial option will rescue here. It will nudge the control // connection deadline right before checking the data closing status. func (c *ServerConn) checkDataShut() error { if c.options.shutTimeout != 0 { shutDeadline := time.Now().Add(c.options.shutTimeout) if err := c.netConn.SetDeadline(shutDeadline); err != nil { return err } } _, _, err := c.conn.ReadResponse(StatusClosingDataConnection) return err } // StorFrom issues a STOR FTP command to store a file to the remote FTP server. // Stor creates the specified file with the content of the io.Reader, writing // on the server will start at the given file offset. // // Hint: io.Pipe() can be used if an io.Writer is required. func (c *ServerConn) StorFrom(path string, r io.Reader, offset uint64) error { conn, err := c.cmdDataConnFrom(offset, "STOR %s", path) if err != nil { return err } var errs *multierror.Error // if the upload fails we still need to try to read the server // response otherwise if the failure is not due to a connection problem, // for example the server denied the upload for quota limits, we miss // the response and we cannot use the connection to send other commands. if n, err := io.Copy(conn, r); err != nil { errs = multierror.Append(errs, err) } else if n == 0 { // If we wrote no bytes and got no error, make sure we call // tls.Handshake on the connection as it won't get called // unless Write() is called. (See comment in openDataConn()). // // ProFTP doesn't like this and returns "Unable to build data // connection: Operation not permitted" when trying to upload // an empty file without this. if do, ok := conn.(interface{ Handshake() error }); ok { if err := do.Handshake(); err != nil { errs = multierror.Append(errs, err) } } } if err := conn.Close(); err != nil { errs = multierror.Append(errs, err) } if err := c.checkDataShut(); err != nil { errs = multierror.Append(errs, err) } return errs.ErrorOrNil() } // Append issues a APPE FTP command to store a file to the remote FTP server. // If a file already exists with the given path, then the content of the // io.Reader is appended. Otherwise, a new file is created with that content. // // Hint: io.Pipe() can be used if an io.Writer is required. func (c *ServerConn) Append(path string, r io.Reader) error { conn, err := c.cmdDataConnFrom(0, "APPE %s", path) if err != nil { return err } var errs *multierror.Error if _, err := io.Copy(conn, r); err != nil { errs = multierror.Append(errs, err) } if err := conn.Close(); err != nil { errs = multierror.Append(errs, err) } if err := c.checkDataShut(); err != nil { errs = multierror.Append(errs, err) } return errs.ErrorOrNil() } // Rename renames a file on the remote FTP server. func (c *ServerConn) Rename(from, to string) error { _, _, err := c.cmd(StatusRequestFilePending, "RNFR %s", from) if err != nil { return err } _, _, err = c.cmd(StatusRequestedFileActionOK, "RNTO %s", to) return err } // Delete issues a DELE FTP command to delete the specified file from the // remote FTP server. func (c *ServerConn) Delete(path string) error { _, _, err := c.cmd(StatusRequestedFileActionOK, "DELE %s", path) return err } // RemoveDirRecur deletes a non-empty folder recursively using // RemoveDir and Delete func (c *ServerConn) RemoveDirRecur(path string) error { err := c.ChangeDir(path) if err != nil { return err } currentDir, err := c.CurrentDir() if err != nil { return err } entries, err := c.List(currentDir) if err != nil { return err } for _, entry := range entries { if entry.Name != ".." && entry.Name != "." { if entry.Type == EntryTypeFolder { err = c.RemoveDirRecur(currentDir + "/" + entry.Name) if err != nil { return err } } else { err = c.Delete(entry.Name) if err != nil { return err } } } } err = c.ChangeDirToParent() if err != nil { return err } err = c.RemoveDir(currentDir) return err } // MakeDir issues a MKD FTP command to create the specified directory on the // remote FTP server. func (c *ServerConn) MakeDir(path string) error { _, _, err := c.cmd(StatusPathCreated, "MKD %s", path) return err } // RemoveDir issues a RMD FTP command to remove the specified directory from // the remote FTP server. func (c *ServerConn) RemoveDir(path string) error { _, _, err := c.cmd(StatusRequestedFileActionOK, "RMD %s", path) return err } // Walk prepares the internal walk function so that the caller can begin traversing the directory func (c *ServerConn) Walk(root string) *Walker { w := new(Walker) w.serverConn = c if !strings.HasSuffix(root, "/") { root += "/" } w.root = root w.descend = true return w } // NoOp issues a NOOP FTP command. // NOOP has no effects and is usually used to prevent the remote FTP server to // close the otherwise idle connection. func (c *ServerConn) NoOp() error { _, _, err := c.cmd(StatusCommandOK, "NOOP") return err } // Logout issues a REIN FTP command to logout the current user. func (c *ServerConn) Logout() error { _, _, err := c.cmd(StatusReady, "REIN") return err } // Quit issues a QUIT FTP command to properly close the connection from the // remote FTP server. func (c *ServerConn) Quit() error { var errs *multierror.Error if _, err := c.conn.Cmd("QUIT"); err != nil { errs = multierror.Append(errs, err) } if err := c.conn.Close(); err != nil { errs = multierror.Append(errs, err) } return errs.ErrorOrNil() } // Read implements the io.Reader interface on a FTP data connection. func (r *Response) Read(buf []byte) (int, error) { return r.conn.Read(buf) } // Close implements the io.Closer interface on a FTP data connection. // After the first call, Close will do nothing and return nil. func (r *Response) Close() error { if r.closed { return nil } var errs *multierror.Error if err := r.conn.Close(); err != nil { errs = multierror.Append(errs, err) } if err := r.c.checkDataShut(); err != nil { errs = multierror.Append(errs, err) } r.closed = true return errs.ErrorOrNil() } // SetDeadline sets the deadlines associated with the connection. func (r *Response) SetDeadline(t time.Time) error { return r.conn.SetDeadline(t) } // String returns the string representation of EntryType t. func (t EntryType) String() string { return [...]string{"file", "folder", "link"}[t] } ftp-0.2.0/go.mod000066400000000000000000000005101443323010400133760ustar00rootroot00000000000000module github.com/jlaffaye/ftp go 1.17 require ( github.com/hashicorp/go-multierror v1.1.1 github.com/stretchr/testify v1.8.3 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ftp-0.2.0/go.sum000066400000000000000000000035301443323010400134300ustar00rootroot00000000000000github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ftp-0.2.0/parse.go000066400000000000000000000155371443323010400137500ustar00rootroot00000000000000package ftp import ( "errors" "fmt" "strconv" "strings" "time" ) var errUnsupportedListLine = errors.New("unsupported LIST line") var errUnsupportedListDate = errors.New("unsupported LIST date") var errUnknownListEntryType = errors.New("unknown entry type") type parseFunc func(string, time.Time, *time.Location) (*Entry, error) var listLineParsers = []parseFunc{ parseRFC3659ListLine, parseLsListLine, parseDirListLine, parseHostedFTPLine, } var dirTimeFormats = []string{ "01-02-06 03:04PM", "2006-01-02 15:04", } // parseRFC3659ListLine parses the style of directory line defined in RFC 3659. func parseRFC3659ListLine(line string, _ time.Time, loc *time.Location) (*Entry, error) { return parseNextRFC3659ListLine(line, loc, &Entry{}) } func parseNextRFC3659ListLine(line string, loc *time.Location, e *Entry) (*Entry, error) { iSemicolon := strings.Index(line, ";") iWhitespace := strings.Index(line, " ") if iSemicolon < 0 || iSemicolon > iWhitespace { return nil, errUnsupportedListLine } name := line[iWhitespace+1:] if e.Name == "" { e.Name = name } else if e.Name != name { // All lines must have the same name return nil, errUnsupportedListLine } for _, field := range strings.Split(line[:iWhitespace-1], ";") { i := strings.Index(field, "=") if i < 1 { return nil, errUnsupportedListLine } key := strings.ToLower(field[:i]) value := field[i+1:] switch key { case "modify": var err error e.Time, err = time.ParseInLocation("20060102150405", value, loc) if err != nil { return nil, err } case "type": switch value { case "dir", "cdir", "pdir": e.Type = EntryTypeFolder case "file": e.Type = EntryTypeFile } case "size": if err := e.setSize(value); err != nil { return nil, err } } } return e, nil } // parseLsListLine parses a directory line in a format based on the output of // the UNIX ls command. func parseLsListLine(line string, now time.Time, loc *time.Location) (*Entry, error) { // Has the first field a length of exactly 10 bytes // - or 10 bytes with an additional '+' character for indicating ACLs? // If not, return. if i := strings.IndexByte(line, ' '); !(i == 10 || (i == 11 && line[10] == '+')) { return nil, errUnsupportedListLine } scanner := newScanner(line) fields := scanner.NextFields(6) if len(fields) < 6 { return nil, errUnsupportedListLine } if fields[1] == "folder" && fields[2] == "0" { e := &Entry{ Type: EntryTypeFolder, Name: scanner.Remaining(), } if err := e.setTime(fields[3:6], now, loc); err != nil { return nil, err } return e, nil } if fields[1] == "0" { fields = append(fields, scanner.Next()) e := &Entry{ Type: EntryTypeFile, Name: scanner.Remaining(), } if err := e.setSize(fields[2]); err != nil { return nil, errUnsupportedListLine } if err := e.setTime(fields[4:7], now, loc); err != nil { return nil, err } return e, nil } // Read two more fields fields = append(fields, scanner.NextFields(2)...) if len(fields) < 8 { return nil, errUnsupportedListLine } e := &Entry{ Name: scanner.Remaining(), } switch fields[0][0] { case '-': e.Type = EntryTypeFile if err := e.setSize(fields[4]); err != nil { return nil, err } case 'd': e.Type = EntryTypeFolder case 'l': e.Type = EntryTypeLink // Split link name and target if i := strings.Index(e.Name, " -> "); i > 0 { e.Target = e.Name[i+4:] e.Name = e.Name[:i] } default: return nil, errUnknownListEntryType } if err := e.setTime(fields[5:8], now, loc); err != nil { return nil, err } return e, nil } // parseDirListLine parses a directory line in a format based on the output of // the MS-DOS DIR command. func parseDirListLine(line string, now time.Time, loc *time.Location) (*Entry, error) { e := &Entry{} var err error // Try various time formats that DIR might use, and stop when one works. for _, format := range dirTimeFormats { if len(line) > len(format) { e.Time, err = time.ParseInLocation(format, line[:len(format)], loc) if err == nil { line = line[len(format):] break } } } if err != nil { // None of the time formats worked. return nil, errUnsupportedListLine } line = strings.TrimLeft(line, " ") if strings.HasPrefix(line, "") { e.Type = EntryTypeFolder line = strings.TrimPrefix(line, "") } else { space := strings.Index(line, " ") if space == -1 { return nil, errUnsupportedListLine } e.Size, err = strconv.ParseUint(line[:space], 10, 64) if err != nil { return nil, errUnsupportedListLine } e.Type = EntryTypeFile line = line[space:] } e.Name = strings.TrimLeft(line, " ") return e, nil } // parseHostedFTPLine parses a directory line in the non-standard format used // by hostedftp.com // -r-------- 0 user group 65222236 Feb 24 00:39 UABlacklistingWeek8.csv // (The link count is inexplicably 0) func parseHostedFTPLine(line string, now time.Time, loc *time.Location) (*Entry, error) { // Has the first field a length of 10 bytes? if strings.IndexByte(line, ' ') != 10 { return nil, errUnsupportedListLine } scanner := newScanner(line) fields := scanner.NextFields(2) if len(fields) < 2 || fields[1] != "0" { return nil, errUnsupportedListLine } // Set link count to 1 and attempt to parse as Unix. return parseLsListLine(fields[0]+" 1 "+scanner.Remaining(), now, loc) } // parseListLine parses the various non-standard format returned by the LIST // FTP command. func parseListLine(line string, now time.Time, loc *time.Location) (*Entry, error) { for _, f := range listLineParsers { e, err := f(line, now, loc) if err != errUnsupportedListLine { return e, err } } return nil, errUnsupportedListLine } func (e *Entry) setSize(str string) (err error) { e.Size, err = strconv.ParseUint(str, 0, 64) return } func (e *Entry) setTime(fields []string, now time.Time, loc *time.Location) (err error) { if strings.Contains(fields[2], ":") { // contains time thisYear, _, _ := now.Date() timeStr := fmt.Sprintf("%s %s %d %s", fields[1], fields[0], thisYear, fields[2]) e.Time, err = time.ParseInLocation("_2 Jan 2006 15:04", timeStr, loc) /* On unix, `info ls` shows: 10.1.6 Formatting file timestamps --------------------------------- A timestamp is considered to be “recent” if it is less than six months old, and is not dated in the future. If a timestamp dated today is not listed in recent form, the timestamp is in the future, which means you probably have clock skew problems which may break programs like ‘make’ that rely on file timestamps. */ if !e.Time.Before(now.AddDate(0, 6, 0)) { e.Time = e.Time.AddDate(-1, 0, 0) } } else { // only the date if len(fields[2]) != 4 { return errUnsupportedListDate } timeStr := fmt.Sprintf("%s %s %s 00:00", fields[1], fields[0], fields[2]) e.Time, err = time.ParseInLocation("_2 Jan 2006 15:04", timeStr, loc) } return } ftp-0.2.0/parse_test.go000066400000000000000000000175231443323010400150040ustar00rootroot00000000000000package ftp import ( "strings" "testing" "time" "github.com/stretchr/testify/assert" ) var ( // now is the current time for all tests now = newTime(2017, time.March, 10, 23, 00) thisYear, _, _ = now.Date() previousYear = thisYear - 1 ) type line struct { line string name string size uint64 entryType EntryType time time.Time } type symlinkLine struct { line string name string target string } type unsupportedLine struct { line string err error } var listTests = []line{ // UNIX ls -l style {"drwxr-xr-x 3 110 1002 3 Dec 02 2009 pub", "pub", 0, EntryTypeFolder, newTime(2009, time.December, 2)}, {"drwxr-xr-x 3 110 1002 3 Dec 02 2009 p u b", "p u b", 0, EntryTypeFolder, newTime(2009, time.December, 2)}, {"-rw-r--r-- 1 marketwired marketwired 12016 Mar 16 2016 2016031611G087802-001.newsml", "2016031611G087802-001.newsml", 12016, EntryTypeFile, newTime(2016, time.March, 16)}, {"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 fileName", "fileName", 1234567, EntryTypeFile, newTime(2009, time.December, 2)}, {"lrwxrwxrwx 1 root other 7 Jan 25 00:17 bin -> usr/bin", "bin", 0, EntryTypeLink, newTime(thisYear, time.January, 25, 0, 17)}, // Another ls style {"drwxr-xr-x folder 0 Aug 15 05:49 !!!-Tipp des Haus!", "!!!-Tipp des Haus!", 0, EntryTypeFolder, newTime(thisYear, time.August, 15, 5, 49)}, {"drwxrwxrwx folder 0 Aug 11 20:32 P0RN", "P0RN", 0, EntryTypeFolder, newTime(thisYear, time.August, 11, 20, 32)}, {"-rw-r--r-- 0 18446744073709551615 18446744073709551615 Nov 16 2006 VIDEO_TS.VOB", "VIDEO_TS.VOB", 18446744073709551615, EntryTypeFile, newTime(2006, time.November, 16)}, // Microsoft's FTP servers for Windows {"---------- 1 owner group 1803128 Jul 10 10:18 ls-lR.Z", "ls-lR.Z", 1803128, EntryTypeFile, newTime(thisYear, time.July, 10, 10, 18)}, {"d--------- 1 owner group 0 Nov 9 19:45 Softlib", "Softlib", 0, EntryTypeFolder, newTime(previousYear, time.November, 9, 19, 45)}, // WFTPD for MSDOS {"-rwxrwxrwx 1 noone nogroup 322 Aug 19 1996 message.ftp", "message.ftp", 322, EntryTypeFile, newTime(1996, time.August, 19)}, // RFC3659 format: https://tools.ietf.org/html/rfc3659#section-7 {"modify=20150813224845;perm=fle;type=cdir;unique=119FBB87U4;UNIX.group=0;UNIX.mode=0755;UNIX.owner=0; .", ".", 0, EntryTypeFolder, newTime(2015, time.August, 13, 22, 48, 45)}, {"modify=20150813224845;perm=fle;type=pdir;unique=119FBB87U4;UNIX.group=0;UNIX.mode=0755;UNIX.owner=0; ..", "..", 0, EntryTypeFolder, newTime(2015, time.August, 13, 22, 48, 45)}, {"modify=20150806235817;perm=fle;type=dir;unique=1B20F360U4;UNIX.group=0;UNIX.mode=0755;UNIX.owner=0; movies", "movies", 0, EntryTypeFolder, newTime(2015, time.August, 6, 23, 58, 17)}, {"modify=20150814172949;perm=flcdmpe;type=dir;unique=85A0C168U4;UNIX.group=0;UNIX.mode=0777;UNIX.owner=0; _upload", "_upload", 0, EntryTypeFolder, newTime(2015, time.August, 14, 17, 29, 49)}, {"modify=20150813175250;perm=adfr;size=951;type=file;unique=119FBB87UE;UNIX.group=0;UNIX.mode=0644;UNIX.owner=0; welcome.msg", "welcome.msg", 951, EntryTypeFile, newTime(2015, time.August, 13, 17, 52, 50)}, // Format and types have first letter UpperCase {"Modify=20150813175250;Perm=adfr;Size=951;Type=file;Unique=119FBB87UE;UNIX.group=0;UNIX.mode=0644;UNIX.owner=0; welcome.msg", "welcome.msg", 951, EntryTypeFile, newTime(2015, time.August, 13, 17, 52, 50)}, // DOS DIR command output {"08-07-15 07:50PM 718 Post_PRR_20150901_1166_265118_13049.dat", "Post_PRR_20150901_1166_265118_13049.dat", 718, EntryTypeFile, newTime(2015, time.August, 7, 19, 50)}, {"08-10-15 02:04PM Billing", "Billing", 0, EntryTypeFolder, newTime(2015, time.August, 10, 14, 4)}, // dir and file names that contain multiple spaces {"drwxr-xr-x 3 110 1002 3 Dec 02 2009 spaces dir name", "spaces dir name", 0, EntryTypeFolder, newTime(2009, time.December, 2)}, {"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 file name", "file name", 1234567, EntryTypeFile, newTime(2009, time.December, 2)}, {"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 foo bar ", " foo bar ", 1234567, EntryTypeFile, newTime(2009, time.December, 2)}, // Odd link count from hostedftp.com {"-r-------- 0 user group 65222236 Feb 24 00:39 RegularFile", "RegularFile", 65222236, EntryTypeFile, newTime(thisYear, time.February, 24, 0, 39)}, // Line with ACL persmissions {"-rwxrw-r--+ 1 521 101 2080 May 21 10:53 data.csv", "data.csv", 2080, EntryTypeFile, newTime(thisYear, time.May, 21, 10, 53)}, } var listTestsSymlink = []symlinkLine{ {"lrwxrwxrwx 1 root other 7 Jan 25 00:17 bin -> usr/bin", "bin", "usr/bin"}, {"lrwxrwxrwx 1 0 1001 27 Jul 07 2017 R-3.4.0.pkg -> el-capitan/base/R-3.4.0.pkg", "R-3.4.0.pkg", "el-capitan/base/R-3.4.0.pkg"}, } // Not supported, we expect a specific error message var listTestsFail = []unsupportedLine{ {"d [R----F--] supervisor 512 Jan 16 18:53 login", errUnsupportedListLine}, {"- [R----F--] rhesus 214059 Oct 20 15:27 cx.exe", errUnsupportedListLine}, {"drwxr-xr-x 3 110 1002 3 Dec 02 209 pub", errUnsupportedListDate}, {"modify=20150806235817;invalid;UNIX.owner=0; movies", errUnsupportedListLine}, {"Zrwxrwxrwx 1 root other 7 Jan 25 00:17 bin -> usr/bin", errUnknownListEntryType}, {"total 1", errUnsupportedListLine}, {"000000000x ", errUnsupportedListLine}, // see https://github.com/jlaffaye/ftp/issues/97 {"", errUnsupportedListLine}, } func TestParseValidListLine(t *testing.T) { for _, lt := range listTests { t.Run(lt.line, func(t *testing.T) { assert := assert.New(t) entry, err := parseListLine(lt.line, now, time.UTC) if assert.NoError(err) { assert.Equal(lt.name, entry.Name) assert.Equal(lt.entryType, entry.Type) assert.Equal(lt.size, entry.Size) assert.Equal(lt.time, entry.Time) } }) } } func TestParseSymlinks(t *testing.T) { for _, lt := range listTestsSymlink { t.Run(lt.line, func(t *testing.T) { assert := assert.New(t) entry, err := parseListLine(lt.line, now, time.UTC) if assert.NoError(err) { assert.Equal(lt.name, entry.Name) assert.Equal(lt.target, entry.Target) assert.Equal(EntryTypeLink, entry.Type) } }) } } func TestParseUnsupportedListLine(t *testing.T) { for _, lt := range listTestsFail { t.Run(lt.line, func(t *testing.T) { _, err := parseListLine(lt.line, now, time.UTC) assert.EqualError(t, err, lt.err.Error()) }) } } func TestSettime(t *testing.T) { tests := []struct { line string expected time.Time }{ // this year, in the past {"Feb 10 23:00", newTime(thisYear, time.February, 10, 23)}, // this year, less than six months in the future {"Sep 10 22:59", newTime(thisYear, time.September, 10, 22, 59)}, // previous year, otherwise it would be more than 6 months in the future {"Sep 10 23:00", newTime(previousYear, time.September, 10, 23)}, // far in the future {"Jan 23 2019", newTime(2019, time.January, 23)}, } for _, test := range tests { t.Run(test.line, func(t *testing.T) { entry := &Entry{} if err := entry.setTime(strings.Fields(test.line), now, time.UTC); err != nil { t.Fatal(err) } assert.Equal(t, test.expected, entry.Time) }) } } // newTime builds a UTC time from the given year, month, day, hour and minute func newTime(year int, month time.Month, day int, hourMinSec ...int) time.Time { var hour, min, sec int switch len(hourMinSec) { case 0: // nothing case 3: sec = hourMinSec[2] fallthrough case 2: min = hourMinSec[1] fallthrough case 1: hour = hourMinSec[0] default: panic("too many arguments") } return time.Date(year, month, day, hour, min, sec, 0, time.UTC) } ftp-0.2.0/scanner.go000066400000000000000000000021361443323010400142560ustar00rootroot00000000000000package ftp // A scanner for fields delimited by one or more whitespace characters type scanner struct { bytes []byte position int } // newScanner creates a new scanner func newScanner(str string) *scanner { return &scanner{ bytes: []byte(str), } } // NextFields returns the next `count` fields func (s *scanner) NextFields(count int) []string { fields := make([]string, 0, count) for i := 0; i < count; i++ { if field := s.Next(); field != "" { fields = append(fields, field) } else { break } } return fields } // Next returns the next field func (s *scanner) Next() string { sLen := len(s.bytes) // skip trailing whitespace for s.position < sLen { if s.bytes[s.position] != ' ' { break } s.position++ } start := s.position // skip non-whitespace for s.position < sLen { if s.bytes[s.position] == ' ' { s.position++ return string(s.bytes[start : s.position-1]) } s.position++ } return string(s.bytes[start:s.position]) } // Remaining returns the remaining string func (s *scanner) Remaining() string { return string(s.bytes[s.position:len(s.bytes)]) } ftp-0.2.0/scanner_test.go000066400000000000000000000012021443323010400153060ustar00rootroot00000000000000package ftp import ( "testing" "github.com/stretchr/testify/assert" ) func TestScanner(t *testing.T) { assert := assert.New(t) s := newScanner("foo bar x y") assert.Equal("foo", s.Next()) assert.Equal(" bar x y", s.Remaining()) assert.Equal("bar", s.Next()) assert.Equal("x y", s.Remaining()) assert.Equal("x", s.Next()) assert.Equal(" y", s.Remaining()) assert.Equal("y", s.Next()) assert.Equal("", s.Next()) assert.Equal("", s.Remaining()) } func TestScannerEmpty(t *testing.T) { assert := assert.New(t) s := newScanner("") assert.Equal("", s.Next()) assert.Equal("", s.Next()) assert.Equal("", s.Remaining()) } ftp-0.2.0/status.go000066400000000000000000000112221443323010400141440ustar00rootroot00000000000000package ftp import "fmt" // FTP status codes, defined in RFC 959 const ( StatusInitiating = 100 StatusRestartMarker = 110 StatusReadyMinute = 120 StatusAlreadyOpen = 125 StatusAboutToSend = 150 StatusCommandOK = 200 StatusCommandNotImplemented = 202 StatusSystem = 211 StatusDirectory = 212 StatusFile = 213 StatusHelp = 214 StatusName = 215 StatusReady = 220 StatusClosing = 221 StatusDataConnectionOpen = 225 StatusClosingDataConnection = 226 StatusPassiveMode = 227 StatusLongPassiveMode = 228 StatusExtendedPassiveMode = 229 StatusLoggedIn = 230 StatusLoggedOut = 231 StatusLogoutAck = 232 StatusAuthOK = 234 StatusRequestedFileActionOK = 250 StatusPathCreated = 257 StatusUserOK = 331 StatusLoginNeedAccount = 332 StatusRequestFilePending = 350 StatusNotAvailable = 421 StatusCanNotOpenDataConnection = 425 StatusTransfertAborted = 426 StatusInvalidCredentials = 430 StatusHostUnavailable = 434 StatusFileActionIgnored = 450 StatusActionAborted = 451 Status452 = 452 StatusBadCommand = 500 StatusBadArguments = 501 StatusNotImplemented = 502 StatusBadSequence = 503 StatusNotImplementedParameter = 504 StatusNotLoggedIn = 530 StatusStorNeedAccount = 532 StatusFileUnavailable = 550 StatusPageTypeUnknown = 551 StatusExceededStorage = 552 StatusBadFileName = 553 ) var statusText = map[int]string{ // 200 StatusCommandOK: "Command okay.", StatusCommandNotImplemented: "Command not implemented, superfluous at this site.", StatusSystem: "System status, or system help reply.", StatusDirectory: "Directory status.", StatusFile: "File status.", StatusHelp: "Help message.", StatusName: "", StatusReady: "Service ready for new user.", StatusClosing: "Service closing control connection.", StatusDataConnectionOpen: "Data connection open; no transfer in progress.", StatusClosingDataConnection: "Closing data connection. Requested file action successful.", StatusPassiveMode: "Entering Passive Mode.", StatusLongPassiveMode: "Entering Long Passive Mode.", StatusExtendedPassiveMode: "Entering Extended Passive Mode.", StatusLoggedIn: "User logged in, proceed.", StatusLoggedOut: "User logged out; service terminated.", StatusLogoutAck: "Logout command noted, will complete when transfer done.", StatusAuthOK: "AUTH command OK", StatusRequestedFileActionOK: "Requested file action okay, completed.", StatusPathCreated: "Path created.", // 300 StatusUserOK: "User name okay, need password.", StatusLoginNeedAccount: "Need account for login.", StatusRequestFilePending: "Requested file action pending further information.", // 400 StatusNotAvailable: "Service not available, closing control connection.", StatusCanNotOpenDataConnection: "Can't open data connection.", StatusTransfertAborted: "Connection closed; transfer aborted.", StatusInvalidCredentials: "Invalid username or password.", StatusHostUnavailable: "Requested host unavailable.", StatusFileActionIgnored: "Requested file action not taken.", StatusActionAborted: "Requested action aborted. Local error in processing.", Status452: "Insufficient storage space in system.", // 500 StatusBadCommand: "Command unrecognized.", StatusBadArguments: "Syntax error in parameters or arguments.", StatusNotImplemented: "Command not implemented.", StatusBadSequence: "Bad sequence of commands.", StatusNotImplementedParameter: "Command not implemented for that parameter.", StatusNotLoggedIn: "Not logged in.", StatusStorNeedAccount: "Need account for storing files.", StatusFileUnavailable: "File unavailable.", StatusPageTypeUnknown: "Page type unknown.", StatusExceededStorage: "Exceeded storage allocation.", StatusBadFileName: "File name not allowed.", } // StatusText returns a text for the FTP status code. It returns the empty string if the code is unknown. func StatusText(code int) string { str, ok := statusText[code] if !ok { str = fmt.Sprintf("Unknown status code: %d", code) } return str } ftp-0.2.0/walker.go000066400000000000000000000041151443323010400141110ustar00rootroot00000000000000package ftp import ( "path" ) // Walker traverses the directory tree of a remote FTP server type Walker struct { serverConn *ServerConn root string cur *item stack []*item descend bool } type item struct { path string entry *Entry err error } // Next advances the Walker to the next file or directory, // which will then be available through the Path, Stat, and Err methods. // It returns false when the walk stops at the end of the tree. func (w *Walker) Next() bool { // check if we need to init cur, maybe this should be inside Walk if w.cur == nil { w.cur = &item{ path: w.root, entry: &Entry{ Type: EntryTypeFolder, }, } } if w.descend && w.cur.entry.Type == EntryTypeFolder { entries, err := w.serverConn.List(w.cur.path) // an error occurred, drop out and stop walking if err != nil { w.cur.err = err return false } for _, entry := range entries { if entry.Name == "." || entry.Name == ".." { continue } item := &item{ path: path.Join(w.cur.path, entry.Name), entry: entry, } w.stack = append(w.stack, item) } } if len(w.stack) == 0 { return false } // update cur i := len(w.stack) - 1 w.cur = w.stack[i] w.stack = w.stack[:i] // reset SkipDir w.descend = true return true } // SkipDir tells the Next function to skip the currently processed directory func (w *Walker) SkipDir() { w.descend = false } // Err returns the error, if any, for the most recent attempt by Next to // visit a file or a directory. If a directory has an error, the walker // will not descend in that directory func (w *Walker) Err() error { return w.cur.err } // Stat returns info for the most recent file or directory // visited by a call to Next. func (w *Walker) Stat() *Entry { return w.cur.entry } // Path returns the path to the most recent file or directory // visited by a call to Next. It contains the argument to Walk // as a prefix; that is, if Walk is called with "dir", which is // a directory containing the file "a", Path will return "dir/a". func (w *Walker) Path() string { return w.cur.path } ftp-0.2.0/walker_test.go000066400000000000000000000067751443323010400151660ustar00rootroot00000000000000package ftp import ( "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWalkReturnsCorrectlyPopulatedWalker(t *testing.T) { mock, err := newFtpMock(t, "127.0.0.1") if err != nil { t.Fatal(err) } defer mock.Close() c, cErr := Connect(mock.Addr()) if cErr != nil { t.Fatal(err) } w := c.Walk("root") assert.Equal(t, "root/", w.root) assert.Equal(t, &c, &w.serverConn) } func TestFieldsReturnCorrectData(t *testing.T) { w := Walker{ cur: &item{ path: "/root/", err: fmt.Errorf("this is an error"), entry: &Entry{ Name: "root", Size: 123, Time: time.Now(), Type: EntryTypeFolder, }, }, } assert.Equal(t, "this is an error", w.Err().Error()) assert.Equal(t, "/root/", w.Path()) assert.Equal(t, EntryTypeFolder, w.Stat().Type) } func TestSkipDirIsCorrectlySet(t *testing.T) { w := Walker{} w.SkipDir() assert.Equal(t, false, w.descend) } func TestNoDescendDoesNotAddToStack(t *testing.T) { mock, err := newFtpMock(t, "127.0.0.1") if err != nil { t.Fatal(err) } defer mock.Close() c, cErr := Connect(mock.Addr()) if cErr != nil { t.Fatal(err) } w := c.Walk("/root") w.cur = &item{ path: "/root/", err: nil, entry: &Entry{ Name: "root", Size: 123, Time: time.Now(), Type: EntryTypeFolder, }, } w.stack = []*item{ { path: "file", err: nil, entry: &Entry{ Name: "file", Size: 123, Time: time.Now(), Type: EntryTypeFile, }, }, } w.SkipDir() result := w.Next() assert.Equal(t, true, result, "Result should return true") assert.Equal(t, 0, len(w.stack)) assert.Equal(t, true, w.descend) } func TestEmptyStackReturnsFalse(t *testing.T) { assert, require := assert.New(t), require.New(t) mock, err := newFtpMock(t, "127.0.0.1") require.Nil(err) defer mock.Close() c, cErr := Connect(mock.Addr()) require.Nil(cErr) w := c.Walk("/root") w.cur = &item{ path: "/root/", err: nil, entry: &Entry{ Name: "root", Size: 123, Time: time.Now(), Type: EntryTypeFolder, }, } w.stack = []*item{} w.SkipDir() result := w.Next() assert.Equal(false, result, "Result should return false") } func TestCurAndStackSetCorrectly(t *testing.T) { assert, require := assert.New(t), require.New(t) mock, err := newFtpMock(t, "127.0.0.1") require.Nil(err) defer mock.Close() c, cErr := Connect(mock.Addr()) require.Nil(cErr) w := c.Walk("/root") w.cur = &item{ path: "root/file1", err: nil, entry: &Entry{ Name: "file1", Size: 123, Time: time.Now(), Type: EntryTypeFile, }, } w.stack = []*item{ { path: "file", err: nil, entry: &Entry{ Name: "file", Size: 123, Time: time.Now(), Type: EntryTypeFile, }, }, { path: "root/file1", err: nil, entry: &Entry{ Name: "file1", Size: 123, Time: time.Now(), Type: EntryTypeFile, }, }, } result := w.Next() assert.Equal(true, result, "Result should return true") result = w.Next() assert.Equal(true, result, "Result should return true") assert.Equal(0, len(w.stack)) assert.Equal("file", w.cur.entry.Name) } func TestCurInit(t *testing.T) { mock, err := newFtpMock(t, "127.0.0.1") if err != nil { t.Fatal(err) } defer mock.Close() c, cErr := Connect(mock.Addr()) if cErr != nil { t.Fatal(err) } w := c.Walk("/root") result := w.Next() // mock fs has one file 'lo' assert.Equal(t, true, result, "Result should return false") assert.Equal(t, 0, len(w.stack)) assert.Equal(t, "/root/lo", w.Path()) }