diff --git a/.gitignore b/.gitignore index b08da20..dd6ede7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ go.work /bin/ coverage.txt gitleaks.tar.gz -/lint-project.sh \ No newline at end of file +/lint-project.sh + +/testdata/ftp-server/newdir/ +/testdata/vsftpd-server/ \ No newline at end of file diff --git a/client.go b/client.go index bf7476e..006c013 100644 --- a/client.go +++ b/client.go @@ -32,6 +32,8 @@ type ClientConfig struct { CAFile string TLSConfig *tls.Config + + CreateUploadDirectories bool } type Client interface { @@ -317,7 +319,7 @@ func (cc *client) UploadFile(path string, contents io.ReadCloser) (err error) { dir, filename := filepath.Split(path) if dir != "" { - // Jump to previous directory after command is done + // Record the current directory we will jump back to after wd, err := conn.CurrentDir() if err != nil { return err @@ -329,6 +331,14 @@ func (cc *client) UploadFile(path string, contents io.ReadCloser) (err error) { } }(wd) + // Create any directories if needed + if cc.cfg.CreateUploadDirectories { + err := conn.MakeDir(dir) + if err != nil { + return fmt.Errorf("FTP: mkdir %s (from %s) failed: %w", dir, wd, err) + } + } + // Move into directory to run the command if err := conn.ChangeDir(dir); err != nil { return err diff --git a/client_integration_test.go b/client_integration_test.go new file mode 100644 index 0000000..b09af73 --- /dev/null +++ b/client_integration_test.go @@ -0,0 +1,36 @@ +package go_ftp_test + +import ( + "bytes" + "io" + "testing" + + go_ftp "github.com/moov-io/go-ftp" + + "github.com/stretchr/testify/require" +) + +func TestIntegration_fauria_vsftpd(t *testing.T) { + config := go_ftp.ClientConfig{ + Hostname: "localhost:4021", + Username: "ftpuser", + Password: "ftppass", + + CreateUploadDirectories: true, + } + client, err := go_ftp.NewClient(config) + require.NoError(t, err) + require.NotNil(t, client) + + t.Run("UploadFile with CreateUploadDirectories", func(t *testing.T) { + err := client.UploadFile("2025/test.txt", io.NopCloser(bytes.NewReader([]byte("hello world")))) + require.NoError(t, err) + + file, err := client.Open("2025/04/03/test.txt") + require.NoError(t, err) + + bs, err := io.ReadAll(file) + require.NoError(t, err) + require.Equal(t, "hello world", string(bs)) + }) +} diff --git a/client_test.go b/client_test.go index 1d09c1d..b8f4d47 100644 --- a/client_test.go +++ b/client_test.go @@ -24,11 +24,12 @@ import ( ) func TestClient(t *testing.T) { - client, err := go_ftp.NewClient(go_ftp.ClientConfig{ + config := go_ftp.ClientConfig{ Hostname: "127.0.0.1:2121", Username: "admin", Password: "123456", - }) + } + client, err := go_ftp.NewClient(config) require.NotNil(t, client) require.NoError(t, err) @@ -127,6 +128,55 @@ func TestClient(t *testing.T) { require.ErrorContains(t, err, "retrieving new.txt failed: 551 File not available") }) + t.Run("mkdir and upload", func(t *testing.T) { + client, err := go_ftp.NewClient(go_ftp.ClientConfig{ + Hostname: config.Hostname, + Username: config.Username, + Password: config.Password, + + CreateUploadDirectories: true, + }) + require.NotNil(t, client) + require.NoError(t, err) + + // Make sure we've delted newdir from any previous test runs + require.NoError(t, os.RemoveAll(filepath.Join("testdata", "ftp-server", "newdir"))) + + // Upload files into new directories + err = client.UploadFile("newdir/first.txt", io.NopCloser(strings.NewReader("first newdir data"))) + require.NoError(t, err) + + err = client.UploadFile("newdir/a/second.txt", io.NopCloser(strings.NewReader("second newdir data"))) + require.NoError(t, err) + + err = client.UploadFile("newdir/a/b/third.txt", io.NopCloser(strings.NewReader("third newdir data"))) + require.NoError(t, err) + + // read all files + file, err := client.Open("newdir/first.txt") + require.NoError(t, err) + + bs, err := io.ReadAll(file) + require.NoError(t, err) + require.Equal(t, "first newdir data", string(bs)) + + // second file + file, err = client.Open("newdir/a/second.txt") + require.NoError(t, err) + + bs, err = io.ReadAll(file) + require.NoError(t, err) + require.Equal(t, "second newdir data", string(bs)) + + // third file + file, err = client.Open("newdir/a/b/third.txt") + require.NoError(t, err) + + bs, err = io.ReadAll(file) + require.NoError(t, err) + require.Equal(t, "third newdir data", string(bs)) + }) + t.Run("delete", func(t *testing.T) { err := client.Delete("/missing.txt") require.NoError(t, err) @@ -217,6 +267,7 @@ func TestClient(t *testing.T) { "archive", "archive/old.txt", "archive/empty2.txt", "with-empty", "with-empty/EMPTY1.txt", "with-empty/empty_file2.txt", "with-empty/data.txt", "with-empty/data2.txt", + "newdir", "newdir/first.txt", "newdir/a", "newdir/a/second.txt", "newdir/a/b", "newdir/a/b/third.txt", }) }) @@ -248,6 +299,7 @@ func TestClient(t *testing.T) { "bigdata", "bigdata/large.txt", "archive", "archive/old.txt", "archive/empty2.txt", "Upper", "Upper/names.txt", + "newdir", "newdir/first.txt", "newdir/a", "newdir/a/second.txt", "newdir/a/b", "newdir/a/b/third.txt", }) }) diff --git a/docker-compose.yml b/docker-compose.yml index d98b06e..5f22147 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,5 +17,18 @@ services: - "-pass=123456" - "-passive-ports=30000-30009" + vsftpd: + image: fauria/vsftpd:latest + ports: + - "4020:20" + - "4021:21" + - "21100-21110:21100-21110" + volumes: + - "./testdata/vsftpd-server:/home/vsftpd" + environment: + FTP_USER: "ftpuser" + FTP_PASS: "ftppass" + LOCAL_UMASK: "022" + networks: intranet: {} diff --git a/go.mod b/go.mod index f343845..eb38ba1 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,9 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/kr/pretty v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.8.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6ae4c5d..b47b7f8 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -7,17 +8,28 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=