├── .github └── workflows │ ├── .editorconfig │ ├── CI.yml │ └── cifuzz.yml ├── .gitignore ├── CONTRIBUTORS ├── LICENSE ├── Makefile ├── README.md ├── allocator.go ├── allocator_test.go ├── attrs.go ├── attrs_stubs.go ├── attrs_test.go ├── attrs_unix.go ├── client.go ├── client_integration_darwin_test.go ├── client_integration_linux_test.go ├── client_integration_test.go ├── client_test.go ├── conn.go ├── debug.go ├── errno_plan9.go ├── errno_posix.go ├── example_test.go ├── examples ├── buffered-read-benchmark │ └── main.go ├── buffered-write-benchmark │ └── main.go ├── go-sftp-server │ ├── README.md │ └── main.go ├── request-server │ └── main.go ├── streaming-read-benchmark │ └── main.go └── streaming-write-benchmark │ └── main.go ├── fuzz.go ├── go.mod ├── go.sum ├── internal └── encoding │ └── ssh │ └── filexfer │ ├── attrs.go │ ├── attrs_test.go │ ├── buffer.go │ ├── extended_packets.go │ ├── extended_packets_test.go │ ├── extensions.go │ ├── extensions_test.go │ ├── filexfer.go │ ├── fx.go │ ├── fx_test.go │ ├── fxp.go │ ├── fxp_test.go │ ├── handle_packets.go │ ├── handle_packets_test.go │ ├── init_packets.go │ ├── init_packets_test.go │ ├── open_packets.go │ ├── open_packets_test.go │ ├── openssh │ ├── fsync.go │ ├── fsync_test.go │ ├── hardlink.go │ ├── hardlink_test.go │ ├── openssh.go │ ├── posix-rename.go │ ├── posix-rename_test.go │ ├── statvfs.go │ └── statvfs_test.go │ ├── packets.go │ ├── packets_test.go │ ├── path_packets.go │ ├── path_packets_test.go │ ├── permissions.go │ ├── response_packets.go │ └── response_packets_test.go ├── ls_formatting.go ├── ls_formatting_test.go ├── ls_plan9.go ├── ls_stub.go ├── ls_unix.go ├── match.go ├── packet-manager.go ├── packet-manager_test.go ├── packet-typing.go ├── packet.go ├── packet_test.go ├── pool.go ├── release.go ├── request-attrs.go ├── request-attrs_test.go ├── request-errors.go ├── request-example.go ├── request-interfaces.go ├── request-plan9.go ├── request-readme.md ├── request-server.go ├── request-server_test.go ├── request-unix.go ├── request.go ├── request_test.go ├── request_windows.go ├── server.go ├── server_integration_test.go ├── server_nowindows_test.go ├── server_plan9.go ├── server_posix.go ├── server_standalone └── main.go ├── server_statvfs_darwin.go ├── server_statvfs_impl.go ├── server_statvfs_linux.go ├── server_statvfs_plan9.go ├── server_statvfs_stubs.go ├── server_test.go ├── server_unix.go ├── server_windows.go ├── server_windows_test.go ├── sftp.go ├── sftp_test.go └── stat.go /.github/workflows/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.yml] 2 | indent_size = 2 3 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | run-tests: 10 | name: Run test cases 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest] 15 | go: ['1.24', '1.23'] 16 | exclude: 17 | - os: macos-latest 18 | go: '1.24' 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ matrix.go }} 27 | 28 | - name: Run tests 29 | run: | 30 | make integration 31 | make integration_w_race 32 | 33 | - name: Run tests on 32-bit arch 34 | if: startsWith(matrix.os, 'ubuntu-') 35 | run: | 36 | make integration 37 | env: 38 | GOARCH: 386 39 | -------------------------------------------------------------------------------- /.github/workflows/cifuzz.yml: -------------------------------------------------------------------------------- 1 | name: CIFuzz 2 | on: [pull_request] 3 | jobs: 4 | Fuzzing: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Build Fuzzers 8 | id: build 9 | uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master 10 | with: 11 | oss-fuzz-project-name: 'go-sftp' 12 | dry-run: false 13 | language: go 14 | - name: Run Fuzzers 15 | uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master 16 | with: 17 | oss-fuzz-project-name: 'go-sftp' 18 | fuzz-seconds: 300 19 | dry-run: false 20 | language: go 21 | - name: Upload Crash 22 | uses: actions/upload-artifact@v4 23 | if: failure() && steps.build.outcome == 'success' 24 | with: 25 | name: artifacts 26 | path: ./out/artifacts 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swo 2 | .*.swp 3 | 4 | server_standalone/server_standalone 5 | 6 | examples/*/id_rsa 7 | examples/*/id_rsa.pub 8 | 9 | memprofile.out 10 | memprofile.svg 11 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Dave Cheney 2 | Saulius Gurklys 3 | John Eikenberry 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Dave Cheney 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: integration integration_w_race benchmark 2 | 3 | integration: 4 | go test -integration -v ./... 5 | go test -testserver -v ./... 6 | go test -integration -testserver -v ./... 7 | go test -integration -allocator -v ./... 8 | go test -testserver -allocator -v ./... 9 | go test -integration -testserver -allocator -v ./... 10 | 11 | integration_w_race: 12 | go test -race -integration -v ./... 13 | go test -race -testserver -v ./... 14 | go test -race -integration -testserver -v ./... 15 | go test -race -integration -allocator -v ./... 16 | go test -race -testserver -allocator -v ./... 17 | go test -race -integration -allocator -testserver -v ./... 18 | 19 | COUNT ?= 1 20 | BENCHMARK_PATTERN ?= "." 21 | 22 | benchmark: 23 | go test -integration -run=NONE -bench=$(BENCHMARK_PATTERN) -benchmem -count=$(COUNT) 24 | 25 | benchmark_w_memprofile: 26 | go test -integration -run=NONE -bench=$(BENCHMARK_PATTERN) -benchmem -count=$(COUNT) -memprofile memprofile.out 27 | go tool pprof -svg -output=memprofile.svg memprofile.out 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sftp 2 | ---- 3 | 4 | The `sftp` package provides support for file system operations on remote ssh 5 | servers using the SFTP subsystem. It also implements an SFTP server for serving 6 | files from the filesystem. 7 | 8 | ![CI Status](https://github.com/pkg/sftp/workflows/CI/badge.svg?branch=master&event=push) [![Go Reference](https://pkg.go.dev/badge/github.com/pkg/sftp.svg)](https://pkg.go.dev/github.com/pkg/sftp) 9 | 10 | usage and examples 11 | ------------------ 12 | 13 | See [https://pkg.go.dev/github.com/pkg/sftp](https://pkg.go.dev/github.com/pkg/sftp) for 14 | examples and usage. 15 | 16 | The basic operation of the package mirrors the facilities of the 17 | [os](http://golang.org/pkg/os) package. 18 | 19 | The Walker interface for directory traversal is heavily inspired by Keith 20 | Rarick's [fs](https://pkg.go.dev/github.com/kr/fs) package. 21 | 22 | roadmap 23 | ------- 24 | 25 | * There is way too much duplication in the Client methods. If there was an 26 | unmarshal(interface{}) method this would reduce a heap of the duplication. 27 | 28 | contributing 29 | ------------ 30 | 31 | We welcome pull requests, bug fixes and issue reports. 32 | 33 | Before proposing a large change, first please discuss your change by raising an 34 | issue. 35 | 36 | For API/code bugs, please include a small, self contained code example to 37 | reproduce the issue. For pull requests, remember test coverage. 38 | 39 | We try to handle issues and pull requests with a 0 open philosophy. That means 40 | we will try to address the submission as soon as possible and will work toward 41 | a resolution. If progress can no longer be made (eg. unreproducible bug) or 42 | stops (eg. unresponsive submitter), we will close the bug. 43 | 44 | Thanks. 45 | -------------------------------------------------------------------------------- /allocator.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type allocator struct { 8 | sync.Mutex 9 | available [][]byte 10 | // map key is the request order 11 | used map[uint32][][]byte 12 | } 13 | 14 | func newAllocator() *allocator { 15 | return &allocator{ 16 | // micro optimization: initialize available pages with an initial capacity 17 | available: make([][]byte, 0, SftpServerWorkerCount*2), 18 | used: make(map[uint32][][]byte), 19 | } 20 | } 21 | 22 | // GetPage returns a previously allocated and unused []byte or create a new one. 23 | // The slice have a fixed size = maxMsgLength, this value is suitable for both 24 | // receiving new packets and reading the files to serve 25 | func (a *allocator) GetPage(requestOrderID uint32) []byte { 26 | a.Lock() 27 | defer a.Unlock() 28 | 29 | var result []byte 30 | 31 | // get an available page and remove it from the available ones. 32 | if len(a.available) > 0 { 33 | truncLength := len(a.available) - 1 34 | result = a.available[truncLength] 35 | 36 | a.available[truncLength] = nil // clear out the internal pointer 37 | a.available = a.available[:truncLength] // truncate the slice 38 | } 39 | 40 | // no preallocated slice found, just allocate a new one 41 | if result == nil { 42 | result = make([]byte, maxMsgLength) 43 | } 44 | 45 | // put result in used pages 46 | a.used[requestOrderID] = append(a.used[requestOrderID], result) 47 | 48 | return result 49 | } 50 | 51 | // ReleasePages marks unused all pages in use for the given requestID 52 | func (a *allocator) ReleasePages(requestOrderID uint32) { 53 | a.Lock() 54 | defer a.Unlock() 55 | 56 | if used := a.used[requestOrderID]; len(used) > 0 { 57 | a.available = append(a.available, used...) 58 | } 59 | delete(a.used, requestOrderID) 60 | } 61 | 62 | // Free removes all the used and available pages. 63 | // Call this method when the allocator is not needed anymore 64 | func (a *allocator) Free() { 65 | a.Lock() 66 | defer a.Unlock() 67 | 68 | a.available = nil 69 | a.used = make(map[uint32][][]byte) 70 | } 71 | 72 | func (a *allocator) countUsedPages() int { 73 | a.Lock() 74 | defer a.Unlock() 75 | 76 | num := 0 77 | for _, p := range a.used { 78 | num += len(p) 79 | } 80 | return num 81 | } 82 | 83 | func (a *allocator) countAvailablePages() int { 84 | a.Lock() 85 | defer a.Unlock() 86 | 87 | return len(a.available) 88 | } 89 | 90 | func (a *allocator) isRequestOrderIDUsed(requestOrderID uint32) bool { 91 | a.Lock() 92 | defer a.Unlock() 93 | 94 | _, ok := a.used[requestOrderID] 95 | return ok 96 | } 97 | -------------------------------------------------------------------------------- /allocator_test.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "strconv" 5 | "sync/atomic" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestAllocator(t *testing.T) { 12 | allocator := newAllocator() 13 | // get a page for request order id 1 14 | page := allocator.GetPage(1) 15 | page[1] = uint8(1) 16 | assert.Equal(t, maxMsgLength, len(page)) 17 | assert.Equal(t, 1, allocator.countUsedPages()) 18 | // get another page for request order id 1, we now have 2 used pages 19 | page = allocator.GetPage(1) 20 | page[0] = uint8(2) 21 | assert.Equal(t, 2, allocator.countUsedPages()) 22 | // get another page for request order id 1, we now have 3 used pages 23 | page = allocator.GetPage(1) 24 | page[2] = uint8(3) 25 | assert.Equal(t, 3, allocator.countUsedPages()) 26 | // release the page for request order id 1, we now have 3 available pages 27 | allocator.ReleasePages(1) 28 | assert.NotContains(t, allocator.used, 1) 29 | assert.Equal(t, 3, allocator.countAvailablePages()) 30 | // get a page for request order id 2 31 | // we get the latest released page, let's verify that by checking the previously written values 32 | // so we are sure we are reusing a previously allocated page 33 | page = allocator.GetPage(2) 34 | assert.Equal(t, uint8(3), page[2]) 35 | assert.Equal(t, 2, allocator.countAvailablePages()) 36 | assert.Equal(t, 1, allocator.countUsedPages()) 37 | page = allocator.GetPage(2) 38 | assert.Equal(t, uint8(2), page[0]) 39 | assert.Equal(t, 1, allocator.countAvailablePages()) 40 | assert.Equal(t, 2, allocator.countUsedPages()) 41 | page = allocator.GetPage(2) 42 | assert.Equal(t, uint8(1), page[1]) 43 | // we now have 3 used pages for request order id 2 and no available pages 44 | assert.Equal(t, 0, allocator.countAvailablePages()) 45 | assert.Equal(t, 3, allocator.countUsedPages()) 46 | assert.True(t, allocator.isRequestOrderIDUsed(2), "page with request order id 2 must be used") 47 | assert.False(t, allocator.isRequestOrderIDUsed(1), "page with request order id 1 must be not used") 48 | // release some request order id with no allocated pages, should have no effect 49 | allocator.ReleasePages(1) 50 | allocator.ReleasePages(3) 51 | assert.Equal(t, 0, allocator.countAvailablePages()) 52 | assert.Equal(t, 3, allocator.countUsedPages()) 53 | assert.True(t, allocator.isRequestOrderIDUsed(2), "page with request order id 2 must be used") 54 | assert.False(t, allocator.isRequestOrderIDUsed(1), "page with request order id 1 must be not used") 55 | // now get some pages for another request order id 56 | allocator.GetPage(3) 57 | // we now must have 3 used pages for request order id 2 and 1 used page for request order id 3 58 | assert.Equal(t, 0, allocator.countAvailablePages()) 59 | assert.Equal(t, 4, allocator.countUsedPages()) 60 | assert.True(t, allocator.isRequestOrderIDUsed(2), "page with request order id 2 must be used") 61 | assert.True(t, allocator.isRequestOrderIDUsed(3), "page with request order id 3 must be used") 62 | assert.False(t, allocator.isRequestOrderIDUsed(1), "page with request order id 1 must be not used") 63 | // get another page for request order id 3 64 | allocator.GetPage(3) 65 | assert.Equal(t, 0, allocator.countAvailablePages()) 66 | assert.Equal(t, 5, allocator.countUsedPages()) 67 | assert.True(t, allocator.isRequestOrderIDUsed(2), "page with request order id 2 must be used") 68 | assert.True(t, allocator.isRequestOrderIDUsed(3), "page with request order id 3 must be used") 69 | assert.False(t, allocator.isRequestOrderIDUsed(1), "page with request order id 1 must be not used") 70 | // now release the pages for request order id 3 71 | allocator.ReleasePages(3) 72 | assert.Equal(t, 2, allocator.countAvailablePages()) 73 | assert.Equal(t, 3, allocator.countUsedPages()) 74 | assert.True(t, allocator.isRequestOrderIDUsed(2), "page with request order id 2 must be used") 75 | assert.False(t, allocator.isRequestOrderIDUsed(1), "page with request order id 1 must be not used") 76 | assert.False(t, allocator.isRequestOrderIDUsed(3), "page with request order id 3 must be not used") 77 | // again check we are reusing previously allocated pages. 78 | // We have written nothing to the 2 last requested page so release them and get the third one 79 | allocator.ReleasePages(2) 80 | assert.Equal(t, 5, allocator.countAvailablePages()) 81 | assert.Equal(t, 0, allocator.countUsedPages()) 82 | assert.False(t, allocator.isRequestOrderIDUsed(2), "page with request order id 2 must be not used") 83 | allocator.GetPage(4) 84 | allocator.GetPage(4) 85 | page = allocator.GetPage(4) 86 | assert.Equal(t, uint8(3), page[2]) 87 | assert.Equal(t, 2, allocator.countAvailablePages()) 88 | assert.Equal(t, 3, allocator.countUsedPages()) 89 | assert.True(t, allocator.isRequestOrderIDUsed(4), "page with request order id 4 must be used") 90 | // free the allocator 91 | allocator.Free() 92 | assert.Equal(t, 0, allocator.countAvailablePages()) 93 | assert.Equal(t, 0, allocator.countUsedPages()) 94 | } 95 | 96 | func BenchmarkAllocatorSerial(b *testing.B) { 97 | allocator := newAllocator() 98 | for i := 0; i < b.N; i++ { 99 | benchAllocator(allocator, uint32(i)) 100 | } 101 | } 102 | 103 | func BenchmarkAllocatorParallel(b *testing.B) { 104 | var counter uint32 105 | allocator := newAllocator() 106 | for i := 1; i <= 8; i *= 2 { 107 | b.Run(strconv.Itoa(i), func(b *testing.B) { 108 | b.SetParallelism(i) 109 | b.RunParallel(func(pb *testing.PB) { 110 | for pb.Next() { 111 | benchAllocator(allocator, atomic.AddUint32(&counter, 1)) 112 | } 113 | }) 114 | }) 115 | } 116 | } 117 | 118 | func benchAllocator(allocator *allocator, requestOrderID uint32) { 119 | // simulates the page requested in recvPacket 120 | allocator.GetPage(requestOrderID) 121 | // simulates the page requested in fileget for downloads 122 | allocator.GetPage(requestOrderID) 123 | // release the allocated pages 124 | allocator.ReleasePages(requestOrderID) 125 | } 126 | 127 | // useful for debug 128 | func printAllocatorContents(allocator *allocator) { 129 | for o, u := range allocator.used { 130 | debug("used order id: %v, values: %+v", o, u) 131 | } 132 | for _, v := range allocator.available { 133 | debug("available, values: %+v", v) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /attrs.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | // ssh_FXP_ATTRS support 4 | // see https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt#section-5 5 | 6 | import ( 7 | "os" 8 | "time" 9 | ) 10 | 11 | const ( 12 | sshFileXferAttrSize = 0x00000001 13 | sshFileXferAttrUIDGID = 0x00000002 14 | sshFileXferAttrPermissions = 0x00000004 15 | sshFileXferAttrACmodTime = 0x00000008 16 | sshFileXferAttrExtended = 0x80000000 17 | 18 | sshFileXferAttrAll = sshFileXferAttrSize | sshFileXferAttrUIDGID | sshFileXferAttrPermissions | 19 | sshFileXferAttrACmodTime | sshFileXferAttrExtended 20 | ) 21 | 22 | // fileInfo is an artificial type designed to satisfy os.FileInfo. 23 | type fileInfo struct { 24 | name string 25 | stat *FileStat 26 | } 27 | 28 | // Name returns the base name of the file. 29 | func (fi *fileInfo) Name() string { return fi.name } 30 | 31 | // Size returns the length in bytes for regular files; system-dependent for others. 32 | func (fi *fileInfo) Size() int64 { return int64(fi.stat.Size) } 33 | 34 | // Mode returns file mode bits. 35 | func (fi *fileInfo) Mode() os.FileMode { return fi.stat.FileMode() } 36 | 37 | // ModTime returns the last modification time of the file. 38 | func (fi *fileInfo) ModTime() time.Time { return fi.stat.ModTime() } 39 | 40 | // IsDir returns true if the file is a directory. 41 | func (fi *fileInfo) IsDir() bool { return fi.Mode().IsDir() } 42 | 43 | func (fi *fileInfo) Sys() interface{} { return fi.stat } 44 | 45 | // FileStat holds the original unmarshalled values from a call to READDIR or 46 | // *STAT. It is exported for the purposes of accessing the raw values via 47 | // os.FileInfo.Sys(). It is also used server side to store the unmarshalled 48 | // values for SetStat. 49 | type FileStat struct { 50 | Size uint64 51 | Mode uint32 52 | Mtime uint32 53 | Atime uint32 54 | UID uint32 55 | GID uint32 56 | Extended []StatExtended 57 | } 58 | 59 | // ModTime returns the Mtime SFTP file attribute converted to a time.Time 60 | func (fs *FileStat) ModTime() time.Time { 61 | return time.Unix(int64(fs.Mtime), 0) 62 | } 63 | 64 | // AccessTime returns the Atime SFTP file attribute converted to a time.Time 65 | func (fs *FileStat) AccessTime() time.Time { 66 | return time.Unix(int64(fs.Atime), 0) 67 | } 68 | 69 | // FileMode returns the Mode SFTP file attribute converted to an os.FileMode 70 | func (fs *FileStat) FileMode() os.FileMode { 71 | return toFileMode(fs.Mode) 72 | } 73 | 74 | // StatExtended contains additional, extended information for a FileStat. 75 | type StatExtended struct { 76 | ExtType string 77 | ExtData string 78 | } 79 | 80 | func fileInfoFromStat(stat *FileStat, name string) os.FileInfo { 81 | return &fileInfo{ 82 | name: name, 83 | stat: stat, 84 | } 85 | } 86 | 87 | // FileInfoUidGid extends os.FileInfo and adds callbacks for Uid and Gid retrieval, 88 | // as an alternative to *syscall.Stat_t objects on unix systems. 89 | type FileInfoUidGid interface { 90 | os.FileInfo 91 | Uid() uint32 92 | Gid() uint32 93 | } 94 | 95 | // FileInfoUidGid extends os.FileInfo and adds a callbacks for extended data retrieval. 96 | type FileInfoExtendedData interface { 97 | os.FileInfo 98 | Extended() []StatExtended 99 | } 100 | 101 | func fileStatFromInfo(fi os.FileInfo) (uint32, *FileStat) { 102 | mtime := fi.ModTime().Unix() 103 | atime := mtime 104 | var flags uint32 = sshFileXferAttrSize | 105 | sshFileXferAttrPermissions | 106 | sshFileXferAttrACmodTime 107 | 108 | fileStat := &FileStat{ 109 | Size: uint64(fi.Size()), 110 | Mode: fromFileMode(fi.Mode()), 111 | Mtime: uint32(mtime), 112 | Atime: uint32(atime), 113 | } 114 | 115 | // os specific file stat decoding 116 | fileStatFromInfoOs(fi, &flags, fileStat) 117 | 118 | // The call above will include the sshFileXferAttrUIDGID in case 119 | // the os.FileInfo can be casted to *syscall.Stat_t on unix. 120 | // If fi implements FileInfoUidGid, retrieve Uid, Gid from it instead. 121 | if fiExt, ok := fi.(FileInfoUidGid); ok { 122 | flags |= sshFileXferAttrUIDGID 123 | fileStat.UID = fiExt.Uid() 124 | fileStat.GID = fiExt.Gid() 125 | } 126 | 127 | // if fi implements FileInfoExtendedData, retrieve extended data from it 128 | if fiExt, ok := fi.(FileInfoExtendedData); ok { 129 | fileStat.Extended = fiExt.Extended() 130 | if len(fileStat.Extended) > 0 { 131 | flags |= sshFileXferAttrExtended 132 | } 133 | } 134 | 135 | return flags, fileStat 136 | } 137 | -------------------------------------------------------------------------------- /attrs_stubs.go: -------------------------------------------------------------------------------- 1 | //go:build plan9 || windows || android 2 | // +build plan9 windows android 3 | 4 | package sftp 5 | 6 | import ( 7 | "os" 8 | ) 9 | 10 | func fileStatFromInfoOs(fi os.FileInfo, flags *uint32, fileStat *FileStat) { 11 | // todo 12 | } 13 | -------------------------------------------------------------------------------- /attrs_test.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // ensure that attrs implemenst os.FileInfo 8 | var _ os.FileInfo = new(fileInfo) 9 | -------------------------------------------------------------------------------- /attrs_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || dragonfly || freebsd || (!android && linux) || netbsd || openbsd || solaris || aix || js || zos 2 | // +build darwin dragonfly freebsd !android,linux netbsd openbsd solaris aix js zos 3 | 4 | package sftp 5 | 6 | import ( 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | func fileStatFromInfoOs(fi os.FileInfo, flags *uint32, fileStat *FileStat) { 12 | if statt, ok := fi.Sys().(*syscall.Stat_t); ok { 13 | *flags |= sshFileXferAttrUIDGID 14 | fileStat.UID = statt.Uid 15 | fileStat.GID = statt.Gid 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client_integration_darwin_test.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "syscall" 5 | "testing" 6 | ) 7 | 8 | func TestClientStatVFS(t *testing.T) { 9 | if *testServerImpl { 10 | t.Skipf("go server does not support FXP_EXTENDED") 11 | } 12 | sftp, cmd := testClient(t, READWRITE, NODELAY) 13 | defer cmd.Wait() 14 | defer sftp.Close() 15 | 16 | vfs, err := sftp.StatVFS("/") 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | // get system stats 22 | s := syscall.Statfs_t{} 23 | err = syscall.Statfs("/", &s) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | // check some stats 29 | if vfs.Files != uint64(s.Files) { 30 | t.Fatal("fr_size does not match") 31 | } 32 | 33 | if vfs.Bfree != uint64(s.Bfree) { 34 | t.Fatal("f_bsize does not match") 35 | } 36 | 37 | if vfs.Favail != uint64(s.Ffree) { 38 | t.Fatal("f_namemax does not match") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client_integration_linux_test.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "syscall" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestClientStatVFS(t *testing.T) { 11 | if *testServerImpl { 12 | t.Skipf("go server does not support FXP_EXTENDED") 13 | } 14 | sftp, cmd := testClient(t, READWRITE, NODELAY) 15 | defer cmd.Wait() 16 | defer sftp.Close() 17 | 18 | _, ok := sftp.HasExtension("statvfs@openssh.com") 19 | require.True(t, ok, "server doesn't list statvfs extension") 20 | 21 | vfs, err := sftp.StatVFS("/") 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | // get system stats 27 | s := syscall.Statfs_t{} 28 | err = syscall.Statfs("/", &s) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | // check some stats 34 | if vfs.Frsize != uint64(s.Frsize) { 35 | t.Fatalf("fr_size does not match, expected: %v, got: %v", s.Frsize, vfs.Frsize) 36 | } 37 | 38 | if vfs.Bsize != uint64(s.Bsize) { 39 | t.Fatalf("f_bsize does not match, expected: %v, got: %v", s.Bsize, vfs.Bsize) 40 | } 41 | 42 | if vfs.Namemax != uint64(s.Namelen) { 43 | t.Fatalf("f_namemax does not match, expected: %v, got: %v", s.Namelen, vfs.Namemax) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "os" 8 | "testing" 9 | 10 | "github.com/kr/fs" 11 | ) 12 | 13 | // assert that *Client implements fs.FileSystem 14 | var _ fs.FileSystem = new(Client) 15 | 16 | // assert that *File implements io.ReadWriteCloser 17 | var _ io.ReadWriteCloser = new(File) 18 | 19 | func TestNormaliseError(t *testing.T) { 20 | var ( 21 | ok = &StatusError{Code: sshFxOk} 22 | eof = &StatusError{Code: sshFxEOF} 23 | fail = &StatusError{Code: sshFxFailure} 24 | noSuchFile = &StatusError{Code: sshFxNoSuchFile} 25 | foo = errors.New("foo") 26 | ) 27 | 28 | var tests = []struct { 29 | desc string 30 | err error 31 | want error 32 | }{ 33 | { 34 | desc: "nil error", 35 | }, 36 | { 37 | desc: "not *StatusError", 38 | err: foo, 39 | want: foo, 40 | }, 41 | { 42 | desc: "*StatusError with ssh_FX_EOF", 43 | err: eof, 44 | want: io.EOF, 45 | }, 46 | { 47 | desc: "*StatusError with ssh_FX_NO_SUCH_FILE", 48 | err: noSuchFile, 49 | want: os.ErrNotExist, 50 | }, 51 | { 52 | desc: "*StatusError with ssh_FX_OK", 53 | err: ok, 54 | }, 55 | { 56 | desc: "*StatusError with ssh_FX_FAILURE", 57 | err: fail, 58 | want: fail, 59 | }, 60 | } 61 | 62 | for _, tt := range tests { 63 | got := normaliseError(tt.err) 64 | if got != tt.want { 65 | t.Errorf("normaliseError(%#v), test %q\n- want: %#v\n- got: %#v", 66 | tt.err, tt.desc, tt.want, got) 67 | } 68 | } 69 | } 70 | 71 | var flagsTests = []struct { 72 | flags int 73 | want uint32 74 | }{ 75 | {os.O_RDONLY, sshFxfRead}, 76 | {os.O_WRONLY, sshFxfWrite}, 77 | {os.O_RDWR, sshFxfRead | sshFxfWrite}, 78 | {os.O_RDWR | os.O_CREATE | os.O_TRUNC, sshFxfRead | sshFxfWrite | sshFxfCreat | sshFxfTrunc}, 79 | {os.O_WRONLY | os.O_APPEND, sshFxfWrite | sshFxfAppend}, 80 | } 81 | 82 | func TestFlags(t *testing.T) { 83 | for i, tt := range flagsTests { 84 | got := toPflags(tt.flags) 85 | if got != tt.want { 86 | t.Errorf("test %v: flags(%x): want: %x, got: %x", i, tt.flags, tt.want, got) 87 | } 88 | } 89 | } 90 | 91 | type packetSizeTest struct { 92 | size int 93 | valid bool 94 | } 95 | 96 | var maxPacketCheckedTests = []packetSizeTest{ 97 | {size: 0, valid: false}, 98 | {size: 1, valid: true}, 99 | {size: 32768, valid: true}, 100 | {size: 32769, valid: false}, 101 | } 102 | 103 | var maxPacketUncheckedTests = []packetSizeTest{ 104 | {size: 0, valid: false}, 105 | {size: 1, valid: true}, 106 | {size: 32768, valid: true}, 107 | {size: 32769, valid: true}, 108 | } 109 | 110 | func TestMaxPacketChecked(t *testing.T) { 111 | for _, tt := range maxPacketCheckedTests { 112 | testMaxPacketOption(t, MaxPacketChecked(tt.size), tt) 113 | } 114 | } 115 | 116 | func TestMaxPacketUnchecked(t *testing.T) { 117 | for _, tt := range maxPacketUncheckedTests { 118 | testMaxPacketOption(t, MaxPacketUnchecked(tt.size), tt) 119 | } 120 | } 121 | 122 | func TestMaxPacket(t *testing.T) { 123 | for _, tt := range maxPacketCheckedTests { 124 | testMaxPacketOption(t, MaxPacket(tt.size), tt) 125 | } 126 | } 127 | 128 | func testMaxPacketOption(t *testing.T, o ClientOption, tt packetSizeTest) { 129 | var c Client 130 | 131 | err := o(&c) 132 | if (err == nil) != tt.valid { 133 | t.Errorf("MaxPacketChecked(%v)\n- want: %v\n- got: %v", tt.size, tt.valid, err == nil) 134 | } 135 | if c.maxPacket != tt.size && tt.valid { 136 | t.Errorf("MaxPacketChecked(%v)\n- want: %v\n- got: %v", tt.size, tt.size, c.maxPacket) 137 | } 138 | } 139 | 140 | func testFstatOption(t *testing.T, o ClientOption, value bool) { 141 | var c Client 142 | 143 | err := o(&c) 144 | if err == nil && c.useFstat != value { 145 | t.Errorf("UseFStat(%v)\n- want: %v\n- got: %v", value, value, c.useFstat) 146 | } 147 | } 148 | 149 | func TestUseFstatChecked(t *testing.T) { 150 | testFstatOption(t, UseFstat(true), true) 151 | testFstatOption(t, UseFstat(false), false) 152 | } 153 | 154 | type sink struct{} 155 | 156 | func (*sink) Close() error { return nil } 157 | func (*sink) Write(p []byte) (int, error) { return len(p), nil } 158 | 159 | func TestClientZeroLengthPacket(t *testing.T) { 160 | // Packet length zero (never valid). This used to crash the client. 161 | packet := []byte{0, 0, 0, 0} 162 | 163 | r := bytes.NewReader(packet) 164 | c, err := NewClientPipe(r, &sink{}) 165 | if err == nil { 166 | t.Error("expected an error, got nil") 167 | } 168 | if c != nil { 169 | c.Close() 170 | } 171 | } 172 | 173 | func TestClientShortPacket(t *testing.T) { 174 | // init packet too short. 175 | packet := []byte{0, 0, 0, 1, 2} 176 | 177 | r := bytes.NewReader(packet) 178 | _, err := NewClientPipe(r, &sink{}) 179 | if !errors.Is(err, errShortPacket) { 180 | t.Fatalf("expected error: %v, got: %v", errShortPacket, err) 181 | } 182 | } 183 | 184 | // Issue #418: panic in clientConn.recv when the sid is incomplete. 185 | func TestClientNoSid(t *testing.T) { 186 | stream := new(bytes.Buffer) 187 | sendPacket(stream, &sshFxVersionPacket{Version: sftpProtocolVersion}) 188 | // Next packet has the sid cut short after two bytes. 189 | stream.Write([]byte{0, 0, 0, 10, 0, 0}) 190 | 191 | c, err := NewClientPipe(stream, &sink{}) 192 | if err != nil { 193 | t.Fatal(err) 194 | } 195 | 196 | _, err = c.Stat("anything") 197 | if !errors.Is(err, ErrSSHFxConnectionLost) { 198 | t.Fatal("expected ErrSSHFxConnectionLost, got", err) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /conn.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "context" 5 | "encoding" 6 | "fmt" 7 | "io" 8 | "sync" 9 | ) 10 | 11 | // conn implements a bidirectional channel on which client and server 12 | // connections are multiplexed. 13 | type conn struct { 14 | io.Reader 15 | io.WriteCloser 16 | // this is the same allocator used in packet manager 17 | alloc *allocator 18 | sync.Mutex // used to serialise writes to sendPacket 19 | } 20 | 21 | // the orderID is used in server mode if the allocator is enabled. 22 | // For the client mode just pass 0. 23 | // It returns io.EOF if the connection is closed and 24 | // there are no more packets to read. 25 | func (c *conn) recvPacket(orderID uint32) (uint8, []byte, error) { 26 | return recvPacket(c, c.alloc, orderID) 27 | } 28 | 29 | func (c *conn) sendPacket(m encoding.BinaryMarshaler) error { 30 | c.Lock() 31 | defer c.Unlock() 32 | 33 | return sendPacket(c, m) 34 | } 35 | 36 | func (c *conn) Close() error { 37 | c.Lock() 38 | defer c.Unlock() 39 | return c.WriteCloser.Close() 40 | } 41 | 42 | type clientConn struct { 43 | conn 44 | wg sync.WaitGroup 45 | 46 | sync.Mutex // protects inflight 47 | inflight map[uint32]chan<- result // outstanding requests 48 | 49 | closed chan struct{} 50 | err error 51 | } 52 | 53 | // Wait blocks until the conn has shut down, and return the error 54 | // causing the shutdown. It can be called concurrently from multiple 55 | // goroutines. 56 | func (c *clientConn) Wait() error { 57 | <-c.closed 58 | return c.err 59 | } 60 | 61 | // Close closes the SFTP session. 62 | func (c *clientConn) Close() error { 63 | defer c.wg.Wait() 64 | return c.conn.Close() 65 | } 66 | 67 | // recv continuously reads from the server and forwards responses to the 68 | // appropriate channel. 69 | func (c *clientConn) recv() error { 70 | defer c.conn.Close() 71 | 72 | for { 73 | typ, data, err := c.recvPacket(0) 74 | if err != nil { 75 | return err 76 | } 77 | sid, _, err := unmarshalUint32Safe(data) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | ch, ok := c.getChannel(sid) 83 | if !ok { 84 | // This is an unexpected occurrence. Send the error 85 | // back to all listeners so that they terminate 86 | // gracefully. 87 | return fmt.Errorf("sid not found: %d", sid) 88 | } 89 | 90 | ch <- result{typ: typ, data: data} 91 | } 92 | } 93 | 94 | func (c *clientConn) putChannel(ch chan<- result, sid uint32) bool { 95 | c.Lock() 96 | defer c.Unlock() 97 | 98 | select { 99 | case <-c.closed: 100 | // already closed with broadcastErr, return error on chan. 101 | ch <- result{err: ErrSSHFxConnectionLost} 102 | return false 103 | default: 104 | } 105 | 106 | c.inflight[sid] = ch 107 | return true 108 | } 109 | 110 | func (c *clientConn) getChannel(sid uint32) (chan<- result, bool) { 111 | c.Lock() 112 | defer c.Unlock() 113 | 114 | ch, ok := c.inflight[sid] 115 | delete(c.inflight, sid) 116 | 117 | return ch, ok 118 | } 119 | 120 | // result captures the result of receiving the a packet from the server 121 | type result struct { 122 | typ byte 123 | data []byte 124 | err error 125 | } 126 | 127 | type idmarshaler interface { 128 | id() uint32 129 | encoding.BinaryMarshaler 130 | } 131 | 132 | func (c *clientConn) sendPacket(ctx context.Context, ch chan result, p idmarshaler) (byte, []byte, error) { 133 | if cap(ch) < 1 { 134 | ch = make(chan result, 1) 135 | } 136 | 137 | c.dispatchRequest(ch, p) 138 | 139 | select { 140 | case <-ctx.Done(): 141 | return 0, nil, ctx.Err() 142 | case s := <-ch: 143 | return s.typ, s.data, s.err 144 | } 145 | } 146 | 147 | // dispatchRequest should ideally only be called by race-detection tests outside of this file, 148 | // where you have to ensure two packets are in flight sequentially after each other. 149 | func (c *clientConn) dispatchRequest(ch chan<- result, p idmarshaler) { 150 | sid := p.id() 151 | 152 | if !c.putChannel(ch, sid) { 153 | // already closed. 154 | return 155 | } 156 | 157 | if err := c.conn.sendPacket(p); err != nil { 158 | if ch, ok := c.getChannel(sid); ok { 159 | ch <- result{err: err} 160 | } 161 | } 162 | } 163 | 164 | // broadcastErr sends an error to all goroutines waiting for a response. 165 | func (c *clientConn) broadcastErr(err error) { 166 | c.Lock() 167 | defer c.Unlock() 168 | 169 | bcastRes := result{err: ErrSSHFxConnectionLost} 170 | for sid, ch := range c.inflight { 171 | ch <- bcastRes 172 | 173 | // Replace the chan in inflight, 174 | // we have hijacked this chan, 175 | // and this guarantees always-only-once sending. 176 | c.inflight[sid] = make(chan<- result, 1) 177 | } 178 | 179 | c.err = err 180 | close(c.closed) 181 | } 182 | 183 | type serverConn struct { 184 | conn 185 | } 186 | 187 | func (s *serverConn) sendError(id uint32, err error) error { 188 | return s.sendPacket(statusFromError(id, err)) 189 | } 190 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | // +build debug 3 | 4 | package sftp 5 | 6 | import "log" 7 | 8 | func debug(fmt string, args ...interface{}) { 9 | log.Printf(fmt, args...) 10 | } 11 | -------------------------------------------------------------------------------- /errno_plan9.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | var EBADF = syscall.NewError("fd out of range or not open") 9 | 10 | func wrapPathError(filepath string, err error) error { 11 | if errno, ok := err.(syscall.ErrorString); ok { 12 | return &os.PathError{Path: filepath, Err: errno} 13 | } 14 | return err 15 | } 16 | 17 | // translateErrno translates a syscall error number to a SFTP error code. 18 | func translateErrno(errno syscall.ErrorString) uint32 { 19 | switch errno { 20 | case "": 21 | return sshFxOk 22 | case syscall.ENOENT: 23 | return sshFxNoSuchFile 24 | case syscall.EPERM: 25 | return sshFxPermissionDenied 26 | } 27 | 28 | return sshFxFailure 29 | } 30 | 31 | func translateSyscallError(err error) (uint32, bool) { 32 | switch e := err.(type) { 33 | case syscall.ErrorString: 34 | return translateErrno(e), true 35 | case *os.PathError: 36 | debug("statusFromError,pathError: error is %T %#v", e.Err, e.Err) 37 | if errno, ok := e.Err.(syscall.ErrorString); ok { 38 | return translateErrno(errno), true 39 | } 40 | } 41 | return 0, false 42 | } 43 | -------------------------------------------------------------------------------- /errno_posix.go: -------------------------------------------------------------------------------- 1 | //go:build !plan9 2 | // +build !plan9 3 | 4 | package sftp 5 | 6 | import ( 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | const EBADF = syscall.EBADF 12 | 13 | func wrapPathError(filepath string, err error) error { 14 | if errno, ok := err.(syscall.Errno); ok { 15 | return &os.PathError{Path: filepath, Err: errno} 16 | } 17 | return err 18 | } 19 | 20 | // translateErrno translates a syscall error number to a SFTP error code. 21 | func translateErrno(errno syscall.Errno) uint32 { 22 | switch errno { 23 | case 0: 24 | return sshFxOk 25 | case syscall.ENOENT: 26 | return sshFxNoSuchFile 27 | case syscall.EACCES, syscall.EPERM: 28 | return sshFxPermissionDenied 29 | } 30 | 31 | return sshFxFailure 32 | } 33 | 34 | func translateSyscallError(err error) (uint32, bool) { 35 | switch e := err.(type) { 36 | case syscall.Errno: 37 | return translateErrno(e), true 38 | case *os.PathError: 39 | debug("statusFromError,pathError: error is %T %#v", e.Err, e.Err) 40 | if errno, ok := e.Err.(syscall.Errno); ok { 41 | return translateErrno(errno), true 42 | } 43 | } 44 | return 0, false 45 | } 46 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package sftp_test 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "strings" 12 | 13 | "github.com/pkg/sftp" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | func Example() { 18 | var conn *ssh.Client 19 | 20 | // open an SFTP session over an existing ssh connection. 21 | client, err := sftp.NewClient(conn) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | defer client.Close() 26 | 27 | // walk a directory 28 | w := client.Walk("/home/user") 29 | for w.Step() { 30 | if w.Err() != nil { 31 | continue 32 | } 33 | log.Println(w.Path()) 34 | } 35 | 36 | // leave your mark 37 | f, err := client.Create("hello.txt") 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | if _, err := f.Write([]byte("Hello world!")); err != nil { 42 | log.Fatal(err) 43 | } 44 | f.Close() 45 | 46 | // check it's there 47 | fi, err := client.Lstat("hello.txt") 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | log.Println(fi) 52 | } 53 | 54 | func ExampleNewClientPipe() { 55 | // Connect to a remote host and request the sftp subsystem via the 'ssh' 56 | // command. This assumes that passwordless login is correctly configured. 57 | cmd := exec.Command("ssh", "example.com", "-s", "sftp") 58 | 59 | // send errors from ssh to stderr 60 | cmd.Stderr = os.Stderr 61 | 62 | // get stdin and stdout 63 | wr, err := cmd.StdinPipe() 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | rd, err := cmd.StdoutPipe() 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | 72 | // start the process 73 | if err := cmd.Start(); err != nil { 74 | log.Fatal(err) 75 | } 76 | defer cmd.Wait() 77 | 78 | // open the SFTP session 79 | client, err := sftp.NewClientPipe(rd, wr) 80 | if err != nil { 81 | log.Fatal(err) 82 | } 83 | 84 | // read a directory 85 | list, err := client.ReadDir("/") 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | // print contents 91 | for _, item := range list { 92 | fmt.Println(item.Name()) 93 | } 94 | 95 | // close the connection 96 | client.Close() 97 | } 98 | 99 | func ExampleClient_Mkdir_parents() { 100 | // Example of mimicing 'mkdir --parents'; I.E. recursively create 101 | // directoryies and don't error if any directories already exists. 102 | var conn *ssh.Client 103 | 104 | client, err := sftp.NewClient(conn) 105 | if err != nil { 106 | log.Fatal(err) 107 | } 108 | defer client.Close() 109 | 110 | sshFxFailure := uint32(4) 111 | mkdirParents := func(client *sftp.Client, dir string) (err error) { 112 | var parents string 113 | 114 | if path.IsAbs(dir) { 115 | // Otherwise, an absolute path given below would be turned in to a relative one 116 | // by splitting on "/" 117 | parents = "/" 118 | } 119 | 120 | for _, name := range strings.Split(dir, "/") { 121 | if name == "" { 122 | // Paths with double-/ in them should just move along 123 | // this will also catch the case of the first character being a "/", i.e. an absolute path 124 | continue 125 | } 126 | parents = path.Join(parents, name) 127 | err = client.Mkdir(parents) 128 | if status, ok := err.(*sftp.StatusError); ok { 129 | if status.Code == sshFxFailure { 130 | var fi os.FileInfo 131 | fi, err = client.Stat(parents) 132 | if err == nil { 133 | if !fi.IsDir() { 134 | return fmt.Errorf("file exists: %s", parents) 135 | } 136 | } 137 | } 138 | } 139 | if err != nil { 140 | break 141 | } 142 | } 143 | return err 144 | } 145 | 146 | err = mkdirParents(client, "/tmp/foo/bar") 147 | if err != nil { 148 | log.Fatal(err) 149 | } 150 | } 151 | 152 | func ExampleFile_ReadFrom_bufio() { 153 | // Using Bufio to buffer writes going to an sftp.File won't buffer as it 154 | // skips buffering if the underlying writer support ReadFrom. The 155 | // workaround is to wrap your writer in a struct that only implements 156 | // io.Writer. 157 | // 158 | // For background see github.com/pkg/sftp/issues/125 159 | 160 | var data_source io.Reader 161 | var f *sftp.File 162 | type writerOnly struct{ io.Writer } 163 | bw := bufio.NewWriter(writerOnly{f}) // no ReadFrom() 164 | bw.ReadFrom(data_source) 165 | } 166 | -------------------------------------------------------------------------------- /examples/buffered-read-benchmark/main.go: -------------------------------------------------------------------------------- 1 | // buffered-read-benchmark benchmarks the peformance of reading 2 | // from /dev/zero on the server to a []byte on the client via io.Copy. 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net" 11 | "os" 12 | "time" 13 | 14 | "golang.org/x/crypto/ssh" 15 | "golang.org/x/crypto/ssh/agent" 16 | 17 | "github.com/pkg/sftp" 18 | ) 19 | 20 | var ( 21 | USER = flag.String("user", os.Getenv("USER"), "ssh username") 22 | HOST = flag.String("host", "localhost", "ssh server hostname") 23 | PORT = flag.Int("port", 22, "ssh server port") 24 | PASS = flag.String("pass", os.Getenv("SOCKSIE_SSH_PASSWORD"), "ssh password") 25 | SIZE = flag.Int("s", 1<<15, "set max packet size") 26 | ) 27 | 28 | func init() { 29 | flag.Parse() 30 | } 31 | 32 | func main() { 33 | var auths []ssh.AuthMethod 34 | if aconn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil { 35 | auths = append(auths, ssh.PublicKeysCallback(agent.NewClient(aconn).Signers)) 36 | 37 | } 38 | if *PASS != "" { 39 | auths = append(auths, ssh.Password(*PASS)) 40 | } 41 | 42 | config := ssh.ClientConfig{ 43 | User: *USER, 44 | Auth: auths, 45 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 46 | } 47 | addr := fmt.Sprintf("%s:%d", *HOST, *PORT) 48 | conn, err := ssh.Dial("tcp", addr, &config) 49 | if err != nil { 50 | log.Fatalf("unable to connect to [%s]: %v", addr, err) 51 | } 52 | defer conn.Close() 53 | 54 | c, err := sftp.NewClient(conn, sftp.MaxPacket(*SIZE)) 55 | if err != nil { 56 | log.Fatalf("unable to start sftp subsytem: %v", err) 57 | } 58 | defer c.Close() 59 | 60 | r, err := c.Open("/dev/zero") 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | defer r.Close() 65 | 66 | const size = 1e9 67 | 68 | log.Printf("reading %v bytes", size) 69 | t1 := time.Now() 70 | n, err := io.ReadFull(r, make([]byte, size)) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | if n != size { 75 | log.Fatalf("copy: expected %v bytes, got %d", size, n) 76 | } 77 | log.Printf("read %v bytes in %s", size, time.Since(t1)) 78 | } 79 | -------------------------------------------------------------------------------- /examples/buffered-write-benchmark/main.go: -------------------------------------------------------------------------------- 1 | // buffered-write-benchmark benchmarks the peformance of writing 2 | // a single large []byte on the client to /dev/null on the server via io.Copy. 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "fmt" 8 | "log" 9 | "net" 10 | "os" 11 | "syscall" 12 | "time" 13 | 14 | "golang.org/x/crypto/ssh" 15 | "golang.org/x/crypto/ssh/agent" 16 | 17 | "github.com/pkg/sftp" 18 | ) 19 | 20 | var ( 21 | USER = flag.String("user", os.Getenv("USER"), "ssh username") 22 | HOST = flag.String("host", "localhost", "ssh server hostname") 23 | PORT = flag.Int("port", 22, "ssh server port") 24 | PASS = flag.String("pass", os.Getenv("SOCKSIE_SSH_PASSWORD"), "ssh password") 25 | SIZE = flag.Int("s", 1<<15, "set max packet size") 26 | ) 27 | 28 | func init() { 29 | flag.Parse() 30 | } 31 | 32 | func main() { 33 | var auths []ssh.AuthMethod 34 | if aconn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil { 35 | auths = append(auths, ssh.PublicKeysCallback(agent.NewClient(aconn).Signers)) 36 | 37 | } 38 | if *PASS != "" { 39 | auths = append(auths, ssh.Password(*PASS)) 40 | } 41 | 42 | config := ssh.ClientConfig{ 43 | User: *USER, 44 | Auth: auths, 45 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 46 | } 47 | addr := fmt.Sprintf("%s:%d", *HOST, *PORT) 48 | conn, err := ssh.Dial("tcp", addr, &config) 49 | if err != nil { 50 | log.Fatalf("unable to connect to [%s]: %v", addr, err) 51 | } 52 | defer conn.Close() 53 | 54 | c, err := sftp.NewClient(conn, sftp.MaxPacket(*SIZE)) 55 | if err != nil { 56 | log.Fatalf("unable to start sftp subsytem: %v", err) 57 | } 58 | defer c.Close() 59 | 60 | w, err := c.OpenFile("/dev/null", syscall.O_WRONLY) 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | defer w.Close() 65 | 66 | f, err := os.Open("/dev/zero") 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | defer f.Close() 71 | 72 | const size = 1e9 73 | 74 | log.Printf("writing %v bytes", size) 75 | t1 := time.Now() 76 | n, err := w.Write(make([]byte, size)) 77 | if err != nil { 78 | log.Fatal(err) 79 | } 80 | if n != size { 81 | log.Fatalf("copy: expected %v bytes, got %d", size, n) 82 | } 83 | log.Printf("wrote %v bytes in %s", size, time.Since(t1)) 84 | } 85 | -------------------------------------------------------------------------------- /examples/go-sftp-server/README.md: -------------------------------------------------------------------------------- 1 | Example SFTP server implementation 2 | === 3 | 4 | In order to use this example you will need an RSA key. 5 | 6 | On linux-like systems with openssh installed, you can use the command: 7 | 8 | ``` 9 | ssh-keygen -t rsa -f id_rsa 10 | ``` 11 | 12 | Then you will be able to run the sftp-server command in the current directory. 13 | -------------------------------------------------------------------------------- /examples/go-sftp-server/main.go: -------------------------------------------------------------------------------- 1 | // An example SFTP server implementation using the golang SSH package. 2 | // Serves the whole filesystem visible to the user, and has a hard-coded username and password, 3 | // so not for real use! 4 | package main 5 | 6 | import ( 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net" 12 | "os" 13 | 14 | "github.com/pkg/sftp" 15 | "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | // Based on example server code from golang.org/x/crypto/ssh and server_standalone 19 | func main() { 20 | var ( 21 | readOnly bool 22 | debugStderr bool 23 | winRoot bool 24 | ) 25 | 26 | flag.BoolVar(&readOnly, "R", false, "read-only server") 27 | flag.BoolVar(&debugStderr, "e", false, "debug to stderr") 28 | flag.BoolVar(&winRoot, "wr", false, "windows root") 29 | 30 | flag.Parse() 31 | 32 | debugStream := io.Discard 33 | if debugStderr { 34 | debugStream = os.Stderr 35 | } 36 | 37 | // An SSH server is represented by a ServerConfig, which holds 38 | // certificate details and handles authentication of ServerConns. 39 | config := &ssh.ServerConfig{ 40 | PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { 41 | // Should use constant-time compare (or better, salt+hash) in 42 | // a production setting. 43 | fmt.Fprintf(debugStream, "Login: %s\n", c.User()) 44 | if c.User() == "testuser" && string(pass) == "tiger" { 45 | return nil, nil 46 | } 47 | return nil, fmt.Errorf("password rejected for %q", c.User()) 48 | }, 49 | } 50 | 51 | privateBytes, err := os.ReadFile("id_rsa") 52 | if err != nil { 53 | log.Fatal("Failed to load private key", err) 54 | } 55 | 56 | private, err := ssh.ParsePrivateKey(privateBytes) 57 | if err != nil { 58 | log.Fatal("Failed to parse private key", err) 59 | } 60 | 61 | config.AddHostKey(private) 62 | 63 | // Once a ServerConfig has been configured, connections can be 64 | // accepted. 65 | listener, err := net.Listen("tcp", "0.0.0.0:2022") 66 | if err != nil { 67 | log.Fatal("failed to listen for connection", err) 68 | } 69 | fmt.Printf("Listening on %v\n", listener.Addr()) 70 | 71 | nConn, err := listener.Accept() 72 | if err != nil { 73 | log.Fatal("failed to accept incoming connection", err) 74 | } 75 | 76 | // Before use, a handshake must be performed on the incoming 77 | // net.Conn. 78 | _, chans, reqs, err := ssh.NewServerConn(nConn, config) 79 | if err != nil { 80 | log.Fatal("failed to handshake", err) 81 | } 82 | fmt.Fprintf(debugStream, "SSH server established\n") 83 | 84 | // The incoming Request channel must be serviced. 85 | go ssh.DiscardRequests(reqs) 86 | 87 | // Service the incoming Channel channel. 88 | for newChannel := range chans { 89 | // Channels have a type, depending on the application level 90 | // protocol intended. In the case of an SFTP session, this is "subsystem" 91 | // with a payload string of "sftp" 92 | fmt.Fprintf(debugStream, "Incoming channel: %s\n", newChannel.ChannelType()) 93 | if newChannel.ChannelType() != "session" { 94 | newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") 95 | fmt.Fprintf(debugStream, "Unknown channel type: %s\n", newChannel.ChannelType()) 96 | continue 97 | } 98 | channel, requests, err := newChannel.Accept() 99 | if err != nil { 100 | log.Fatal("could not accept channel.", err) 101 | } 102 | fmt.Fprintf(debugStream, "Channel accepted\n") 103 | 104 | // Sessions have out-of-band requests such as "shell", 105 | // "pty-req" and "env". Here we handle only the 106 | // "subsystem" request. 107 | go func(in <-chan *ssh.Request) { 108 | for req := range in { 109 | fmt.Fprintf(debugStream, "Request: %v\n", req.Type) 110 | ok := false 111 | switch req.Type { 112 | case "subsystem": 113 | fmt.Fprintf(debugStream, "Subsystem: %s\n", req.Payload[4:]) 114 | if string(req.Payload[4:]) == "sftp" { 115 | ok = true 116 | } 117 | } 118 | fmt.Fprintf(debugStream, " - accepted: %v\n", ok) 119 | req.Reply(ok, nil) 120 | } 121 | }(requests) 122 | 123 | serverOptions := []sftp.ServerOption{ 124 | sftp.WithDebug(debugStream), 125 | } 126 | 127 | if readOnly { 128 | serverOptions = append(serverOptions, sftp.ReadOnly()) 129 | fmt.Fprintf(debugStream, "Read-only server\n") 130 | } else { 131 | fmt.Fprintf(debugStream, "Read write server\n") 132 | } 133 | 134 | if winRoot { 135 | serverOptions = append(serverOptions, sftp.WindowsRootEnumeratesDrives()) 136 | fmt.Fprintf(debugStream, "Windows root enabled\n") 137 | } 138 | 139 | server, err := sftp.NewServer( 140 | channel, 141 | serverOptions..., 142 | ) 143 | if err != nil { 144 | log.Fatal(err) 145 | } 146 | if err := server.Serve(); err != nil { 147 | if err != io.EOF { 148 | log.Fatal("sftp server completed with error:", err) 149 | } 150 | } 151 | server.Close() 152 | log.Print("sftp client exited session.") 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /examples/request-server/main.go: -------------------------------------------------------------------------------- 1 | // An example SFTP server implementation using the golang SSH package. 2 | // Serves the whole filesystem visible to the user, and has a hard-coded username and password, 3 | // so not for real use! 4 | package main 5 | 6 | import ( 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net" 12 | "os" 13 | 14 | "github.com/pkg/sftp" 15 | "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | // Based on example server code from golang.org/x/crypto/ssh and server_standalone 19 | func main() { 20 | var ( 21 | readOnly bool 22 | debugStderr bool 23 | ) 24 | 25 | flag.BoolVar(&readOnly, "R", false, "read-only server") 26 | flag.BoolVar(&debugStderr, "e", false, "debug to stderr") 27 | flag.Parse() 28 | 29 | debugStream := io.Discard 30 | if debugStderr { 31 | debugStream = os.Stderr 32 | } 33 | 34 | // An SSH server is represented by a ServerConfig, which holds 35 | // certificate details and handles authentication of ServerConns. 36 | config := &ssh.ServerConfig{ 37 | PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { 38 | // Should use constant-time compare (or better, salt+hash) in 39 | // a production setting. 40 | fmt.Fprintf(debugStream, "Login: %s\n", c.User()) 41 | if c.User() == "testuser" && string(pass) == "tiger" { 42 | return nil, nil 43 | } 44 | return nil, fmt.Errorf("password rejected for %q", c.User()) 45 | }, 46 | } 47 | 48 | privateBytes, err := os.ReadFile("id_rsa") 49 | if err != nil { 50 | log.Fatal("Failed to load private key", err) 51 | } 52 | 53 | private, err := ssh.ParsePrivateKey(privateBytes) 54 | if err != nil { 55 | log.Fatal("Failed to parse private key", err) 56 | } 57 | 58 | config.AddHostKey(private) 59 | 60 | // Once a ServerConfig has been configured, connections can be 61 | // accepted. 62 | listener, err := net.Listen("tcp", "0.0.0.0:2022") 63 | if err != nil { 64 | log.Fatal("failed to listen for connection", err) 65 | } 66 | fmt.Printf("Listening on %v\n", listener.Addr()) 67 | 68 | nConn, err := listener.Accept() 69 | if err != nil { 70 | log.Fatal("failed to accept incoming connection", err) 71 | } 72 | 73 | // Before use, a handshake must be performed on the incoming net.Conn. 74 | sconn, chans, reqs, err := ssh.NewServerConn(nConn, config) 75 | if err != nil { 76 | log.Fatal("failed to handshake", err) 77 | } 78 | log.Println("login detected:", sconn.User()) 79 | fmt.Fprintf(debugStream, "SSH server established\n") 80 | 81 | // The incoming Request channel must be serviced. 82 | go ssh.DiscardRequests(reqs) 83 | 84 | // Service the incoming Channel channel. 85 | for newChannel := range chans { 86 | // Channels have a type, depending on the application level 87 | // protocol intended. In the case of an SFTP session, this is "subsystem" 88 | // with a payload string of "sftp" 89 | fmt.Fprintf(debugStream, "Incoming channel: %s\n", newChannel.ChannelType()) 90 | if newChannel.ChannelType() != "session" { 91 | newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") 92 | fmt.Fprintf(debugStream, "Unknown channel type: %s\n", newChannel.ChannelType()) 93 | continue 94 | } 95 | channel, requests, err := newChannel.Accept() 96 | if err != nil { 97 | log.Fatal("could not accept channel.", err) 98 | } 99 | fmt.Fprintf(debugStream, "Channel accepted\n") 100 | 101 | // Sessions have out-of-band requests such as "shell", 102 | // "pty-req" and "env". Here we handle only the 103 | // "subsystem" request. 104 | go func(in <-chan *ssh.Request) { 105 | for req := range in { 106 | fmt.Fprintf(debugStream, "Request: %v\n", req.Type) 107 | ok := false 108 | switch req.Type { 109 | case "subsystem": 110 | fmt.Fprintf(debugStream, "Subsystem: %s\n", req.Payload[4:]) 111 | if string(req.Payload[4:]) == "sftp" { 112 | ok = true 113 | } 114 | } 115 | fmt.Fprintf(debugStream, " - accepted: %v\n", ok) 116 | req.Reply(ok, nil) 117 | } 118 | }(requests) 119 | 120 | root := sftp.InMemHandler() 121 | server := sftp.NewRequestServer(channel, root) 122 | if err := server.Serve(); err != nil { 123 | if err != io.EOF { 124 | log.Fatal("sftp server completed with error:", err) 125 | } 126 | } 127 | server.Close() 128 | log.Print("sftp client exited session.") 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /examples/streaming-read-benchmark/main.go: -------------------------------------------------------------------------------- 1 | // streaming-read-benchmark benchmarks the peformance of reading 2 | // from /dev/zero on the server to /dev/null on the client via io.Copy. 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net" 11 | "os" 12 | "syscall" 13 | "time" 14 | 15 | "golang.org/x/crypto/ssh" 16 | "golang.org/x/crypto/ssh/agent" 17 | 18 | "github.com/pkg/sftp" 19 | ) 20 | 21 | var ( 22 | USER = flag.String("user", os.Getenv("USER"), "ssh username") 23 | HOST = flag.String("host", "localhost", "ssh server hostname") 24 | PORT = flag.Int("port", 22, "ssh server port") 25 | PASS = flag.String("pass", os.Getenv("SOCKSIE_SSH_PASSWORD"), "ssh password") 26 | SIZE = flag.Int("s", 1<<15, "set max packet size") 27 | ) 28 | 29 | func init() { 30 | flag.Parse() 31 | } 32 | 33 | func main() { 34 | var auths []ssh.AuthMethod 35 | if aconn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil { 36 | auths = append(auths, ssh.PublicKeysCallback(agent.NewClient(aconn).Signers)) 37 | 38 | } 39 | if *PASS != "" { 40 | auths = append(auths, ssh.Password(*PASS)) 41 | } 42 | 43 | config := ssh.ClientConfig{ 44 | User: *USER, 45 | Auth: auths, 46 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 47 | } 48 | addr := fmt.Sprintf("%s:%d", *HOST, *PORT) 49 | conn, err := ssh.Dial("tcp", addr, &config) 50 | if err != nil { 51 | log.Fatalf("unable to connect to [%s]: %v", addr, err) 52 | } 53 | defer conn.Close() 54 | 55 | c, err := sftp.NewClient(conn, sftp.MaxPacket(*SIZE)) 56 | if err != nil { 57 | log.Fatalf("unable to start sftp subsytem: %v", err) 58 | } 59 | defer c.Close() 60 | 61 | r, err := c.Open("/dev/zero") 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | defer r.Close() 66 | 67 | w, err := os.OpenFile("/dev/null", syscall.O_WRONLY, 0600) 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | defer w.Close() 72 | 73 | const size int64 = 1e9 74 | 75 | log.Printf("reading %v bytes", size) 76 | t1 := time.Now() 77 | n, err := io.Copy(w, io.LimitReader(r, size)) 78 | if err != nil { 79 | log.Fatal(err) 80 | } 81 | if n != size { 82 | log.Fatalf("copy: expected %v bytes, got %d", size, n) 83 | } 84 | log.Printf("read %v bytes in %s", size, time.Since(t1)) 85 | } 86 | -------------------------------------------------------------------------------- /examples/streaming-write-benchmark/main.go: -------------------------------------------------------------------------------- 1 | // streaming-write-benchmark benchmarks the peformance of writing 2 | // from /dev/zero on the client to /dev/null on the server via io.Copy. 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net" 11 | "os" 12 | "syscall" 13 | "time" 14 | 15 | "golang.org/x/crypto/ssh" 16 | "golang.org/x/crypto/ssh/agent" 17 | 18 | "github.com/pkg/sftp" 19 | ) 20 | 21 | var ( 22 | USER = flag.String("user", os.Getenv("USER"), "ssh username") 23 | HOST = flag.String("host", "localhost", "ssh server hostname") 24 | PORT = flag.Int("port", 22, "ssh server port") 25 | PASS = flag.String("pass", os.Getenv("SOCKSIE_SSH_PASSWORD"), "ssh password") 26 | SIZE = flag.Int("s", 1<<15, "set max packet size") 27 | ) 28 | 29 | func init() { 30 | flag.Parse() 31 | } 32 | 33 | func main() { 34 | var auths []ssh.AuthMethod 35 | if aconn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil { 36 | auths = append(auths, ssh.PublicKeysCallback(agent.NewClient(aconn).Signers)) 37 | 38 | } 39 | if *PASS != "" { 40 | auths = append(auths, ssh.Password(*PASS)) 41 | } 42 | 43 | config := ssh.ClientConfig{ 44 | User: *USER, 45 | Auth: auths, 46 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 47 | } 48 | addr := fmt.Sprintf("%s:%d", *HOST, *PORT) 49 | conn, err := ssh.Dial("tcp", addr, &config) 50 | if err != nil { 51 | log.Fatalf("unable to connect to [%s]: %v", addr, err) 52 | } 53 | defer conn.Close() 54 | 55 | c, err := sftp.NewClient(conn, sftp.MaxPacket(*SIZE)) 56 | if err != nil { 57 | log.Fatalf("unable to start sftp subsytem: %v", err) 58 | } 59 | defer c.Close() 60 | 61 | w, err := c.OpenFile("/dev/null", syscall.O_WRONLY) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | defer w.Close() 66 | 67 | f, err := os.Open("/dev/zero") 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | defer f.Close() 72 | 73 | const size int64 = 1e9 74 | 75 | log.Printf("writing %v bytes", size) 76 | t1 := time.Now() 77 | n, err := io.Copy(w, io.LimitReader(f, size)) 78 | if err != nil { 79 | log.Fatal(err) 80 | } 81 | if n != size { 82 | log.Fatalf("copy: expected %v bytes, got %d", size, n) 83 | } 84 | log.Printf("wrote %v bytes in %s", size, time.Since(t1)) 85 | } 86 | -------------------------------------------------------------------------------- /fuzz.go: -------------------------------------------------------------------------------- 1 | //go:build gofuzz 2 | // +build gofuzz 3 | 4 | package sftp 5 | 6 | import "bytes" 7 | 8 | type sinkfuzz struct{} 9 | 10 | func (*sinkfuzz) Close() error { return nil } 11 | func (*sinkfuzz) Write(p []byte) (int, error) { return len(p), nil } 12 | 13 | var devnull = &sinkfuzz{} 14 | 15 | // To run: go-fuzz-build && go-fuzz 16 | func Fuzz(data []byte) int { 17 | c, err := NewClientPipe(bytes.NewReader(data), devnull) 18 | if err != nil { 19 | return 0 20 | } 21 | c.Close() 22 | return 1 23 | } 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pkg/sftp 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/kr/fs v0.1.0 7 | github.com/stretchr/testify v1.10.0 8 | golang.org/x/crypto v0.36.0 9 | golang.org/x/sys v0.31.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 4 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 8 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 10 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 11 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 12 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 13 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 14 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/attrs_test.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestAttributes(t *testing.T) { 9 | const ( 10 | size uint64 = 0x123456789ABCDEF0 11 | uid = 1000 12 | gid = 100 13 | perms FileMode = 0x87654321 14 | atime = 0x2A2B2C2D 15 | mtime = 0x42434445 16 | ) 17 | 18 | extAttr := ExtendedAttribute{ 19 | Type: "foo", 20 | Data: "bar", 21 | } 22 | 23 | attr := &Attributes{ 24 | Size: size, 25 | UID: uid, 26 | GID: gid, 27 | Permissions: perms, 28 | ATime: atime, 29 | MTime: mtime, 30 | ExtendedAttributes: []ExtendedAttribute{ 31 | extAttr, 32 | }, 33 | } 34 | 35 | type test struct { 36 | name string 37 | flags uint32 38 | encoded []byte 39 | } 40 | 41 | tests := []test{ 42 | { 43 | name: "empty", 44 | encoded: []byte{ 45 | 0x00, 0x00, 0x00, 0x00, 46 | }, 47 | }, 48 | { 49 | name: "size", 50 | flags: AttrSize, 51 | encoded: []byte{ 52 | 0x00, 0x00, 0x00, 0x01, 53 | 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 54 | }, 55 | }, 56 | { 57 | name: "uidgid", 58 | flags: AttrUIDGID, 59 | encoded: []byte{ 60 | 0x00, 0x00, 0x00, 0x02, 61 | 0x00, 0x00, 0x03, 0xE8, 62 | 0x00, 0x00, 0x00, 100, 63 | }, 64 | }, 65 | { 66 | name: "permissions", 67 | flags: AttrPermissions, 68 | encoded: []byte{ 69 | 0x00, 0x00, 0x00, 0x04, 70 | 0x87, 0x65, 0x43, 0x21, 71 | }, 72 | }, 73 | { 74 | name: "acmodtime", 75 | flags: AttrACModTime, 76 | encoded: []byte{ 77 | 0x00, 0x00, 0x00, 0x08, 78 | 0x2A, 0x2B, 0x2C, 0x2D, 79 | 0x42, 0x43, 0x44, 0x45, 80 | }, 81 | }, 82 | { 83 | name: "extended", 84 | flags: AttrExtended, 85 | encoded: []byte{ 86 | 0x80, 0x00, 0x00, 0x00, 87 | 0x00, 0x00, 0x00, 0x01, 88 | 0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o', 89 | 0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r', 90 | }, 91 | }, 92 | { 93 | name: "size uidgid permisssions acmodtime extended", 94 | flags: AttrSize | AttrUIDGID | AttrPermissions | AttrACModTime | AttrExtended, 95 | encoded: []byte{ 96 | 0x80, 0x00, 0x00, 0x0F, 97 | 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 98 | 0x00, 0x00, 0x03, 0xE8, 99 | 0x00, 0x00, 0x00, 100, 100 | 0x87, 0x65, 0x43, 0x21, 101 | 0x2A, 0x2B, 0x2C, 0x2D, 102 | 0x42, 0x43, 0x44, 0x45, 103 | 0x00, 0x00, 0x00, 0x01, 104 | 0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o', 105 | 0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r', 106 | }, 107 | }, 108 | } 109 | 110 | for _, tt := range tests { 111 | attr := *attr 112 | 113 | t.Run(tt.name, func(t *testing.T) { 114 | attr.Flags = tt.flags 115 | 116 | buf, err := attr.MarshalBinary() 117 | if err != nil { 118 | t.Fatal("unexpected error:", err) 119 | } 120 | 121 | if !bytes.Equal(buf, tt.encoded) { 122 | t.Fatalf("MarshalBinary() = %X, but wanted %X", buf, tt.encoded) 123 | } 124 | 125 | attr = Attributes{} 126 | 127 | if err := attr.UnmarshalBinary(buf); err != nil { 128 | t.Fatal("unexpected error:", err) 129 | } 130 | 131 | if attr.Flags != tt.flags { 132 | t.Errorf("UnmarshalBinary(): Flags was %x, but wanted %x", attr.Flags, tt.flags) 133 | } 134 | 135 | if attr.Flags&AttrSize != 0 && attr.Size != size { 136 | t.Errorf("UnmarshalBinary(): Size was %x, but wanted %x", attr.Size, size) 137 | } 138 | 139 | if attr.Flags&AttrUIDGID != 0 { 140 | if attr.UID != uid { 141 | t.Errorf("UnmarshalBinary(): UID was %x, but wanted %x", attr.UID, uid) 142 | } 143 | 144 | if attr.GID != gid { 145 | t.Errorf("UnmarshalBinary(): GID was %x, but wanted %x", attr.GID, gid) 146 | } 147 | } 148 | 149 | if attr.Flags&AttrPermissions != 0 && attr.Permissions != perms { 150 | t.Errorf("UnmarshalBinary(): Permissions was %#v, but wanted %#v", attr.Permissions, perms) 151 | } 152 | 153 | if attr.Flags&AttrACModTime != 0 { 154 | if attr.ATime != atime { 155 | t.Errorf("UnmarshalBinary(): ATime was %x, but wanted %x", attr.ATime, atime) 156 | } 157 | 158 | if attr.MTime != mtime { 159 | t.Errorf("UnmarshalBinary(): MTime was %x, but wanted %x", attr.MTime, mtime) 160 | } 161 | } 162 | 163 | if attr.Flags&AttrExtended != 0 { 164 | extAttrs := attr.ExtendedAttributes 165 | 166 | if count := len(extAttrs); count != 1 { 167 | t.Fatalf("UnmarshalBinary(): len(ExtendedAttributes) was %d, but wanted %d", count, 1) 168 | } 169 | 170 | if got := extAttrs[0]; got != extAttr { 171 | t.Errorf("UnmarshalBinary(): ExtendedAttributes[0] was %#v, but wanted %#v", got, extAttr) 172 | } 173 | } 174 | }) 175 | } 176 | } 177 | 178 | func TestNameEntry(t *testing.T) { 179 | const ( 180 | filename = "foo" 181 | longname = "bar" 182 | perms FileMode = 0x87654321 183 | ) 184 | 185 | e := &NameEntry{ 186 | Filename: filename, 187 | Longname: longname, 188 | Attrs: Attributes{ 189 | Flags: AttrPermissions, 190 | Permissions: perms, 191 | }, 192 | } 193 | 194 | buf, err := e.MarshalBinary() 195 | if err != nil { 196 | t.Fatal("unexpected error:", err) 197 | } 198 | 199 | want := []byte{ 200 | 0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o', 201 | 0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r', 202 | 0x00, 0x00, 0x00, 0x04, 203 | 0x87, 0x65, 0x43, 0x21, 204 | } 205 | 206 | if !bytes.Equal(buf, want) { 207 | t.Fatalf("MarshalBinary() = %X, but wanted %X", buf, want) 208 | } 209 | 210 | *e = NameEntry{} 211 | 212 | if err := e.UnmarshalBinary(buf); err != nil { 213 | t.Fatal("unexpected error:", err) 214 | } 215 | 216 | if e.Filename != filename { 217 | t.Errorf("UnmarhsalFrom(): Filename was %q, but expected %q", e.Filename, filename) 218 | } 219 | 220 | if e.Longname != longname { 221 | t.Errorf("UnmarhsalFrom(): Longname was %q, but expected %q", e.Longname, longname) 222 | } 223 | 224 | if e.Attrs.Flags != AttrPermissions { 225 | t.Errorf("UnmarshalBinary(): Attrs.Flag was %#x, but expected %#x", e.Attrs.Flags, AttrPermissions) 226 | } 227 | 228 | if e.Attrs.Permissions != perms { 229 | t.Errorf("UnmarshalBinary(): Attrs.Permissions was %#v, but expected %#v", e.Attrs.Permissions, perms) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/extended_packets.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "encoding" 5 | "sync" 6 | ) 7 | 8 | // ExtendedData aliases the untyped interface composition of encoding.BinaryMarshaler and encoding.BinaryUnmarshaler. 9 | type ExtendedData = interface { 10 | encoding.BinaryMarshaler 11 | encoding.BinaryUnmarshaler 12 | } 13 | 14 | // ExtendedDataConstructor defines a function that returns a new(ArbitraryExtendedPacket). 15 | type ExtendedDataConstructor func() ExtendedData 16 | 17 | var extendedPacketTypes = struct { 18 | mu sync.RWMutex 19 | constructors map[string]ExtendedDataConstructor 20 | }{ 21 | constructors: make(map[string]ExtendedDataConstructor), 22 | } 23 | 24 | // RegisterExtendedPacketType defines a specific ExtendedDataConstructor for the given extension string. 25 | func RegisterExtendedPacketType(extension string, constructor ExtendedDataConstructor) { 26 | extendedPacketTypes.mu.Lock() 27 | defer extendedPacketTypes.mu.Unlock() 28 | 29 | if _, exist := extendedPacketTypes.constructors[extension]; exist { 30 | panic("encoding/ssh/filexfer: multiple registration of extended packet type " + extension) 31 | } 32 | 33 | extendedPacketTypes.constructors[extension] = constructor 34 | } 35 | 36 | func newExtendedPacket(extension string) ExtendedData { 37 | extendedPacketTypes.mu.RLock() 38 | defer extendedPacketTypes.mu.RUnlock() 39 | 40 | if f := extendedPacketTypes.constructors[extension]; f != nil { 41 | return f() 42 | } 43 | 44 | return new(Buffer) 45 | } 46 | 47 | // ExtendedPacket defines the SSH_FXP_CLOSE packet. 48 | type ExtendedPacket struct { 49 | ExtendedRequest string 50 | 51 | Data ExtendedData 52 | } 53 | 54 | // Type returns the SSH_FXP_xy value associated with this packet type. 55 | func (p *ExtendedPacket) Type() PacketType { 56 | return PacketTypeExtended 57 | } 58 | 59 | // MarshalPacket returns p as a two-part binary encoding of p. 60 | // 61 | // The Data is marshaled into binary, and returned as the payload. 62 | func (p *ExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 63 | buf := NewBuffer(b) 64 | if buf.Cap() < 9 { 65 | size := 4 + len(p.ExtendedRequest) // string(extended-request) 66 | buf = NewMarshalBuffer(size) 67 | } 68 | 69 | buf.StartPacket(PacketTypeExtended, reqid) 70 | buf.AppendString(p.ExtendedRequest) 71 | 72 | if p.Data != nil { 73 | payload, err = p.Data.MarshalBinary() 74 | if err != nil { 75 | return nil, nil, err 76 | } 77 | } 78 | 79 | return buf.Packet(payload) 80 | } 81 | 82 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 83 | // It is assumed that the uint32(request-id) has already been consumed. 84 | // 85 | // If p.Data is nil, and the extension has been registered, a new type will be made from the registration. 86 | // If the extension has not been registered, then a new Buffer will be allocated. 87 | // Then the request-specific-data will be unmarshaled from the rest of the buffer. 88 | func (p *ExtendedPacket) UnmarshalPacketBody(buf *Buffer) (err error) { 89 | p.ExtendedRequest = buf.ConsumeString() 90 | if buf.Err != nil { 91 | return buf.Err 92 | } 93 | 94 | if p.Data == nil { 95 | p.Data = newExtendedPacket(p.ExtendedRequest) 96 | } 97 | 98 | return p.Data.UnmarshalBinary(buf.Bytes()) 99 | } 100 | 101 | // ExtendedReplyPacket defines the SSH_FXP_CLOSE packet. 102 | type ExtendedReplyPacket struct { 103 | Data ExtendedData 104 | } 105 | 106 | // Type returns the SSH_FXP_xy value associated with this packet type. 107 | func (p *ExtendedReplyPacket) Type() PacketType { 108 | return PacketTypeExtendedReply 109 | } 110 | 111 | // MarshalPacket returns p as a two-part binary encoding of p. 112 | // 113 | // The Data is marshaled into binary, and returned as the payload. 114 | func (p *ExtendedReplyPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 115 | buf := NewBuffer(b) 116 | if buf.Cap() < 9 { 117 | buf = NewMarshalBuffer(0) 118 | } 119 | 120 | buf.StartPacket(PacketTypeExtendedReply, reqid) 121 | 122 | if p.Data != nil { 123 | payload, err = p.Data.MarshalBinary() 124 | if err != nil { 125 | return nil, nil, err 126 | } 127 | } 128 | 129 | return buf.Packet(payload) 130 | } 131 | 132 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 133 | // It is assumed that the uint32(request-id) has already been consumed. 134 | // 135 | // If p.Data is nil, and there is request-specific-data, 136 | // then the request-specific-data will be wrapped in a Buffer and assigned to p.Data. 137 | func (p *ExtendedReplyPacket) UnmarshalPacketBody(buf *Buffer) (err error) { 138 | if p.Data == nil { 139 | p.Data = new(Buffer) 140 | } 141 | 142 | return p.Data.UnmarshalBinary(buf.Bytes()) 143 | } 144 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/extended_packets_test.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | type testExtendedData struct { 9 | value uint8 10 | } 11 | 12 | func (d *testExtendedData) MarshalBinary() ([]byte, error) { 13 | buf := NewBuffer(make([]byte, 0, 4)) 14 | 15 | buf.AppendUint8(d.value ^ 0x2a) 16 | 17 | return buf.Bytes(), nil 18 | } 19 | 20 | func (d *testExtendedData) UnmarshalBinary(data []byte) error { 21 | buf := NewBuffer(data) 22 | 23 | v := buf.ConsumeUint8() 24 | if buf.Err != nil { 25 | return buf.Err 26 | } 27 | 28 | d.value = v ^ 0x2a 29 | 30 | return nil 31 | } 32 | 33 | var _ Packet = &ExtendedPacket{} 34 | 35 | func TestExtendedPacketNoData(t *testing.T) { 36 | const ( 37 | id = 42 38 | extendedRequest = "foo@example" 39 | ) 40 | 41 | p := &ExtendedPacket{ 42 | ExtendedRequest: extendedRequest, 43 | } 44 | 45 | buf, err := ComposePacket(p.MarshalPacket(id, nil)) 46 | if err != nil { 47 | t.Fatal("unexpected error:", err) 48 | } 49 | 50 | want := []byte{ 51 | 0x00, 0x00, 0x00, 20, 52 | 200, 53 | 0x00, 0x00, 0x00, 42, 54 | 0x00, 0x00, 0x00, 11, 'f', 'o', 'o', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e', 55 | } 56 | 57 | if !bytes.Equal(buf, want) { 58 | t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want) 59 | } 60 | 61 | *p = ExtendedPacket{} 62 | 63 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 64 | if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil { 65 | t.Fatal("unexpected error:", err) 66 | } 67 | 68 | if p.ExtendedRequest != extendedRequest { 69 | t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest) 70 | } 71 | } 72 | 73 | func TestExtendedPacketTestData(t *testing.T) { 74 | const ( 75 | id = 42 76 | extendedRequest = "foo@example" 77 | textValue = 13 78 | ) 79 | 80 | const value = 13 81 | 82 | p := &ExtendedPacket{ 83 | ExtendedRequest: extendedRequest, 84 | Data: &testExtendedData{ 85 | value: textValue, 86 | }, 87 | } 88 | 89 | buf, err := ComposePacket(p.MarshalPacket(id, nil)) 90 | if err != nil { 91 | t.Fatal("unexpected error:", err) 92 | } 93 | 94 | want := []byte{ 95 | 0x00, 0x00, 0x00, 21, 96 | 200, 97 | 0x00, 0x00, 0x00, 42, 98 | 0x00, 0x00, 0x00, 11, 'f', 'o', 'o', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e', 99 | 0x27, 100 | } 101 | 102 | if !bytes.Equal(buf, want) { 103 | t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want) 104 | } 105 | 106 | *p = ExtendedPacket{ 107 | Data: new(testExtendedData), 108 | } 109 | 110 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 111 | if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil { 112 | t.Fatal("unexpected error:", err) 113 | } 114 | 115 | if p.ExtendedRequest != extendedRequest { 116 | t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest) 117 | } 118 | 119 | if buf, ok := p.Data.(*testExtendedData); !ok { 120 | t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, buf) 121 | 122 | } else if buf.value != value { 123 | t.Errorf("UnmarshalPacketBody(): Data.value was %#x, but expected %#x", buf.value, value) 124 | } 125 | 126 | *p = ExtendedPacket{} 127 | 128 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 129 | if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil { 130 | t.Fatal("unexpected error:", err) 131 | } 132 | 133 | if p.ExtendedRequest != extendedRequest { 134 | t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest) 135 | } 136 | 137 | wantBuffer := []byte{0x27} 138 | 139 | if buf, ok := p.Data.(*Buffer); !ok { 140 | t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, buf) 141 | 142 | } else if !bytes.Equal(buf.b, wantBuffer) { 143 | t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", buf.b, wantBuffer) 144 | } 145 | } 146 | 147 | var _ Packet = &ExtendedReplyPacket{} 148 | 149 | func TestExtendedReplyNoData(t *testing.T) { 150 | const ( 151 | id = 42 152 | ) 153 | 154 | p := &ExtendedReplyPacket{} 155 | 156 | buf, err := ComposePacket(p.MarshalPacket(id, nil)) 157 | if err != nil { 158 | t.Fatal("unexpected error:", err) 159 | } 160 | 161 | want := []byte{ 162 | 0x00, 0x00, 0x00, 5, 163 | 201, 164 | 0x00, 0x00, 0x00, 42, 165 | } 166 | 167 | if !bytes.Equal(buf, want) { 168 | t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want) 169 | } 170 | 171 | *p = ExtendedReplyPacket{} 172 | 173 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 174 | if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil { 175 | t.Fatal("unexpected error:", err) 176 | } 177 | } 178 | 179 | func TestExtendedReplyPacketTestData(t *testing.T) { 180 | const ( 181 | id = 42 182 | textValue = 13 183 | ) 184 | 185 | const value = 13 186 | 187 | p := &ExtendedReplyPacket{ 188 | Data: &testExtendedData{ 189 | value: textValue, 190 | }, 191 | } 192 | 193 | buf, err := ComposePacket(p.MarshalPacket(id, nil)) 194 | if err != nil { 195 | t.Fatal("unexpected error:", err) 196 | } 197 | 198 | want := []byte{ 199 | 0x00, 0x00, 0x00, 6, 200 | 201, 201 | 0x00, 0x00, 0x00, 42, 202 | 0x27, 203 | } 204 | 205 | if !bytes.Equal(buf, want) { 206 | t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want) 207 | } 208 | 209 | *p = ExtendedReplyPacket{ 210 | Data: new(testExtendedData), 211 | } 212 | 213 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 214 | if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil { 215 | t.Fatal("unexpected error:", err) 216 | } 217 | 218 | if buf, ok := p.Data.(*testExtendedData); !ok { 219 | t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, buf) 220 | 221 | } else if buf.value != value { 222 | t.Errorf("UnmarshalPacketBody(): Data.value was %#x, but expected %#x", buf.value, value) 223 | } 224 | 225 | *p = ExtendedReplyPacket{} 226 | 227 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 228 | if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil { 229 | t.Fatal("unexpected error:", err) 230 | } 231 | 232 | wantBuffer := []byte{0x27} 233 | 234 | if buf, ok := p.Data.(*Buffer); !ok { 235 | t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, buf) 236 | 237 | } else if !bytes.Equal(buf.b, wantBuffer) { 238 | t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", buf.b, wantBuffer) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/extensions.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | // ExtensionPair defines the extension-pair type defined in draft-ietf-secsh-filexfer-13. 4 | // This type is backwards-compatible with how draft-ietf-secsh-filexfer-02 defines extensions. 5 | // 6 | // Defined in: https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-4.2 7 | type ExtensionPair struct { 8 | Name string 9 | Data string 10 | } 11 | 12 | // Len returns the number of bytes e would marshal into. 13 | func (e *ExtensionPair) Len() int { 14 | return 4 + len(e.Name) + 4 + len(e.Data) 15 | } 16 | 17 | // MarshalInto marshals e onto the end of the given Buffer. 18 | func (e *ExtensionPair) MarshalInto(buf *Buffer) { 19 | buf.AppendString(e.Name) 20 | buf.AppendString(e.Data) 21 | } 22 | 23 | // MarshalBinary returns e as the binary encoding of e. 24 | func (e *ExtensionPair) MarshalBinary() ([]byte, error) { 25 | buf := NewBuffer(make([]byte, 0, e.Len())) 26 | e.MarshalInto(buf) 27 | return buf.Bytes(), nil 28 | } 29 | 30 | // UnmarshalFrom unmarshals an ExtensionPair from the given Buffer into e. 31 | func (e *ExtensionPair) UnmarshalFrom(buf *Buffer) (err error) { 32 | *e = ExtensionPair{ 33 | Name: buf.ConsumeString(), 34 | Data: buf.ConsumeString(), 35 | } 36 | 37 | return buf.Err 38 | } 39 | 40 | // UnmarshalBinary decodes the binary encoding of ExtensionPair into e. 41 | func (e *ExtensionPair) UnmarshalBinary(data []byte) error { 42 | return e.UnmarshalFrom(NewBuffer(data)) 43 | } 44 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/extensions_test.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestExtensionPair(t *testing.T) { 9 | const ( 10 | name = "foo" 11 | data = "1" 12 | ) 13 | 14 | pair := &ExtensionPair{ 15 | Name: name, 16 | Data: data, 17 | } 18 | 19 | buf, err := pair.MarshalBinary() 20 | if err != nil { 21 | t.Fatal("unexpected error:", err) 22 | } 23 | 24 | want := []byte{ 25 | 0x00, 0x00, 0x00, 3, 26 | 'f', 'o', 'o', 27 | 0x00, 0x00, 0x00, 1, 28 | '1', 29 | } 30 | 31 | if !bytes.Equal(buf, want) { 32 | t.Errorf("ExtensionPair.MarshalBinary() = %X, but wanted %X", buf, want) 33 | } 34 | 35 | *pair = ExtensionPair{} 36 | 37 | if err := pair.UnmarshalBinary(buf); err != nil { 38 | t.Fatal("unexpected error:", err) 39 | } 40 | 41 | if pair.Name != name { 42 | t.Errorf("ExtensionPair.UnmarshalBinary(): Name was %q, but expected %q", pair.Name, name) 43 | } 44 | 45 | if pair.Data != data { 46 | t.Errorf("RawPacket.UnmarshalBinary(): Data was %q, but expected %q", pair.Data, data) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/filexfer.go: -------------------------------------------------------------------------------- 1 | // Package sshfx implements the wire encoding for secsh-filexfer as described in https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt 2 | package sshfx 3 | 4 | // PacketMarshaller narrowly defines packets that will only be transmitted. 5 | // 6 | // ExtendedPacket types will often only implement this interface, 7 | // since decoding the whole packet body of an ExtendedPacket can only be done dependent on the ExtendedRequest field. 8 | type PacketMarshaller interface { 9 | // MarshalPacket is the primary intended way to encode a packet. 10 | // The request-id for the packet is set from reqid. 11 | // 12 | // An optional buffer may be given in b. 13 | // If the buffer has a minimum capacity, it shall be truncated and used to marshal the header into. 14 | // The minimum capacity for the packet must be a constant expression, and should be at least 9. 15 | // 16 | // It shall return the main body of the encoded packet in header, 17 | // and may optionally return an additional payload to be written immediately after the header. 18 | // 19 | // It shall encode in the first 4-bytes of the header the proper length of the rest of the header+payload. 20 | MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) 21 | } 22 | 23 | // Packet defines the behavior of a full generic SFTP packet. 24 | // 25 | // InitPacket, and VersionPacket are not generic SFTP packets, and instead implement (Un)MarshalBinary. 26 | // 27 | // ExtendedPacket types should not iplement this interface, 28 | // since decoding the whole packet body of an ExtendedPacket can only be done dependent on the ExtendedRequest field. 29 | type Packet interface { 30 | PacketMarshaller 31 | 32 | // Type returns the SSH_FXP_xy value associated with the specific packet. 33 | Type() PacketType 34 | 35 | // UnmarshalPacketBody decodes a packet body from the given Buffer. 36 | // It is assumed that the common header values of the length, type and request-id have already been consumed. 37 | // 38 | // Implementations should not alias the given Buffer, 39 | // instead they can consider prepopulating an internal buffer as a hint, 40 | // and copying into that buffer if it has sufficient length. 41 | UnmarshalPacketBody(buf *Buffer) error 42 | } 43 | 44 | // ComposePacket converts returns from MarshalPacket into an equivalent call to MarshalBinary. 45 | func ComposePacket(header, payload []byte, err error) ([]byte, error) { 46 | return append(header, payload...), err 47 | } 48 | 49 | // Default length values, 50 | // Defined in draft-ietf-secsh-filexfer-02 section 3. 51 | const ( 52 | DefaultMaxPacketLength = 34000 53 | DefaultMaxDataLength = 32768 54 | ) 55 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/fx.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Status defines the SFTP error codes used in SSH_FXP_STATUS response packets. 8 | type Status uint32 9 | 10 | // Defines the various SSH_FX_* values. 11 | const ( 12 | // see draft-ietf-secsh-filexfer-02 13 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt#section-7 14 | StatusOK = Status(iota) 15 | StatusEOF 16 | StatusNoSuchFile 17 | StatusPermissionDenied 18 | StatusFailure 19 | StatusBadMessage 20 | StatusNoConnection 21 | StatusConnectionLost 22 | StatusOPUnsupported 23 | 24 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-03.txt#section-7 25 | StatusV4InvalidHandle 26 | StatusV4NoSuchPath 27 | StatusV4FileAlreadyExists 28 | StatusV4WriteProtect 29 | 30 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-04.txt#section-7 31 | StatusV4NoMedia 32 | 33 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-05.txt#section-7 34 | StatusV5NoSpaceOnFilesystem 35 | StatusV5QuotaExceeded 36 | StatusV5UnknownPrincipal 37 | StatusV5LockConflict 38 | 39 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-06.txt#section-8 40 | StatusV6DirNotEmpty 41 | StatusV6NotADirectory 42 | StatusV6InvalidFilename 43 | StatusV6LinkLoop 44 | 45 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-07.txt#section-8 46 | StatusV6CannotDelete 47 | StatusV6InvalidParameter 48 | StatusV6FileIsADirectory 49 | StatusV6ByteRangeLockConflict 50 | StatusV6ByteRangeLockRefused 51 | StatusV6DeletePending 52 | 53 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-08.txt#section-8.1 54 | StatusV6FileCorrupt 55 | 56 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-10.txt#section-9.1 57 | StatusV6OwnerInvalid 58 | StatusV6GroupInvalid 59 | 60 | // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-9.1 61 | StatusV6NoMatchingByteRangeLock 62 | ) 63 | 64 | func (s Status) Error() string { 65 | return s.String() 66 | } 67 | 68 | // Is returns true if the target is the same Status code, 69 | // or target is a StatusPacket with the same Status code. 70 | func (s Status) Is(target error) bool { 71 | if target, ok := target.(*StatusPacket); ok { 72 | return target.StatusCode == s 73 | } 74 | 75 | return s == target 76 | } 77 | 78 | func (s Status) String() string { 79 | switch s { 80 | case StatusOK: 81 | return "SSH_FX_OK" 82 | case StatusEOF: 83 | return "SSH_FX_EOF" 84 | case StatusNoSuchFile: 85 | return "SSH_FX_NO_SUCH_FILE" 86 | case StatusPermissionDenied: 87 | return "SSH_FX_PERMISSION_DENIED" 88 | case StatusFailure: 89 | return "SSH_FX_FAILURE" 90 | case StatusBadMessage: 91 | return "SSH_FX_BAD_MESSAGE" 92 | case StatusNoConnection: 93 | return "SSH_FX_NO_CONNECTION" 94 | case StatusConnectionLost: 95 | return "SSH_FX_CONNECTION_LOST" 96 | case StatusOPUnsupported: 97 | return "SSH_FX_OP_UNSUPPORTED" 98 | case StatusV4InvalidHandle: 99 | return "SSH_FX_INVALID_HANDLE" 100 | case StatusV4NoSuchPath: 101 | return "SSH_FX_NO_SUCH_PATH" 102 | case StatusV4FileAlreadyExists: 103 | return "SSH_FX_FILE_ALREADY_EXISTS" 104 | case StatusV4WriteProtect: 105 | return "SSH_FX_WRITE_PROTECT" 106 | case StatusV4NoMedia: 107 | return "SSH_FX_NO_MEDIA" 108 | case StatusV5NoSpaceOnFilesystem: 109 | return "SSH_FX_NO_SPACE_ON_FILESYSTEM" 110 | case StatusV5QuotaExceeded: 111 | return "SSH_FX_QUOTA_EXCEEDED" 112 | case StatusV5UnknownPrincipal: 113 | return "SSH_FX_UNKNOWN_PRINCIPAL" 114 | case StatusV5LockConflict: 115 | return "SSH_FX_LOCK_CONFLICT" 116 | case StatusV6DirNotEmpty: 117 | return "SSH_FX_DIR_NOT_EMPTY" 118 | case StatusV6NotADirectory: 119 | return "SSH_FX_NOT_A_DIRECTORY" 120 | case StatusV6InvalidFilename: 121 | return "SSH_FX_INVALID_FILENAME" 122 | case StatusV6LinkLoop: 123 | return "SSH_FX_LINK_LOOP" 124 | case StatusV6CannotDelete: 125 | return "SSH_FX_CANNOT_DELETE" 126 | case StatusV6InvalidParameter: 127 | return "SSH_FX_INVALID_PARAMETER" 128 | case StatusV6FileIsADirectory: 129 | return "SSH_FX_FILE_IS_A_DIRECTORY" 130 | case StatusV6ByteRangeLockConflict: 131 | return "SSH_FX_BYTE_RANGE_LOCK_CONFLICT" 132 | case StatusV6ByteRangeLockRefused: 133 | return "SSH_FX_BYTE_RANGE_LOCK_REFUSED" 134 | case StatusV6DeletePending: 135 | return "SSH_FX_DELETE_PENDING" 136 | case StatusV6FileCorrupt: 137 | return "SSH_FX_FILE_CORRUPT" 138 | case StatusV6OwnerInvalid: 139 | return "SSH_FX_OWNER_INVALID" 140 | case StatusV6GroupInvalid: 141 | return "SSH_FX_GROUP_INVALID" 142 | case StatusV6NoMatchingByteRangeLock: 143 | return "SSH_FX_NO_MATCHING_BYTE_RANGE_LOCK" 144 | default: 145 | return fmt.Sprintf("SSH_FX_UNKNOWN(%d)", s) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/fx_test.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | // This string data is copied verbatim from https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-13.txt 13 | var fxStandardsText = ` 14 | SSH_FX_OK 0 15 | SSH_FX_EOF 1 16 | SSH_FX_NO_SUCH_FILE 2 17 | SSH_FX_PERMISSION_DENIED 3 18 | SSH_FX_FAILURE 4 19 | SSH_FX_BAD_MESSAGE 5 20 | SSH_FX_NO_CONNECTION 6 21 | SSH_FX_CONNECTION_LOST 7 22 | SSH_FX_OP_UNSUPPORTED 8 23 | SSH_FX_INVALID_HANDLE 9 24 | SSH_FX_NO_SUCH_PATH 10 25 | SSH_FX_FILE_ALREADY_EXISTS 11 26 | SSH_FX_WRITE_PROTECT 12 27 | SSH_FX_NO_MEDIA 13 28 | SSH_FX_NO_SPACE_ON_FILESYSTEM 14 29 | SSH_FX_QUOTA_EXCEEDED 15 30 | SSH_FX_UNKNOWN_PRINCIPAL 16 31 | SSH_FX_LOCK_CONFLICT 17 32 | SSH_FX_DIR_NOT_EMPTY 18 33 | SSH_FX_NOT_A_DIRECTORY 19 34 | SSH_FX_INVALID_FILENAME 20 35 | SSH_FX_LINK_LOOP 21 36 | SSH_FX_CANNOT_DELETE 22 37 | SSH_FX_INVALID_PARAMETER 23 38 | SSH_FX_FILE_IS_A_DIRECTORY 24 39 | SSH_FX_BYTE_RANGE_LOCK_CONFLICT 25 40 | SSH_FX_BYTE_RANGE_LOCK_REFUSED 26 41 | SSH_FX_DELETE_PENDING 27 42 | SSH_FX_FILE_CORRUPT 28 43 | SSH_FX_OWNER_INVALID 29 44 | SSH_FX_GROUP_INVALID 30 45 | SSH_FX_NO_MATCHING_BYTE_RANGE_LOCK 31 46 | ` 47 | 48 | func TestFxNames(t *testing.T) { 49 | whitespace := regexp.MustCompile(`[[:space:]]+`) 50 | 51 | scan := bufio.NewScanner(strings.NewReader(fxStandardsText)) 52 | 53 | for scan.Scan() { 54 | line := scan.Text() 55 | if i := strings.Index(line, "//"); i >= 0 { 56 | line = line[:i] 57 | } 58 | 59 | line = strings.TrimSpace(line) 60 | if line == "" { 61 | continue 62 | } 63 | 64 | fields := whitespace.Split(line, 2) 65 | if len(fields) < 2 { 66 | t.Fatalf("unexpected standards text line: %q", line) 67 | } 68 | 69 | name, value := fields[0], fields[1] 70 | n, err := strconv.Atoi(value) 71 | if err != nil { 72 | t.Fatal("unexpected error:", err) 73 | } 74 | 75 | fx := Status(n) 76 | 77 | if got := fx.String(); got != name { 78 | t.Errorf("fx name mismatch for %d: got %q, but want %q", n, got, name) 79 | } 80 | } 81 | 82 | if err := scan.Err(); err != nil { 83 | t.Fatal("unexpected error:", err) 84 | } 85 | } 86 | 87 | func TestStatusIs(t *testing.T) { 88 | status := StatusFailure 89 | 90 | if !errors.Is(status, StatusFailure) { 91 | t.Error("errors.Is(StatusFailure, StatusFailure) != true") 92 | } 93 | if !errors.Is(status, &StatusPacket{StatusCode: StatusFailure}) { 94 | t.Error("errors.Is(StatusFailure, StatusPacket{StatusFailure}) != true") 95 | } 96 | if errors.Is(status, StatusOK) { 97 | t.Error("errors.Is(StatusFailure, StatusFailure) == true") 98 | } 99 | if errors.Is(status, &StatusPacket{StatusCode: StatusOK}) { 100 | t.Error("errors.Is(StatusFailure, StatusPacket{StatusFailure}) == true") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/fxp.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // PacketType defines the various SFTP packet types. 8 | type PacketType uint8 9 | 10 | // Request packet types. 11 | const ( 12 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt#section-3 13 | PacketTypeInit = PacketType(iota + 1) 14 | PacketTypeVersion 15 | PacketTypeOpen 16 | PacketTypeClose 17 | PacketTypeRead 18 | PacketTypeWrite 19 | PacketTypeLStat 20 | PacketTypeFStat 21 | PacketTypeSetstat 22 | PacketTypeFSetstat 23 | PacketTypeOpenDir 24 | PacketTypeReadDir 25 | PacketTypeRemove 26 | PacketTypeMkdir 27 | PacketTypeRmdir 28 | PacketTypeRealPath 29 | PacketTypeStat 30 | PacketTypeRename 31 | PacketTypeReadLink 32 | PacketTypeSymlink 33 | 34 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-07.txt#section-3.3 35 | PacketTypeV6Link 36 | 37 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-08.txt#section-3.3 38 | PacketTypeV6Block 39 | PacketTypeV6Unblock 40 | ) 41 | 42 | // Response packet types. 43 | const ( 44 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt#section-3 45 | PacketTypeStatus = PacketType(iota + 101) 46 | PacketTypeHandle 47 | PacketTypeData 48 | PacketTypeName 49 | PacketTypeAttrs 50 | ) 51 | 52 | // Extended packet types. 53 | const ( 54 | // https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt#section-3 55 | PacketTypeExtended = PacketType(iota + 200) 56 | PacketTypeExtendedReply 57 | ) 58 | 59 | func (f PacketType) String() string { 60 | switch f { 61 | case PacketTypeInit: 62 | return "SSH_FXP_INIT" 63 | case PacketTypeVersion: 64 | return "SSH_FXP_VERSION" 65 | case PacketTypeOpen: 66 | return "SSH_FXP_OPEN" 67 | case PacketTypeClose: 68 | return "SSH_FXP_CLOSE" 69 | case PacketTypeRead: 70 | return "SSH_FXP_READ" 71 | case PacketTypeWrite: 72 | return "SSH_FXP_WRITE" 73 | case PacketTypeLStat: 74 | return "SSH_FXP_LSTAT" 75 | case PacketTypeFStat: 76 | return "SSH_FXP_FSTAT" 77 | case PacketTypeSetstat: 78 | return "SSH_FXP_SETSTAT" 79 | case PacketTypeFSetstat: 80 | return "SSH_FXP_FSETSTAT" 81 | case PacketTypeOpenDir: 82 | return "SSH_FXP_OPENDIR" 83 | case PacketTypeReadDir: 84 | return "SSH_FXP_READDIR" 85 | case PacketTypeRemove: 86 | return "SSH_FXP_REMOVE" 87 | case PacketTypeMkdir: 88 | return "SSH_FXP_MKDIR" 89 | case PacketTypeRmdir: 90 | return "SSH_FXP_RMDIR" 91 | case PacketTypeRealPath: 92 | return "SSH_FXP_REALPATH" 93 | case PacketTypeStat: 94 | return "SSH_FXP_STAT" 95 | case PacketTypeRename: 96 | return "SSH_FXP_RENAME" 97 | case PacketTypeReadLink: 98 | return "SSH_FXP_READLINK" 99 | case PacketTypeSymlink: 100 | return "SSH_FXP_SYMLINK" 101 | case PacketTypeV6Link: 102 | return "SSH_FXP_LINK" 103 | case PacketTypeV6Block: 104 | return "SSH_FXP_BLOCK" 105 | case PacketTypeV6Unblock: 106 | return "SSH_FXP_UNBLOCK" 107 | case PacketTypeStatus: 108 | return "SSH_FXP_STATUS" 109 | case PacketTypeHandle: 110 | return "SSH_FXP_HANDLE" 111 | case PacketTypeData: 112 | return "SSH_FXP_DATA" 113 | case PacketTypeName: 114 | return "SSH_FXP_NAME" 115 | case PacketTypeAttrs: 116 | return "SSH_FXP_ATTRS" 117 | case PacketTypeExtended: 118 | return "SSH_FXP_EXTENDED" 119 | case PacketTypeExtendedReply: 120 | return "SSH_FXP_EXTENDED_REPLY" 121 | default: 122 | return fmt.Sprintf("SSH_FXP_UNKNOWN(%d)", f) 123 | } 124 | } 125 | 126 | func newPacketFromType(typ PacketType) (Packet, error) { 127 | switch typ { 128 | case PacketTypeOpen: 129 | return new(OpenPacket), nil 130 | case PacketTypeClose: 131 | return new(ClosePacket), nil 132 | case PacketTypeRead: 133 | return new(ReadPacket), nil 134 | case PacketTypeWrite: 135 | return new(WritePacket), nil 136 | case PacketTypeLStat: 137 | return new(LStatPacket), nil 138 | case PacketTypeFStat: 139 | return new(FStatPacket), nil 140 | case PacketTypeSetstat: 141 | return new(SetstatPacket), nil 142 | case PacketTypeFSetstat: 143 | return new(FSetstatPacket), nil 144 | case PacketTypeOpenDir: 145 | return new(OpenDirPacket), nil 146 | case PacketTypeReadDir: 147 | return new(ReadDirPacket), nil 148 | case PacketTypeRemove: 149 | return new(RemovePacket), nil 150 | case PacketTypeMkdir: 151 | return new(MkdirPacket), nil 152 | case PacketTypeRmdir: 153 | return new(RmdirPacket), nil 154 | case PacketTypeRealPath: 155 | return new(RealPathPacket), nil 156 | case PacketTypeStat: 157 | return new(StatPacket), nil 158 | case PacketTypeRename: 159 | return new(RenamePacket), nil 160 | case PacketTypeReadLink: 161 | return new(ReadLinkPacket), nil 162 | case PacketTypeSymlink: 163 | return new(SymlinkPacket), nil 164 | case PacketTypeExtended: 165 | return new(ExtendedPacket), nil 166 | default: 167 | return nil, fmt.Errorf("unexpected request packet type: %v", typ) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/fxp_test.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "bufio" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | // This string data is copied verbatim from https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-13.txt 12 | // except where commented that it was taken from a different source. 13 | var fxpStandardsText = ` 14 | SSH_FXP_INIT 1 15 | SSH_FXP_VERSION 2 16 | SSH_FXP_OPEN 3 17 | SSH_FXP_CLOSE 4 18 | SSH_FXP_READ 5 19 | SSH_FXP_WRITE 6 20 | SSH_FXP_LSTAT 7 21 | SSH_FXP_FSTAT 8 22 | SSH_FXP_SETSTAT 9 23 | SSH_FXP_FSETSTAT 10 24 | SSH_FXP_OPENDIR 11 25 | SSH_FXP_READDIR 12 26 | SSH_FXP_REMOVE 13 27 | SSH_FXP_MKDIR 14 28 | SSH_FXP_RMDIR 15 29 | SSH_FXP_REALPATH 16 30 | SSH_FXP_STAT 17 31 | SSH_FXP_RENAME 18 32 | SSH_FXP_READLINK 19 33 | SSH_FXP_SYMLINK 20 // Deprecated in filexfer-13 added from filexfer-02 34 | SSH_FXP_LINK 21 35 | SSH_FXP_BLOCK 22 36 | SSH_FXP_UNBLOCK 23 37 | 38 | SSH_FXP_STATUS 101 39 | SSH_FXP_HANDLE 102 40 | SSH_FXP_DATA 103 41 | SSH_FXP_NAME 104 42 | SSH_FXP_ATTRS 105 43 | 44 | SSH_FXP_EXTENDED 200 45 | SSH_FXP_EXTENDED_REPLY 201 46 | ` 47 | 48 | func TestFxpNames(t *testing.T) { 49 | whitespace := regexp.MustCompile(`[[:space:]]+`) 50 | 51 | scan := bufio.NewScanner(strings.NewReader(fxpStandardsText)) 52 | 53 | for scan.Scan() { 54 | line := scan.Text() 55 | if i := strings.Index(line, "//"); i >= 0 { 56 | line = line[:i] 57 | } 58 | 59 | line = strings.TrimSpace(line) 60 | if line == "" { 61 | continue 62 | } 63 | 64 | fields := whitespace.Split(line, 2) 65 | if len(fields) < 2 { 66 | t.Fatalf("unexpected standards text line: %q", line) 67 | } 68 | 69 | name, value := fields[0], fields[1] 70 | n, err := strconv.Atoi(value) 71 | if err != nil { 72 | t.Fatal("unexpected error:", err) 73 | } 74 | 75 | fxp := PacketType(n) 76 | 77 | if got := fxp.String(); got != name { 78 | t.Errorf("fxp name mismatch for %d: got %q, but want %q", n, got, name) 79 | } 80 | } 81 | 82 | if err := scan.Err(); err != nil { 83 | t.Fatal("unexpected error:", err) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/handle_packets.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | // ClosePacket defines the SSH_FXP_CLOSE packet. 4 | type ClosePacket struct { 5 | Handle string 6 | } 7 | 8 | // Type returns the SSH_FXP_xy value associated with this packet type. 9 | func (p *ClosePacket) Type() PacketType { 10 | return PacketTypeClose 11 | } 12 | 13 | // MarshalPacket returns p as a two-part binary encoding of p. 14 | func (p *ClosePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 15 | buf := NewBuffer(b) 16 | if buf.Cap() < 9 { 17 | size := 4 + len(p.Handle) // string(handle) 18 | buf = NewMarshalBuffer(size) 19 | } 20 | 21 | buf.StartPacket(PacketTypeClose, reqid) 22 | buf.AppendString(p.Handle) 23 | 24 | return buf.Packet(payload) 25 | } 26 | 27 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 28 | // It is assumed that the uint32(request-id) has already been consumed. 29 | func (p *ClosePacket) UnmarshalPacketBody(buf *Buffer) (err error) { 30 | *p = ClosePacket{ 31 | Handle: buf.ConsumeString(), 32 | } 33 | 34 | return buf.Err 35 | } 36 | 37 | // ReadPacket defines the SSH_FXP_READ packet. 38 | type ReadPacket struct { 39 | Handle string 40 | Offset uint64 41 | Length uint32 42 | } 43 | 44 | // Type returns the SSH_FXP_xy value associated with this packet type. 45 | func (p *ReadPacket) Type() PacketType { 46 | return PacketTypeRead 47 | } 48 | 49 | // MarshalPacket returns p as a two-part binary encoding of p. 50 | func (p *ReadPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 51 | buf := NewBuffer(b) 52 | if buf.Cap() < 9 { 53 | // string(handle) + uint64(offset) + uint32(len) 54 | size := 4 + len(p.Handle) + 8 + 4 55 | buf = NewMarshalBuffer(size) 56 | } 57 | 58 | buf.StartPacket(PacketTypeRead, reqid) 59 | buf.AppendString(p.Handle) 60 | buf.AppendUint64(p.Offset) 61 | buf.AppendUint32(p.Length) 62 | 63 | return buf.Packet(payload) 64 | } 65 | 66 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 67 | // It is assumed that the uint32(request-id) has already been consumed. 68 | func (p *ReadPacket) UnmarshalPacketBody(buf *Buffer) (err error) { 69 | *p = ReadPacket{ 70 | Handle: buf.ConsumeString(), 71 | Offset: buf.ConsumeUint64(), 72 | Length: buf.ConsumeUint32(), 73 | } 74 | 75 | return buf.Err 76 | } 77 | 78 | // WritePacket defines the SSH_FXP_WRITE packet. 79 | type WritePacket struct { 80 | Handle string 81 | Offset uint64 82 | Data []byte 83 | } 84 | 85 | // Type returns the SSH_FXP_xy value associated with this packet type. 86 | func (p *WritePacket) Type() PacketType { 87 | return PacketTypeWrite 88 | } 89 | 90 | // MarshalPacket returns p as a two-part binary encoding of p. 91 | func (p *WritePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 92 | buf := NewBuffer(b) 93 | if buf.Cap() < 9 { 94 | // string(handle) + uint64(offset) + uint32(len(data)); data content in payload 95 | size := 4 + len(p.Handle) + 8 + 4 96 | buf = NewMarshalBuffer(size) 97 | } 98 | 99 | buf.StartPacket(PacketTypeWrite, reqid) 100 | buf.AppendString(p.Handle) 101 | buf.AppendUint64(p.Offset) 102 | buf.AppendUint32(uint32(len(p.Data))) 103 | 104 | return buf.Packet(p.Data) 105 | } 106 | 107 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 108 | // It is assumed that the uint32(request-id) has already been consumed. 109 | // 110 | // If p.Data is already populated, and of sufficient length to hold the data, 111 | // then this will copy the data into that byte slice. 112 | // 113 | // If p.Data has a length insufficient to hold the data, 114 | // then this will make a new slice of sufficient length, and copy the data into that. 115 | // 116 | // This means this _does not_ alias any of the data buffer that is passed in. 117 | func (p *WritePacket) UnmarshalPacketBody(buf *Buffer) (err error) { 118 | *p = WritePacket{ 119 | Handle: buf.ConsumeString(), 120 | Offset: buf.ConsumeUint64(), 121 | Data: buf.ConsumeByteSliceCopy(p.Data), 122 | } 123 | 124 | return buf.Err 125 | } 126 | 127 | // FStatPacket defines the SSH_FXP_FSTAT packet. 128 | type FStatPacket struct { 129 | Handle string 130 | } 131 | 132 | // Type returns the SSH_FXP_xy value associated with this packet type. 133 | func (p *FStatPacket) Type() PacketType { 134 | return PacketTypeFStat 135 | } 136 | 137 | // MarshalPacket returns p as a two-part binary encoding of p. 138 | func (p *FStatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 139 | buf := NewBuffer(b) 140 | if buf.Cap() < 9 { 141 | size := 4 + len(p.Handle) // string(handle) 142 | buf = NewMarshalBuffer(size) 143 | } 144 | 145 | buf.StartPacket(PacketTypeFStat, reqid) 146 | buf.AppendString(p.Handle) 147 | 148 | return buf.Packet(payload) 149 | } 150 | 151 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 152 | // It is assumed that the uint32(request-id) has already been consumed. 153 | func (p *FStatPacket) UnmarshalPacketBody(buf *Buffer) (err error) { 154 | *p = FStatPacket{ 155 | Handle: buf.ConsumeString(), 156 | } 157 | 158 | return buf.Err 159 | } 160 | 161 | // FSetstatPacket defines the SSH_FXP_FSETSTAT packet. 162 | type FSetstatPacket struct { 163 | Handle string 164 | Attrs Attributes 165 | } 166 | 167 | // Type returns the SSH_FXP_xy value associated with this packet type. 168 | func (p *FSetstatPacket) Type() PacketType { 169 | return PacketTypeFSetstat 170 | } 171 | 172 | // MarshalPacket returns p as a two-part binary encoding of p. 173 | func (p *FSetstatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 174 | buf := NewBuffer(b) 175 | if buf.Cap() < 9 { 176 | size := 4 + len(p.Handle) + p.Attrs.Len() // string(handle) + ATTRS(attrs) 177 | buf = NewMarshalBuffer(size) 178 | } 179 | 180 | buf.StartPacket(PacketTypeFSetstat, reqid) 181 | buf.AppendString(p.Handle) 182 | 183 | p.Attrs.MarshalInto(buf) 184 | 185 | return buf.Packet(payload) 186 | } 187 | 188 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 189 | // It is assumed that the uint32(request-id) has already been consumed. 190 | func (p *FSetstatPacket) UnmarshalPacketBody(buf *Buffer) (err error) { 191 | *p = FSetstatPacket{ 192 | Handle: buf.ConsumeString(), 193 | } 194 | 195 | return p.Attrs.UnmarshalFrom(buf) 196 | } 197 | 198 | // ReadDirPacket defines the SSH_FXP_READDIR packet. 199 | type ReadDirPacket struct { 200 | Handle string 201 | } 202 | 203 | // Type returns the SSH_FXP_xy value associated with this packet type. 204 | func (p *ReadDirPacket) Type() PacketType { 205 | return PacketTypeReadDir 206 | } 207 | 208 | // MarshalPacket returns p as a two-part binary encoding of p. 209 | func (p *ReadDirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 210 | buf := NewBuffer(b) 211 | if buf.Cap() < 9 { 212 | size := 4 + len(p.Handle) // string(handle) 213 | buf = NewMarshalBuffer(size) 214 | } 215 | 216 | buf.StartPacket(PacketTypeReadDir, reqid) 217 | buf.AppendString(p.Handle) 218 | 219 | return buf.Packet(payload) 220 | } 221 | 222 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 223 | // It is assumed that the uint32(request-id) has already been consumed. 224 | func (p *ReadDirPacket) UnmarshalPacketBody(buf *Buffer) (err error) { 225 | *p = ReadDirPacket{ 226 | Handle: buf.ConsumeString(), 227 | } 228 | 229 | return buf.Err 230 | } 231 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/init_packets.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | // InitPacket defines the SSH_FXP_INIT packet. 4 | type InitPacket struct { 5 | Version uint32 6 | Extensions []*ExtensionPair 7 | } 8 | 9 | // MarshalBinary returns p as the binary encoding of p. 10 | func (p *InitPacket) MarshalBinary() ([]byte, error) { 11 | size := 1 + 4 // byte(type) + uint32(version) 12 | 13 | for _, ext := range p.Extensions { 14 | size += ext.Len() 15 | } 16 | 17 | b := NewBuffer(make([]byte, 4, 4+size)) 18 | b.AppendUint8(uint8(PacketTypeInit)) 19 | b.AppendUint32(p.Version) 20 | 21 | for _, ext := range p.Extensions { 22 | ext.MarshalInto(b) 23 | } 24 | 25 | b.PutLength(size) 26 | 27 | return b.Bytes(), nil 28 | } 29 | 30 | // UnmarshalBinary unmarshals a full raw packet out of the given data. 31 | // It is assumed that the uint32(length) has already been consumed to receive the data. 32 | // It is also assumed that the uint8(type) has already been consumed to which packet to unmarshal into. 33 | func (p *InitPacket) UnmarshalBinary(data []byte) (err error) { 34 | buf := NewBuffer(data) 35 | 36 | *p = InitPacket{ 37 | Version: buf.ConsumeUint32(), 38 | } 39 | 40 | for buf.Len() > 0 { 41 | var ext ExtensionPair 42 | if err := ext.UnmarshalFrom(buf); err != nil { 43 | return err 44 | } 45 | 46 | p.Extensions = append(p.Extensions, &ext) 47 | } 48 | 49 | return buf.Err 50 | } 51 | 52 | // VersionPacket defines the SSH_FXP_VERSION packet. 53 | type VersionPacket struct { 54 | Version uint32 55 | Extensions []*ExtensionPair 56 | } 57 | 58 | // MarshalBinary returns p as the binary encoding of p. 59 | func (p *VersionPacket) MarshalBinary() ([]byte, error) { 60 | size := 1 + 4 // byte(type) + uint32(version) 61 | 62 | for _, ext := range p.Extensions { 63 | size += ext.Len() 64 | } 65 | 66 | b := NewBuffer(make([]byte, 4, 4+size)) 67 | b.AppendUint8(uint8(PacketTypeVersion)) 68 | b.AppendUint32(p.Version) 69 | 70 | for _, ext := range p.Extensions { 71 | ext.MarshalInto(b) 72 | } 73 | 74 | b.PutLength(size) 75 | 76 | return b.Bytes(), nil 77 | } 78 | 79 | // UnmarshalBinary unmarshals a full raw packet out of the given data. 80 | // It is assumed that the uint32(length) has already been consumed to receive the data. 81 | // It is also assumed that the uint8(type) has already been consumed to which packet to unmarshal into. 82 | func (p *VersionPacket) UnmarshalBinary(data []byte) (err error) { 83 | buf := NewBuffer(data) 84 | 85 | *p = VersionPacket{ 86 | Version: buf.ConsumeUint32(), 87 | } 88 | 89 | for buf.Len() > 0 { 90 | var ext ExtensionPair 91 | if err := ext.UnmarshalFrom(buf); err != nil { 92 | return err 93 | } 94 | 95 | p.Extensions = append(p.Extensions, &ext) 96 | } 97 | 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/init_packets_test.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestInitPacket(t *testing.T) { 9 | var version uint8 = 3 10 | 11 | p := &InitPacket{ 12 | Version: uint32(version), 13 | Extensions: []*ExtensionPair{ 14 | { 15 | Name: "foo", 16 | Data: "1", 17 | }, 18 | }, 19 | } 20 | 21 | buf, err := p.MarshalBinary() 22 | if err != nil { 23 | t.Fatal("unexpected error:", err) 24 | } 25 | 26 | want := []byte{ 27 | 0x00, 0x00, 0x00, 17, 28 | 1, 29 | 0x00, 0x00, 0x00, version, 30 | 0x00, 0x00, 0x00, 3, 'f', 'o', 'o', 31 | 0x00, 0x00, 0x00, 1, '1', 32 | } 33 | 34 | if !bytes.Equal(buf, want) { 35 | t.Fatalf("MarshalBinary() = %X, but wanted %X", buf, want) 36 | } 37 | 38 | *p = InitPacket{} 39 | 40 | // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed. 41 | if err := p.UnmarshalBinary(buf[5:]); err != nil { 42 | t.Fatal("unexpected error:", err) 43 | } 44 | 45 | if p.Version != uint32(version) { 46 | t.Errorf("UnmarshalBinary(): Version was %d, but expected %d", p.Version, version) 47 | } 48 | 49 | if len(p.Extensions) != 1 { 50 | t.Fatalf("UnmarshalBinary(): len(p.Extensions) was %d, but expected %d", len(p.Extensions), 1) 51 | } 52 | 53 | if got, want := p.Extensions[0].Name, "foo"; got != want { 54 | t.Errorf("UnmarshalBinary(): p.Extensions[0].Name was %q, but expected %q", got, want) 55 | } 56 | 57 | if got, want := p.Extensions[0].Data, "1"; got != want { 58 | t.Errorf("UnmarshalBinary(): p.Extensions[0].Data was %q, but expected %q", got, want) 59 | } 60 | } 61 | 62 | func TestVersionPacket(t *testing.T) { 63 | var version uint8 = 3 64 | 65 | p := &VersionPacket{ 66 | Version: uint32(version), 67 | Extensions: []*ExtensionPair{ 68 | { 69 | Name: "foo", 70 | Data: "1", 71 | }, 72 | }, 73 | } 74 | 75 | buf, err := p.MarshalBinary() 76 | if err != nil { 77 | t.Fatal("unexpected error:", err) 78 | } 79 | 80 | want := []byte{ 81 | 0x00, 0x00, 0x00, 17, 82 | 2, 83 | 0x00, 0x00, 0x00, version, 84 | 0x00, 0x00, 0x00, 3, 'f', 'o', 'o', 85 | 0x00, 0x00, 0x00, 1, '1', 86 | } 87 | 88 | if !bytes.Equal(buf, want) { 89 | t.Fatalf("MarshalBinary() = %X, but wanted %X", buf, want) 90 | } 91 | 92 | *p = VersionPacket{} 93 | 94 | // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed. 95 | if err := p.UnmarshalBinary(buf[5:]); err != nil { 96 | t.Fatal("unexpected error:", err) 97 | } 98 | 99 | if p.Version != uint32(version) { 100 | t.Errorf("UnmarshalBinary(): Version was %d, but expected %d", p.Version, version) 101 | } 102 | 103 | if len(p.Extensions) != 1 { 104 | t.Fatalf("UnmarshalBinary(): len(p.Extensions) was %d, but expected %d", len(p.Extensions), 1) 105 | } 106 | 107 | if got, want := p.Extensions[0].Name, "foo"; got != want { 108 | t.Errorf("UnmarshalBinary(): p.Extensions[0].Name was %q, but expected %q", got, want) 109 | } 110 | 111 | if got, want := p.Extensions[0].Data, "1"; got != want { 112 | t.Errorf("UnmarshalBinary(): p.Extensions[0].Data was %q, but expected %q", got, want) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/open_packets.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | // SSH_FXF_* flags. 4 | const ( 5 | FlagRead = 1 << iota // SSH_FXF_READ 6 | FlagWrite // SSH_FXF_WRITE 7 | FlagAppend // SSH_FXF_APPEND 8 | FlagCreate // SSH_FXF_CREAT 9 | FlagTruncate // SSH_FXF_TRUNC 10 | FlagExclusive // SSH_FXF_EXCL 11 | ) 12 | 13 | // OpenPacket defines the SSH_FXP_OPEN packet. 14 | type OpenPacket struct { 15 | Filename string 16 | PFlags uint32 17 | Attrs Attributes 18 | } 19 | 20 | // Type returns the SSH_FXP_xy value associated with this packet type. 21 | func (p *OpenPacket) Type() PacketType { 22 | return PacketTypeOpen 23 | } 24 | 25 | // MarshalPacket returns p as a two-part binary encoding of p. 26 | func (p *OpenPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 27 | buf := NewBuffer(b) 28 | if buf.Cap() < 9 { 29 | // string(filename) + uint32(pflags) + ATTRS(attrs) 30 | size := 4 + len(p.Filename) + 4 + p.Attrs.Len() 31 | buf = NewMarshalBuffer(size) 32 | } 33 | 34 | buf.StartPacket(PacketTypeOpen, reqid) 35 | buf.AppendString(p.Filename) 36 | buf.AppendUint32(p.PFlags) 37 | 38 | p.Attrs.MarshalInto(buf) 39 | 40 | return buf.Packet(payload) 41 | } 42 | 43 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 44 | // It is assumed that the uint32(request-id) has already been consumed. 45 | func (p *OpenPacket) UnmarshalPacketBody(buf *Buffer) (err error) { 46 | *p = OpenPacket{ 47 | Filename: buf.ConsumeString(), 48 | PFlags: buf.ConsumeUint32(), 49 | } 50 | 51 | return p.Attrs.UnmarshalFrom(buf) 52 | } 53 | 54 | // OpenDirPacket defines the SSH_FXP_OPENDIR packet. 55 | type OpenDirPacket struct { 56 | Path string 57 | } 58 | 59 | // Type returns the SSH_FXP_xy value associated with this packet type. 60 | func (p *OpenDirPacket) Type() PacketType { 61 | return PacketTypeOpenDir 62 | } 63 | 64 | // MarshalPacket returns p as a two-part binary encoding of p. 65 | func (p *OpenDirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 66 | buf := NewBuffer(b) 67 | if buf.Cap() < 9 { 68 | size := 4 + len(p.Path) // string(path) 69 | buf = NewMarshalBuffer(size) 70 | } 71 | 72 | buf.StartPacket(PacketTypeOpenDir, reqid) 73 | buf.AppendString(p.Path) 74 | 75 | return buf.Packet(payload) 76 | } 77 | 78 | // UnmarshalPacketBody unmarshals the packet body from the given Buffer. 79 | // It is assumed that the uint32(request-id) has already been consumed. 80 | func (p *OpenDirPacket) UnmarshalPacketBody(buf *Buffer) (err error) { 81 | *p = OpenDirPacket{ 82 | Path: buf.ConsumeString(), 83 | } 84 | 85 | return buf.Err 86 | } 87 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/open_packets_test.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | var _ Packet = &OpenPacket{} 9 | 10 | func TestOpenPacket(t *testing.T) { 11 | const ( 12 | id = 42 13 | filename = "/foo" 14 | perms FileMode = 0x87654321 15 | ) 16 | 17 | p := &OpenPacket{ 18 | Filename: "/foo", 19 | PFlags: FlagRead, 20 | Attrs: Attributes{ 21 | Flags: AttrPermissions, 22 | Permissions: perms, 23 | }, 24 | } 25 | 26 | buf, err := ComposePacket(p.MarshalPacket(id, nil)) 27 | if err != nil { 28 | t.Fatal("unexpected error:", err) 29 | } 30 | 31 | want := []byte{ 32 | 0x00, 0x00, 0x00, 25, 33 | 3, 34 | 0x00, 0x00, 0x00, 42, 35 | 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', 36 | 0x00, 0x00, 0x00, 1, 37 | 0x00, 0x00, 0x00, 0x04, 38 | 0x87, 0x65, 0x43, 0x21, 39 | } 40 | 41 | if !bytes.Equal(buf, want) { 42 | t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want) 43 | } 44 | 45 | *p = OpenPacket{} 46 | 47 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 48 | if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil { 49 | t.Fatal("unexpected error:", err) 50 | } 51 | 52 | if p.Filename != filename { 53 | t.Errorf("UnmarshalPacketBody(): Filename was %q, but expected %q", p.Filename, filename) 54 | } 55 | 56 | if p.PFlags != FlagRead { 57 | t.Errorf("UnmarshalPacketBody(): PFlags was %#x, but expected %#x", p.PFlags, FlagRead) 58 | } 59 | 60 | if p.Attrs.Flags != AttrPermissions { 61 | t.Errorf("UnmarshalPacketBody(): Attrs.Flags was %#x, but expected %#x", p.Attrs.Flags, AttrPermissions) 62 | } 63 | 64 | if p.Attrs.Permissions != perms { 65 | t.Errorf("UnmarshalPacketBody(): Attrs.Permissions was %#v, but expected %#v", p.Attrs.Permissions, perms) 66 | } 67 | } 68 | 69 | var _ Packet = &OpenDirPacket{} 70 | 71 | func TestOpenDirPacket(t *testing.T) { 72 | const ( 73 | id = 42 74 | path = "/foo" 75 | ) 76 | 77 | p := &OpenDirPacket{ 78 | Path: path, 79 | } 80 | 81 | buf, err := ComposePacket(p.MarshalPacket(id, nil)) 82 | if err != nil { 83 | t.Fatal("unexpected error:", err) 84 | } 85 | 86 | want := []byte{ 87 | 0x00, 0x00, 0x00, 13, 88 | 11, 89 | 0x00, 0x00, 0x00, 42, 90 | 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', 91 | } 92 | 93 | if !bytes.Equal(buf, want) { 94 | t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want) 95 | } 96 | 97 | *p = OpenDirPacket{} 98 | 99 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 100 | if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil { 101 | t.Fatal("unexpected error:", err) 102 | } 103 | 104 | if p.Path != path { 105 | t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/openssh/fsync.go: -------------------------------------------------------------------------------- 1 | package openssh 2 | 3 | import ( 4 | sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" 5 | ) 6 | 7 | const extensionFSync = "fsync@openssh.com" 8 | 9 | // RegisterExtensionFSync registers the "fsync@openssh.com" extended packet with the encoding/ssh/filexfer package. 10 | func RegisterExtensionFSync() { 11 | sshfx.RegisterExtendedPacketType(extensionFSync, func() sshfx.ExtendedData { 12 | return new(FSyncExtendedPacket) 13 | }) 14 | } 15 | 16 | // ExtensionFSync returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket. 17 | func ExtensionFSync() *sshfx.ExtensionPair { 18 | return &sshfx.ExtensionPair{ 19 | Name: extensionFSync, 20 | Data: "1", 21 | } 22 | } 23 | 24 | // FSyncExtendedPacket defines the fsync@openssh.com extend packet. 25 | type FSyncExtendedPacket struct { 26 | Handle string 27 | } 28 | 29 | // Type returns the SSH_FXP_EXTENDED packet type. 30 | func (ep *FSyncExtendedPacket) Type() sshfx.PacketType { 31 | return sshfx.PacketTypeExtended 32 | } 33 | 34 | // MarshalPacket returns ep as a two-part binary encoding of the full extended packet. 35 | func (ep *FSyncExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 36 | p := &sshfx.ExtendedPacket{ 37 | ExtendedRequest: extensionFSync, 38 | 39 | Data: ep, 40 | } 41 | return p.MarshalPacket(reqid, b) 42 | } 43 | 44 | // MarshalInto encodes ep into the binary encoding of the fsync@openssh.com extended packet-specific data. 45 | func (ep *FSyncExtendedPacket) MarshalInto(buf *sshfx.Buffer) { 46 | buf.AppendString(ep.Handle) 47 | } 48 | 49 | // MarshalBinary encodes ep into the binary encoding of the fsync@openssh.com extended packet-specific data. 50 | // 51 | // NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet. 52 | func (ep *FSyncExtendedPacket) MarshalBinary() ([]byte, error) { 53 | // string(handle) 54 | size := 4 + len(ep.Handle) 55 | 56 | buf := sshfx.NewBuffer(make([]byte, 0, size)) 57 | ep.MarshalInto(buf) 58 | return buf.Bytes(), nil 59 | } 60 | 61 | // UnmarshalFrom decodes the fsync@openssh.com extended packet-specific data from buf. 62 | func (ep *FSyncExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) { 63 | *ep = FSyncExtendedPacket{ 64 | Handle: buf.ConsumeString(), 65 | } 66 | 67 | return buf.Err 68 | } 69 | 70 | // UnmarshalBinary decodes the fsync@openssh.com extended packet-specific data into ep. 71 | func (ep *FSyncExtendedPacket) UnmarshalBinary(data []byte) (err error) { 72 | return ep.UnmarshalFrom(sshfx.NewBuffer(data)) 73 | } 74 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/openssh/fsync_test.go: -------------------------------------------------------------------------------- 1 | package openssh 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" 8 | ) 9 | 10 | var _ sshfx.PacketMarshaller = &FSyncExtendedPacket{} 11 | 12 | func init() { 13 | RegisterExtensionFSync() 14 | } 15 | 16 | func TestFSyncExtendedPacket(t *testing.T) { 17 | const ( 18 | id = 42 19 | handle = "somehandle" 20 | ) 21 | 22 | ep := &FSyncExtendedPacket{ 23 | Handle: handle, 24 | } 25 | 26 | data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil)) 27 | if err != nil { 28 | t.Fatal("unexpected error:", err) 29 | } 30 | 31 | want := []byte{ 32 | 0x00, 0x00, 0x00, 40, 33 | 200, 34 | 0x00, 0x00, 0x00, 42, 35 | 0x00, 0x00, 0x00, 17, 'f', 's', 'y', 'n', 'c', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm', 36 | 0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e', 37 | } 38 | 39 | if !bytes.Equal(data, want) { 40 | t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want) 41 | } 42 | 43 | var p sshfx.ExtendedPacket 44 | 45 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 46 | if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil { 47 | t.Fatal("unexpected error:", err) 48 | } 49 | 50 | if p.ExtendedRequest != extensionFSync { 51 | t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionFSync) 52 | } 53 | 54 | ep, ok := p.Data.(*FSyncExtendedPacket) 55 | if !ok { 56 | t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *FSyncExtendedPacket", p.Data) 57 | } 58 | 59 | if ep.Handle != handle { 60 | t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", ep.Handle, handle) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/openssh/hardlink.go: -------------------------------------------------------------------------------- 1 | package openssh 2 | 3 | import ( 4 | sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" 5 | ) 6 | 7 | const extensionHardlink = "hardlink@openssh.com" 8 | 9 | // RegisterExtensionHardlink registers the "hardlink@openssh.com" extended packet with the encoding/ssh/filexfer package. 10 | func RegisterExtensionHardlink() { 11 | sshfx.RegisterExtendedPacketType(extensionHardlink, func() sshfx.ExtendedData { 12 | return new(HardlinkExtendedPacket) 13 | }) 14 | } 15 | 16 | // ExtensionHardlink returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket. 17 | func ExtensionHardlink() *sshfx.ExtensionPair { 18 | return &sshfx.ExtensionPair{ 19 | Name: extensionHardlink, 20 | Data: "1", 21 | } 22 | } 23 | 24 | // HardlinkExtendedPacket defines the hardlink@openssh.com extend packet. 25 | type HardlinkExtendedPacket struct { 26 | OldPath string 27 | NewPath string 28 | } 29 | 30 | // Type returns the SSH_FXP_EXTENDED packet type. 31 | func (ep *HardlinkExtendedPacket) Type() sshfx.PacketType { 32 | return sshfx.PacketTypeExtended 33 | } 34 | 35 | // MarshalPacket returns ep as a two-part binary encoding of the full extended packet. 36 | func (ep *HardlinkExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 37 | p := &sshfx.ExtendedPacket{ 38 | ExtendedRequest: extensionHardlink, 39 | 40 | Data: ep, 41 | } 42 | return p.MarshalPacket(reqid, b) 43 | } 44 | 45 | // MarshalInto encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data. 46 | func (ep *HardlinkExtendedPacket) MarshalInto(buf *sshfx.Buffer) { 47 | buf.AppendString(ep.OldPath) 48 | buf.AppendString(ep.NewPath) 49 | } 50 | 51 | // MarshalBinary encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data. 52 | // 53 | // NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet. 54 | func (ep *HardlinkExtendedPacket) MarshalBinary() ([]byte, error) { 55 | // string(oldpath) + string(newpath) 56 | size := 4 + len(ep.OldPath) + 4 + len(ep.NewPath) 57 | 58 | buf := sshfx.NewBuffer(make([]byte, 0, size)) 59 | ep.MarshalInto(buf) 60 | return buf.Bytes(), nil 61 | } 62 | 63 | // UnmarshalFrom decodes the hardlink@openssh.com extended packet-specific data from buf. 64 | func (ep *HardlinkExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) { 65 | *ep = HardlinkExtendedPacket{ 66 | OldPath: buf.ConsumeString(), 67 | NewPath: buf.ConsumeString(), 68 | } 69 | 70 | return buf.Err 71 | } 72 | 73 | // UnmarshalBinary decodes the hardlink@openssh.com extended packet-specific data into ep. 74 | func (ep *HardlinkExtendedPacket) UnmarshalBinary(data []byte) (err error) { 75 | return ep.UnmarshalFrom(sshfx.NewBuffer(data)) 76 | } 77 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/openssh/hardlink_test.go: -------------------------------------------------------------------------------- 1 | package openssh 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" 8 | ) 9 | 10 | var _ sshfx.PacketMarshaller = &HardlinkExtendedPacket{} 11 | 12 | func init() { 13 | RegisterExtensionHardlink() 14 | } 15 | 16 | func TestHardlinkExtendedPacket(t *testing.T) { 17 | const ( 18 | id = 42 19 | oldpath = "/foo" 20 | newpath = "/bar" 21 | ) 22 | 23 | ep := &HardlinkExtendedPacket{ 24 | OldPath: oldpath, 25 | NewPath: newpath, 26 | } 27 | 28 | data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil)) 29 | if err != nil { 30 | t.Fatal("unexpected error:", err) 31 | } 32 | 33 | want := []byte{ 34 | 0x00, 0x00, 0x00, 45, 35 | 200, 36 | 0x00, 0x00, 0x00, 42, 37 | 0x00, 0x00, 0x00, 20, 'h', 'a', 'r', 'd', 'l', 'i', 'n', 'k', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm', 38 | 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', 39 | 0x00, 0x00, 0x00, 4, '/', 'b', 'a', 'r', 40 | } 41 | 42 | if !bytes.Equal(data, want) { 43 | t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want) 44 | } 45 | 46 | var p sshfx.ExtendedPacket 47 | 48 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 49 | if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil { 50 | t.Fatal("unexpected error:", err) 51 | } 52 | 53 | if p.ExtendedRequest != extensionHardlink { 54 | t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionHardlink) 55 | } 56 | 57 | ep, ok := p.Data.(*HardlinkExtendedPacket) 58 | if !ok { 59 | t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *HardlinkExtendedPacket", p.Data) 60 | } 61 | 62 | if ep.OldPath != oldpath { 63 | t.Errorf("UnmarshalPacketBody(): OldPath was %q, but expected %q", ep.OldPath, oldpath) 64 | } 65 | 66 | if ep.NewPath != newpath { 67 | t.Errorf("UnmarshalPacketBody(): NewPath was %q, but expected %q", ep.NewPath, newpath) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/openssh/openssh.go: -------------------------------------------------------------------------------- 1 | // Package openssh implements the openssh secsh-filexfer extensions as described in https://github.com/openssh/openssh-portable/blob/master/PROTOCOL 2 | package openssh 3 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/openssh/posix-rename.go: -------------------------------------------------------------------------------- 1 | package openssh 2 | 3 | import ( 4 | sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" 5 | ) 6 | 7 | const extensionPOSIXRename = "posix-rename@openssh.com" 8 | 9 | // RegisterExtensionPOSIXRename registers the "posix-rename@openssh.com" extended packet with the encoding/ssh/filexfer package. 10 | func RegisterExtensionPOSIXRename() { 11 | sshfx.RegisterExtendedPacketType(extensionPOSIXRename, func() sshfx.ExtendedData { 12 | return new(POSIXRenameExtendedPacket) 13 | }) 14 | } 15 | 16 | // ExtensionPOSIXRename returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket. 17 | func ExtensionPOSIXRename() *sshfx.ExtensionPair { 18 | return &sshfx.ExtensionPair{ 19 | Name: extensionPOSIXRename, 20 | Data: "1", 21 | } 22 | } 23 | 24 | // POSIXRenameExtendedPacket defines the posix-rename@openssh.com extend packet. 25 | type POSIXRenameExtendedPacket struct { 26 | OldPath string 27 | NewPath string 28 | } 29 | 30 | // Type returns the SSH_FXP_EXTENDED packet type. 31 | func (ep *POSIXRenameExtendedPacket) Type() sshfx.PacketType { 32 | return sshfx.PacketTypeExtended 33 | } 34 | 35 | // MarshalPacket returns ep as a two-part binary encoding of the full extended packet. 36 | func (ep *POSIXRenameExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { 37 | p := &sshfx.ExtendedPacket{ 38 | ExtendedRequest: extensionPOSIXRename, 39 | 40 | Data: ep, 41 | } 42 | return p.MarshalPacket(reqid, b) 43 | } 44 | 45 | // MarshalInto encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data. 46 | func (ep *POSIXRenameExtendedPacket) MarshalInto(buf *sshfx.Buffer) { 47 | buf.AppendString(ep.OldPath) 48 | buf.AppendString(ep.NewPath) 49 | } 50 | 51 | // MarshalBinary encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data. 52 | // 53 | // NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet. 54 | func (ep *POSIXRenameExtendedPacket) MarshalBinary() ([]byte, error) { 55 | // string(oldpath) + string(newpath) 56 | size := 4 + len(ep.OldPath) + 4 + len(ep.NewPath) 57 | 58 | buf := sshfx.NewBuffer(make([]byte, 0, size)) 59 | ep.MarshalInto(buf) 60 | return buf.Bytes(), nil 61 | } 62 | 63 | // UnmarshalFrom decodes the hardlink@openssh.com extended packet-specific data from buf. 64 | func (ep *POSIXRenameExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) { 65 | *ep = POSIXRenameExtendedPacket{ 66 | OldPath: buf.ConsumeString(), 67 | NewPath: buf.ConsumeString(), 68 | } 69 | 70 | return buf.Err 71 | } 72 | 73 | // UnmarshalBinary decodes the hardlink@openssh.com extended packet-specific data into ep. 74 | func (ep *POSIXRenameExtendedPacket) UnmarshalBinary(data []byte) (err error) { 75 | return ep.UnmarshalFrom(sshfx.NewBuffer(data)) 76 | } 77 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/openssh/posix-rename_test.go: -------------------------------------------------------------------------------- 1 | package openssh 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" 8 | ) 9 | 10 | var _ sshfx.PacketMarshaller = &POSIXRenameExtendedPacket{} 11 | 12 | func init() { 13 | RegisterExtensionPOSIXRename() 14 | } 15 | 16 | func TestPOSIXRenameExtendedPacket(t *testing.T) { 17 | const ( 18 | id = 42 19 | oldpath = "/foo" 20 | newpath = "/bar" 21 | ) 22 | 23 | ep := &POSIXRenameExtendedPacket{ 24 | OldPath: oldpath, 25 | NewPath: newpath, 26 | } 27 | 28 | data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil)) 29 | if err != nil { 30 | t.Fatal("unexpected error:", err) 31 | } 32 | 33 | want := []byte{ 34 | 0x00, 0x00, 0x00, 49, 35 | 200, 36 | 0x00, 0x00, 0x00, 42, 37 | 0x00, 0x00, 0x00, 24, 'p', 'o', 's', 'i', 'x', '-', 'r', 'e', 'n', 'a', 'm', 'e', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm', 38 | 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', 39 | 0x00, 0x00, 0x00, 4, '/', 'b', 'a', 'r', 40 | } 41 | 42 | if !bytes.Equal(data, want) { 43 | t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want) 44 | } 45 | 46 | var p sshfx.ExtendedPacket 47 | 48 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 49 | if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil { 50 | t.Fatal("unexpected error:", err) 51 | } 52 | 53 | if p.ExtendedRequest != extensionPOSIXRename { 54 | t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionPOSIXRename) 55 | } 56 | 57 | ep, ok := p.Data.(*POSIXRenameExtendedPacket) 58 | if !ok { 59 | t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *POSIXRenameExtendedPacket", p.Data) 60 | } 61 | 62 | if ep.OldPath != oldpath { 63 | t.Errorf("UnmarshalPacketBody(): OldPath was %q, but expected %q", ep.OldPath, oldpath) 64 | } 65 | 66 | if ep.NewPath != newpath { 67 | t.Errorf("UnmarshalPacketBody(): NewPath was %q, but expected %q", ep.NewPath, newpath) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/openssh/statvfs_test.go: -------------------------------------------------------------------------------- 1 | package openssh 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" 8 | ) 9 | 10 | var _ sshfx.PacketMarshaller = &StatVFSExtendedPacket{} 11 | 12 | func init() { 13 | RegisterExtensionStatVFS() 14 | } 15 | 16 | func TestStatVFSExtendedPacket(t *testing.T) { 17 | const ( 18 | id = 42 19 | path = "/foo" 20 | ) 21 | 22 | ep := &StatVFSExtendedPacket{ 23 | Path: path, 24 | } 25 | 26 | data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil)) 27 | if err != nil { 28 | t.Fatal("unexpected error:", err) 29 | } 30 | 31 | want := []byte{ 32 | 0x00, 0x00, 0x00, 36, 33 | 200, 34 | 0x00, 0x00, 0x00, 42, 35 | 0x00, 0x00, 0x00, 19, 's', 't', 'a', 't', 'v', 'f', 's', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm', 36 | 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', 37 | } 38 | 39 | if !bytes.Equal(data, want) { 40 | t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want) 41 | } 42 | 43 | var p sshfx.ExtendedPacket 44 | 45 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 46 | if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil { 47 | t.Fatal("unexpected error:", err) 48 | } 49 | 50 | if p.ExtendedRequest != extensionStatVFS { 51 | t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionStatVFS) 52 | } 53 | 54 | ep, ok := p.Data.(*StatVFSExtendedPacket) 55 | if !ok { 56 | t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *StatVFSExtendedPacket", p.Data) 57 | } 58 | 59 | if ep.Path != path { 60 | t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", ep.Path, path) 61 | } 62 | } 63 | 64 | var _ sshfx.PacketMarshaller = &FStatVFSExtendedPacket{} 65 | 66 | func init() { 67 | RegisterExtensionFStatVFS() 68 | } 69 | 70 | func TestFStatVFSExtendedPacket(t *testing.T) { 71 | const ( 72 | id = 42 73 | path = "/foo" 74 | ) 75 | 76 | ep := &FStatVFSExtendedPacket{ 77 | Path: path, 78 | } 79 | 80 | data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil)) 81 | if err != nil { 82 | t.Fatal("unexpected error:", err) 83 | } 84 | 85 | want := []byte{ 86 | 0x00, 0x00, 0x00, 37, 87 | 200, 88 | 0x00, 0x00, 0x00, 42, 89 | 0x00, 0x00, 0x00, 20, 'f', 's', 't', 'a', 't', 'v', 'f', 's', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm', 90 | 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', 91 | } 92 | 93 | if !bytes.Equal(data, want) { 94 | t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want) 95 | } 96 | 97 | var p sshfx.ExtendedPacket 98 | 99 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 100 | if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil { 101 | t.Fatal("unexpected error:", err) 102 | } 103 | 104 | if p.ExtendedRequest != extensionFStatVFS { 105 | t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionFStatVFS) 106 | } 107 | 108 | ep, ok := p.Data.(*FStatVFSExtendedPacket) 109 | if !ok { 110 | t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *FStatVFSExtendedPacket", p.Data) 111 | } 112 | 113 | if ep.Path != path { 114 | t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", ep.Path, path) 115 | } 116 | } 117 | 118 | var _ sshfx.Packet = &StatVFSExtendedReplyPacket{} 119 | 120 | func TestStatVFSExtendedReplyPacket(t *testing.T) { 121 | const ( 122 | id = 42 123 | path = "/foo" 124 | ) 125 | 126 | const ( 127 | BlockSize = uint64(iota + 13) 128 | FragmentSize 129 | Blocks 130 | BlocksFree 131 | BlocksAvail 132 | Files 133 | FilesFree 134 | FilesAvail 135 | FilesystemID 136 | MountFlags 137 | MaxNameLength 138 | ) 139 | 140 | ep := &StatVFSExtendedReplyPacket{ 141 | BlockSize: BlockSize, 142 | FragmentSize: FragmentSize, 143 | Blocks: Blocks, 144 | BlocksFree: BlocksFree, 145 | BlocksAvail: BlocksAvail, 146 | Files: Files, 147 | FilesFree: FilesFree, 148 | FilesAvail: FilesAvail, 149 | FilesystemID: FilesystemID, 150 | MountFlags: MountFlags, 151 | MaxNameLength: MaxNameLength, 152 | } 153 | 154 | data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil)) 155 | if err != nil { 156 | t.Fatal("unexpected error:", err) 157 | } 158 | 159 | want := []byte{ 160 | 0x00, 0x00, 0x00, 93, 161 | 201, 162 | 0x00, 0x00, 0x00, 42, 163 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 13, 164 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 14, 165 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 15, 166 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 16, 167 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 17, 168 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 18, 169 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 19, 170 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 20, 171 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 21, 172 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 22, 173 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 23, 174 | } 175 | 176 | if !bytes.Equal(data, want) { 177 | t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want) 178 | } 179 | 180 | *ep = StatVFSExtendedReplyPacket{} 181 | 182 | p := sshfx.ExtendedReplyPacket{ 183 | Data: ep, 184 | } 185 | 186 | // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. 187 | if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil { 188 | t.Fatal("unexpected error:", err) 189 | } 190 | 191 | ep, ok := p.Data.(*StatVFSExtendedReplyPacket) 192 | if !ok { 193 | t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *StatVFSExtendedReplyPacket", p.Data) 194 | } 195 | 196 | if ep.BlockSize != BlockSize { 197 | t.Errorf("UnmarshalPacketBody(): BlockSize was %d, but expected %d", ep.BlockSize, BlockSize) 198 | } 199 | 200 | if ep.FragmentSize != FragmentSize { 201 | t.Errorf("UnmarshalPacketBody(): FragmentSize was %d, but expected %d", ep.FragmentSize, FragmentSize) 202 | } 203 | 204 | if ep.Blocks != Blocks { 205 | t.Errorf("UnmarshalPacketBody(): Blocks was %d, but expected %d", ep.Blocks, Blocks) 206 | } 207 | 208 | if ep.BlocksFree != BlocksFree { 209 | t.Errorf("UnmarshalPacketBody(): BlocksFree was %d, but expected %d", ep.BlocksFree, BlocksFree) 210 | } 211 | 212 | if ep.BlocksAvail != BlocksAvail { 213 | t.Errorf("UnmarshalPacketBody(): BlocksAvail was %d, but expected %d", ep.BlocksAvail, BlocksAvail) 214 | } 215 | 216 | if ep.Files != Files { 217 | t.Errorf("UnmarshalPacketBody(): Files was %d, but expected %d", ep.Files, Files) 218 | } 219 | 220 | if ep.FilesFree != FilesFree { 221 | t.Errorf("UnmarshalPacketBody(): FilesFree was %d, but expected %d", ep.FilesFree, FilesFree) 222 | } 223 | 224 | if ep.FilesAvail != FilesAvail { 225 | t.Errorf("UnmarshalPacketBody(): FilesAvail was %d, but expected %d", ep.FilesAvail, FilesAvail) 226 | } 227 | 228 | if ep.FilesystemID != FilesystemID { 229 | t.Errorf("UnmarshalPacketBody(): FilesystemID was %d, but expected %d", ep.FilesystemID, FilesystemID) 230 | } 231 | 232 | if ep.MountFlags != MountFlags { 233 | t.Errorf("UnmarshalPacketBody(): MountFlags was %d, but expected %d", ep.MountFlags, MountFlags) 234 | } 235 | 236 | if ep.MaxNameLength != MaxNameLength { 237 | t.Errorf("UnmarshalPacketBody(): MaxNameLength was %d, but expected %d", ep.MaxNameLength, MaxNameLength) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/packets_test.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestRawPacket(t *testing.T) { 9 | const ( 10 | id = 42 11 | errMsg = "eof" 12 | langTag = "en" 13 | ) 14 | 15 | p := &RawPacket{ 16 | PacketType: PacketTypeStatus, 17 | RequestID: id, 18 | Data: Buffer{ 19 | b: []byte{ 20 | 0x00, 0x00, 0x00, 0x01, 21 | 0x00, 0x00, 0x00, 0x03, 'e', 'o', 'f', 22 | 0x00, 0x00, 0x00, 0x02, 'e', 'n', 23 | }, 24 | }, 25 | } 26 | 27 | buf, err := p.MarshalBinary() 28 | if err != nil { 29 | t.Fatal("unexpected error:", err) 30 | } 31 | 32 | want := []byte{ 33 | 0x00, 0x00, 0x00, 22, 34 | 101, 35 | 0x00, 0x00, 0x00, 42, 36 | 0x00, 0x00, 0x00, 0x01, 37 | 0x00, 0x00, 0x00, 3, 'e', 'o', 'f', 38 | 0x00, 0x00, 0x00, 2, 'e', 'n', 39 | } 40 | 41 | if !bytes.Equal(buf, want) { 42 | t.Errorf("RawPacket.MarshalBinary() = %X, but wanted %X", buf, want) 43 | } 44 | 45 | *p = RawPacket{} 46 | 47 | if err := p.ReadFrom(bytes.NewReader(buf), nil, DefaultMaxPacketLength); err != nil { 48 | t.Fatal("unexpected error:", err) 49 | } 50 | 51 | if p.PacketType != PacketTypeStatus { 52 | t.Errorf("RawPacket.UnmarshalBinary(): Type was %v, but expected %v", p.PacketType, PacketTypeStat) 53 | } 54 | 55 | if p.RequestID != uint32(id) { 56 | t.Errorf("RawPacket.UnmarshalBinary(): RequestID was %d, but expected %d", p.RequestID, id) 57 | } 58 | 59 | want = []byte{ 60 | 0x00, 0x00, 0x00, 0x01, 61 | 0x00, 0x00, 0x00, 3, 'e', 'o', 'f', 62 | 0x00, 0x00, 0x00, 2, 'e', 'n', 63 | } 64 | 65 | if !bytes.Equal(p.Data.Bytes(), want) { 66 | t.Fatalf("RawPacket.UnmarshalBinary(): Data was %X, but expected %X", p.Data, want) 67 | } 68 | 69 | var resp StatusPacket 70 | resp.UnmarshalPacketBody(&p.Data) 71 | 72 | if resp.StatusCode != StatusEOF { 73 | t.Errorf("UnmarshalPacketBody(): StatusCode was %v, but expected %v", resp.StatusCode, StatusEOF) 74 | } 75 | 76 | if resp.ErrorMessage != errMsg { 77 | t.Errorf("UnmarshalPacketBody(): ErrorMessage was %q, but expected %q", resp.ErrorMessage, errMsg) 78 | } 79 | 80 | if resp.LanguageTag != langTag { 81 | t.Errorf("UnmarshalPacketBody(): LanguageTag was %q, but expected %q", resp.LanguageTag, langTag) 82 | } 83 | } 84 | 85 | func TestRequestPacket(t *testing.T) { 86 | const ( 87 | id = 42 88 | path = "foo" 89 | ) 90 | 91 | p := &RequestPacket{ 92 | RequestID: id, 93 | Request: &StatPacket{ 94 | Path: path, 95 | }, 96 | } 97 | 98 | buf, err := p.MarshalBinary() 99 | if err != nil { 100 | t.Fatal("unexpected error:", err) 101 | } 102 | 103 | want := []byte{ 104 | 0x00, 0x00, 0x00, 12, 105 | 17, 106 | 0x00, 0x00, 0x00, 42, 107 | 0x00, 0x00, 0x00, 3, 'f', 'o', 'o', 108 | } 109 | 110 | if !bytes.Equal(buf, want) { 111 | t.Errorf("RequestPacket.MarshalBinary() = %X, but wanted %X", buf, want) 112 | } 113 | 114 | *p = RequestPacket{} 115 | 116 | if err := p.ReadFrom(bytes.NewReader(buf), nil, DefaultMaxPacketLength); err != nil { 117 | t.Fatal("unexpected error:", err) 118 | } 119 | 120 | if p.RequestID != uint32(id) { 121 | t.Errorf("RequestPacket.UnmarshalBinary(): RequestID was %d, but expected %d", p.RequestID, id) 122 | } 123 | 124 | req, ok := p.Request.(*StatPacket) 125 | if !ok { 126 | t.Fatalf("unexpected Request type was %T, but expected %T", p.Request, req) 127 | } 128 | 129 | if req.Path != path { 130 | t.Errorf("RequestPacket.UnmarshalBinary(): Request.Path was %q, but expected %q", req.Path, path) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /internal/encoding/ssh/filexfer/permissions.go: -------------------------------------------------------------------------------- 1 | package sshfx 2 | 3 | // FileMode represents a file’s mode and permission bits. 4 | // The bits are defined according to POSIX standards, 5 | // and may not apply to the OS being built for. 6 | type FileMode uint32 7 | 8 | // Permission flags, defined here to avoid potential inconsistencies in individual OS implementations. 9 | const ( 10 | ModePerm FileMode = 0o0777 // S_IRWXU | S_IRWXG | S_IRWXO 11 | ModeUserRead FileMode = 0o0400 // S_IRUSR 12 | ModeUserWrite FileMode = 0o0200 // S_IWUSR 13 | ModeUserExec FileMode = 0o0100 // S_IXUSR 14 | ModeGroupRead FileMode = 0o0040 // S_IRGRP 15 | ModeGroupWrite FileMode = 0o0020 // S_IWGRP 16 | ModeGroupExec FileMode = 0o0010 // S_IXGRP 17 | ModeOtherRead FileMode = 0o0004 // S_IROTH 18 | ModeOtherWrite FileMode = 0o0002 // S_IWOTH 19 | ModeOtherExec FileMode = 0o0001 // S_IXOTH 20 | 21 | ModeSetUID FileMode = 0o4000 // S_ISUID 22 | ModeSetGID FileMode = 0o2000 // S_ISGID 23 | ModeSticky FileMode = 0o1000 // S_ISVTX 24 | 25 | ModeType FileMode = 0xF000 // S_IFMT 26 | ModeNamedPipe FileMode = 0x1000 // S_IFIFO 27 | ModeCharDevice FileMode = 0x2000 // S_IFCHR 28 | ModeDir FileMode = 0x4000 // S_IFDIR 29 | ModeDevice FileMode = 0x6000 // S_IFBLK 30 | ModeRegular FileMode = 0x8000 // S_IFREG 31 | ModeSymlink FileMode = 0xA000 // S_IFLNK 32 | ModeSocket FileMode = 0xC000 // S_IFSOCK 33 | ) 34 | 35 | // IsDir reports whether m describes a directory. 36 | // That is, it tests for m.Type() == ModeDir. 37 | func (m FileMode) IsDir() bool { 38 | return (m & ModeType) == ModeDir 39 | } 40 | 41 | // IsRegular reports whether m describes a regular file. 42 | // That is, it tests for m.Type() == ModeRegular 43 | func (m FileMode) IsRegular() bool { 44 | return (m & ModeType) == ModeRegular 45 | } 46 | 47 | // Perm returns the POSIX permission bits in m (m & ModePerm). 48 | func (m FileMode) Perm() FileMode { 49 | return (m & ModePerm) 50 | } 51 | 52 | // Type returns the type bits in m (m & ModeType). 53 | func (m FileMode) Type() FileMode { 54 | return (m & ModeType) 55 | } 56 | 57 | // String returns a `-rwxrwxrwx` style string representing the `ls -l` POSIX permissions string. 58 | func (m FileMode) String() string { 59 | var buf [10]byte 60 | 61 | switch m.Type() { 62 | case ModeRegular: 63 | buf[0] = '-' 64 | case ModeDir: 65 | buf[0] = 'd' 66 | case ModeSymlink: 67 | buf[0] = 'l' 68 | case ModeDevice: 69 | buf[0] = 'b' 70 | case ModeCharDevice: 71 | buf[0] = 'c' 72 | case ModeNamedPipe: 73 | buf[0] = 'p' 74 | case ModeSocket: 75 | buf[0] = 's' 76 | default: 77 | buf[0] = '?' 78 | } 79 | 80 | const rwx = "rwxrwxrwx" 81 | for i, c := range rwx { 82 | if m&(1< p.blen*2 { 45 | // DO NOT reuse buffers with insufficient capacity. 46 | // This could cause panics when resizing to p.blen. 47 | 48 | // DO NOT reuse buffers with excessive capacity. 49 | // This could cause memory leaks. 50 | return 51 | } 52 | 53 | select { 54 | case p.ch <- b: 55 | default: 56 | } 57 | } 58 | 59 | type resChanPool chan chan result 60 | 61 | func newResChanPool(depth int) resChanPool { 62 | return make(chan chan result, depth) 63 | } 64 | 65 | func (p resChanPool) Get() chan result { 66 | select { 67 | case ch := <-p: 68 | return ch 69 | default: 70 | return make(chan result, 1) 71 | } 72 | } 73 | 74 | func (p resChanPool) Put(ch chan result) { 75 | select { 76 | case p <- ch: 77 | default: 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /release.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | // +build !debug 3 | 4 | package sftp 5 | 6 | func debug(fmt string, args ...interface{}) {} 7 | -------------------------------------------------------------------------------- /request-attrs.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | // Methods on the Request object to make working with the Flags bitmasks and 4 | // Attr(ibutes) byte blob easier. Use Pflags() when working with an Open/Write 5 | // request and AttrFlags() and Attributes() when working with SetStat requests. 6 | 7 | // FileOpenFlags defines Open and Write Flags. Correlate directly with with os.OpenFile flags 8 | // (https://golang.org/pkg/os/#pkg-constants). 9 | type FileOpenFlags struct { 10 | Read, Write, Append, Creat, Trunc, Excl bool 11 | } 12 | 13 | func newFileOpenFlags(flags uint32) FileOpenFlags { 14 | return FileOpenFlags{ 15 | Read: flags&sshFxfRead != 0, 16 | Write: flags&sshFxfWrite != 0, 17 | Append: flags&sshFxfAppend != 0, 18 | Creat: flags&sshFxfCreat != 0, 19 | Trunc: flags&sshFxfTrunc != 0, 20 | Excl: flags&sshFxfExcl != 0, 21 | } 22 | } 23 | 24 | // Pflags converts the bitmap/uint32 from SFTP Open packet pflag values, 25 | // into a FileOpenFlags struct with booleans set for flags set in bitmap. 26 | func (r *Request) Pflags() FileOpenFlags { 27 | return newFileOpenFlags(r.Flags) 28 | } 29 | 30 | // FileAttrFlags that indicate whether SFTP file attributes were passed. When a flag is 31 | // true the corresponding attribute should be available from the FileStat 32 | // object returned by Attributes method. Used with SetStat. 33 | type FileAttrFlags struct { 34 | Size, UidGid, Permissions, Acmodtime bool 35 | } 36 | 37 | func newFileAttrFlags(flags uint32) FileAttrFlags { 38 | return FileAttrFlags{ 39 | Size: (flags & sshFileXferAttrSize) != 0, 40 | UidGid: (flags & sshFileXferAttrUIDGID) != 0, 41 | Permissions: (flags & sshFileXferAttrPermissions) != 0, 42 | Acmodtime: (flags & sshFileXferAttrACmodTime) != 0, 43 | } 44 | } 45 | 46 | // AttrFlags returns a FileAttrFlags boolean struct based on the 47 | // bitmap/uint32 file attribute flags from the SFTP packaet. 48 | func (r *Request) AttrFlags() FileAttrFlags { 49 | return newFileAttrFlags(r.Flags) 50 | } 51 | 52 | // Attributes parses file attributes byte blob and return them in a 53 | // FileStat object. 54 | func (r *Request) Attributes() *FileStat { 55 | fs, _, _ := unmarshalFileStat(r.Flags, r.Attrs) 56 | return fs 57 | } 58 | -------------------------------------------------------------------------------- /request-attrs_test.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRequestPflags(t *testing.T) { 12 | pflags := newFileOpenFlags(sshFxfRead | sshFxfWrite | sshFxfAppend) 13 | assert.True(t, pflags.Read) 14 | assert.True(t, pflags.Write) 15 | assert.True(t, pflags.Append) 16 | assert.False(t, pflags.Creat) 17 | assert.False(t, pflags.Trunc) 18 | assert.False(t, pflags.Excl) 19 | } 20 | 21 | func TestRequestAflags(t *testing.T) { 22 | aflags := newFileAttrFlags( 23 | sshFileXferAttrSize | sshFileXferAttrUIDGID) 24 | assert.True(t, aflags.Size) 25 | assert.True(t, aflags.UidGid) 26 | assert.False(t, aflags.Acmodtime) 27 | assert.False(t, aflags.Permissions) 28 | } 29 | 30 | func TestRequestAttributes(t *testing.T) { 31 | // UID/GID 32 | fa := FileStat{UID: 1, GID: 2} 33 | fl := uint32(sshFileXferAttrUIDGID) 34 | at := []byte{} 35 | at = marshalUint32(at, 1) 36 | at = marshalUint32(at, 2) 37 | testFs, _, err := unmarshalFileStat(fl, at) 38 | require.NoError(t, err) 39 | assert.Equal(t, fa, *testFs) 40 | // Size and Mode 41 | fa = FileStat{Mode: 0700, Size: 99} 42 | fl = uint32(sshFileXferAttrSize | sshFileXferAttrPermissions) 43 | at = []byte{} 44 | at = marshalUint64(at, 99) 45 | at = marshalUint32(at, 0700) 46 | testFs, _, err = unmarshalFileStat(fl, at) 47 | require.NoError(t, err) 48 | assert.Equal(t, fa, *testFs) 49 | // FileMode 50 | assert.True(t, testFs.FileMode().IsRegular()) 51 | assert.False(t, testFs.FileMode().IsDir()) 52 | assert.Equal(t, testFs.FileMode().Perm(), os.FileMode(0700).Perm()) 53 | } 54 | 55 | func TestRequestAttributesEmpty(t *testing.T) { 56 | fs, b, err := unmarshalFileStat(sshFileXferAttrAll, []byte{ 57 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // size 58 | 0x00, 0x00, 0x00, 0x00, // mode 59 | 0x00, 0x00, 0x00, 0x00, // mtime 60 | 0x00, 0x00, 0x00, 0x00, // atime 61 | 0x00, 0x00, 0x00, 0x00, // uid 62 | 0x00, 0x00, 0x00, 0x00, // gid 63 | 0x00, 0x00, 0x00, 0x00, // extended_count 64 | }) 65 | require.NoError(t, err) 66 | assert.Equal(t, &FileStat{ 67 | Extended: []StatExtended{}, 68 | }, fs) 69 | assert.Empty(t, b) 70 | } 71 | -------------------------------------------------------------------------------- /request-errors.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | type fxerr uint32 4 | 5 | // Error types that match the SFTP's SSH_FXP_STATUS codes. Gives you more 6 | // direct control of the errors being sent vs. letting the library work them 7 | // out from the standard os/io errors. 8 | const ( 9 | ErrSSHFxOk = fxerr(sshFxOk) 10 | ErrSSHFxEOF = fxerr(sshFxEOF) 11 | ErrSSHFxNoSuchFile = fxerr(sshFxNoSuchFile) 12 | ErrSSHFxPermissionDenied = fxerr(sshFxPermissionDenied) 13 | ErrSSHFxFailure = fxerr(sshFxFailure) 14 | ErrSSHFxBadMessage = fxerr(sshFxBadMessage) 15 | ErrSSHFxNoConnection = fxerr(sshFxNoConnection) 16 | ErrSSHFxConnectionLost = fxerr(sshFxConnectionLost) 17 | ErrSSHFxOpUnsupported = fxerr(sshFxOPUnsupported) 18 | ) 19 | 20 | // Deprecated error types, these are aliases for the new ones, please use the new ones directly 21 | const ( 22 | ErrSshFxOk = ErrSSHFxOk 23 | ErrSshFxEof = ErrSSHFxEOF 24 | ErrSshFxNoSuchFile = ErrSSHFxNoSuchFile 25 | ErrSshFxPermissionDenied = ErrSSHFxPermissionDenied 26 | ErrSshFxFailure = ErrSSHFxFailure 27 | ErrSshFxBadMessage = ErrSSHFxBadMessage 28 | ErrSshFxNoConnection = ErrSSHFxNoConnection 29 | ErrSshFxConnectionLost = ErrSSHFxConnectionLost 30 | ErrSshFxOpUnsupported = ErrSSHFxOpUnsupported 31 | ) 32 | 33 | func (e fxerr) Error() string { 34 | switch e { 35 | case ErrSSHFxOk: 36 | return "OK" 37 | case ErrSSHFxEOF: 38 | return "EOF" 39 | case ErrSSHFxNoSuchFile: 40 | return "no such file" 41 | case ErrSSHFxPermissionDenied: 42 | return "permission denied" 43 | case ErrSSHFxBadMessage: 44 | return "bad message" 45 | case ErrSSHFxNoConnection: 46 | return "no connection" 47 | case ErrSSHFxConnectionLost: 48 | return "connection lost" 49 | case ErrSSHFxOpUnsupported: 50 | return "operation unsupported" 51 | default: 52 | return "failure" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /request-interfaces.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | // WriterAtReaderAt defines the interface to return when a file is to 9 | // be opened for reading and writing 10 | type WriterAtReaderAt interface { 11 | io.WriterAt 12 | io.ReaderAt 13 | } 14 | 15 | // Interfaces are differentiated based on required returned values. 16 | // All input arguments are to be pulled from Request (the only arg). 17 | 18 | // The Handler interfaces all take the Request object as its only argument. 19 | // All the data you should need to handle the call are in the Request object. 20 | // The request.Method attribute is initially the most important one as it 21 | // determines which Handler gets called. 22 | 23 | // FileReader should return an io.ReaderAt for the filepath 24 | // Note in cases of an error, the error text will be sent to the client. 25 | // Called for Methods: Get 26 | type FileReader interface { 27 | Fileread(*Request) (io.ReaderAt, error) 28 | } 29 | 30 | // FileWriter should return an io.WriterAt for the filepath. 31 | // 32 | // The request server code will call Close() on the returned io.WriterAt 33 | // object if an io.Closer type assertion succeeds. 34 | // Note in cases of an error, the error text will be sent to the client. 35 | // Note when receiving an Append flag it is important to not open files using 36 | // O_APPEND if you plan to use WriteAt, as they conflict. 37 | // Called for Methods: Put, Open 38 | type FileWriter interface { 39 | Filewrite(*Request) (io.WriterAt, error) 40 | } 41 | 42 | // OpenFileWriter is a FileWriter that implements the generic OpenFile method. 43 | // You need to implement this optional interface if you want to be able 44 | // to read and write from/to the same handle. 45 | // Called for Methods: Open 46 | type OpenFileWriter interface { 47 | FileWriter 48 | OpenFile(*Request) (WriterAtReaderAt, error) 49 | } 50 | 51 | // FileCmder should return an error 52 | // Note in cases of an error, the error text will be sent to the client. 53 | // Called for Methods: Setstat, Rename, Rmdir, Mkdir, Link, Symlink, Remove 54 | type FileCmder interface { 55 | Filecmd(*Request) error 56 | } 57 | 58 | // PosixRenameFileCmder is a FileCmder that implements the PosixRename method. 59 | // If this interface is implemented PosixRename requests will call it 60 | // otherwise they will be handled in the same way as Rename 61 | type PosixRenameFileCmder interface { 62 | FileCmder 63 | PosixRename(*Request) error 64 | } 65 | 66 | // StatVFSFileCmder is a FileCmder that implements the StatVFS method. 67 | // You need to implement this interface if you want to handle statvfs requests. 68 | // Please also be sure that the statvfs@openssh.com extension is enabled 69 | type StatVFSFileCmder interface { 70 | FileCmder 71 | StatVFS(*Request) (*StatVFS, error) 72 | } 73 | 74 | // FileLister should return an object that fulfils the ListerAt interface 75 | // Note in cases of an error, the error text will be sent to the client. 76 | // Called for Methods: List, Stat, Readlink 77 | // 78 | // Since Filelist returns an os.FileInfo, this can make it non-ideal for implementing Readlink. 79 | // This is because the Name receiver method defined by that interface defines that it should only return the base name. 80 | // However, Readlink is required to be capable of returning essentially any arbitrary valid path relative or absolute. 81 | // In order to implement this more expressive requirement, implement [ReadlinkFileLister] which will then be used instead. 82 | type FileLister interface { 83 | Filelist(*Request) (ListerAt, error) 84 | } 85 | 86 | // LstatFileLister is a FileLister that implements the Lstat method. 87 | // If this interface is implemented Lstat requests will call it 88 | // otherwise they will be handled in the same way as Stat 89 | type LstatFileLister interface { 90 | FileLister 91 | Lstat(*Request) (ListerAt, error) 92 | } 93 | 94 | // RealPathFileLister is a FileLister that implements the Realpath method. 95 | // The built-in RealPath implementation does not resolve symbolic links. 96 | // By implementing this interface you can customize the returned path 97 | // and, for example, resolve symbolinc links if needed for your use case. 98 | // You have to return an absolute POSIX path. 99 | // 100 | // Up to v1.13.5 the signature for the RealPath method was: 101 | // 102 | // # RealPath(string) string 103 | // 104 | // we have added a legacyRealPathFileLister that implements the old method 105 | // to ensure that your code does not break. 106 | // You should use the new method signature to avoid future issues 107 | type RealPathFileLister interface { 108 | FileLister 109 | RealPath(string) (string, error) 110 | } 111 | 112 | // ReadlinkFileLister is a FileLister that implements the Readlink method. 113 | // By implementing the Readlink method, it is possible to return any arbitrary valid path relative or absolute. 114 | // This allows giving a better response than via the default FileLister (which is limited to os.FileInfo, whose Name method should only return the base name of a file) 115 | type ReadlinkFileLister interface { 116 | FileLister 117 | Readlink(string) (string, error) 118 | } 119 | 120 | // This interface is here for backward compatibility only 121 | type legacyRealPathFileLister interface { 122 | FileLister 123 | RealPath(string) string 124 | } 125 | 126 | // NameLookupFileLister is a FileLister that implmeents the LookupUsername and LookupGroupName methods. 127 | // If this interface is implemented, then longname ls formatting will use these to convert usernames and groupnames. 128 | type NameLookupFileLister interface { 129 | FileLister 130 | LookupUserName(string) string 131 | LookupGroupName(string) string 132 | } 133 | 134 | // ListerAt does for file lists what io.ReaderAt does for files, i.e. a []os.FileInfo buffer is passed to the ListAt function 135 | // and the entries that are populated in the buffer will be passed to the client. 136 | // 137 | // ListAt should return the number of entries copied and an io.EOF error if at end of list. 138 | // This is testable by comparing how many you copied to how many could be copied (eg. n < len(ls) below). 139 | // The copy() builtin is best for the copying. 140 | // 141 | // Uid and gid information will on unix systems be retrieved from [os.FileInfo.Sys] 142 | // if this function returns a [syscall.Stat_t] when called on a populated entry. 143 | // Alternatively, if the entry implements [FileInfoUidGid], it will be used for uid and gid information. 144 | // 145 | // If a populated entry implements [FileInfoExtendedData], extended attributes will also be returned to the client. 146 | // 147 | // The request server code will call Close() on ListerAt if an io.Closer type assertion succeeds. 148 | // 149 | // Note in cases of an error, the error text will be sent to the client. 150 | type ListerAt interface { 151 | ListAt([]os.FileInfo, int64) (int, error) 152 | } 153 | 154 | // TransferError is an optional interface that readerAt and writerAt 155 | // can implement to be notified about the error causing Serve() to exit 156 | // with the request still open 157 | type TransferError interface { 158 | TransferError(err error) 159 | } 160 | -------------------------------------------------------------------------------- /request-plan9.go: -------------------------------------------------------------------------------- 1 | //go:build plan9 2 | // +build plan9 3 | 4 | package sftp 5 | 6 | import ( 7 | "syscall" 8 | ) 9 | 10 | func fakeFileInfoSys() interface{} { 11 | return &syscall.Dir{} 12 | } 13 | 14 | func testOsSys(sys interface{}) error { 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /request-readme.md: -------------------------------------------------------------------------------- 1 | # Request Based SFTP API 2 | 3 | The request based API allows for custom backends in a way similar to the http 4 | package. In order to create a backend you need to implement 4 handler 5 | interfaces; one for reading, one for writing, one for misc commands and one for 6 | listing files. Each has 1 required method and in each case those methods take 7 | the Request as the only parameter and they each return something different. 8 | These 4 interfaces are enough to handle all the SFTP traffic in a simplified 9 | manner. 10 | 11 | The Request structure has 5 public fields which you will deal with. 12 | 13 | - Method (string) - string name of incoming call 14 | - Filepath (string) - POSIX path of file to act on 15 | - Flags (uint32) - 32bit bitmask value of file open/create flags 16 | - Attrs ([]byte) - byte string of file attribute data 17 | - Target (string) - target path for renames and sym-links 18 | 19 | Below are the methods and a brief description of what they need to do. 20 | 21 | ### Fileread(*Request) (io.Reader, error) 22 | 23 | Handler for "Get" method and returns an io.Reader for the file which the server 24 | then sends to the client. 25 | 26 | ### Filewrite(*Request) (io.Writer, error) 27 | 28 | Handler for "Put" method and returns an io.Writer for the file which the server 29 | then writes the uploaded file to. The file opening "pflags" are currently 30 | preserved in the Request.Flags field as a 32bit bitmask value. See the [SFTP 31 | spec](https://filezilla-project.org/specs/draft-ietf-secsh-filexfer-02.txt#section-6.3) for 32 | details. 33 | 34 | ### Filecmd(*Request) error 35 | 36 | Handles "SetStat", "Rename", "Rmdir", "Mkdir" and "Symlink" methods. Makes the 37 | appropriate changes and returns nil for success or an filesystem like error 38 | (eg. os.ErrNotExist). The attributes are currently propagated in their raw form 39 | ([]byte) and will need to be unmarshalled to be useful. See the respond method 40 | on sshFxpSetstatPacket for example of you might want to do this. 41 | 42 | ### Fileinfo(*Request) ([]os.FileInfo, error) 43 | 44 | Handles "List", "Stat", "Readlink" methods. Gathers/creates FileInfo structs 45 | with the data on the files and returns in a list (list of 1 for Stat and 46 | Readlink). 47 | 48 | 49 | ## TODO 50 | 51 | - Add support for API users to see trace/debugging info of what is going on 52 | inside SFTP server. 53 | - Unmarshal the file attributes into a structure on the Request object. 54 | -------------------------------------------------------------------------------- /request-unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !plan9 2 | // +build !windows,!plan9 3 | 4 | package sftp 5 | 6 | import ( 7 | "errors" 8 | "syscall" 9 | ) 10 | 11 | func fakeFileInfoSys() interface{} { 12 | return &syscall.Stat_t{Uid: 65534, Gid: 65534} 13 | } 14 | 15 | func testOsSys(sys interface{}) error { 16 | fstat := sys.(*FileStat) 17 | if fstat.UID != uint32(65534) { 18 | return errors.New("Uid failed to match") 19 | } 20 | if fstat.GID != uint32(65534) { 21 | return errors.New("Gid failed to match") 22 | } 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /request_windows.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "syscall" 5 | ) 6 | 7 | func fakeFileInfoSys() interface{} { 8 | return syscall.Win32FileAttributeData{} 9 | } 10 | 11 | func testOsSys(sys interface{}) error { 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /server_nowindows_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package sftp 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestServer_toLocalPath(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | withWorkDir string 14 | p string 15 | want string 16 | }{ 17 | { 18 | name: "empty path with no workdir", 19 | p: "", 20 | want: "", 21 | }, 22 | { 23 | name: "relative path with no workdir", 24 | p: "file", 25 | want: "file", 26 | }, 27 | { 28 | name: "absolute path with no workdir", 29 | p: "/file", 30 | want: "/file", 31 | }, 32 | { 33 | name: "workdir and empty path", 34 | withWorkDir: "/home/user", 35 | p: "", 36 | want: "/home/user", 37 | }, 38 | { 39 | name: "workdir and relative path", 40 | withWorkDir: "/home/user", 41 | p: "file", 42 | want: "/home/user/file", 43 | }, 44 | { 45 | name: "workdir and relative path with .", 46 | withWorkDir: "/home/user", 47 | p: ".", 48 | want: "/home/user", 49 | }, 50 | { 51 | name: "workdir and relative path with . and file", 52 | withWorkDir: "/home/user", 53 | p: "./file", 54 | want: "/home/user/file", 55 | }, 56 | { 57 | name: "workdir and absolute path", 58 | withWorkDir: "/home/user", 59 | p: "/file", 60 | want: "/file", 61 | }, 62 | { 63 | name: "workdir and non-unixy path prefixes workdir", 64 | withWorkDir: "/home/user", 65 | p: "C:\\file", 66 | // This may look like a bug but it is the result of passing 67 | // invalid input (a non-unixy path) to the server. 68 | want: "/home/user/C:\\file", 69 | }, 70 | } 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | // We don't need to initialize the Server further to test 74 | // toLocalPath behavior. 75 | s := &Server{} 76 | if tt.withWorkDir != "" { 77 | if err := WithServerWorkingDirectory(tt.withWorkDir)(s); err != nil { 78 | t.Fatal(err) 79 | } 80 | } 81 | 82 | if got := s.toLocalPath(tt.p); got != tt.want { 83 | t.Errorf("Server.toLocalPath() = %q, want %q", got, tt.want) 84 | } 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /server_plan9.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "path" 5 | "path/filepath" 6 | ) 7 | 8 | func (s *Server) toLocalPath(p string) string { 9 | if s.workDir != "" && !path.IsAbs(p) { 10 | p = path.Join(s.workDir, p) 11 | } 12 | 13 | lp := filepath.FromSlash(p) 14 | 15 | if path.IsAbs(p) { 16 | tmp := lp[1:] 17 | 18 | if filepath.IsAbs(tmp) { 19 | // If the FromSlash without any starting slashes is absolute, 20 | // then we have a filepath encoded with a prefix '/'. 21 | // e.g. "/#s/boot" to "#s/boot" 22 | return tmp 23 | } 24 | } 25 | 26 | return lp 27 | } 28 | -------------------------------------------------------------------------------- /server_posix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package sftp 5 | 6 | import ( 7 | "io/fs" 8 | "os" 9 | ) 10 | 11 | func (s *Server) openfile(path string, flag int, mode fs.FileMode) (file, error) { 12 | return os.OpenFile(path, flag, mode) 13 | } 14 | 15 | func (s *Server) lstat(name string) (os.FileInfo, error) { 16 | return os.Lstat(name) 17 | } 18 | 19 | func (s *Server) stat(name string) (os.FileInfo, error) { 20 | return os.Stat(name) 21 | } 22 | -------------------------------------------------------------------------------- /server_standalone/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // small wrapper around sftp server that allows it to be used as a separate process subsystem call by the ssh server. 4 | // in practice this will statically link; however this allows unit testing from the sftp client. 5 | 6 | import ( 7 | "flag" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "os" 12 | 13 | "github.com/pkg/sftp" 14 | ) 15 | 16 | func main() { 17 | var ( 18 | readOnly bool 19 | debugStderr bool 20 | debugLevel string 21 | options []sftp.ServerOption 22 | ) 23 | 24 | flag.BoolVar(&readOnly, "R", false, "read-only server") 25 | flag.BoolVar(&debugStderr, "e", false, "debug to stderr") 26 | flag.StringVar(&debugLevel, "l", "none", "debug level (ignored)") 27 | flag.Parse() 28 | 29 | debugStream := ioutil.Discard 30 | if debugStderr { 31 | debugStream = os.Stderr 32 | } 33 | options = append(options, sftp.WithDebug(debugStream)) 34 | 35 | if readOnly { 36 | options = append(options, sftp.ReadOnly()) 37 | } 38 | 39 | svr, _ := sftp.NewServer( 40 | struct { 41 | io.Reader 42 | io.WriteCloser 43 | }{os.Stdin, 44 | os.Stdout, 45 | }, 46 | options..., 47 | ) 48 | if err := svr.Serve(); err != nil { 49 | fmt.Fprintf(debugStream, "sftp server completed with error: %v", err) 50 | os.Exit(1) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server_statvfs_darwin.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "syscall" 5 | ) 6 | 7 | func statvfsFromStatfst(stat *syscall.Statfs_t) (*StatVFS, error) { 8 | return &StatVFS{ 9 | Bsize: uint64(stat.Bsize), 10 | Frsize: uint64(stat.Bsize), // fragment size is a linux thing; use block size here 11 | Blocks: stat.Blocks, 12 | Bfree: stat.Bfree, 13 | Bavail: stat.Bavail, 14 | Files: stat.Files, 15 | Ffree: stat.Ffree, 16 | Favail: stat.Ffree, // not sure how to calculate Favail 17 | Fsid: uint64(uint64(stat.Fsid.Val[1])<<32 | uint64(stat.Fsid.Val[0])), // endianness? 18 | Flag: uint64(stat.Flags), // assuming POSIX? 19 | Namemax: 1024, // man 2 statfs shows: #define MAXPATHLEN 1024 20 | }, nil 21 | } 22 | -------------------------------------------------------------------------------- /server_statvfs_impl.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || linux 2 | // +build darwin linux 3 | 4 | // fill in statvfs structure with OS specific values 5 | // Statfs_t is different per-kernel, and only exists on some unixes (not Solaris for instance) 6 | 7 | package sftp 8 | 9 | import ( 10 | "syscall" 11 | ) 12 | 13 | func (p *sshFxpExtendedPacketStatVFS) respond(svr *Server) responsePacket { 14 | retPkt, err := getStatVFSForPath(p.Path) 15 | if err != nil { 16 | return statusFromError(p.ID, err) 17 | } 18 | retPkt.ID = p.ID 19 | 20 | return retPkt 21 | } 22 | 23 | func getStatVFSForPath(name string) (*StatVFS, error) { 24 | var stat syscall.Statfs_t 25 | if err := syscall.Statfs(name, &stat); err != nil { 26 | return nil, err 27 | } 28 | 29 | return statvfsFromStatfst(&stat) 30 | } 31 | -------------------------------------------------------------------------------- /server_statvfs_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package sftp 5 | 6 | import ( 7 | "syscall" 8 | ) 9 | 10 | func statvfsFromStatfst(stat *syscall.Statfs_t) (*StatVFS, error) { 11 | return &StatVFS{ 12 | Bsize: uint64(stat.Bsize), 13 | Frsize: uint64(stat.Frsize), 14 | Blocks: stat.Blocks, 15 | Bfree: stat.Bfree, 16 | Bavail: stat.Bavail, 17 | Files: stat.Files, 18 | Ffree: stat.Ffree, 19 | Favail: stat.Ffree, // not sure how to calculate Favail 20 | Flag: uint64(stat.Flags), // assuming POSIX? 21 | Namemax: uint64(stat.Namelen), 22 | }, nil 23 | } 24 | -------------------------------------------------------------------------------- /server_statvfs_plan9.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "syscall" 5 | ) 6 | 7 | func (p *sshFxpExtendedPacketStatVFS) respond(svr *Server) responsePacket { 8 | return statusFromError(p.ID, syscall.EPLAN9) 9 | } 10 | 11 | func getStatVFSForPath(name string) (*StatVFS, error) { 12 | return nil, syscall.EPLAN9 13 | } 14 | -------------------------------------------------------------------------------- /server_statvfs_stubs.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin && !linux && !plan9 2 | // +build !darwin,!linux,!plan9 3 | 4 | package sftp 5 | 6 | import ( 7 | "syscall" 8 | ) 9 | 10 | func (p *sshFxpExtendedPacketStatVFS) respond(svr *Server) responsePacket { 11 | return statusFromError(p.ID, syscall.ENOTSUP) 12 | } 13 | 14 | func getStatVFSForPath(name string) (*StatVFS, error) { 15 | return nil, syscall.ENOTSUP 16 | } 17 | -------------------------------------------------------------------------------- /server_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !plan9 2 | // +build !windows,!plan9 3 | 4 | package sftp 5 | 6 | import ( 7 | "path" 8 | ) 9 | 10 | func (s *Server) toLocalPath(p string) string { 11 | if s.workDir != "" && !path.IsAbs(p) { 12 | p = path.Join(s.workDir, p) 13 | } 14 | 15 | return p 16 | } 17 | -------------------------------------------------------------------------------- /server_windows.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "time" 11 | 12 | "golang.org/x/sys/windows" 13 | ) 14 | 15 | func (s *Server) toLocalPath(p string) string { 16 | if s.workDir != "" && !path.IsAbs(p) { 17 | p = path.Join(s.workDir, p) 18 | } 19 | 20 | lp := filepath.FromSlash(p) 21 | 22 | if path.IsAbs(p) { // starts with '/' 23 | if len(p) == 1 && s.winRoot { 24 | return `\\.\` // for openfile 25 | } 26 | 27 | tmp := lp 28 | for len(tmp) > 0 && tmp[0] == '\\' { 29 | tmp = tmp[1:] 30 | } 31 | 32 | if filepath.IsAbs(tmp) { 33 | // If the FromSlash without any starting slashes is absolute, 34 | // then we have a filepath encoded with a prefix '/'. 35 | // e.g. "/C:/Windows" to "C:\\Windows" 36 | return tmp 37 | } 38 | 39 | tmp += "\\" 40 | 41 | if filepath.IsAbs(tmp) { 42 | // If the FromSlash without any starting slashes but with extra end slash is absolute, 43 | // then we have a filepath encoded with a prefix '/' and a dropped '/' at the end. 44 | // e.g. "/C:" to "C:\\" 45 | return tmp 46 | } 47 | 48 | if s.winRoot { 49 | // Make it so that "/Windows" is not found, and "/c:/Windows" has to be used 50 | return `\\.\` + tmp 51 | } 52 | } 53 | 54 | return lp 55 | } 56 | 57 | func bitsToDrives(bitmap uint32) []string { 58 | var drive rune = 'a' 59 | var drives []string 60 | 61 | for bitmap != 0 && drive <= 'z' { 62 | if bitmap&1 == 1 { 63 | drives = append(drives, string(drive)+":") 64 | } 65 | drive++ 66 | bitmap >>= 1 67 | } 68 | 69 | return drives 70 | } 71 | 72 | func getDrives() ([]string, error) { 73 | mask, err := windows.GetLogicalDrives() 74 | if err != nil { 75 | return nil, fmt.Errorf("GetLogicalDrives: %w", err) 76 | } 77 | return bitsToDrives(mask), nil 78 | } 79 | 80 | type driveInfo struct { 81 | fs.FileInfo 82 | name string 83 | } 84 | 85 | func (i *driveInfo) Name() string { 86 | return i.name // since the Name() returned from a os.Stat("C:\\") is "\\" 87 | } 88 | 89 | type winRoot struct { 90 | drives []string 91 | } 92 | 93 | func newWinRoot() (*winRoot, error) { 94 | drives, err := getDrives() 95 | if err != nil { 96 | return nil, err 97 | } 98 | return &winRoot{ 99 | drives: drives, 100 | }, nil 101 | } 102 | 103 | func (f *winRoot) Readdir(n int) ([]os.FileInfo, error) { 104 | drives := f.drives 105 | if n > 0 && len(drives) > n { 106 | drives = drives[:n] 107 | } 108 | f.drives = f.drives[len(drives):] 109 | if len(drives) == 0 { 110 | return nil, io.EOF 111 | } 112 | 113 | var infos []os.FileInfo 114 | for _, drive := range drives { 115 | fi, err := os.Stat(drive + `\`) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | di := &driveInfo{ 121 | FileInfo: fi, 122 | name: drive, 123 | } 124 | infos = append(infos, di) 125 | } 126 | 127 | return infos, nil 128 | } 129 | 130 | func (f *winRoot) Stat() (os.FileInfo, error) { 131 | return rootFileInfo, nil 132 | } 133 | func (f *winRoot) ReadAt(b []byte, off int64) (int, error) { 134 | return 0, os.ErrPermission 135 | } 136 | func (f *winRoot) WriteAt(b []byte, off int64) (int, error) { 137 | return 0, os.ErrPermission 138 | } 139 | func (f *winRoot) Name() string { 140 | return "/" 141 | } 142 | func (f *winRoot) Truncate(int64) error { 143 | return os.ErrPermission 144 | } 145 | func (f *winRoot) Chmod(mode fs.FileMode) error { 146 | return os.ErrPermission 147 | } 148 | func (f *winRoot) Chown(uid, gid int) error { 149 | return os.ErrPermission 150 | } 151 | func (f *winRoot) Close() error { 152 | f.drives = nil 153 | return nil 154 | } 155 | 156 | func (s *Server) openfile(path string, flag int, mode fs.FileMode) (file, error) { 157 | if path == `\\.\` && s.winRoot { 158 | return newWinRoot() 159 | } 160 | return os.OpenFile(path, flag, mode) 161 | } 162 | 163 | type winRootFileInfo struct { 164 | name string 165 | modTime time.Time 166 | } 167 | 168 | func (w *winRootFileInfo) Name() string { return w.name } 169 | func (w *winRootFileInfo) Size() int64 { return 0 } 170 | func (w *winRootFileInfo) Mode() fs.FileMode { return fs.ModeDir | 0555 } // read+execute for all 171 | func (w *winRootFileInfo) ModTime() time.Time { return w.modTime } 172 | func (w *winRootFileInfo) IsDir() bool { return true } 173 | func (w *winRootFileInfo) Sys() interface{} { return nil } 174 | 175 | // Create a new root FileInfo 176 | var rootFileInfo = &winRootFileInfo{ 177 | name: "/", 178 | modTime: time.Now(), 179 | } 180 | 181 | func (s *Server) lstat(name string) (os.FileInfo, error) { 182 | if name == `\\.\` && s.winRoot { 183 | return rootFileInfo, nil 184 | } 185 | return os.Lstat(name) 186 | } 187 | 188 | func (s *Server) stat(name string) (os.FileInfo, error) { 189 | if name == `\\.\` && s.winRoot { 190 | return rootFileInfo, nil 191 | } 192 | return os.Stat(name) 193 | } 194 | -------------------------------------------------------------------------------- /server_windows_test.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestServer_toLocalPath(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | withWorkDir string 11 | p string 12 | want string 13 | }{ 14 | { 15 | name: "empty path with no workdir", 16 | p: "", 17 | want: "", 18 | }, 19 | { 20 | name: "relative path with no workdir", 21 | p: "file", 22 | want: "file", 23 | }, 24 | { 25 | name: "absolute path with no workdir", 26 | p: "/file", 27 | want: "\\file", 28 | }, 29 | { 30 | name: "workdir and empty path", 31 | withWorkDir: "C:\\Users\\User", 32 | p: "", 33 | want: "C:\\Users\\User", 34 | }, 35 | { 36 | name: "workdir and relative path", 37 | withWorkDir: "C:\\Users\\User", 38 | p: "file", 39 | want: "C:\\Users\\User\\file", 40 | }, 41 | { 42 | name: "workdir and relative path with .", 43 | withWorkDir: "C:\\Users\\User", 44 | p: ".", 45 | want: "C:\\Users\\User", 46 | }, 47 | { 48 | name: "workdir and relative path with . and file", 49 | withWorkDir: "C:\\Users\\User", 50 | p: "./file", 51 | want: "C:\\Users\\User\\file", 52 | }, 53 | { 54 | name: "workdir and absolute path", 55 | withWorkDir: "C:\\Users\\User", 56 | p: "/C:/file", 57 | want: "C:\\file", 58 | }, 59 | { 60 | name: "workdir and non-unixy path prefixes workdir", 61 | withWorkDir: "C:\\Users\\User", 62 | p: "C:\\file", 63 | // This may look like a bug but it is the result of passing 64 | // invalid input (a non-unixy path) to the server. 65 | want: "C:\\Users\\User\\C:\\file", 66 | }, 67 | } 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | // We don't need to initialize the Server further to test 71 | // toLocalPath behavior. 72 | s := &Server{} 73 | if tt.withWorkDir != "" { 74 | if err := WithServerWorkingDirectory(tt.withWorkDir)(s); err != nil { 75 | t.Fatal(err) 76 | } 77 | } 78 | 79 | if got := s.toLocalPath(tt.p); got != tt.want { 80 | t.Errorf("Server.toLocalPath() = %q, want %q", got, tt.want) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /sftp_test.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "syscall" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestErrFxCode(t *testing.T) { 14 | table := []struct { 15 | err error 16 | fx fxerr 17 | }{ 18 | {err: errors.New("random error"), fx: ErrSSHFxFailure}, 19 | {err: EBADF, fx: ErrSSHFxFailure}, 20 | {err: syscall.ENOENT, fx: ErrSSHFxNoSuchFile}, 21 | {err: syscall.EPERM, fx: ErrSSHFxPermissionDenied}, 22 | {err: io.EOF, fx: ErrSSHFxEOF}, 23 | {err: fmt.Errorf("wrapped permission denied error: %w", ErrSSHFxPermissionDenied), fx: ErrSSHFxPermissionDenied}, 24 | {err: fmt.Errorf("wrapped op unsupported error: %w", ErrSSHFxOpUnsupported), fx: ErrSSHFxOpUnsupported}, 25 | } 26 | for _, tt := range table { 27 | statusErr := statusFromError(1, tt.err).StatusError 28 | assert.Equal(t, statusErr.FxCode(), tt.fx) 29 | } 30 | } 31 | 32 | func TestSupportedExtensions(t *testing.T) { 33 | for _, supportedExtension := range supportedSFTPExtensions { 34 | _, err := getSupportedExtensionByName(supportedExtension.Name) 35 | assert.NoError(t, err) 36 | } 37 | _, err := getSupportedExtensionByName("invalid@example.com") 38 | assert.Error(t, err) 39 | } 40 | 41 | func TestExtensions(t *testing.T) { 42 | var supportedExtensions []string 43 | for _, supportedExtension := range supportedSFTPExtensions { 44 | supportedExtensions = append(supportedExtensions, supportedExtension.Name) 45 | } 46 | 47 | testSFTPExtensions := []string{"hardlink@openssh.com"} 48 | expectedSFTPExtensions := []sshExtensionPair{ 49 | {"hardlink@openssh.com", "1"}, 50 | } 51 | err := SetSFTPExtensions(testSFTPExtensions...) 52 | assert.NoError(t, err) 53 | assert.Equal(t, expectedSFTPExtensions, sftpExtensions) 54 | 55 | invalidSFTPExtensions := []string{"invalid@example.com"} 56 | err = SetSFTPExtensions(invalidSFTPExtensions...) 57 | assert.Error(t, err) 58 | assert.Equal(t, expectedSFTPExtensions, sftpExtensions) 59 | 60 | emptySFTPExtensions := []string{} 61 | expectedSFTPExtensions = []sshExtensionPair{} 62 | err = SetSFTPExtensions(emptySFTPExtensions...) 63 | assert.NoError(t, err) 64 | assert.Equal(t, expectedSFTPExtensions, sftpExtensions) 65 | 66 | // if we only have an invalid extension nothing will be modified. 67 | invalidSFTPExtensions = []string{ 68 | "hardlink@openssh.com", 69 | "invalid@example.com", 70 | } 71 | err = SetSFTPExtensions(invalidSFTPExtensions...) 72 | assert.Error(t, err) 73 | assert.Equal(t, expectedSFTPExtensions, sftpExtensions) 74 | 75 | err = SetSFTPExtensions(supportedExtensions...) 76 | assert.NoError(t, err) 77 | assert.Equal(t, supportedSFTPExtensions, sftpExtensions) 78 | } 79 | -------------------------------------------------------------------------------- /stat.go: -------------------------------------------------------------------------------- 1 | package sftp 2 | 3 | import ( 4 | "os" 5 | 6 | sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" 7 | ) 8 | 9 | // isRegular returns true if the mode describes a regular file. 10 | func isRegular(mode uint32) bool { 11 | return sshfx.FileMode(mode)&sshfx.ModeType == sshfx.ModeRegular 12 | } 13 | 14 | // toFileMode converts sftp filemode bits to the os.FileMode specification 15 | func toFileMode(mode uint32) os.FileMode { 16 | var fm = os.FileMode(mode & 0777) 17 | 18 | switch sshfx.FileMode(mode) & sshfx.ModeType { 19 | case sshfx.ModeDevice: 20 | fm |= os.ModeDevice 21 | case sshfx.ModeCharDevice: 22 | fm |= os.ModeDevice | os.ModeCharDevice 23 | case sshfx.ModeDir: 24 | fm |= os.ModeDir 25 | case sshfx.ModeNamedPipe: 26 | fm |= os.ModeNamedPipe 27 | case sshfx.ModeSymlink: 28 | fm |= os.ModeSymlink 29 | case sshfx.ModeRegular: 30 | // nothing to do 31 | case sshfx.ModeSocket: 32 | fm |= os.ModeSocket 33 | } 34 | 35 | if sshfx.FileMode(mode)&sshfx.ModeSetUID != 0 { 36 | fm |= os.ModeSetuid 37 | } 38 | if sshfx.FileMode(mode)&sshfx.ModeSetGID != 0 { 39 | fm |= os.ModeSetgid 40 | } 41 | if sshfx.FileMode(mode)&sshfx.ModeSticky != 0 { 42 | fm |= os.ModeSticky 43 | } 44 | 45 | return fm 46 | } 47 | 48 | // fromFileMode converts from the os.FileMode specification to sftp filemode bits 49 | func fromFileMode(mode os.FileMode) uint32 { 50 | ret := sshfx.FileMode(mode & os.ModePerm) 51 | 52 | switch mode & os.ModeType { 53 | case os.ModeDevice | os.ModeCharDevice: 54 | ret |= sshfx.ModeCharDevice 55 | case os.ModeDevice: 56 | ret |= sshfx.ModeDevice 57 | case os.ModeDir: 58 | ret |= sshfx.ModeDir 59 | case os.ModeNamedPipe: 60 | ret |= sshfx.ModeNamedPipe 61 | case os.ModeSymlink: 62 | ret |= sshfx.ModeSymlink 63 | case 0: 64 | ret |= sshfx.ModeRegular 65 | case os.ModeSocket: 66 | ret |= sshfx.ModeSocket 67 | } 68 | 69 | if mode&os.ModeSetuid != 0 { 70 | ret |= sshfx.ModeSetUID 71 | } 72 | if mode&os.ModeSetgid != 0 { 73 | ret |= sshfx.ModeSetGID 74 | } 75 | if mode&os.ModeSticky != 0 { 76 | ret |= sshfx.ModeSticky 77 | } 78 | 79 | return uint32(ret) 80 | } 81 | 82 | const ( 83 | s_ISUID = uint32(sshfx.ModeSetUID) 84 | s_ISGID = uint32(sshfx.ModeSetGID) 85 | s_ISVTX = uint32(sshfx.ModeSticky) 86 | ) 87 | 88 | // S_IFMT is a legacy export, and was brought in to support GOOS environments whose sysconfig.S_IFMT may be different from the value used internally by SFTP standards. 89 | // There should be no reason why you need to import it, or use it, but unexporting it could cause code to break in a way that cannot be readily fixed. 90 | // As such, we continue to export this value as the value used in the SFTP standard. 91 | // 92 | // Deprecated: Remove use of this value, and avoid any future use as well. 93 | // There is no alternative provided, you should never need to access this value. 94 | const S_IFMT = uint32(sshfx.ModeType) 95 | --------------------------------------------------------------------------------