Skip to content

Commit 938a604

Browse files
committed
Implement chmod function
Closes #450
1 parent 6602e98 commit 938a604

File tree

6 files changed

+108
-33
lines changed

6 files changed

+108
-33
lines changed

.github/proftpd.conf

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# File creation mask
2+
Umask 022
3+
AllowStoreRestart on
4+
# Jail users to their home directory
5+
DefaultRoot ~
6+
7+
# Passive ports
8+
PassivePorts 60100 60150
9+
10+
# Authentication
11+
AuthOrder mod_auth_unix.c
12+
RequireValidShell off
13+
14+
# Logging
15+
SystemLog /var/log/proftpd/proftpd.log
16+
TransferLog /var/log/proftpd/xfer.log
17+
ExtendedLog /var/log/proftpd/access.log WRITE,READ default
18+
19+
# Restrict login only to user 'ftp-test'
20+
<Limit LOGIN>
21+
DenyAll
22+
AllowUser ftp-test
23+
</Limit>

.github/workflows/unit_tests.yaml

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,18 @@ jobs:
55
name: test
66
runs-on: ubuntu-latest
77
steps:
8-
- uses: actions/[email protected]
9-
- name: Setup go
10-
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00
11-
with:
12-
go-version: 1.25
13-
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
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@e4612787670fc5b5f49026b8c29c5569921de1db
25-
- name: Coveralls
26-
uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b
27-
with:
28-
github-token: ${{ secrets.github_token }}
29-
path-to-lcov: coverage.lcov
8+
- uses: actions/[email protected]
9+
name: Checkout Repository
10+
- name: Setup go
11+
uses: actions/setup-go@v6
12+
with:
13+
go-version: 1.19
14+
- name: Run tests
15+
run: go test -v -covermode=count -coverprofile=coverage.out
16+
- name: Convert coverage to lcov
17+
uses: jandelgado/gcov2lcov-action@4e1989767862652e6ca8d3e2e61aabe6d43be28b
18+
- name: Coveralls
19+
uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b
20+
with:
21+
github-token: ${{ secrets.github_token }}
22+
path-to-lcov: coverage.lcov

