├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml ├── stale.yml └── workflows │ ├── codeql-analysis.yml │ ├── golangci-lint.yaml │ └── unit_tests.yaml ├── LICENSE ├── README.md ├── client_test.go ├── conn_test.go ├── constants_test.go ├── debug.go ├── ftp.go ├── ftp_test.go ├── go.mod ├── go.sum ├── parse.go ├── parse_test.go ├── scanner.go ├── scanner_test.go ├── status.go ├── walker.go └── walker_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: defect 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | ``` 15 | Please include a mininal test case as code 16 | ``` 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **FTP server** 22 | - Name and version: 23 | - Public URL if applicable 24 | 25 | **Debug output** 26 | ``` 27 | Please include the ouput generated via DialWithDebugOuput 28 | ``` 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | assignees: 13 | - "jlaffaye" 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 14 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - accepted 8 | # Label to use when marking an issue as stale 9 | staleLabel: stale 10 | # Comment to post when marking an issue as stale. Set to `false` to disable 11 | markComment: > 12 | This issue has been automatically marked as stale because it has not had 13 | recent activity. It will be closed if no further activity occurs. Thank you 14 | for your contributions. 15 | # Comment to post when closing a stale issue. Set to `false` to disable 16 | closeComment: false 17 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '20 19 * * 2' 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: [ 'go' ] 39 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v4.2.2 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v3 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | 55 | # 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 56 | # queries: security-extended,security-and-quality 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 60 | 61 | # If the Autobuild fails above, remove it and uncomment the following three lines. 62 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 63 | 64 | # - run: | 65 | # echo "Run, Build Application using script" 66 | # ./location_of_script_within_repo/buildscript.sh 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@64e61baeac852f409b48440cebec029a2d978f90 70 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | golangci-lint: 6 | name: lint 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read # for actions/checkout to fetch code 10 | pull-requests: read # for golangci/golangci-lint-action to fetch pull requests 11 | steps: 12 | - uses: actions/checkout@v4.2.2 13 | - name: golangci-lint 14 | uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 15 | with: 16 | only-new-issues: true 17 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yaml: -------------------------------------------------------------------------------- 1 | name: Units tests 2 | on: [push, pull_request] 3 | jobs: 4 | checks: 5 | name: test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4.2.2 9 | - name: Setup go 10 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 11 | with: 12 | go-version: 1.19 13 | - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 14 | with: 15 | path: | 16 | ~/go/pkg/mod 17 | ~/.cache/go-build 18 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 19 | restore-keys: | 20 | ${{ runner.os }}-go- 21 | - name: Run tests 22 | run: go test -v -covermode=count -coverprofile=coverage.out 23 | - name: Convert coverage to lcov 24 | uses: jandelgado/gcov2lcov-action@4e1989767862652e6ca8d3e2e61aabe6d43be28b 25 | - name: Coveralls 26 | uses: coverallsapp/github-action@cfd0633edbd2411b532b808ba7a8b5e04f76d2c8 27 | with: 28 | github-token: ${{ secrets.github_token }} 29 | path-to-lcov: coverage.lcov 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2013, Julien Laffaye 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # goftp # 2 | 3 | [![Units tests](https://github.com/jlaffaye/ftp/actions/workflows/unit_tests.yaml/badge.svg)](https://github.com/jlaffaye/ftp/actions/workflows/unit_tests.yaml) 4 | [![Coverage Status](https://coveralls.io/repos/jlaffaye/ftp/badge.svg?branch=master&service=github)](https://coveralls.io/github/jlaffaye/ftp?branch=master) 5 | [![golangci-lint](https://github.com/jlaffaye/ftp/actions/workflows/golangci-lint.yaml/badge.svg)](https://github.com/jlaffaye/ftp/actions/workflows/golangci-lint.yaml) 6 | [![CodeQL](https://github.com/jlaffaye/ftp/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/jlaffaye/ftp/actions/workflows/codeql-analysis.yml) 7 | [![Go ReportCard](https://goreportcard.com/badge/jlaffaye/ftp)](http://goreportcard.com/report/jlaffaye/ftp) 8 | [![Go Reference](https://pkg.go.dev/badge/github.com/jlaffaye/ftp.svg)](https://pkg.go.dev/github.com/jlaffaye/ftp) 9 | 10 | A FTP client package for Go 11 | 12 | ## Install ## 13 | 14 | ``` 15 | go get -u github.com/jlaffaye/ftp 16 | ``` 17 | 18 | ## Documentation ## 19 | 20 | https://pkg.go.dev/github.com/jlaffaye/ftp 21 | 22 | ## Example ## 23 | 24 | ```go 25 | c, err := ftp.Dial("ftp.example.org:21", ftp.DialWithTimeout(5*time.Second)) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | err = c.Login("anonymous", "anonymous") 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | // Do something with the FTP conn 36 | 37 | if err := c.Quit(); err != nil { 38 | log.Fatal(err) 39 | } 40 | ``` 41 | 42 | ## Store a file example ## 43 | 44 | ```go 45 | data := bytes.NewBufferString("Hello World") 46 | err = c.Stor("test-file.txt", data) 47 | if err != nil { 48 | panic(err) 49 | } 50 | ``` 51 | 52 | ## Read a file example ## 53 | 54 | ```go 55 | r, err := c.Retr("test-file.txt") 56 | if err != nil { 57 | panic(err) 58 | } 59 | defer r.Close() 60 | 61 | buf, err := ioutil.ReadAll(r) 62 | println(string(buf)) 63 | ``` 64 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net" 8 | "syscall" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | const ( 16 | testData = "Just some text" 17 | testDir = "mydir" 18 | ) 19 | 20 | func TestConnPASV(t *testing.T) { 21 | testConn(t, true) 22 | } 23 | 24 | func TestConnEPSV(t *testing.T) { 25 | testConn(t, false) 26 | } 27 | 28 | func testConn(t *testing.T, disableEPSV bool) { 29 | assert := assert.New(t) 30 | mock, c := openConn(t, "127.0.0.1", DialWithTimeout(5*time.Second), DialWithDisabledEPSV(disableEPSV)) 31 | 32 | err := c.Login("anonymous", "anonymous") 33 | assert.NoError(err) 34 | 35 | err = c.NoOp() 36 | assert.NoError(err) 37 | 38 | err = c.ChangeDir("incoming") 39 | assert.NoError(err) 40 | 41 | dir, err := c.CurrentDir() 42 | if assert.NoError(err) { 43 | assert.Equal("/incoming", dir) 44 | } 45 | 46 | data := bytes.NewBufferString(testData) 47 | err = c.Stor("test", data) 48 | assert.NoError(err) 49 | 50 | _, err = c.List(".") 51 | assert.NoError(err) 52 | 53 | err = c.Rename("test", "tset") 54 | assert.NoError(err) 55 | 56 | // Read without deadline 57 | r, err := c.Retr("tset") 58 | if assert.NoError(err) { 59 | buf, err := io.ReadAll(r) 60 | if assert.NoError(err) { 61 | assert.Equal(testData, string(buf)) 62 | } 63 | 64 | r.Close() 65 | r.Close() // test we can close two times 66 | } 67 | 68 | // Read with deadline 69 | r, err = c.Retr("tset") 70 | if assert.NoError(err) { 71 | if err := r.SetDeadline(time.Now()); err != nil { 72 | t.Fatal(err) 73 | } 74 | _, err = io.ReadAll(r) 75 | assert.ErrorContains(err, "i/o timeout") 76 | r.Close() 77 | } 78 | 79 | // Read with offset 80 | r, err = c.RetrFrom("tset", 5) 81 | if assert.NoError(err) { 82 | buf, err := io.ReadAll(r) 83 | if assert.NoError(err) { 84 | expected := testData[5:] 85 | assert.Equal(expected, string(buf)) 86 | } 87 | 88 | r.Close() 89 | } 90 | 91 | data2 := bytes.NewBufferString(testData) 92 | err = c.Append("tset", data2) 93 | assert.NoError(err) 94 | 95 | // Read without deadline, after append 96 | r, err = c.Retr("tset") 97 | if assert.NoError(err) { 98 | buf, err := io.ReadAll(r) 99 | if assert.NoError(err) { 100 | assert.Equal(testData+testData, string(buf)) 101 | } 102 | 103 | r.Close() 104 | } 105 | 106 | fileSize, err := c.FileSize("magic-file") 107 | assert.NoError(err) 108 | assert.Equal(int64(42), fileSize) 109 | 110 | _, err = c.FileSize("not-found") 111 | assert.Error(err) 112 | 113 | entry, err := c.GetEntry("magic-file") 114 | if err != nil { 115 | t.Error(err) 116 | } 117 | if entry == nil { 118 | t.Fatal("expected entry, got nil") 119 | } 120 | if entry.Size != 42 { 121 | t.Errorf("entry size %q, expected %q", entry.Size, 42) 122 | } 123 | if entry.Type != EntryTypeFile { 124 | t.Errorf("entry type %q, expected %q", entry.Type, EntryTypeFile) 125 | } 126 | if entry.Name != "magic-file" { 127 | t.Errorf("entry name %q, expected %q", entry.Name, "magic-file") 128 | } 129 | 130 | entry, err = c.GetEntry("multiline-dir") 131 | if err != nil { 132 | t.Error(err) 133 | } 134 | if entry == nil { 135 | t.Fatal("expected entry, got nil") 136 | } 137 | if entry.Size != 0 { 138 | t.Errorf("entry size %q, expected %q", entry.Size, 0) 139 | } 140 | if entry.Type != EntryTypeFolder { 141 | t.Errorf("entry type %q, expected %q", entry.Type, EntryTypeFolder) 142 | } 143 | if entry.Name != "multiline-dir" { 144 | t.Errorf("entry name %q, expected %q", entry.Name, "multiline-dir") 145 | } 146 | 147 | err = c.Delete("tset") 148 | assert.NoError(err) 149 | 150 | err = c.MakeDir(testDir) 151 | assert.NoError(err) 152 | 153 | err = c.ChangeDir(testDir) 154 | assert.NoError(err) 155 | 156 | err = c.ChangeDirToParent() 157 | assert.NoError(err) 158 | 159 | entries, err := c.NameList("/") 160 | assert.NoError(err) 161 | assert.Equal([]string{"/incoming"}, entries) 162 | 163 | err = c.RemoveDir(testDir) 164 | assert.NoError(err) 165 | 166 | err = c.Logout() 167 | assert.NoError(err) 168 | 169 | if err = c.Quit(); err != nil { 170 | t.Fatal(err) 171 | } 172 | 173 | // Wait for the connection to close 174 | mock.Wait() 175 | 176 | err = c.NoOp() 177 | assert.Error(err, "should error on closed conn") 178 | } 179 | 180 | // TestConnect tests the legacy Connect function 181 | func TestConnect(t *testing.T) { 182 | mock, err := newFtpMock(t, "127.0.0.1") 183 | if err != nil { 184 | t.Fatal(err) 185 | } 186 | defer mock.Close() 187 | 188 | c, err := Connect(mock.Addr()) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | if err := c.Quit(); err != nil { 194 | t.Fatal(err) 195 | } 196 | mock.Wait() 197 | } 198 | 199 | func TestTimeout(t *testing.T) { 200 | if testing.Short() { 201 | t.Skip("skipping test in short mode.") 202 | } 203 | 204 | if c, err := DialTimeout("localhost:2121", 1*time.Second); err == nil { 205 | _ = c.Quit() 206 | t.Fatal("expected timeout, got nil error") 207 | } 208 | } 209 | 210 | func TestWrongLogin(t *testing.T) { 211 | mock, err := newFtpMock(t, "127.0.0.1") 212 | if err != nil { 213 | t.Fatal(err) 214 | } 215 | defer mock.Close() 216 | 217 | c, err := DialTimeout(mock.Addr(), 5*time.Second) 218 | if err != nil { 219 | t.Fatal(err) 220 | } 221 | defer func() { 222 | if err := c.Quit(); err != nil { 223 | t.Errorf("can not quit: %s", err) 224 | } 225 | }() 226 | 227 | err = c.Login("zoo2Shia", "fei5Yix9") 228 | if err == nil { 229 | t.Fatal("expected error, got nil") 230 | } 231 | } 232 | 233 | func TestDeleteDirRecur(t *testing.T) { 234 | mock, c := openConn(t, "127.0.0.1") 235 | 236 | err := c.RemoveDirRecur("testDir") 237 | if err != nil { 238 | t.Error(err) 239 | } 240 | 241 | if err := c.Quit(); err != nil { 242 | t.Fatal(err) 243 | } 244 | 245 | // Wait for the connection to close 246 | mock.Wait() 247 | } 248 | 249 | // func TestFileDeleteDirRecur(t *testing.T) { 250 | // mock, c := openConn(t, "127.0.0.1") 251 | 252 | // err := c.RemoveDirRecur("testFile") 253 | // if err == nil { 254 | // t.Fatal("expected error got nil") 255 | // } 256 | 257 | // if err := c.Quit(); err != nil { 258 | // t.Fatal(err) 259 | // } 260 | 261 | // // Wait for the connection to close 262 | // mock.Wait() 263 | // } 264 | 265 | func TestMissingFolderDeleteDirRecur(t *testing.T) { 266 | mock, c := openConn(t, "127.0.0.1") 267 | 268 | err := c.RemoveDirRecur("missing-dir") 269 | if err == nil { 270 | t.Fatal("expected error got nil") 271 | } 272 | 273 | if err := c.Quit(); err != nil { 274 | t.Fatal(err) 275 | } 276 | 277 | // Wait for the connection to close 278 | mock.Wait() 279 | } 280 | 281 | func TestListCurrentDir(t *testing.T) { 282 | mock, c := openConnExt(t, "127.0.0.1", "no-time", DialWithDisabledMLSD(true)) 283 | 284 | _, err := c.List("") 285 | assert.NoError(t, err) 286 | assert.Equal(t, "LIST", mock.lastFull, "LIST must not have a trailing whitespace") 287 | 288 | _, err = c.NameList("") 289 | assert.NoError(t, err) 290 | assert.Equal(t, "NLST", mock.lastFull, "NLST must not have a trailing whitespace") 291 | 292 | err = c.Quit() 293 | assert.NoError(t, err) 294 | 295 | mock.Wait() 296 | } 297 | 298 | func TestListCurrentDirWithForceListHidden(t *testing.T) { 299 | mock, c := openConnExt(t, "127.0.0.1", "no-time", DialWithDisabledMLSD(true), DialWithForceListHidden(true)) 300 | 301 | assert.True(t, c.options.forceListHidden) 302 | _, err := c.List("") 303 | assert.NoError(t, err) 304 | assert.Equal(t, "LIST -a", mock.lastFull, "LIST -a must not have a trailing whitespace") 305 | 306 | err = c.Quit() 307 | assert.NoError(t, err) 308 | 309 | mock.Wait() 310 | } 311 | 312 | func TestTimeUnsupported(t *testing.T) { 313 | mock, c := openConnExt(t, "127.0.0.1", "no-time") 314 | 315 | assert.False(t, c.mdtmSupported, "MDTM must NOT be supported") 316 | assert.False(t, c.mfmtSupported, "MFMT must NOT be supported") 317 | 318 | assert.False(t, c.IsGetTimeSupported(), "GetTime must NOT be supported") 319 | assert.False(t, c.IsSetTimeSupported(), "SetTime must NOT be supported") 320 | 321 | _, err := c.GetTime("file1") 322 | assert.NotNil(t, err) 323 | 324 | err = c.SetTime("file1", time.Now()) 325 | assert.NotNil(t, err) 326 | 327 | assert.NoError(t, c.Quit()) 328 | mock.Wait() 329 | } 330 | 331 | func TestTimeStandard(t *testing.T) { 332 | mock, c := openConnExt(t, "127.0.0.1", "std-time") 333 | 334 | assert.True(t, c.mdtmSupported, "MDTM must be supported") 335 | assert.True(t, c.mfmtSupported, "MFMT must be supported") 336 | 337 | assert.True(t, c.IsGetTimeSupported(), "GetTime must be supported") 338 | assert.True(t, c.IsSetTimeSupported(), "SetTime must be supported") 339 | 340 | tm, err := c.GetTime("file1") 341 | assert.NoError(t, err) 342 | assert.False(t, tm.IsZero(), "GetTime must return valid time") 343 | 344 | err = c.SetTime("file1", time.Now()) 345 | assert.NoError(t, err) 346 | 347 | assert.NoError(t, c.Quit()) 348 | mock.Wait() 349 | } 350 | 351 | func TestTimeVsftpdPartial(t *testing.T) { 352 | mock, c := openConnExt(t, "127.0.0.1", "vsftpd") 353 | 354 | assert.True(t, c.mdtmSupported, "MDTM must be supported") 355 | assert.False(t, c.mfmtSupported, "MFMT must NOT be supported") 356 | 357 | assert.True(t, c.IsGetTimeSupported(), "GetTime must be supported") 358 | assert.False(t, c.IsSetTimeSupported(), "SetTime must NOT be supported") 359 | 360 | tm, err := c.GetTime("file1") 361 | assert.NoError(t, err) 362 | assert.False(t, tm.IsZero(), "GetTime must return valid time") 363 | 364 | err = c.SetTime("file1", time.Now()) 365 | assert.NotNil(t, err) 366 | 367 | assert.NoError(t, c.Quit()) 368 | mock.Wait() 369 | } 370 | 371 | func TestTimeVsftpdFull(t *testing.T) { 372 | mock, c := openConnExt(t, "127.0.0.1", "vsftpd", DialWithWritingMDTM(true)) 373 | 374 | assert.True(t, c.mdtmSupported, "MDTM must be supported") 375 | assert.False(t, c.mfmtSupported, "MFMT must NOT be supported") 376 | 377 | assert.True(t, c.IsGetTimeSupported(), "GetTime must be supported") 378 | assert.True(t, c.IsSetTimeSupported(), "SetTime must be supported") 379 | 380 | tm, err := c.GetTime("file1") 381 | assert.NoError(t, err) 382 | assert.False(t, tm.IsZero(), "GetTime must return valid time") 383 | 384 | err = c.SetTime("file1", time.Now()) 385 | assert.NoError(t, err) 386 | 387 | assert.NoError(t, c.Quit()) 388 | mock.Wait() 389 | } 390 | 391 | func TestDialWithDialFunc(t *testing.T) { 392 | dialErr := fmt.Errorf("this is proof that dial function was called") 393 | 394 | f := func(network, address string) (net.Conn, error) { 395 | return nil, dialErr 396 | } 397 | 398 | _, err := Dial("bogus-address", DialWithDialFunc(f)) 399 | assert.Equal(t, dialErr, err) 400 | } 401 | 402 | func TestDialWithDialer(t *testing.T) { 403 | dialerCalled := false 404 | dialer := net.Dialer{ 405 | Control: func(network, address string, c syscall.RawConn) error { 406 | dialerCalled = true 407 | return nil 408 | }, 409 | } 410 | 411 | mock, err := newFtpMock(t, "127.0.0.1") 412 | assert.NoError(t, err) 413 | 414 | c, err := Dial(mock.Addr(), DialWithDialer(dialer)) 415 | assert.NoError(t, err) 416 | assert.NoError(t, c.Quit()) 417 | 418 | assert.Equal(t, true, dialerCalled) 419 | } 420 | -------------------------------------------------------------------------------- /conn_test.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "net" 8 | "net/textproto" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | type ftpMock struct { 20 | t *testing.T 21 | address string 22 | modtime string // no-time, std-time, vsftpd 23 | listener *net.TCPListener 24 | proto *textproto.Conn 25 | commands []string // list of received commands 26 | lastFull string // full last command 27 | rest int 28 | fileCont *bytes.Buffer 29 | dataConn *mockDataConn 30 | sync.WaitGroup 31 | } 32 | 33 | // newFtpMock returns a mock implementation of a FTP server 34 | // For simplication, a mock instance only accepts a signle connection and terminates afer 35 | func newFtpMock(t *testing.T, address string) (*ftpMock, error) { 36 | return newFtpMockExt(t, address, "no-time") 37 | } 38 | 39 | func newFtpMockExt(t *testing.T, address, modtime string) (*ftpMock, error) { 40 | var err error 41 | mock := &ftpMock{ 42 | t: t, 43 | address: address, 44 | modtime: modtime, 45 | } 46 | 47 | l, err := net.Listen("tcp", address+":0") 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | tcpListener, ok := l.(*net.TCPListener) 53 | if !ok { 54 | return nil, errors.New("listener is not a net.TCPListener") 55 | } 56 | mock.listener = tcpListener 57 | 58 | go mock.listen() 59 | 60 | return mock, nil 61 | } 62 | 63 | func (mock *ftpMock) listen() { 64 | // Listen for an incoming connection. 65 | conn, err := mock.listener.Accept() 66 | if err != nil { 67 | mock.t.Errorf("can not accept: %s", err) 68 | return 69 | } 70 | 71 | // Do not accept incoming connections anymore 72 | mock.listener.Close() 73 | 74 | mock.Add(1) 75 | defer mock.Done() 76 | defer conn.Close() 77 | 78 | mock.proto = textproto.NewConn(conn) 79 | mock.printfLine("220 FTP Server ready.") 80 | 81 | for { 82 | fullCommand, _ := mock.proto.ReadLine() 83 | mock.lastFull = fullCommand 84 | 85 | cmdParts := strings.Split(fullCommand, " ") 86 | 87 | // Append to list of received commands 88 | mock.commands = append(mock.commands, cmdParts[0]) 89 | 90 | // At least one command must have a multiline response 91 | switch cmdParts[0] { 92 | case "FEAT": 93 | features := "211-Features:\r\n FEAT\r\n PASV\r\n EPSV\r\n UTF8\r\n SIZE\r\n MLST\r\n" 94 | switch mock.modtime { 95 | case "std-time": 96 | features += " MDTM\r\n MFMT\r\n" 97 | case "vsftpd": 98 | features += " MDTM\r\n" 99 | } 100 | features += "211 End" 101 | mock.printfLine(features) 102 | case "USER": 103 | if cmdParts[1] == "anonymous" { 104 | mock.printfLine("331 Please send your password") 105 | } else { 106 | mock.printfLine("530 This FTP server is anonymous only") 107 | } 108 | case "PASS": 109 | mock.printfLine("230-Hey,\r\nWelcome to my FTP\r\n230 Access granted") 110 | case "TYPE": 111 | mock.printfLine("200 Type set ok") 112 | case "CWD": 113 | if cmdParts[1] == "missing-dir" { 114 | mock.printfLine("550 %s: No such file or directory", cmdParts[1]) 115 | } else { 116 | mock.printfLine("250 Directory successfully changed.") 117 | } 118 | case "DELE": 119 | mock.printfLine("250 File successfully removed.") 120 | case "MKD": 121 | mock.printfLine("257 Directory successfully created.") 122 | case "RMD": 123 | if cmdParts[1] == "missing-dir" { 124 | mock.printfLine("550 No such file or directory") 125 | } else { 126 | mock.printfLine("250 Directory successfully removed.") 127 | } 128 | case "PWD": 129 | mock.printfLine("257 \"/incoming\"") 130 | case "CDUP": 131 | mock.printfLine("250 CDUP command successful") 132 | case "SIZE": 133 | if cmdParts[1] == "magic-file" { 134 | mock.printfLine("213 42") 135 | } else { 136 | mock.printfLine("550 Could not get file size.") 137 | } 138 | case "PASV": 139 | p, err := mock.listenDataConn() 140 | if err != nil { 141 | mock.printfLine("451 %s.", err) 142 | break 143 | } 144 | 145 | p1 := int(p / 256) 146 | p2 := p % 256 147 | 148 | mock.printfLine("227 Entering Passive Mode (127,0,0,1,%d,%d).", p1, p2) 149 | case "EPSV": 150 | p, err := mock.listenDataConn() 151 | if err != nil { 152 | mock.printfLine("451 %s.", err) 153 | break 154 | } 155 | mock.printfLine("229 Entering Extended Passive Mode (|||%d|)", p) 156 | case "STOR": 157 | if mock.dataConn == nil { 158 | mock.printfLine("425 Unable to build data connection: Connection refused") 159 | break 160 | } 161 | mock.printfLine("150 please send") 162 | mock.recvDataConn(false) 163 | case "APPE": 164 | if mock.dataConn == nil { 165 | mock.printfLine("425 Unable to build data connection: Connection refused") 166 | break 167 | } 168 | mock.printfLine("150 please send") 169 | mock.recvDataConn(true) 170 | case "LIST": 171 | if mock.dataConn == nil { 172 | mock.printfLine("425 Unable to build data connection: Connection refused") 173 | break 174 | } 175 | 176 | mock.dataConn.Wait() 177 | mock.printfLine("150 Opening ASCII mode data connection for file list") 178 | mock.dataConn.write([]byte("-rw-r--r-- 1 ftp wheel 0 Jan 29 10:29 lo\r\ntotal 1")) 179 | mock.printfLine("226 Transfer complete") 180 | mock.closeDataConn() 181 | case "MLSD": 182 | if mock.dataConn == nil { 183 | mock.printfLine("425 Unable to build data connection: Connection refused") 184 | break 185 | } 186 | 187 | mock.dataConn.Wait() 188 | mock.printfLine("150 Opening data connection for file list") 189 | mock.dataConn.write([]byte("Type=file;Size=0;Modify=20201213202400; lo\r\n")) 190 | mock.printfLine("226 Transfer complete") 191 | mock.closeDataConn() 192 | case "MLST": 193 | if cmdParts[1] == "multiline-dir" { 194 | mock.printfLine("250-File data\r\n Type=dir;Size=0; multiline-dir\r\n Modify=20201213202400; multiline-dir\r\n250 End") 195 | } else { 196 | mock.printfLine("250-File data\r\n Type=file;Size=42;Modify=20201213202400; magic-file\r\n \r\n250 End") 197 | } 198 | case "NLST": 199 | if mock.dataConn == nil { 200 | mock.printfLine("425 Unable to build data connection: Connection refused") 201 | break 202 | } 203 | 204 | mock.dataConn.Wait() 205 | mock.printfLine("150 Opening ASCII mode data connection for file list") 206 | mock.dataConn.write([]byte("/incoming")) 207 | mock.printfLine("226 Transfer complete") 208 | mock.closeDataConn() 209 | case "RETR": 210 | if mock.dataConn == nil { 211 | mock.printfLine("425 Unable to build data connection: Connection refused") 212 | break 213 | } 214 | 215 | mock.dataConn.Wait() 216 | mock.printfLine("150 Opening ASCII mode data connection for file list") 217 | mock.dataConn.write(mock.fileCont.Bytes()[mock.rest:]) 218 | mock.rest = 0 219 | mock.printfLine("226 Transfer complete") 220 | mock.closeDataConn() 221 | case "RNFR": 222 | mock.printfLine("350 File or directory exists, ready for destination name") 223 | case "RNTO": 224 | mock.printfLine("250 Rename successful") 225 | case "REST": 226 | if len(cmdParts) != 2 { 227 | mock.printfLine("500 wrong number of arguments") 228 | break 229 | } 230 | rest, err := strconv.Atoi(cmdParts[1]) 231 | if err != nil { 232 | mock.printfLine("500 REST: %s", err) 233 | break 234 | } 235 | mock.rest = rest 236 | mock.printfLine("350 Restarting at %s. Send STORE or RETRIEVE to initiate transfer", cmdParts[1]) 237 | case "MDTM": 238 | var answer string 239 | switch { 240 | case mock.modtime == "no-time": 241 | answer = "500 Unknown command MDTM" 242 | case len(cmdParts) == 3 && mock.modtime == "vsftpd": 243 | answer = "213 UTIME OK" 244 | _, err := time.ParseInLocation(timeFormat, cmdParts[1], time.UTC) 245 | if err != nil { 246 | answer = "501 Can't get a time stamp" 247 | } 248 | case len(cmdParts) == 2: 249 | answer = "213 20201213202400" 250 | default: 251 | answer = "500 wrong number of arguments" 252 | } 253 | mock.printfLine(answer) 254 | case "MFMT": 255 | var answer string 256 | switch { 257 | case mock.modtime == "std-time" && len(cmdParts) == 3: 258 | answer = "213 UTIME OK" 259 | _, err := time.ParseInLocation(timeFormat, cmdParts[1], time.UTC) 260 | if err != nil { 261 | answer = "501 Can't get a time stamp" 262 | } 263 | default: 264 | answer = "500 Unknown command MFMT" 265 | } 266 | mock.printfLine(answer) 267 | case "NOOP": 268 | mock.printfLine("200 NOOP ok.") 269 | case "OPTS": 270 | if len(cmdParts) != 3 { 271 | mock.printfLine("500 wrong number of arguments") 272 | break 273 | } 274 | if (strings.Join(cmdParts[1:], " ")) == "UTF8 ON" { 275 | mock.printfLine("200 OK, UTF-8 enabled") 276 | } 277 | case "REIN": 278 | mock.printfLine("220 Logged out") 279 | case "QUIT": 280 | mock.printfLine("221 Goodbye.") 281 | return 282 | default: 283 | mock.printfLine("500 Unknown command %s.", cmdParts[0]) 284 | } 285 | } 286 | } 287 | 288 | func (mock *ftpMock) printfLine(format string, args ...interface{}) { 289 | if err := mock.proto.Writer.PrintfLine(format, args...); err != nil { 290 | mock.t.Fatal(err) 291 | } 292 | } 293 | 294 | func (mock *ftpMock) closeDataConn() { 295 | if mock.dataConn != nil { 296 | if err := mock.dataConn.Close(); err != nil { 297 | mock.t.Fatal(err) 298 | } 299 | mock.dataConn = nil 300 | } 301 | } 302 | 303 | type mockDataConn struct { 304 | t *testing.T 305 | listener *net.TCPListener 306 | conn net.Conn 307 | // WaitGroup is done when conn is accepted and stored 308 | sync.WaitGroup 309 | } 310 | 311 | func (d *mockDataConn) Close() (err error) { 312 | if d.listener != nil { 313 | err = d.listener.Close() 314 | } 315 | if d.conn != nil { 316 | err = d.conn.Close() 317 | } 318 | return 319 | } 320 | 321 | func (d *mockDataConn) write(b []byte) { 322 | if d.conn == nil { 323 | d.t.Fatal("data conn is not opened") 324 | } 325 | 326 | if _, err := d.conn.Write(b); err != nil { 327 | d.t.Fatal(err) 328 | } 329 | } 330 | 331 | func (mock *ftpMock) listenDataConn() (int64, error) { 332 | mock.closeDataConn() 333 | 334 | l, err := net.Listen("tcp", mock.address+":0") 335 | if err != nil { 336 | return 0, err 337 | } 338 | 339 | tcpListener, ok := l.(*net.TCPListener) 340 | if !ok { 341 | return 0, errors.New("listener is not a net.TCPListener") 342 | } 343 | 344 | addr := tcpListener.Addr().String() 345 | 346 | _, port, err := net.SplitHostPort(addr) 347 | if err != nil { 348 | return 0, err 349 | } 350 | 351 | p, err := strconv.ParseInt(port, 10, 32) 352 | if err != nil { 353 | return 0, err 354 | } 355 | 356 | dataConn := &mockDataConn{ 357 | t: mock.t, 358 | listener: tcpListener, 359 | } 360 | dataConn.Add(1) 361 | 362 | go func() { 363 | // Listen for an incoming connection. 364 | conn, err := dataConn.listener.Accept() 365 | if err != nil { 366 | // mock.t.Fatalf("can not accept data conn: %s", err) 367 | return 368 | } 369 | 370 | dataConn.conn = conn 371 | dataConn.Done() 372 | }() 373 | 374 | mock.dataConn = dataConn 375 | return p, nil 376 | } 377 | 378 | func (mock *ftpMock) recvDataConn(append bool) { 379 | mock.dataConn.Wait() 380 | if !append { 381 | mock.fileCont = new(bytes.Buffer) 382 | } 383 | 384 | if _, err := io.Copy(mock.fileCont, mock.dataConn.conn); err != nil { 385 | mock.t.Fatal(err) 386 | } 387 | 388 | mock.printfLine("226 Transfer Complete") 389 | mock.closeDataConn() 390 | } 391 | 392 | func (mock *ftpMock) Addr() string { 393 | return mock.listener.Addr().String() 394 | } 395 | 396 | // Closes the listening socket 397 | func (mock *ftpMock) Close() { 398 | mock.listener.Close() 399 | } 400 | 401 | // Helper to return a client connected to a mock server 402 | func openConn(t *testing.T, addr string, options ...DialOption) (*ftpMock, *ServerConn) { 403 | return openConnExt(t, addr, "no-time", options...) 404 | } 405 | 406 | func openConnExt(t *testing.T, addr, modtime string, options ...DialOption) (*ftpMock, *ServerConn) { 407 | mock, err := newFtpMockExt(t, addr, modtime) 408 | require.NoError(t, err) 409 | defer mock.Close() 410 | 411 | c, err := Dial(mock.Addr(), options...) 412 | require.NoError(t, err) 413 | 414 | err = c.Login("anonymous", "anonymous") 415 | require.NoError(t, err) 416 | 417 | return mock, c 418 | } 419 | 420 | // Helper to close a client connected to a mock server 421 | func closeConn(t *testing.T, mock *ftpMock, c *ServerConn, commands []string) { 422 | expected := []string{"USER", "PASS", "FEAT", "TYPE", "OPTS"} 423 | expected = append(expected, commands...) 424 | expected = append(expected, "QUIT") 425 | 426 | if err := c.Quit(); err != nil { 427 | t.Fatal(err) 428 | } 429 | 430 | // Wait for the connection to close 431 | mock.Wait() 432 | 433 | assert.Equal(t, expected, mock.commands, "unexpected sequence of commands") 434 | } 435 | 436 | func TestConn4(t *testing.T) { 437 | mock, c := openConn(t, "127.0.0.1") 438 | closeConn(t, mock, c, nil) 439 | } 440 | 441 | func TestConn6(t *testing.T) { 442 | mock, c := openConn(t, "[::1]") 443 | closeConn(t, mock, c, nil) 444 | } 445 | -------------------------------------------------------------------------------- /constants_test.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestStatusText(t *testing.T) { 10 | assert.Equal(t, "Unknown status code: 0", StatusText(0)) 11 | assert.Equal(t, "Invalid username or password.", StatusText(StatusInvalidCredentials)) 12 | } 13 | 14 | func TestEntryTypeString(t *testing.T) { 15 | assert.Equal(t, "file", EntryTypeFile.String()) 16 | assert.Equal(t, "folder", EntryTypeFolder.String()) 17 | assert.Equal(t, "link", EntryTypeLink.String()) 18 | } 19 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import "io" 4 | 5 | type debugWrapper struct { 6 | conn io.ReadWriteCloser 7 | io.Reader 8 | io.Writer 9 | } 10 | 11 | func newDebugWrapper(conn io.ReadWriteCloser, w io.Writer) io.ReadWriteCloser { 12 | return &debugWrapper{ 13 | Reader: io.TeeReader(conn, w), 14 | Writer: io.MultiWriter(w, conn), 15 | conn: conn, 16 | } 17 | } 18 | 19 | func (w *debugWrapper) Close() error { 20 | return w.conn.Close() 21 | } 22 | 23 | type streamDebugWrapper struct { 24 | io.Reader 25 | closer io.ReadCloser 26 | } 27 | 28 | func newStreamDebugWrapper(rd io.ReadCloser, w io.Writer) io.ReadCloser { 29 | return &streamDebugWrapper{ 30 | Reader: io.TeeReader(rd, w), 31 | closer: rd, 32 | } 33 | } 34 | 35 | func (w *streamDebugWrapper) Close() error { 36 | return w.closer.Close() 37 | } 38 | -------------------------------------------------------------------------------- /ftp.go: -------------------------------------------------------------------------------- 1 | // Package ftp implements a FTP client as described in RFC 959. 2 | // 3 | // A textproto.Error is returned for errors at the protocol level. 4 | package ftp 5 | 6 | import ( 7 | "bufio" 8 | "context" 9 | "crypto/tls" 10 | "errors" 11 | "io" 12 | "net" 13 | "net/textproto" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "github.com/hashicorp/go-multierror" 19 | ) 20 | 21 | const ( 22 | // 30 seconds was chosen as it's the 23 | // same duration as http.DefaultTransport's timeout. 24 | DefaultDialTimeout = 30 * time.Second 25 | ) 26 | 27 | // EntryType describes the different types of an Entry. 28 | type EntryType int 29 | 30 | // The differents types of an Entry 31 | const ( 32 | EntryTypeFile EntryType = iota 33 | EntryTypeFolder 34 | EntryTypeLink 35 | ) 36 | 37 | // TransferType denotes the formats for transferring Entries. 38 | type TransferType string 39 | 40 | // The different transfer types 41 | const ( 42 | TransferTypeBinary = TransferType("I") 43 | TransferTypeASCII = TransferType("A") 44 | ) 45 | 46 | // Time format used by the MDTM and MFMT commands 47 | const timeFormat = "20060102150405" 48 | 49 | // ServerConn represents the connection to a remote FTP server. 50 | // A single connection only supports one in-flight data connection. 51 | // It is not safe to be called concurrently. 52 | type ServerConn struct { 53 | options *dialOptions 54 | conn *textproto.Conn // connection wrapper for text protocol 55 | netConn net.Conn // underlying network connection 56 | host string 57 | 58 | // Server capabilities discovered at runtime 59 | features map[string]string 60 | skipEPSV bool 61 | mlstSupported bool 62 | mfmtSupported bool 63 | mdtmSupported bool 64 | mdtmCanWrite bool 65 | usePRET bool 66 | } 67 | 68 | // DialOption represents an option to start a new connection with Dial 69 | type DialOption struct { 70 | setup func(do *dialOptions) 71 | } 72 | 73 | // dialOptions contains all the options set by DialOption.setup 74 | type dialOptions struct { 75 | context context.Context 76 | dialer net.Dialer 77 | tlsConfig *tls.Config 78 | explicitTLS bool 79 | disableEPSV bool 80 | disableUTF8 bool 81 | disableMLSD bool 82 | writingMDTM bool 83 | forceListHidden bool 84 | location *time.Location 85 | debugOutput io.Writer 86 | dialFunc func(network, address string) (net.Conn, error) 87 | shutTimeout time.Duration // time to wait for data connection closing status 88 | } 89 | 90 | // Entry describes a file and is returned by List(). 91 | type Entry struct { 92 | Name string 93 | Target string // target of symbolic link 94 | Type EntryType 95 | Size uint64 96 | Time time.Time 97 | } 98 | 99 | // Response represents a data-connection 100 | type Response struct { 101 | conn net.Conn 102 | c *ServerConn 103 | closed bool 104 | } 105 | 106 | // Dial connects to the specified address with optional options 107 | func Dial(addr string, options ...DialOption) (*ServerConn, error) { 108 | do := &dialOptions{} 109 | for _, option := range options { 110 | option.setup(do) 111 | } 112 | 113 | if do.location == nil { 114 | do.location = time.UTC 115 | } 116 | 117 | dialFunc := do.dialFunc 118 | 119 | if dialFunc == nil { 120 | ctx := do.context 121 | 122 | if ctx == nil { 123 | ctx = context.Background() 124 | } 125 | if _, ok := ctx.Deadline(); !ok { 126 | var cancel context.CancelFunc 127 | ctx, cancel = context.WithTimeout(ctx, DefaultDialTimeout) 128 | defer cancel() 129 | } 130 | 131 | if do.tlsConfig != nil && !do.explicitTLS { 132 | dialFunc = func(network, address string) (net.Conn, error) { 133 | tlsDialer := &tls.Dialer{ 134 | NetDialer: &do.dialer, 135 | Config: do.tlsConfig, 136 | } 137 | return tlsDialer.DialContext(ctx, network, addr) 138 | } 139 | } else { 140 | 141 | dialFunc = func(network, address string) (net.Conn, error) { 142 | return do.dialer.DialContext(ctx, network, addr) 143 | } 144 | } 145 | } 146 | 147 | tconn, err := dialFunc("tcp", addr) 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | // Use the resolved IP address in case addr contains a domain name 153 | // If we use the domain name, we might not resolve to the same IP. 154 | remoteAddr := tconn.RemoteAddr().(*net.TCPAddr) 155 | 156 | c := &ServerConn{ 157 | options: do, 158 | features: make(map[string]string), 159 | conn: textproto.NewConn(do.wrapConn(tconn)), 160 | netConn: tconn, 161 | host: remoteAddr.IP.String(), 162 | } 163 | 164 | _, _, err = c.conn.ReadResponse(StatusReady) 165 | if err != nil { 166 | _ = c.Quit() 167 | return nil, err 168 | } 169 | 170 | if do.explicitTLS { 171 | if err := c.authTLS(); err != nil { 172 | _ = c.Quit() 173 | return nil, err 174 | } 175 | tconn = tls.Client(tconn, do.tlsConfig) 176 | c.conn = textproto.NewConn(do.wrapConn(tconn)) 177 | } 178 | 179 | return c, nil 180 | } 181 | 182 | // DialWithTimeout returns a DialOption that configures the ServerConn with specified timeout 183 | func DialWithTimeout(timeout time.Duration) DialOption { 184 | return DialOption{func(do *dialOptions) { 185 | do.dialer.Timeout = timeout 186 | }} 187 | } 188 | 189 | // DialWithShutTimeout returns a DialOption that configures the ServerConn with 190 | // maximum time to wait for the data closing status on control connection 191 | // and nudging the control connection deadline before reading status. 192 | func DialWithShutTimeout(shutTimeout time.Duration) DialOption { 193 | return DialOption{func(do *dialOptions) { 194 | do.shutTimeout = shutTimeout 195 | }} 196 | } 197 | 198 | // DialWithDialer returns a DialOption that configures the ServerConn with specified net.Dialer 199 | func DialWithDialer(dialer net.Dialer) DialOption { 200 | return DialOption{func(do *dialOptions) { 201 | do.dialer = dialer 202 | }} 203 | } 204 | 205 | // DialWithNetConn returns a DialOption that configures the ServerConn with the underlying net.Conn 206 | // 207 | // Deprecated: Use [DialWithDialFunc] instead 208 | func DialWithNetConn(conn net.Conn) DialOption { 209 | return DialWithDialFunc(func(network, address string) (net.Conn, error) { 210 | return conn, nil 211 | }) 212 | } 213 | 214 | // DialWithDisabledEPSV returns a DialOption that configures the ServerConn with EPSV disabled 215 | // Note that EPSV is only used when advertised in the server features. 216 | func DialWithDisabledEPSV(disabled bool) DialOption { 217 | return DialOption{func(do *dialOptions) { 218 | do.disableEPSV = disabled 219 | }} 220 | } 221 | 222 | // DialWithDisabledUTF8 returns a DialOption that configures the ServerConn with UTF8 option disabled 223 | func DialWithDisabledUTF8(disabled bool) DialOption { 224 | return DialOption{func(do *dialOptions) { 225 | do.disableUTF8 = disabled 226 | }} 227 | } 228 | 229 | // DialWithDisabledMLSD returns a DialOption that configures the ServerConn with MLSD option disabled 230 | // 231 | // This is useful for servers which advertise MLSD (eg some versions 232 | // of Serv-U) but don't support it properly. 233 | func DialWithDisabledMLSD(disabled bool) DialOption { 234 | return DialOption{func(do *dialOptions) { 235 | do.disableMLSD = disabled 236 | }} 237 | } 238 | 239 | // DialWithWritingMDTM returns a DialOption making ServerConn use MDTM to set file time 240 | // 241 | // This option addresses a quirk in the VsFtpd server which doesn't support 242 | // the MFMT command for setting file time like other servers but by default 243 | // uses the MDTM command with non-standard arguments for that. 244 | // See "mdtm_write" in https://security.appspot.com/vsftpd/vsftpd_conf.html 245 | func DialWithWritingMDTM(enabled bool) DialOption { 246 | return DialOption{func(do *dialOptions) { 247 | do.writingMDTM = enabled 248 | }} 249 | } 250 | 251 | // DialWithForceListHidden returns a DialOption making ServerConn use LIST -a to include hidden files and folders in directory listings 252 | // 253 | // This is useful for servers that do not do this by default, but it forces the use of the LIST command 254 | // even if the server supports MLST. 255 | func DialWithForceListHidden(enabled bool) DialOption { 256 | return DialOption{func(do *dialOptions) { 257 | do.forceListHidden = enabled 258 | }} 259 | } 260 | 261 | // DialWithLocation returns a DialOption that configures the ServerConn with specified time.Location 262 | // The location is used to parse the dates sent by the server which are in server's timezone 263 | func DialWithLocation(location *time.Location) DialOption { 264 | return DialOption{func(do *dialOptions) { 265 | do.location = location 266 | }} 267 | } 268 | 269 | // DialWithContext returns a DialOption that configures the ServerConn with specified context 270 | // The context will be used for the initial connection setup 271 | func DialWithContext(ctx context.Context) DialOption { 272 | return DialOption{func(do *dialOptions) { 273 | do.context = ctx 274 | }} 275 | } 276 | 277 | // DialWithTLS returns a DialOption that configures the ServerConn with specified TLS config 278 | // 279 | // If called together with the DialWithDialFunc option, the DialWithDialFunc function 280 | // will be used when dialing new connections but regardless of the function, 281 | // the connection will be treated as a TLS connection. 282 | func DialWithTLS(tlsConfig *tls.Config) DialOption { 283 | return DialOption{func(do *dialOptions) { 284 | do.tlsConfig = tlsConfig 285 | }} 286 | } 287 | 288 | // DialWithExplicitTLS returns a DialOption that configures the ServerConn to be upgraded to TLS 289 | // See DialWithTLS for general TLS documentation 290 | func DialWithExplicitTLS(tlsConfig *tls.Config) DialOption { 291 | return DialOption{func(do *dialOptions) { 292 | do.explicitTLS = true 293 | do.tlsConfig = tlsConfig 294 | }} 295 | } 296 | 297 | // DialWithDebugOutput returns a DialOption that configures the ServerConn to write to the Writer 298 | // everything it reads from the server 299 | func DialWithDebugOutput(w io.Writer) DialOption { 300 | return DialOption{func(do *dialOptions) { 301 | do.debugOutput = w 302 | }} 303 | } 304 | 305 | // DialWithDialFunc returns a DialOption that configures the ServerConn to use the 306 | // specified function to establish both control and data connections 307 | // 308 | // If used together with the DialWithNetConn option, the DialWithNetConn 309 | // takes precedence for the control connection, while data connections will 310 | // be established using function specified with the DialWithDialFunc option 311 | func DialWithDialFunc(f func(network, address string) (net.Conn, error)) DialOption { 312 | return DialOption{func(do *dialOptions) { 313 | do.dialFunc = f 314 | }} 315 | } 316 | 317 | func (o *dialOptions) wrapConn(netConn net.Conn) io.ReadWriteCloser { 318 | if o.debugOutput == nil { 319 | return netConn 320 | } 321 | 322 | return newDebugWrapper(netConn, o.debugOutput) 323 | } 324 | 325 | func (o *dialOptions) wrapStream(rd io.ReadCloser) io.ReadCloser { 326 | if o.debugOutput == nil { 327 | return rd 328 | } 329 | 330 | return newStreamDebugWrapper(rd, o.debugOutput) 331 | } 332 | 333 | // Connect is an alias to Dial, for backward compatibility 334 | // 335 | // Deprecated: Use [Dial] instead 336 | func Connect(addr string) (*ServerConn, error) { 337 | return Dial(addr) 338 | } 339 | 340 | // DialTimeout initializes the connection to the specified ftp server address. 341 | // 342 | // Deprecated: Use [Dial] with [DialWithTimeout] option instead 343 | func DialTimeout(addr string, timeout time.Duration) (*ServerConn, error) { 344 | return Dial(addr, DialWithTimeout(timeout)) 345 | } 346 | 347 | // Login authenticates the client with specified user and password. 348 | // 349 | // "anonymous"/"anonymous" is a common user/password scheme for FTP servers 350 | // that allows anonymous read-only accounts. 351 | func (c *ServerConn) Login(user, password string) error { 352 | code, message, err := c.cmd(-1, "USER %s", user) 353 | if err != nil { 354 | return err 355 | } 356 | 357 | switch code { 358 | case StatusLoggedIn: 359 | case StatusUserOK: 360 | _, _, err = c.cmd(StatusLoggedIn, "PASS %s", password) 361 | if err != nil { 362 | return err 363 | } 364 | default: 365 | return errors.New(message) 366 | } 367 | 368 | // Probe features 369 | err = c.feat() 370 | if err != nil { 371 | return err 372 | } 373 | if _, mlstSupported := c.features["MLST"]; mlstSupported && !c.options.disableMLSD { 374 | c.mlstSupported = true 375 | } 376 | _, c.usePRET = c.features["PRET"] 377 | 378 | _, c.mfmtSupported = c.features["MFMT"] 379 | _, c.mdtmSupported = c.features["MDTM"] 380 | c.mdtmCanWrite = c.mdtmSupported && c.options.writingMDTM 381 | 382 | // Switch to binary mode 383 | if err = c.Type(TransferTypeBinary); err != nil { 384 | return err 385 | } 386 | 387 | // Switch to UTF-8 388 | if !c.options.disableUTF8 { 389 | err = c.setUTF8() 390 | } 391 | 392 | // If using implicit TLS, make data connections also use TLS 393 | if c.options.tlsConfig != nil { 394 | if _, _, err = c.cmd(StatusCommandOK, "PBSZ 0"); err != nil { 395 | return err 396 | } 397 | if _, _, err = c.cmd(StatusCommandOK, "PROT P"); err != nil { 398 | return err 399 | } 400 | } 401 | 402 | return err 403 | } 404 | 405 | // authTLS upgrades the connection to use TLS 406 | func (c *ServerConn) authTLS() error { 407 | _, _, err := c.cmd(StatusAuthOK, "AUTH TLS") 408 | return err 409 | } 410 | 411 | // feat issues a FEAT FTP command to list the additional commands supported by 412 | // the remote FTP server. 413 | // FEAT is described in RFC 2389 414 | func (c *ServerConn) feat() error { 415 | code, message, err := c.cmd(-1, "FEAT") 416 | if err != nil { 417 | return err 418 | } 419 | 420 | if code != StatusSystem { 421 | // The server does not support the FEAT command. This is not an 422 | // error: we consider that there is no additional feature. 423 | return nil 424 | } 425 | 426 | lines := strings.Split(message, "\n") 427 | for _, line := range lines { 428 | if !strings.HasPrefix(line, " ") { 429 | continue 430 | } 431 | 432 | line = strings.TrimSpace(line) 433 | featureElements := strings.SplitN(line, " ", 2) 434 | 435 | command := featureElements[0] 436 | 437 | var commandDesc string 438 | if len(featureElements) == 2 { 439 | commandDesc = featureElements[1] 440 | } 441 | 442 | c.features[command] = commandDesc 443 | } 444 | 445 | return nil 446 | } 447 | 448 | // setUTF8 issues an "OPTS UTF8 ON" command. 449 | func (c *ServerConn) setUTF8() error { 450 | if _, ok := c.features["UTF8"]; !ok { 451 | return nil 452 | } 453 | 454 | code, message, err := c.cmd(-1, "OPTS UTF8 ON") 455 | if err != nil { 456 | return err 457 | } 458 | 459 | // Workaround for FTP servers, that does not support this option. 460 | if code == StatusBadArguments || code == StatusNotImplementedParameter { 461 | return nil 462 | } 463 | 464 | // The ftpd "filezilla-server" has FEAT support for UTF8, but always returns 465 | // "202 UTF8 mode is always enabled. No need to send this command." when 466 | // trying to use it. That's OK 467 | if code == StatusCommandNotImplemented { 468 | return nil 469 | } 470 | 471 | if code != StatusCommandOK { 472 | return errors.New(message) 473 | } 474 | 475 | return nil 476 | } 477 | 478 | // epsv issues an "EPSV" command to get a port number for a data connection. 479 | func (c *ServerConn) epsv() (port int, err error) { 480 | _, line, err := c.cmd(StatusExtendedPassiveMode, "EPSV") 481 | if err != nil { 482 | return 0, err 483 | } 484 | 485 | start := strings.Index(line, "|||") 486 | end := strings.LastIndex(line, "|") 487 | if start == -1 || end == -1 { 488 | return 0, errors.New("invalid EPSV response format") 489 | } 490 | port, err = strconv.Atoi(line[start+3 : end]) 491 | return port, err 492 | } 493 | 494 | // pasv issues a "PASV" command to get a port number for a data connection. 495 | func (c *ServerConn) pasv() (host string, port int, err error) { 496 | _, line, err := c.cmd(StatusPassiveMode, "PASV") 497 | if err != nil { 498 | return "", 0, err 499 | } 500 | 501 | // PASV response format : 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2). 502 | start := strings.Index(line, "(") 503 | end := strings.LastIndex(line, ")") 504 | if start == -1 || end == -1 { 505 | return "", 0, errors.New("invalid PASV response format") 506 | } 507 | 508 | // We have to split the response string 509 | pasvData := strings.Split(line[start+1:end], ",") 510 | 511 | if len(pasvData) < 6 { 512 | return "", 0, errors.New("invalid PASV response format") 513 | } 514 | 515 | // Let's compute the port number 516 | portPart1, err := strconv.Atoi(pasvData[4]) 517 | if err != nil { 518 | return "", 0, err 519 | } 520 | 521 | portPart2, err := strconv.Atoi(pasvData[5]) 522 | if err != nil { 523 | return "", 0, err 524 | } 525 | 526 | // Recompose port 527 | port = portPart1*256 + portPart2 528 | 529 | // Make the IP address to connect to 530 | host = strings.Join(pasvData[0:4], ".") 531 | 532 | if c.host != host { 533 | if cmdIP := net.ParseIP(c.host); cmdIP != nil { 534 | if dataIP := net.ParseIP(host); dataIP != nil { 535 | if isBogusDataIP(cmdIP, dataIP) { 536 | return c.host, port, nil 537 | } 538 | } 539 | } 540 | } 541 | return host, port, nil 542 | } 543 | 544 | func isBogusDataIP(cmdIP, dataIP net.IP) bool { 545 | // Logic stolen from lftp (https://github.com/lavv17/lftp/blob/d67fc14d085849a6b0418bb3e912fea2e94c18d1/src/ftpclass.cc#L769) 546 | return dataIP.IsMulticast() || 547 | cmdIP.IsPrivate() != dataIP.IsPrivate() || 548 | cmdIP.IsLoopback() != dataIP.IsLoopback() 549 | } 550 | 551 | // getDataConnPort returns a host, port for a new data connection 552 | // it uses the best available method to do so 553 | func (c *ServerConn) getDataConnPort() (string, int, error) { 554 | if !c.options.disableEPSV && !c.skipEPSV { 555 | if port, err := c.epsv(); err == nil { 556 | return c.host, port, nil 557 | } 558 | 559 | // if there is an error, skip EPSV for the next attempts 560 | c.skipEPSV = true 561 | } 562 | 563 | return c.pasv() 564 | } 565 | 566 | // openDataConn creates a new FTP data connection. 567 | func (c *ServerConn) openDataConn() (net.Conn, error) { 568 | host, port, err := c.getDataConnPort() 569 | if err != nil { 570 | return nil, err 571 | } 572 | 573 | addr := net.JoinHostPort(host, strconv.Itoa(port)) 574 | if c.options.dialFunc != nil { 575 | return c.options.dialFunc("tcp", addr) 576 | } 577 | 578 | if c.options.tlsConfig != nil { 579 | // We don't use tls.DialWithDialer here (which does Dial, create 580 | // the Client and then do the Handshake) because it seems to 581 | // hang with some FTP servers, namely proftpd and pureftpd. 582 | // 583 | // Instead we do Dial, create the Client and wait for the first 584 | // Read or Write to trigger the Handshake. 585 | // 586 | // This means that if we are uploading a zero sized file, we 587 | // need to make sure we do the Handshake explicitly as Write 588 | // won't have been called. This is done in StorFrom(). 589 | // 590 | // See: https://github.com/jlaffaye/ftp/issues/282 591 | conn, err := c.options.dialer.Dial("tcp", addr) 592 | if err != nil { 593 | return nil, err 594 | } 595 | tlsConn := tls.Client(conn, c.options.tlsConfig) 596 | return tlsConn, nil 597 | } 598 | 599 | return c.options.dialer.Dial("tcp", addr) 600 | } 601 | 602 | // cmd is a helper function to execute a command and check for the expected FTP 603 | // return code 604 | func (c *ServerConn) cmd(expected int, format string, args ...interface{}) (int, string, error) { 605 | _, err := c.conn.Cmd(format, args...) 606 | if err != nil { 607 | return 0, "", err 608 | } 609 | 610 | return c.conn.ReadResponse(expected) 611 | } 612 | 613 | // cmdDataConnFrom executes a command which require a FTP data connection. 614 | // Issues a REST FTP command to specify the number of bytes to skip for the transfer. 615 | func (c *ServerConn) cmdDataConnFrom(offset uint64, format string, args ...interface{}) (net.Conn, error) { 616 | // If server requires PRET send the PRET command to warm it up 617 | // See: https://tools.ietf.org/html/draft-dd-pret-00 618 | if c.usePRET { 619 | _, _, err := c.cmd(-1, "PRET "+format, args...) 620 | if err != nil { 621 | return nil, err 622 | } 623 | } 624 | 625 | conn, err := c.openDataConn() 626 | if err != nil { 627 | return nil, err 628 | } 629 | 630 | if offset != 0 { 631 | _, _, err = c.cmd(StatusRequestFilePending, "REST %d", offset) 632 | if err != nil { 633 | _ = conn.Close() 634 | return nil, err 635 | } 636 | } 637 | 638 | _, err = c.conn.Cmd(format, args...) 639 | if err != nil { 640 | _ = conn.Close() 641 | return nil, err 642 | } 643 | 644 | code, msg, err := c.conn.ReadResponse(-1) 645 | if err != nil { 646 | _ = conn.Close() 647 | return nil, err 648 | } 649 | if code != StatusAlreadyOpen && code != StatusAboutToSend { 650 | _ = conn.Close() 651 | return nil, &textproto.Error{Code: code, Msg: msg} 652 | } 653 | 654 | return conn, nil 655 | } 656 | 657 | // Type switches the transfer mode for the connection. 658 | func (c *ServerConn) Type(transferType TransferType) (err error) { 659 | _, _, err = c.cmd(StatusCommandOK, "TYPE "+string(transferType)) 660 | return err 661 | } 662 | 663 | // NameList issues an NLST FTP command. 664 | func (c *ServerConn) NameList(path string) (entries []string, err error) { 665 | space := " " 666 | if path == "" { 667 | space = "" 668 | } 669 | conn, err := c.cmdDataConnFrom(0, "NLST%s%s", space, path) 670 | if err != nil { 671 | return nil, err 672 | } 673 | 674 | var errs *multierror.Error 675 | 676 | r := &Response{conn: conn, c: c} 677 | 678 | scanner := bufio.NewScanner(c.options.wrapStream(r)) 679 | for scanner.Scan() { 680 | entries = append(entries, scanner.Text()) 681 | } 682 | 683 | if err := scanner.Err(); err != nil { 684 | errs = multierror.Append(errs, err) 685 | } 686 | if err := r.Close(); err != nil { 687 | errs = multierror.Append(errs, err) 688 | } 689 | 690 | return entries, errs.ErrorOrNil() 691 | } 692 | 693 | // List issues a LIST FTP command. 694 | func (c *ServerConn) List(path string) (entries []*Entry, err error) { 695 | var cmd string 696 | var parser parseFunc 697 | 698 | if c.mlstSupported && !c.options.forceListHidden { 699 | cmd = "MLSD" 700 | parser = parseRFC3659ListLine 701 | } else { 702 | cmd = "LIST" 703 | if c.options.forceListHidden { 704 | cmd += " -a" 705 | } 706 | parser = parseListLine 707 | } 708 | 709 | space := " " 710 | if path == "" { 711 | space = "" 712 | } 713 | conn, err := c.cmdDataConnFrom(0, "%s%s%s", cmd, space, path) 714 | if err != nil { 715 | return nil, err 716 | } 717 | 718 | var errs *multierror.Error 719 | 720 | r := &Response{conn: conn, c: c} 721 | 722 | scanner := bufio.NewScanner(c.options.wrapStream(r)) 723 | now := time.Now() 724 | for scanner.Scan() { 725 | entry, errParse := parser(scanner.Text(), now, c.options.location) 726 | if errParse == nil { 727 | entries = append(entries, entry) 728 | } 729 | } 730 | 731 | if err := scanner.Err(); err != nil { 732 | errs = multierror.Append(errs, err) 733 | } 734 | if err := r.Close(); err != nil { 735 | errs = multierror.Append(errs, err) 736 | } 737 | 738 | return entries, errs.ErrorOrNil() 739 | } 740 | 741 | // GetEntry issues a MLST FTP command which retrieves one single Entry using the 742 | // control connection. The returnedEntry will describe the current directory 743 | // when no path is given. 744 | func (c *ServerConn) GetEntry(path string) (entry *Entry, err error) { 745 | if !c.mlstSupported { 746 | return nil, &textproto.Error{Code: StatusNotImplemented, Msg: StatusText(StatusNotImplemented)} 747 | } 748 | space := " " 749 | if path == "" { 750 | space = "" 751 | } 752 | _, msg, err := c.cmd(StatusRequestedFileActionOK, "%s%s%s", "MLST", space, path) 753 | if err != nil { 754 | return nil, err 755 | } 756 | 757 | // The expected reply will look something like: 758 | // 759 | // 250-File details 760 | // Type=file;Size=1024;Modify=20220813133357; path 761 | // 250 End 762 | // 763 | // Multiple lines are allowed though, so it can also be in the form: 764 | // 765 | // 250-File details 766 | // Type=file;Size=1024; path 767 | // Modify=20220813133357; path 768 | // 250 End 769 | lines := strings.Split(msg, "\n") 770 | lc := len(lines) 771 | 772 | // lines must be a multi-line message with a length of 3 or more, and we 773 | // don't care about the first and last line 774 | if lc < 3 { 775 | return nil, errors.New("invalid response") 776 | } 777 | 778 | e := &Entry{} 779 | for _, l := range lines[1 : lc-1] { 780 | // According to RFC 3659, the entry lines must start with a space when passed over the 781 | // control connection. Some servers don't seem to add that space though. Both forms are 782 | // accepted here. 783 | if len(l) > 0 && l[0] == ' ' { 784 | l = l[1:] 785 | } 786 | // Some severs seem to send a blank line at the end which we ignore 787 | if l == "" { 788 | continue 789 | } 790 | if e, err = parseNextRFC3659ListLine(l, c.options.location, e); err != nil { 791 | return nil, err 792 | } 793 | } 794 | return e, nil 795 | } 796 | 797 | // IsTimePreciseInList returns true if client and server support the MLSD 798 | // command so List can return time with 1-second precision for all files. 799 | func (c *ServerConn) IsTimePreciseInList() bool { 800 | return c.mlstSupported 801 | } 802 | 803 | // ChangeDir issues a CWD FTP command, which changes the current directory to 804 | // the specified path. 805 | func (c *ServerConn) ChangeDir(path string) error { 806 | _, _, err := c.cmd(StatusRequestedFileActionOK, "CWD %s", path) 807 | return err 808 | } 809 | 810 | // ChangeDirToParent issues a CDUP FTP command, which changes the current 811 | // directory to the parent directory. This is similar to a call to ChangeDir 812 | // with a path set to "..". 813 | func (c *ServerConn) ChangeDirToParent() error { 814 | _, _, err := c.cmd(StatusRequestedFileActionOK, "CDUP") 815 | return err 816 | } 817 | 818 | // CurrentDir issues a PWD FTP command, which Returns the path of the current 819 | // directory. 820 | func (c *ServerConn) CurrentDir() (string, error) { 821 | _, msg, err := c.cmd(StatusPathCreated, "PWD") 822 | if err != nil { 823 | return "", err 824 | } 825 | 826 | start := strings.Index(msg, "\"") 827 | end := strings.LastIndex(msg, "\"") 828 | 829 | if start == -1 || end == -1 { 830 | return "", errors.New("unsuported PWD response format") 831 | } 832 | 833 | return msg[start+1 : end], nil 834 | } 835 | 836 | // FileSize issues a SIZE FTP command, which Returns the size of the file 837 | func (c *ServerConn) FileSize(path string) (int64, error) { 838 | _, msg, err := c.cmd(StatusFile, "SIZE %s", path) 839 | if err != nil { 840 | return 0, err 841 | } 842 | 843 | return strconv.ParseInt(msg, 10, 64) 844 | } 845 | 846 | // GetTime issues the MDTM FTP command to obtain the file modification time. 847 | // It returns a UTC time. 848 | func (c *ServerConn) GetTime(path string) (time.Time, error) { 849 | var t time.Time 850 | if !c.mdtmSupported { 851 | return t, errors.New("GetTime is not supported") 852 | } 853 | _, msg, err := c.cmd(StatusFile, "MDTM %s", path) 854 | if err != nil { 855 | return t, err 856 | } 857 | return time.ParseInLocation(timeFormat, msg, time.UTC) 858 | } 859 | 860 | // IsGetTimeSupported allows library callers to check in advance that they 861 | // can use GetTime to get file time. 862 | func (c *ServerConn) IsGetTimeSupported() bool { 863 | return c.mdtmSupported 864 | } 865 | 866 | // SetTime issues the MFMT FTP command to set the file modification time. 867 | // Also it can use a non-standard form of the MDTM command supported by 868 | // the VsFtpd server instead of MFMT for the same purpose. 869 | // See "mdtm_write" in https://security.appspot.com/vsftpd/vsftpd_conf.html 870 | func (c *ServerConn) SetTime(path string, t time.Time) (err error) { 871 | utime := t.In(time.UTC).Format(timeFormat) 872 | switch { 873 | case c.mfmtSupported: 874 | _, _, err = c.cmd(StatusFile, "MFMT %s %s", utime, path) 875 | case c.mdtmCanWrite: 876 | _, _, err = c.cmd(StatusFile, "MDTM %s %s", utime, path) 877 | default: 878 | err = errors.New("SetTime is not supported") 879 | } 880 | return 881 | } 882 | 883 | // IsSetTimeSupported allows library callers to check in advance that they 884 | // can use SetTime to set file time. 885 | func (c *ServerConn) IsSetTimeSupported() bool { 886 | return c.mfmtSupported || c.mdtmCanWrite 887 | } 888 | 889 | // Retr issues a RETR FTP command to fetch the specified file from the remote 890 | // FTP server. 891 | // 892 | // The returned ReadCloser must be closed to cleanup the FTP data connection. 893 | func (c *ServerConn) Retr(path string) (*Response, error) { 894 | return c.RetrFrom(path, 0) 895 | } 896 | 897 | // RetrFrom issues a RETR FTP command to fetch the specified file from the remote 898 | // FTP server, the server will not send the offset first bytes of the file. 899 | // 900 | // The returned ReadCloser must be closed to cleanup the FTP data connection. 901 | func (c *ServerConn) RetrFrom(path string, offset uint64) (*Response, error) { 902 | conn, err := c.cmdDataConnFrom(offset, "RETR %s", path) 903 | if err != nil { 904 | return nil, err 905 | } 906 | 907 | return &Response{conn: conn, c: c}, nil 908 | } 909 | 910 | // Stor issues a STOR FTP command to store a file to the remote FTP server. 911 | // Stor creates the specified file with the content of the io.Reader. 912 | // 913 | // Hint: io.Pipe() can be used if an io.Writer is required. 914 | func (c *ServerConn) Stor(path string, r io.Reader) error { 915 | return c.StorFrom(path, r, 0) 916 | } 917 | 918 | // checkDataShut reads the "closing data connection" status from the 919 | // control connection. It is called after transferring a piece of data 920 | // on the data connection during which the control connection was idle. 921 | // This may result in the idle timeout triggering on the control connection 922 | // right when we try to read the response. 923 | // The ShutTimeout dial option will rescue here. It will nudge the control 924 | // connection deadline right before checking the data closing status. 925 | func (c *ServerConn) checkDataShut() error { 926 | if c.options.shutTimeout != 0 { 927 | shutDeadline := time.Now().Add(c.options.shutTimeout) 928 | if err := c.netConn.SetDeadline(shutDeadline); err != nil { 929 | return err 930 | } 931 | } 932 | _, _, err := c.conn.ReadResponse(StatusClosingDataConnection) 933 | return err 934 | } 935 | 936 | // StorFrom issues a STOR FTP command to store a file to the remote FTP server. 937 | // Stor creates the specified file with the content of the io.Reader, writing 938 | // on the server will start at the given file offset. 939 | // 940 | // Hint: io.Pipe() can be used if an io.Writer is required. 941 | func (c *ServerConn) StorFrom(path string, r io.Reader, offset uint64) error { 942 | conn, err := c.cmdDataConnFrom(offset, "STOR %s", path) 943 | if err != nil { 944 | return err 945 | } 946 | 947 | var errs *multierror.Error 948 | 949 | // if the upload fails we still need to try to read the server 950 | // response otherwise if the failure is not due to a connection problem, 951 | // for example the server denied the upload for quota limits, we miss 952 | // the response and we cannot use the connection to send other commands. 953 | if n, err := io.Copy(conn, r); err != nil { 954 | errs = multierror.Append(errs, err) 955 | } else if n == 0 { 956 | // If we wrote no bytes and got no error, make sure we call 957 | // tls.Handshake on the connection as it won't get called 958 | // unless Write() is called. (See comment in openDataConn()). 959 | // 960 | // ProFTP doesn't like this and returns "Unable to build data 961 | // connection: Operation not permitted" when trying to upload 962 | // an empty file without this. 963 | if do, ok := conn.(interface{ Handshake() error }); ok { 964 | if err := do.Handshake(); err != nil { 965 | errs = multierror.Append(errs, err) 966 | } 967 | } 968 | } 969 | 970 | if err := conn.Close(); err != nil { 971 | errs = multierror.Append(errs, err) 972 | } 973 | 974 | if err := c.checkDataShut(); err != nil { 975 | errs = multierror.Append(errs, err) 976 | } 977 | 978 | return errs.ErrorOrNil() 979 | } 980 | 981 | // Append issues a APPE FTP command to store a file to the remote FTP server. 982 | // If a file already exists with the given path, then the content of the 983 | // io.Reader is appended. Otherwise, a new file is created with that content. 984 | // 985 | // Hint: io.Pipe() can be used if an io.Writer is required. 986 | func (c *ServerConn) Append(path string, r io.Reader) error { 987 | conn, err := c.cmdDataConnFrom(0, "APPE %s", path) 988 | if err != nil { 989 | return err 990 | } 991 | 992 | var errs *multierror.Error 993 | 994 | if _, err := io.Copy(conn, r); err != nil { 995 | errs = multierror.Append(errs, err) 996 | } 997 | 998 | if err := conn.Close(); err != nil { 999 | errs = multierror.Append(errs, err) 1000 | } 1001 | 1002 | if err := c.checkDataShut(); err != nil { 1003 | errs = multierror.Append(errs, err) 1004 | } 1005 | 1006 | return errs.ErrorOrNil() 1007 | } 1008 | 1009 | // Rename renames a file on the remote FTP server. 1010 | func (c *ServerConn) Rename(from, to string) error { 1011 | _, _, err := c.cmd(StatusRequestFilePending, "RNFR %s", from) 1012 | if err != nil { 1013 | return err 1014 | } 1015 | 1016 | _, _, err = c.cmd(StatusRequestedFileActionOK, "RNTO %s", to) 1017 | return err 1018 | } 1019 | 1020 | // Delete issues a DELE FTP command to delete the specified file from the 1021 | // remote FTP server. 1022 | func (c *ServerConn) Delete(path string) error { 1023 | _, _, err := c.cmd(StatusRequestedFileActionOK, "DELE %s", path) 1024 | return err 1025 | } 1026 | 1027 | // RemoveDirRecur deletes a non-empty folder recursively using 1028 | // RemoveDir and Delete 1029 | func (c *ServerConn) RemoveDirRecur(path string) error { 1030 | err := c.ChangeDir(path) 1031 | if err != nil { 1032 | return err 1033 | } 1034 | currentDir, err := c.CurrentDir() 1035 | if err != nil { 1036 | return err 1037 | } 1038 | 1039 | entries, err := c.List(currentDir) 1040 | if err != nil { 1041 | return err 1042 | } 1043 | 1044 | for _, entry := range entries { 1045 | if entry.Name != ".." && entry.Name != "." { 1046 | if entry.Type == EntryTypeFolder { 1047 | err = c.RemoveDirRecur(currentDir + "/" + entry.Name) 1048 | if err != nil { 1049 | return err 1050 | } 1051 | } else { 1052 | err = c.Delete(entry.Name) 1053 | if err != nil { 1054 | return err 1055 | } 1056 | } 1057 | } 1058 | } 1059 | err = c.ChangeDirToParent() 1060 | if err != nil { 1061 | return err 1062 | } 1063 | err = c.RemoveDir(currentDir) 1064 | return err 1065 | } 1066 | 1067 | // MakeDir issues a MKD FTP command to create the specified directory on the 1068 | // remote FTP server. 1069 | func (c *ServerConn) MakeDir(path string) error { 1070 | _, _, err := c.cmd(StatusPathCreated, "MKD %s", path) 1071 | return err 1072 | } 1073 | 1074 | // RemoveDir issues a RMD FTP command to remove the specified directory from 1075 | // the remote FTP server. 1076 | func (c *ServerConn) RemoveDir(path string) error { 1077 | _, _, err := c.cmd(StatusRequestedFileActionOK, "RMD %s", path) 1078 | return err 1079 | } 1080 | 1081 | // Walk prepares the internal walk function so that the caller can begin traversing the directory 1082 | func (c *ServerConn) Walk(root string) *Walker { 1083 | w := new(Walker) 1084 | w.serverConn = c 1085 | 1086 | if !strings.HasSuffix(root, "/") { 1087 | root += "/" 1088 | } 1089 | 1090 | w.root = root 1091 | w.descend = true 1092 | 1093 | return w 1094 | } 1095 | 1096 | // NoOp issues a NOOP FTP command. 1097 | // NOOP has no effects and is usually used to prevent the remote FTP server to 1098 | // close the otherwise idle connection. 1099 | func (c *ServerConn) NoOp() error { 1100 | _, _, err := c.cmd(StatusCommandOK, "NOOP") 1101 | return err 1102 | } 1103 | 1104 | // Logout issues a REIN FTP command to logout the current user. 1105 | func (c *ServerConn) Logout() error { 1106 | _, _, err := c.cmd(StatusReady, "REIN") 1107 | return err 1108 | } 1109 | 1110 | // Quit issues a QUIT FTP command to properly close the connection from the 1111 | // remote FTP server. 1112 | func (c *ServerConn) Quit() error { 1113 | var errs *multierror.Error 1114 | 1115 | if _, err := c.conn.Cmd("QUIT"); err != nil { 1116 | errs = multierror.Append(errs, err) 1117 | } 1118 | 1119 | if err := c.conn.Close(); err != nil { 1120 | errs = multierror.Append(errs, err) 1121 | } 1122 | 1123 | return errs.ErrorOrNil() 1124 | } 1125 | 1126 | // Read implements the io.Reader interface on a FTP data connection. 1127 | func (r *Response) Read(buf []byte) (int, error) { 1128 | return r.conn.Read(buf) 1129 | } 1130 | 1131 | // Close implements the io.Closer interface on a FTP data connection. 1132 | // After the first call, Close will do nothing and return nil. 1133 | func (r *Response) Close() error { 1134 | if r.closed { 1135 | return nil 1136 | } 1137 | 1138 | var errs *multierror.Error 1139 | 1140 | if err := r.conn.Close(); err != nil { 1141 | errs = multierror.Append(errs, err) 1142 | } 1143 | 1144 | if err := r.c.checkDataShut(); err != nil { 1145 | errs = multierror.Append(errs, err) 1146 | } 1147 | 1148 | r.closed = true 1149 | return errs.ErrorOrNil() 1150 | } 1151 | 1152 | // SetDeadline sets the deadlines associated with the connection. 1153 | func (r *Response) SetDeadline(t time.Time) error { 1154 | return r.conn.SetDeadline(t) 1155 | } 1156 | 1157 | // String returns the string representation of EntryType t. 1158 | func (t EntryType) String() string { 1159 | return [...]string{"file", "folder", "link"}[t] 1160 | } 1161 | -------------------------------------------------------------------------------- /ftp_test.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | ) 7 | 8 | func TestBogusDataIP(t *testing.T) { 9 | for _, tC := range []struct { 10 | cmd, data net.IP 11 | bogus bool 12 | }{ 13 | {net.IPv4(192, 168, 1, 1), net.IPv4(192, 168, 1, 1), false}, 14 | {net.IPv4(192, 168, 1, 1), net.IPv4(1, 1, 1, 1), true}, 15 | {net.IPv4(10, 65, 1, 1), net.IPv4(1, 1, 1, 1), true}, 16 | {net.IPv4(10, 65, 25, 1), net.IPv4(10, 65, 8, 1), false}, 17 | } { 18 | if got, want := isBogusDataIP(tC.cmd, tC.data), tC.bogus; got != want { 19 | t.Errorf("%s,%s got %t, wanted %t", tC.cmd, tC.data, got, want) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jlaffaye/ftp 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/hashicorp/go-multierror v1.1.1 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/hashicorp/errwrap v1.0.0 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 5 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 6 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 7 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 12 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 13 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 14 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 16 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 17 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 18 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 22 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 23 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 24 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | var errUnsupportedListLine = errors.New("unsupported LIST line") 12 | var errUnsupportedListDate = errors.New("unsupported LIST date") 13 | var errUnknownListEntryType = errors.New("unknown entry type") 14 | 15 | type parseFunc func(string, time.Time, *time.Location) (*Entry, error) 16 | 17 | var listLineParsers = []parseFunc{ 18 | parseRFC3659ListLine, 19 | parseLsListLine, 20 | parseDirListLine, 21 | parseHostedFTPLine, 22 | } 23 | 24 | var dirTimeFormats = []string{ 25 | "01-02-06 03:04PM", 26 | "2006-01-02 15:04", 27 | "01-02-2006 03:04PM", 28 | "01-02-2006 15:04", 29 | } 30 | 31 | // parseRFC3659ListLine parses the style of directory line defined in RFC 3659. 32 | func parseRFC3659ListLine(line string, _ time.Time, loc *time.Location) (*Entry, error) { 33 | return parseNextRFC3659ListLine(line, loc, &Entry{}) 34 | } 35 | 36 | func parseNextRFC3659ListLine(line string, loc *time.Location, e *Entry) (*Entry, error) { 37 | iSemicolon := strings.Index(line, ";") 38 | iWhitespace := strings.Index(line, " ") 39 | 40 | if iSemicolon < 0 || iSemicolon > iWhitespace { 41 | return nil, errUnsupportedListLine 42 | } 43 | 44 | name := line[iWhitespace+1:] 45 | if e.Name == "" { 46 | e.Name = name 47 | } else if e.Name != name { 48 | // All lines must have the same name 49 | return nil, errUnsupportedListLine 50 | } 51 | 52 | for _, field := range strings.Split(line[:iWhitespace-1], ";") { 53 | i := strings.Index(field, "=") 54 | if i < 1 { 55 | return nil, errUnsupportedListLine 56 | } 57 | 58 | key := strings.ToLower(field[:i]) 59 | value := field[i+1:] 60 | 61 | switch key { 62 | case "modify": 63 | var err error 64 | e.Time, err = time.ParseInLocation("20060102150405", value, loc) 65 | if err != nil { 66 | return nil, err 67 | } 68 | case "type": 69 | switch value { 70 | case "dir", "cdir", "pdir": 71 | e.Type = EntryTypeFolder 72 | case "file": 73 | e.Type = EntryTypeFile 74 | } 75 | case "size": 76 | if err := e.setSize(value); err != nil { 77 | return nil, err 78 | } 79 | } 80 | } 81 | return e, nil 82 | } 83 | 84 | // parseLsListLine parses a directory line in a format based on the output of 85 | // the UNIX ls command. 86 | func parseLsListLine(line string, now time.Time, loc *time.Location) (*Entry, error) { 87 | 88 | // Has the first field a length of exactly 10 bytes 89 | // - or 10 bytes with an additional '+' character for indicating ACLs? 90 | // If not, return. 91 | if i := strings.IndexByte(line, ' '); !(i == 10 || (i == 11 && line[10] == '+')) { 92 | return nil, errUnsupportedListLine 93 | } 94 | 95 | scanner := newScanner(line) 96 | fields := scanner.NextFields(6) 97 | 98 | if len(fields) < 6 { 99 | return nil, errUnsupportedListLine 100 | } 101 | 102 | if fields[1] == "folder" && fields[2] == "0" { 103 | e := &Entry{ 104 | Type: EntryTypeFolder, 105 | Name: scanner.Remaining(), 106 | } 107 | if err := e.setTime(fields[3:6], now, loc); err != nil { 108 | return nil, err 109 | } 110 | 111 | return e, nil 112 | } 113 | 114 | if fields[1] == "0" { 115 | fields = append(fields, scanner.Next()) 116 | e := &Entry{ 117 | Type: EntryTypeFile, 118 | Name: scanner.Remaining(), 119 | } 120 | 121 | if err := e.setSize(fields[2]); err != nil { 122 | return nil, errUnsupportedListLine 123 | } 124 | if err := e.setTime(fields[4:7], now, loc); err != nil { 125 | return nil, err 126 | } 127 | 128 | return e, nil 129 | } 130 | 131 | // Read two more fields 132 | fields = append(fields, scanner.NextFields(2)...) 133 | if len(fields) < 8 { 134 | return nil, errUnsupportedListLine 135 | } 136 | 137 | e := &Entry{ 138 | Name: scanner.Remaining(), 139 | } 140 | switch fields[0][0] { 141 | case '-': 142 | e.Type = EntryTypeFile 143 | if err := e.setSize(fields[4]); err != nil { 144 | return nil, err 145 | } 146 | case 'd': 147 | e.Type = EntryTypeFolder 148 | case 'l': 149 | e.Type = EntryTypeLink 150 | 151 | // Split link name and target 152 | if i := strings.Index(e.Name, " -> "); i > 0 { 153 | e.Target = e.Name[i+4:] 154 | e.Name = e.Name[:i] 155 | } 156 | default: 157 | return nil, errUnknownListEntryType 158 | } 159 | 160 | if err := e.setTime(fields[5:8], now, loc); err != nil { 161 | return nil, err 162 | } 163 | 164 | return e, nil 165 | } 166 | 167 | // parseDirListLine parses a directory line in a format based on the output of 168 | // the MS-DOS DIR command. 169 | func parseDirListLine(line string, now time.Time, loc *time.Location) (*Entry, error) { 170 | e := &Entry{} 171 | var err error 172 | 173 | // Try various time formats that DIR might use, and stop when one works. 174 | for _, format := range dirTimeFormats { 175 | if len(line) > len(format) { 176 | e.Time, err = time.ParseInLocation(format, line[:len(format)], loc) 177 | if err == nil { 178 | line = line[len(format):] 179 | break 180 | } 181 | } 182 | } 183 | if err != nil { 184 | // None of the time formats worked. 185 | return nil, errUnsupportedListLine 186 | } 187 | 188 | line = strings.TrimLeft(line, " ") 189 | if strings.HasPrefix(line, "") { 190 | e.Type = EntryTypeFolder 191 | line = strings.TrimPrefix(line, "") 192 | } else { 193 | space := strings.Index(line, " ") 194 | if space == -1 { 195 | return nil, errUnsupportedListLine 196 | } 197 | e.Size, err = strconv.ParseUint(line[:space], 10, 64) 198 | if err != nil { 199 | return nil, errUnsupportedListLine 200 | } 201 | e.Type = EntryTypeFile 202 | line = line[space:] 203 | } 204 | 205 | e.Name = strings.TrimLeft(line, " ") 206 | return e, nil 207 | } 208 | 209 | // parseHostedFTPLine parses a directory line in the non-standard format used 210 | // by hostedftp.com 211 | // -r-------- 0 user group 65222236 Feb 24 00:39 UABlacklistingWeek8.csv 212 | // (The link count is inexplicably 0) 213 | func parseHostedFTPLine(line string, now time.Time, loc *time.Location) (*Entry, error) { 214 | // Has the first field a length of 10 bytes? 215 | if strings.IndexByte(line, ' ') != 10 { 216 | return nil, errUnsupportedListLine 217 | } 218 | 219 | scanner := newScanner(line) 220 | fields := scanner.NextFields(2) 221 | 222 | if len(fields) < 2 || fields[1] != "0" { 223 | return nil, errUnsupportedListLine 224 | } 225 | 226 | // Set link count to 1 and attempt to parse as Unix. 227 | return parseLsListLine(fields[0]+" 1 "+scanner.Remaining(), now, loc) 228 | } 229 | 230 | // parseListLine parses the various non-standard format returned by the LIST 231 | // FTP command. 232 | func parseListLine(line string, now time.Time, loc *time.Location) (*Entry, error) { 233 | for _, f := range listLineParsers { 234 | e, err := f(line, now, loc) 235 | if err != errUnsupportedListLine { 236 | return e, err 237 | } 238 | } 239 | return nil, errUnsupportedListLine 240 | } 241 | 242 | func (e *Entry) setSize(str string) (err error) { 243 | e.Size, err = strconv.ParseUint(str, 0, 64) 244 | return 245 | } 246 | 247 | func (e *Entry) setTime(fields []string, now time.Time, loc *time.Location) (err error) { 248 | if strings.Contains(fields[2], ":") { // contains time 249 | thisYear, _, _ := now.Date() 250 | timeStr := fmt.Sprintf("%s %s %d %s", fields[1], fields[0], thisYear, fields[2]) 251 | e.Time, err = time.ParseInLocation("_2 Jan 2006 15:04", timeStr, loc) 252 | 253 | /* 254 | On unix, `info ls` shows: 255 | 256 | 10.1.6 Formatting file timestamps 257 | --------------------------------- 258 | 259 | A timestamp is considered to be “recent” if it is less than six 260 | months old, and is not dated in the future. If a timestamp dated today 261 | is not listed in recent form, the timestamp is in the future, which 262 | means you probably have clock skew problems which may break programs 263 | like ‘make’ that rely on file timestamps. 264 | */ 265 | if !e.Time.Before(now.AddDate(0, 6, 0)) { 266 | e.Time = e.Time.AddDate(-1, 0, 0) 267 | } 268 | 269 | } else { // only the date 270 | if len(fields[2]) != 4 { 271 | return errUnsupportedListDate 272 | } 273 | timeStr := fmt.Sprintf("%s %s %s 00:00", fields[1], fields[0], fields[2]) 274 | e.Time, err = time.ParseInLocation("_2 Jan 2006 15:04", timeStr, loc) 275 | } 276 | return 277 | } 278 | -------------------------------------------------------------------------------- /parse_test.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var ( 12 | // now is the current time for all tests 13 | now = newTime(2017, time.March, 10, 23, 00) 14 | 15 | thisYear, _, _ = now.Date() 16 | previousYear = thisYear - 1 17 | ) 18 | 19 | type line struct { 20 | line string 21 | name string 22 | size uint64 23 | entryType EntryType 24 | time time.Time 25 | } 26 | 27 | type symlinkLine struct { 28 | line string 29 | name string 30 | target string 31 | } 32 | 33 | type unsupportedLine struct { 34 | line string 35 | err error 36 | } 37 | 38 | var listTests = []line{ 39 | // UNIX ls -l style 40 | {"drwxr-xr-x 3 110 1002 3 Dec 02 2009 pub", "pub", 0, EntryTypeFolder, newTime(2009, time.December, 2)}, 41 | {"drwxr-xr-x 3 110 1002 3 Dec 02 2009 p u b", "p u b", 0, EntryTypeFolder, newTime(2009, time.December, 2)}, 42 | {"-rw-r--r-- 1 marketwired marketwired 12016 Mar 16 2016 2016031611G087802-001.newsml", "2016031611G087802-001.newsml", 12016, EntryTypeFile, newTime(2016, time.March, 16)}, 43 | 44 | {"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 fileName", "fileName", 1234567, EntryTypeFile, newTime(2009, time.December, 2)}, 45 | {"lrwxrwxrwx 1 root other 7 Jan 25 00:17 bin -> usr/bin", "bin", 0, EntryTypeLink, newTime(thisYear, time.January, 25, 0, 17)}, 46 | 47 | // Another ls style 48 | {"drwxr-xr-x folder 0 Aug 15 05:49 !!!-Tipp des Haus!", "!!!-Tipp des Haus!", 0, EntryTypeFolder, newTime(thisYear, time.August, 15, 5, 49)}, 49 | {"drwxrwxrwx folder 0 Aug 11 20:32 P0RN", "P0RN", 0, EntryTypeFolder, newTime(thisYear, time.August, 11, 20, 32)}, 50 | {"-rw-r--r-- 0 18446744073709551615 18446744073709551615 Nov 16 2006 VIDEO_TS.VOB", "VIDEO_TS.VOB", 18446744073709551615, EntryTypeFile, newTime(2006, time.November, 16)}, 51 | 52 | // Microsoft's FTP servers for Windows 53 | {"---------- 1 owner group 1803128 Jul 10 10:18 ls-lR.Z", "ls-lR.Z", 1803128, EntryTypeFile, newTime(thisYear, time.July, 10, 10, 18)}, 54 | {"d--------- 1 owner group 0 Nov 9 19:45 Softlib", "Softlib", 0, EntryTypeFolder, newTime(previousYear, time.November, 9, 19, 45)}, 55 | 56 | // WFTPD for MSDOS 57 | {"-rwxrwxrwx 1 noone nogroup 322 Aug 19 1996 message.ftp", "message.ftp", 322, EntryTypeFile, newTime(1996, time.August, 19)}, 58 | 59 | // RFC3659 format: https://tools.ietf.org/html/rfc3659#section-7 60 | {"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)}, 61 | {"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)}, 62 | {"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)}, 63 | {"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)}, 64 | {"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)}, 65 | // Format and types have first letter UpperCase 66 | {"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)}, 67 | 68 | // DOS DIR command output 69 | {"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)}, 70 | {"08-10-15 02:04PM Billing", "Billing", 0, EntryTypeFolder, newTime(2015, time.August, 10, 14, 4)}, 71 | {"08-07-2015 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)}, 72 | {"08-10-2015 02:04PM Billing", "Billing", 0, EntryTypeFolder, newTime(2015, time.August, 10, 14, 4)}, 73 | 74 | // dir and file names that contain multiple spaces 75 | {"drwxr-xr-x 3 110 1002 3 Dec 02 2009 spaces dir name", "spaces dir name", 0, EntryTypeFolder, newTime(2009, time.December, 2)}, 76 | {"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 file name", "file name", 1234567, EntryTypeFile, newTime(2009, time.December, 2)}, 77 | {"-rwxr-xr-x 3 110 1002 1234567 Dec 02 2009 foo bar ", " foo bar ", 1234567, EntryTypeFile, newTime(2009, time.December, 2)}, 78 | 79 | // Odd link count from hostedftp.com 80 | {"-r-------- 0 user group 65222236 Feb 24 00:39 RegularFile", "RegularFile", 65222236, EntryTypeFile, newTime(thisYear, time.February, 24, 0, 39)}, 81 | 82 | // Line with ACL persmissions 83 | {"-rwxrw-r--+ 1 521 101 2080 May 21 10:53 data.csv", "data.csv", 2080, EntryTypeFile, newTime(thisYear, time.May, 21, 10, 53)}, 84 | } 85 | 86 | var listTestsSymlink = []symlinkLine{ 87 | {"lrwxrwxrwx 1 root other 7 Jan 25 00:17 bin -> usr/bin", "bin", "usr/bin"}, 88 | {"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"}, 89 | } 90 | 91 | // Not supported, we expect a specific error message 92 | var listTestsFail = []unsupportedLine{ 93 | {"d [R----F--] supervisor 512 Jan 16 18:53 login", errUnsupportedListLine}, 94 | {"- [R----F--] rhesus 214059 Oct 20 15:27 cx.exe", errUnsupportedListLine}, 95 | {"drwxr-xr-x 3 110 1002 3 Dec 02 209 pub", errUnsupportedListDate}, 96 | {"modify=20150806235817;invalid;UNIX.owner=0; movies", errUnsupportedListLine}, 97 | {"Zrwxrwxrwx 1 root other 7 Jan 25 00:17 bin -> usr/bin", errUnknownListEntryType}, 98 | {"total 1", errUnsupportedListLine}, 99 | {"000000000x ", errUnsupportedListLine}, // see https://github.com/jlaffaye/ftp/issues/97 100 | {"", errUnsupportedListLine}, 101 | } 102 | 103 | func TestParseValidListLine(t *testing.T) { 104 | for _, lt := range listTests { 105 | t.Run(lt.line, func(t *testing.T) { 106 | assert := assert.New(t) 107 | entry, err := parseListLine(lt.line, now, time.UTC) 108 | 109 | if assert.NoError(err) { 110 | assert.Equal(lt.name, entry.Name) 111 | assert.Equal(lt.entryType, entry.Type) 112 | assert.Equal(lt.size, entry.Size) 113 | assert.Equal(lt.time, entry.Time) 114 | } 115 | }) 116 | } 117 | } 118 | 119 | func TestParseSymlinks(t *testing.T) { 120 | for _, lt := range listTestsSymlink { 121 | t.Run(lt.line, func(t *testing.T) { 122 | assert := assert.New(t) 123 | entry, err := parseListLine(lt.line, now, time.UTC) 124 | 125 | if assert.NoError(err) { 126 | assert.Equal(lt.name, entry.Name) 127 | assert.Equal(lt.target, entry.Target) 128 | assert.Equal(EntryTypeLink, entry.Type) 129 | } 130 | }) 131 | } 132 | } 133 | 134 | func TestParseUnsupportedListLine(t *testing.T) { 135 | for _, lt := range listTestsFail { 136 | t.Run(lt.line, func(t *testing.T) { 137 | _, err := parseListLine(lt.line, now, time.UTC) 138 | 139 | assert.EqualError(t, err, lt.err.Error()) 140 | }) 141 | } 142 | } 143 | 144 | func TestSettime(t *testing.T) { 145 | tests := []struct { 146 | line string 147 | expected time.Time 148 | }{ 149 | // this year, in the past 150 | {"Feb 10 23:00", newTime(thisYear, time.February, 10, 23)}, 151 | 152 | // this year, less than six months in the future 153 | {"Sep 10 22:59", newTime(thisYear, time.September, 10, 22, 59)}, 154 | 155 | // previous year, otherwise it would be more than 6 months in the future 156 | {"Sep 10 23:00", newTime(previousYear, time.September, 10, 23)}, 157 | 158 | // far in the future 159 | {"Jan 23 2019", newTime(2019, time.January, 23)}, 160 | } 161 | 162 | for _, test := range tests { 163 | t.Run(test.line, func(t *testing.T) { 164 | entry := &Entry{} 165 | if err := entry.setTime(strings.Fields(test.line), now, time.UTC); err != nil { 166 | t.Fatal(err) 167 | } 168 | 169 | assert.Equal(t, test.expected, entry.Time) 170 | }) 171 | } 172 | } 173 | 174 | // newTime builds a UTC time from the given year, month, day, hour and minute 175 | func newTime(year int, month time.Month, day int, hourMinSec ...int) time.Time { 176 | var hour, min, sec int 177 | 178 | switch len(hourMinSec) { 179 | case 0: 180 | // nothing 181 | case 3: 182 | sec = hourMinSec[2] 183 | fallthrough 184 | case 2: 185 | min = hourMinSec[1] 186 | fallthrough 187 | case 1: 188 | hour = hourMinSec[0] 189 | default: 190 | panic("too many arguments") 191 | } 192 | 193 | return time.Date(year, month, day, hour, min, sec, 0, time.UTC) 194 | } 195 | -------------------------------------------------------------------------------- /scanner.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | // A scanner for fields delimited by one or more whitespace characters 4 | type scanner struct { 5 | bytes []byte 6 | position int 7 | } 8 | 9 | // newScanner creates a new scanner 10 | func newScanner(str string) *scanner { 11 | return &scanner{ 12 | bytes: []byte(str), 13 | } 14 | } 15 | 16 | // NextFields returns the next `count` fields 17 | func (s *scanner) NextFields(count int) []string { 18 | fields := make([]string, 0, count) 19 | for i := 0; i < count; i++ { 20 | if field := s.Next(); field != "" { 21 | fields = append(fields, field) 22 | } else { 23 | break 24 | } 25 | } 26 | return fields 27 | } 28 | 29 | // Next returns the next field 30 | func (s *scanner) Next() string { 31 | sLen := len(s.bytes) 32 | 33 | // skip trailing whitespace 34 | for s.position < sLen { 35 | if s.bytes[s.position] != ' ' { 36 | break 37 | } 38 | s.position++ 39 | } 40 | 41 | start := s.position 42 | 43 | // skip non-whitespace 44 | for s.position < sLen { 45 | if s.bytes[s.position] == ' ' { 46 | s.position++ 47 | return string(s.bytes[start : s.position-1]) 48 | } 49 | s.position++ 50 | } 51 | 52 | return string(s.bytes[start:s.position]) 53 | } 54 | 55 | // Remaining returns the remaining string 56 | func (s *scanner) Remaining() string { 57 | return string(s.bytes[s.position:len(s.bytes)]) 58 | } 59 | -------------------------------------------------------------------------------- /scanner_test.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestScanner(t *testing.T) { 10 | assert := assert.New(t) 11 | 12 | s := newScanner("foo bar x y") 13 | assert.Equal("foo", s.Next()) 14 | assert.Equal(" bar x y", s.Remaining()) 15 | assert.Equal("bar", s.Next()) 16 | assert.Equal("x y", s.Remaining()) 17 | assert.Equal("x", s.Next()) 18 | assert.Equal(" y", s.Remaining()) 19 | assert.Equal("y", s.Next()) 20 | assert.Equal("", s.Next()) 21 | assert.Equal("", s.Remaining()) 22 | } 23 | 24 | func TestScannerEmpty(t *testing.T) { 25 | assert := assert.New(t) 26 | 27 | s := newScanner("") 28 | assert.Equal("", s.Next()) 29 | assert.Equal("", s.Next()) 30 | assert.Equal("", s.Remaining()) 31 | } 32 | -------------------------------------------------------------------------------- /status.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import "fmt" 4 | 5 | // FTP status codes, defined in RFC 959 6 | const ( 7 | StatusInitiating = 100 8 | StatusRestartMarker = 110 9 | StatusReadyMinute = 120 10 | StatusAlreadyOpen = 125 11 | StatusAboutToSend = 150 12 | 13 | StatusCommandOK = 200 14 | StatusCommandNotImplemented = 202 15 | StatusSystem = 211 16 | StatusDirectory = 212 17 | StatusFile = 213 18 | StatusHelp = 214 19 | StatusName = 215 20 | StatusReady = 220 21 | StatusClosing = 221 22 | StatusDataConnectionOpen = 225 23 | StatusClosingDataConnection = 226 24 | StatusPassiveMode = 227 25 | StatusLongPassiveMode = 228 26 | StatusExtendedPassiveMode = 229 27 | StatusLoggedIn = 230 28 | StatusLoggedOut = 231 29 | StatusLogoutAck = 232 30 | StatusAuthOK = 234 31 | StatusRequestedFileActionOK = 250 32 | StatusPathCreated = 257 33 | 34 | StatusUserOK = 331 35 | StatusLoginNeedAccount = 332 36 | StatusRequestFilePending = 350 37 | 38 | StatusNotAvailable = 421 39 | StatusCanNotOpenDataConnection = 425 40 | StatusTransfertAborted = 426 41 | StatusInvalidCredentials = 430 42 | StatusHostUnavailable = 434 43 | StatusFileActionIgnored = 450 44 | StatusActionAborted = 451 45 | Status452 = 452 46 | 47 | StatusBadCommand = 500 48 | StatusBadArguments = 501 49 | StatusNotImplemented = 502 50 | StatusBadSequence = 503 51 | StatusNotImplementedParameter = 504 52 | StatusNotLoggedIn = 530 53 | StatusStorNeedAccount = 532 54 | StatusFileUnavailable = 550 55 | StatusPageTypeUnknown = 551 56 | StatusExceededStorage = 552 57 | StatusBadFileName = 553 58 | ) 59 | 60 | var statusText = map[int]string{ 61 | // 200 62 | StatusCommandOK: "Command okay.", 63 | StatusCommandNotImplemented: "Command not implemented, superfluous at this site.", 64 | StatusSystem: "System status, or system help reply.", 65 | StatusDirectory: "Directory status.", 66 | StatusFile: "File status.", 67 | StatusHelp: "Help message.", 68 | StatusName: "", 69 | StatusReady: "Service ready for new user.", 70 | StatusClosing: "Service closing control connection.", 71 | StatusDataConnectionOpen: "Data connection open; no transfer in progress.", 72 | StatusClosingDataConnection: "Closing data connection. Requested file action successful.", 73 | StatusPassiveMode: "Entering Passive Mode.", 74 | StatusLongPassiveMode: "Entering Long Passive Mode.", 75 | StatusExtendedPassiveMode: "Entering Extended Passive Mode.", 76 | StatusLoggedIn: "User logged in, proceed.", 77 | StatusLoggedOut: "User logged out; service terminated.", 78 | StatusLogoutAck: "Logout command noted, will complete when transfer done.", 79 | StatusAuthOK: "AUTH command OK", 80 | StatusRequestedFileActionOK: "Requested file action okay, completed.", 81 | StatusPathCreated: "Path created.", 82 | 83 | // 300 84 | StatusUserOK: "User name okay, need password.", 85 | StatusLoginNeedAccount: "Need account for login.", 86 | StatusRequestFilePending: "Requested file action pending further information.", 87 | 88 | // 400 89 | StatusNotAvailable: "Service not available, closing control connection.", 90 | StatusCanNotOpenDataConnection: "Can't open data connection.", 91 | StatusTransfertAborted: "Connection closed; transfer aborted.", 92 | StatusInvalidCredentials: "Invalid username or password.", 93 | StatusHostUnavailable: "Requested host unavailable.", 94 | StatusFileActionIgnored: "Requested file action not taken.", 95 | StatusActionAborted: "Requested action aborted. Local error in processing.", 96 | Status452: "Insufficient storage space in system.", 97 | 98 | // 500 99 | StatusBadCommand: "Command unrecognized.", 100 | StatusBadArguments: "Syntax error in parameters or arguments.", 101 | StatusNotImplemented: "Command not implemented.", 102 | StatusBadSequence: "Bad sequence of commands.", 103 | StatusNotImplementedParameter: "Command not implemented for that parameter.", 104 | StatusNotLoggedIn: "Not logged in.", 105 | StatusStorNeedAccount: "Need account for storing files.", 106 | StatusFileUnavailable: "File unavailable.", 107 | StatusPageTypeUnknown: "Page type unknown.", 108 | StatusExceededStorage: "Exceeded storage allocation.", 109 | StatusBadFileName: "File name not allowed.", 110 | } 111 | 112 | // StatusText returns a text for the FTP status code. It returns the empty string if the code is unknown. 113 | func StatusText(code int) string { 114 | str, ok := statusText[code] 115 | if !ok { 116 | str = fmt.Sprintf("Unknown status code: %d", code) 117 | } 118 | return str 119 | } 120 | -------------------------------------------------------------------------------- /walker.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "path" 5 | ) 6 | 7 | // Walker traverses the directory tree of a remote FTP server 8 | type Walker struct { 9 | serverConn *ServerConn 10 | root string 11 | cur *item 12 | stack []*item 13 | descend bool 14 | } 15 | 16 | type item struct { 17 | path string 18 | entry *Entry 19 | err error 20 | } 21 | 22 | // Next advances the Walker to the next file or directory, 23 | // which will then be available through the Path, Stat, and Err methods. 24 | // It returns false when the walk stops at the end of the tree. 25 | func (w *Walker) Next() bool { 26 | // check if we need to init cur, maybe this should be inside Walk 27 | if w.cur == nil { 28 | w.cur = &item{ 29 | path: w.root, 30 | entry: &Entry{ 31 | Type: EntryTypeFolder, 32 | }, 33 | } 34 | } 35 | 36 | if w.descend && w.cur.entry.Type == EntryTypeFolder { 37 | entries, err := w.serverConn.List(w.cur.path) 38 | 39 | // an error occurred, drop out and stop walking 40 | if err != nil { 41 | w.cur.err = err 42 | return false 43 | } 44 | 45 | for _, entry := range entries { 46 | if entry.Name == "." || entry.Name == ".." { 47 | continue 48 | } 49 | 50 | item := &item{ 51 | path: path.Join(w.cur.path, entry.Name), 52 | entry: entry, 53 | } 54 | 55 | w.stack = append(w.stack, item) 56 | } 57 | } 58 | 59 | if len(w.stack) == 0 { 60 | return false 61 | } 62 | 63 | // update cur 64 | i := len(w.stack) - 1 65 | w.cur = w.stack[i] 66 | w.stack = w.stack[:i] 67 | 68 | // reset SkipDir 69 | w.descend = true 70 | 71 | return true 72 | } 73 | 74 | // SkipDir tells the Next function to skip the currently processed directory 75 | func (w *Walker) SkipDir() { 76 | w.descend = false 77 | } 78 | 79 | // Err returns the error, if any, for the most recent attempt by Next to 80 | // visit a file or a directory. If a directory has an error, the walker 81 | // will not descend in that directory 82 | func (w *Walker) Err() error { 83 | return w.cur.err 84 | } 85 | 86 | // Stat returns info for the most recent file or directory 87 | // visited by a call to Next. 88 | func (w *Walker) Stat() *Entry { 89 | return w.cur.entry 90 | } 91 | 92 | // Path returns the path to the most recent file or directory 93 | // visited by a call to Next. It contains the argument to Walk 94 | // as a prefix; that is, if Walk is called with "dir", which is 95 | // a directory containing the file "a", Path will return "dir/a". 96 | func (w *Walker) Path() string { 97 | return w.cur.path 98 | } 99 | -------------------------------------------------------------------------------- /walker_test.go: -------------------------------------------------------------------------------- 1 | package ftp 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestWalkReturnsCorrectlyPopulatedWalker(t *testing.T) { 13 | mock, err := newFtpMock(t, "127.0.0.1") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | defer mock.Close() 18 | 19 | c, cErr := Connect(mock.Addr()) 20 | if cErr != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | w := c.Walk("root") 25 | 26 | assert.Equal(t, "root/", w.root) 27 | assert.Equal(t, &c, &w.serverConn) 28 | } 29 | 30 | func TestFieldsReturnCorrectData(t *testing.T) { 31 | w := Walker{ 32 | cur: &item{ 33 | path: "/root/", 34 | err: fmt.Errorf("this is an error"), 35 | entry: &Entry{ 36 | Name: "root", 37 | Size: 123, 38 | Time: time.Now(), 39 | Type: EntryTypeFolder, 40 | }, 41 | }, 42 | } 43 | 44 | assert.Equal(t, "this is an error", w.Err().Error()) 45 | assert.Equal(t, "/root/", w.Path()) 46 | assert.Equal(t, EntryTypeFolder, w.Stat().Type) 47 | } 48 | 49 | func TestSkipDirIsCorrectlySet(t *testing.T) { 50 | w := Walker{} 51 | 52 | w.SkipDir() 53 | 54 | assert.Equal(t, false, w.descend) 55 | } 56 | 57 | func TestNoDescendDoesNotAddToStack(t *testing.T) { 58 | mock, err := newFtpMock(t, "127.0.0.1") 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | defer mock.Close() 63 | 64 | c, cErr := Connect(mock.Addr()) 65 | if cErr != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | w := c.Walk("/root") 70 | w.cur = &item{ 71 | path: "/root/", 72 | err: nil, 73 | entry: &Entry{ 74 | Name: "root", 75 | Size: 123, 76 | Time: time.Now(), 77 | Type: EntryTypeFolder, 78 | }, 79 | } 80 | 81 | w.stack = []*item{ 82 | { 83 | path: "file", 84 | err: nil, 85 | entry: &Entry{ 86 | Name: "file", 87 | Size: 123, 88 | Time: time.Now(), 89 | Type: EntryTypeFile, 90 | }, 91 | }, 92 | } 93 | 94 | w.SkipDir() 95 | 96 | result := w.Next() 97 | 98 | assert.Equal(t, true, result, "Result should return true") 99 | assert.Equal(t, 0, len(w.stack)) 100 | assert.Equal(t, true, w.descend) 101 | } 102 | 103 | func TestEmptyStackReturnsFalse(t *testing.T) { 104 | assert, require := assert.New(t), require.New(t) 105 | 106 | mock, err := newFtpMock(t, "127.0.0.1") 107 | require.Nil(err) 108 | defer mock.Close() 109 | 110 | c, cErr := Connect(mock.Addr()) 111 | require.Nil(cErr) 112 | 113 | w := c.Walk("/root") 114 | 115 | w.cur = &item{ 116 | path: "/root/", 117 | err: nil, 118 | entry: &Entry{ 119 | Name: "root", 120 | Size: 123, 121 | Time: time.Now(), 122 | Type: EntryTypeFolder, 123 | }, 124 | } 125 | 126 | w.stack = []*item{} 127 | 128 | w.SkipDir() 129 | 130 | result := w.Next() 131 | 132 | assert.Equal(false, result, "Result should return false") 133 | } 134 | 135 | func TestCurAndStackSetCorrectly(t *testing.T) { 136 | assert, require := assert.New(t), require.New(t) 137 | 138 | mock, err := newFtpMock(t, "127.0.0.1") 139 | require.Nil(err) 140 | defer mock.Close() 141 | 142 | c, cErr := Connect(mock.Addr()) 143 | require.Nil(cErr) 144 | 145 | w := c.Walk("/root") 146 | w.cur = &item{ 147 | path: "root/file1", 148 | err: nil, 149 | entry: &Entry{ 150 | Name: "file1", 151 | Size: 123, 152 | Time: time.Now(), 153 | Type: EntryTypeFile, 154 | }, 155 | } 156 | 157 | w.stack = []*item{ 158 | { 159 | path: "file", 160 | err: nil, 161 | entry: &Entry{ 162 | Name: "file", 163 | Size: 123, 164 | Time: time.Now(), 165 | Type: EntryTypeFile, 166 | }, 167 | }, 168 | { 169 | path: "root/file1", 170 | err: nil, 171 | entry: &Entry{ 172 | Name: "file1", 173 | Size: 123, 174 | Time: time.Now(), 175 | Type: EntryTypeFile, 176 | }, 177 | }, 178 | } 179 | 180 | result := w.Next() 181 | assert.Equal(true, result, "Result should return true") 182 | 183 | result = w.Next() 184 | 185 | assert.Equal(true, result, "Result should return true") 186 | assert.Equal(0, len(w.stack)) 187 | assert.Equal("file", w.cur.entry.Name) 188 | } 189 | 190 | func TestCurInit(t *testing.T) { 191 | mock, err := newFtpMock(t, "127.0.0.1") 192 | if err != nil { 193 | t.Fatal(err) 194 | } 195 | defer mock.Close() 196 | 197 | c, cErr := Connect(mock.Addr()) 198 | if cErr != nil { 199 | t.Fatal(err) 200 | } 201 | 202 | w := c.Walk("/root") 203 | 204 | result := w.Next() 205 | 206 | // mock fs has one file 'lo' 207 | 208 | assert.Equal(t, true, result, "Result should return false") 209 | assert.Equal(t, 0, len(w.stack)) 210 | assert.Equal(t, "/root/lo", w.Path()) 211 | } 212 | --------------------------------------------------------------------------------