Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/proftpd.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# File creation mask
Umask 022
AllowStoreRestart on
# Jail users to their home directory
DefaultRoot ~

# Passive ports
PassivePorts 60100 60150

# Authentication
AuthOrder mod_auth_unix.c
RequireValidShell off

# Logging
SystemLog /var/log/proftpd/proftpd.log
TransferLog /var/log/proftpd/xfer.log
ExtendedLog /var/log/proftpd/access.log WRITE,READ default

# Restrict login only to user 'ftp-test'
<Limit LOGIN>
DenyAll
AllowUser ftp-test
</Limit>
40 changes: 18 additions & 22 deletions .github/workflows/unit_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,22 @@ jobs:
checks:
name: test
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.19', '1.20', '1.21', '1.22', '1.23', '1.24', '1.25']
steps:
- uses: actions/checkout@v4.2.2
- name: Setup go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00
with:
go-version: 1.25
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run tests
run: go test -v -covermode=count -coverprofile=coverage.out
- name: Convert coverage to lcov
uses: jandelgado/gcov2lcov-action@e4612787670fc5b5f49026b8c29c5569921de1db
- name: Coveralls
uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b
with:
github-token: ${{ secrets.github_token }}
path-to-lcov: coverage.lcov
- uses: actions/checkout@v4.2.2
name: Checkout Repository
- name: Setup go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- name: Run tests
run: go test -v -covermode=count -coverprofile=coverage.out
- name: Convert coverage to lcov
uses: jandelgado/gcov2lcov-action@4e1989767862652e6ca8d3e2e61aabe6d43be28b
- name: Coveralls
uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b
with:
github-token: ${{ secrets.github_token }}
path-to-lcov: coverage.lcov
10 changes: 9 additions & 1 deletion client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net"
"os"
"syscall"
"testing"
"time"
Expand All @@ -29,7 +30,7 @@ func testConn(t *testing.T, disableEPSV bool) {
assert := assert.New(t)
mock, c := openConn(t, "127.0.0.1", DialWithTimeout(5*time.Second), DialWithDisabledEPSV(disableEPSV))

err := c.Login("anonymous", "anonymous")
err := c.Login("ftp-test", "ftp-test")
assert.NoError(err)

err = c.NoOp()
Expand All @@ -47,6 +48,9 @@ func testConn(t *testing.T, disableEPSV bool) {
err = c.Stor("test", data)
assert.NoError(err)

err = c.Chmod("test", 0o755)
assert.NoError(err)

_, err = c.List(".")
assert.NoError(err)

Expand Down Expand Up @@ -126,6 +130,7 @@ func testConn(t *testing.T, disableEPSV bool) {
if entry.Name != "magic-file" {
t.Errorf("entry name %q, expected %q", entry.Name, "magic-file")
}
assert.Equal(os.FileMode(0o644).Perm(), entry.FileMode.Perm())

entry, err = c.GetEntry("multiline-dir")
if err != nil {
Expand All @@ -143,6 +148,9 @@ func testConn(t *testing.T, disableEPSV bool) {
if entry.Name != "multiline-dir" {
t.Errorf("entry name %q, expected %q", entry.Name, "multiline-dir")
}
assert.Equal(os.FileMode(0o755).Perm(), entry.FileMode.Perm())
err = c.Chmod("multiline-dir", 0o744)
assert.NoError(err)

err = c.Delete("tset")
assert.NoError(err)
Expand Down
23 changes: 19 additions & 4 deletions conn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func (mock *ftpMock) listen() {
features += "211 End"
mock.printfLine(features)
case "USER":
if cmdParts[1] == "anonymous" {
if cmdParts[1] == "ftp-test" {
mock.printfLine("331 Please send your password")
} else {
mock.printfLine("530 This FTP server is anonymous only")
Expand Down Expand Up @@ -191,9 +191,9 @@ func (mock *ftpMock) listen() {
mock.closeDataConn()
case "MLST":
if cmdParts[1] == "multiline-dir" {
mock.printfLine("250-File data\r\n Type=dir;Size=0; multiline-dir\r\n Modify=20201213202400; multiline-dir\r\n250 End")
mock.printfLine("250-File data\r\n Type=dir;Size=0;UNIX.mode=0755; multiline-dir\r\n Modify=20201213202400; multiline-dir\r\n250 End")
} else {
mock.printfLine("250-File data\r\n Type=file;Size=42;Modify=20201213202400; magic-file\r\n \r\n250 End")
mock.printfLine("250-File data\r\n Type=file;Size=42;Modify=20201213202400;UNIX.mode=0644; magic-file\r\n \r\n250 End")
}
case "NLST":
if mock.dataConn == nil {
Expand Down Expand Up @@ -274,6 +274,21 @@ func (mock *ftpMock) listen() {
if (strings.Join(cmdParts[1:], " ")) == "UTF8 ON" {
mock.printfLine("200 OK, UTF-8 enabled")
}
case "SITE":
if len(cmdParts) < 2 {
mock.printfLine("500 SITE command needs argument")
break
}
switch cmdParts[1] {
case "CHMOD":
if len(cmdParts) != 4 {
mock.printfLine("500 SITE CHMOD needs mode and path arguments")
break
}
mock.printfLine("200 SITE CHMOD command successful")
default:
mock.printfLine("500 Unknown SITE command %s", cmdParts[1])
}
case "REIN":
mock.printfLine("220 Logged out")
case "QUIT":
Expand Down Expand Up @@ -411,7 +426,7 @@ func openConnExt(t *testing.T, addr, modtime string, options ...DialOption) (*ft
c, err := Dial(mock.Addr(), options...)
require.NoError(t, err)

err = c.Login("anonymous", "anonymous")
err = c.Login("ftp-test", "ftp-test")
require.NoError(t, err)

return mock, c
Expand Down
19 changes: 14 additions & 5 deletions ftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/textproto"
"os"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -89,11 +91,12 @@ type dialOptions struct {

// Entry describes a file and is returned by List().
type Entry struct {
Name string
Target string // target of symbolic link
Type EntryType
Size uint64
Time time.Time
Name string
FileMode os.FileMode
Target string // target of symbolic link
Type EntryType
Size uint64
Time time.Time
}

// Response represents a data-connection
Expand Down Expand Up @@ -1123,6 +1126,12 @@ func (c *ServerConn) Quit() error {
return errs.ErrorOrNil()
}

// Chmod issues a SITE CHMOD command to set permissions for file/directory
func (c *ServerConn) Chmod(path string, perm os.FileMode) error {
_, _, err := c.cmd(StatusCommandOK, "SITE CHMOD %s %s", fmt.Sprintf("%o", perm.Perm()), path)
return err
}

// Read implements the io.Reader interface on a FTP data connection.
func (r *Response) Read(buf []byte) (int, error) {
return r.conn.Read(buf)
Expand Down
29 changes: 28 additions & 1 deletion parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ package ftp
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)

var permMap = []struct {
char byte
bit os.FileMode
}{
{'r', 0400}, {'w', 0200}, {'x', 0100}, // owner
{'r', 0040}, {'w', 0020}, {'x', 0010}, // group
{'r', 0004}, {'w', 0002}, {'x', 0001}, // others
}

var errUnsupportedListLine = errors.New("unsupported LIST line")
var errUnsupportedListDate = errors.New("unsupported LIST date")
var errUnknownListEntryType = errors.New("unknown entry type")
Expand Down Expand Up @@ -59,6 +69,12 @@ func parseNextRFC3659ListLine(line string, loc *time.Location, e *Entry) (*Entry
value := field[i+1:]

switch key {
case "unix.mode":
if parsedFileMode, err := strconv.ParseInt(value, 8, 64); err != nil {
return nil, err
} else {
e.FileMode = os.FileMode(parsedFileMode)
}
case "modify":
var err error
e.Time, err = time.ParseInLocation("20060102150405", value, loc)
Expand Down Expand Up @@ -99,6 +115,16 @@ func parseLsListLine(line string, now time.Time, loc *time.Location) (*Entry, er
return nil, errUnsupportedListLine
}

fileMode := os.FileMode(0)
if len(fields[0]) == 10 {
for i, pm := range permMap {
c := fields[0][i+1]
if c == pm.char || (pm.char == 'x' && c == 's') {
fileMode |= pm.bit
}
}
}

if fields[1] == "folder" && fields[2] == "0" {
e := &Entry{
Type: EntryTypeFolder,
Expand Down Expand Up @@ -135,7 +161,8 @@ func parseLsListLine(line string, now time.Time, loc *time.Location) (*Entry, er
}

e := &Entry{
Name: scanner.Remaining(),
FileMode: fileMode,
Name: scanner.Remaining(),
}
switch fields[0][0] {
case '-':
Expand Down