client_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"net"
8+
"os"
89
"syscall"
910
"testing"
1011
"time"
@@ -29,7 +30,7 @@ func testConn(t *testing.T, disableEPSV bool) {
2930
assert := assert.New(t)
3031
mock, c := openConn(t, "127.0.0.1", DialWithTimeout(5*time.Second), DialWithDisabledEPSV(disableEPSV))
3132

32-
err := c.Login("anonymous", "anonymous")
33+
err := c.Login("ftp-test", "ftp-test")
3334
assert.NoError(err)
3435

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

51+
err = c.Chmod("test", 0o755)
52+
assert.NoError(err)
53+
5054
_, err = c.List(".")
5155
assert.NoError(err)
5256

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

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

147155
err = c.Delete("tset")
148156
assert.NoError(err)

conn_test.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func (mock *ftpMock) listen() {
100100
features += "211 End"
101101
mock.printfLine(features)
102102
case "USER":
103-
if cmdParts[1] == "anonymous" {
103+
if cmdParts[1] == "ftp-test" {
104104
mock.printfLine("331 Please send your password")
105105
} else {
106106
mock.printfLine("530 This FTP server is anonymous only")
@@ -191,9 +191,9 @@ func (mock *ftpMock) listen() {
191191
mock.closeDataConn()
192192
case "MLST":
193193
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")
194+
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")
195195
} else {
196-
mock.printfLine("250-File data\r\n Type=file;Size=42;Modify=20201213202400; magic-file\r\n \r\n250 End")
196+
mock.printfLine("250-File data\r\n Type=file;Size=42;Modify=20201213202400;UNIX.mode=0644; magic-file\r\n \r\n250 End")
197197
}
198198
case "NLST":
199199
if mock.dataConn == nil {
@@ -274,6 +274,21 @@ func (mock *ftpMock) listen() {
274274
if (strings.Join(cmdParts[1:], " ")) == "UTF8 ON" {
275275
mock.printfLine("200 OK, UTF-8 enabled")
276276
}
277+
case "SITE":
278+
if len(cmdParts) < 2 {
279+
mock.printfLine("500 SITE command needs argument")
280+
break
281+
}
282+
switch cmdParts[1] {
283+
case "CHMOD":
284+
if len(cmdParts) != 4 {
285+
mock.printfLine("500 SITE CHMOD needs mode and path arguments")
286+
break
287+
}
288+
mock.printfLine("200 SITE CHMOD command successful")
289+
default:
290+
mock.printfLine("500 Unknown SITE command %s", cmdParts[1])
291+
}
277292
case "REIN":
278293
mock.printfLine("220 Logged out")
279294
case "QUIT":
@@ -411,7 +426,7 @@ func openConnExt(t *testing.T, addr, modtime string, options ...DialOption) (*ft
411426
c, err := Dial(mock.Addr(), options...)
412427
require.NoError(t, err)
413428

414-
err = c.Login("anonymous", "anonymous")
429+
err = c.Login("ftp-test", "ftp-test")
415430
require.NoError(t, err)
416431

417432
return mock, c

ftp.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"context"
99
"crypto/tls"
1010
"errors"
11+
"fmt"
1112
"io"
1213
"net"
1314
"net/textproto"
15+
"os"
1416
"strconv"
1517
"strings"
1618
"time"
@@ -89,11 +91,12 @@ type dialOptions struct {
8991

9092
// Entry describes a file and is returned by List().
9193
type Entry struct {
92-
Name string
93-
Target string // target of symbolic link
94-
Type EntryType
95-
Size uint64
96-
Time time.Time
94+
Name string
95+
FileMode os.FileMode
96+
Target string // target of symbolic link
97+
Type EntryType
98+
Size uint64
99+
Time time.Time
97100
}
98101

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

1129+
// Chmod issues a SITE CHMOD command to set permissions for file/directory
1130+
func (c *ServerConn) Chmod(path string, perm os.FileMode) error {
1131+
_, _, err := c.cmd(StatusCommandOK, "SITE CHMOD %s %s", fmt.Sprintf("%o", perm.Perm()), path)
1132+
return err
1133+
}
1134+
11261135
// Read implements the io.Reader interface on a FTP data connection.
11271136
func (r *Response) Read(buf []byte) (int, error) {
11281137
return r.conn.Read(buf)

parse.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@ package ftp
33
import (
44
"errors"
55
"fmt"
6+
"os"
67
"strconv"
78
"strings"
89
"time"
910
)
1011

12+
var permMap = []struct {
13+
char byte
14+
bit os.FileMode
15+
}{
16+
{'r', 0400}, {'w', 0200}, {'x', 0100}, // owner
17+
{'r', 0040}, {'w', 0020}, {'x', 0010}, // group
18+
{'r', 0004}, {'w', 0002}, {'x', 0001}, // others
19+
}
20+
1121
var errUnsupportedListLine = errors.New("unsupported LIST line")
1222
var errUnsupportedListDate = errors.New("unsupported LIST date")
1323
var errUnknownListEntryType = errors.New("unknown entry type")
@@ -59,6 +69,12 @@ func parseNextRFC3659ListLine(line string, loc *time.Location, e *Entry) (*Entry
5969
value := field[i+1:]
6070

6171
switch key {
72+
case "unix.mode":
73+
if parsedFileMode, err := strconv.ParseInt(value, 8, 64); err != nil {
74+
return nil, err
75+
} else {
76+
e.FileMode = os.FileMode(parsedFileMode)
77+
}
6278
case "modify":
6379
var err error
6480
e.Time, err = time.ParseInLocation("20060102150405", value, loc)
@@ -99,6 +115,16 @@ func parseLsListLine(line string, now time.Time, loc *time.Location) (*Entry, er
99115
return nil, errUnsupportedListLine
100116
}
101117

118+
fileMode := os.FileMode(0)
119+
if len(fields[0]) == 10 {
120+
for i, pm := range permMap {
121+
c := fields[0][i+1]
122+
if c == pm.char || (pm.char == 'x' && c == 's') {
123+
fileMode |= pm.bit
124+
}
125+
}
126+
}
127+
102128
if fields[1] == "folder" && fields[2] == "0" {
103129
e := &Entry{
104130
Type: EntryTypeFolder,
@@ -135,7 +161,8 @@ func parseLsListLine(line string, now time.Time, loc *time.Location) (*Entry, er
135161
}
136162

137163
e := &Entry{
138-
Name: scanner.Remaining(),
164+
FileMode: fileMode,
165+
Name: scanner.Remaining(),
139166
}
140167
switch fields[0][0] {
141168
case '-':

0 commit comments

Comments
 (0)