aboutsummaryrefslogtreecommitdiff
path: root/lib/ssh/sftp
diff options
context:
space:
mode:
authorShulhan <ms@kilabit.info>2021-07-08 16:50:06 +0700
committerShulhan <ms@kilabit.info>2021-07-12 02:23:47 +0700
commit2f6bdb4e2cce7b5bdf4255b21f2588b1d6b7444f (patch)
tree732cfe84c677475f4b43508c3dc102d819209ecb /lib/ssh/sftp
parenta15f96aae0574de614b8ab33cfbb09fd70f00182 (diff)
downloadpakakeh.go-2f6bdb4e2cce7b5bdf4255b21f2588b1d6b7444f.tar.xz
ssh/sftp: new package that implement SSH File Transport Protocol v3
The sftp package extend the golang.org/x/crypto/ssh package by implementing "sftp" subsystem using the ssh.Client connection.
Diffstat (limited to 'lib/ssh/sftp')
-rw-r--r--lib/ssh/sftp/client.go726
-rw-r--r--lib/ssh/sftp/client_test.go281
-rw-r--r--lib/ssh/sftp/extensions.go29
-rw-r--r--lib/ssh/sftp/file_attrs.go247
-rw-r--r--lib/ssh/sftp/file_handle.go9
-rw-r--r--lib/ssh/sftp/node.go14
-rw-r--r--lib/ssh/sftp/packet.go389
-rw-r--r--lib/ssh/sftp/sftp.go47
-rw-r--r--lib/ssh/sftp/sftp_test.go57
-rw-r--r--lib/ssh/sftp/testdata/id_ed255197
-rw-r--r--lib/ssh/sftp/testdata/id_ed25519.pub1
11 files changed, 1807 insertions, 0 deletions
diff --git a/lib/ssh/sftp/client.go b/lib/ssh/sftp/client.go
new file mode 100644
index 00000000..babd1dfa
--- /dev/null
+++ b/lib/ssh/sftp/client.go
@@ -0,0 +1,726 @@
+// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package sftp
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+//
+// Client for SFTP.
+//
+type Client struct {
+ sess *ssh.Session
+ version uint32
+ exts extensions
+ pipeIn io.WriteCloser
+ pipeOut io.Reader
+ pipeErr io.Reader
+
+ // The requestId is unique number that will be incremented by client,
+ // to prevent the same ID generated on concurrent operations.
+ mtxId sync.Mutex
+ requestId uint32
+}
+
+//
+// NewClient create and initialize new client for SSH file transfer protocol.
+//
+func NewClient(sshc *ssh.Client) (cl *Client, err error) {
+ logp := "New"
+ cl = &Client{}
+
+ cl.sess, err = sshc.NewSession()
+ if err != nil {
+ return nil, fmt.Errorf("%s: NewSession: %w", logp, err)
+ }
+
+ cl.pipeIn, err = cl.sess.StdinPipe()
+ if err != nil {
+ return nil, fmt.Errorf("%s: StdinPipe: %w", logp, err)
+ }
+ cl.pipeOut, err = cl.sess.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("%s: StdoutPipe: %w", logp, err)
+ }
+ cl.pipeErr, err = cl.sess.StderrPipe()
+ if err != nil {
+ return nil, fmt.Errorf("%s: StderrPipe: %w", logp, err)
+ }
+
+ err = cl.sess.RequestSubsystem(subsystemNameSftp)
+ if err != nil {
+ return nil, fmt.Errorf("%s: RequestSubsystem: %w", logp, err)
+ }
+
+ cl.requestId = uint32(time.Now().Unix())
+
+ err = cl.init()
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+
+ return cl, nil
+}
+
+//
+// Close the remote file handle.
+//
+func (cl *Client) Close(fh *FileHandle) (err error) {
+ if fh == nil {
+ return nil
+ }
+ var (
+ logp = "Close"
+ req = cl.generatePacket()
+ payload = req.fxpClose(fh)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message)
+ }
+
+ return nil
+}
+
+//
+// Create creates or truncates the named file.
+// If the remote file does not exist, it will be created.
+// If the remote file already exists, it will be truncated.
+// On success, it will return the remote FileHandle ready for write only.
+//
+func (cl *Client) Create(remoteFile string, fa *FileAttrs) (*FileHandle, error) {
+ pflags := ssh_FXF_WRITE | ssh_FXF_CREAT | ssh_FXF_TRUNC
+ return cl.open(remoteFile, pflags, fa)
+}
+
+//
+// Fsetstat set the file attributes based on the opened remote file handle.
+//
+func (cl *Client) Fsetstat(fh *FileHandle, fa *FileAttrs) (err error) {
+ var (
+ logp = "Fsetstat"
+ req = cl.generatePacket()
+ )
+ if fh == nil || fa == nil {
+ return nil
+ }
+
+ payload := req.fxpFsetstat(fh, fa)
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+ return nil
+}
+
+//
+// Fstat get the file attributes based on the opened remote file handle.
+//
+func (cl *Client) Fstat(fh *FileHandle) (fa *FileAttrs, err error) {
+ var (
+ logp = "Fstat"
+ req = cl.generatePacket()
+ payload = req.fxpFstat(fh)
+ )
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind == packetKindFxpStatus {
+ return nil, fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message)
+ }
+ if res.kind != packetKindFxpAttrs {
+ return nil, ErrUnexpectedResponse(packetKindFxpAttrs, res.kind)
+ }
+
+ return res.fa, nil
+}
+
+//
+// Get copy remote file to local.
+// The local file will be created if its not exist; otherwise it will
+// truncated.
+//
+func (cl *Client) Get(remoteFile, localFile string) (err error) {
+ var (
+ logp = "Get"
+ offset uint64
+ )
+
+ fin, err := cl.Open(remoteFile)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ fout, err := os.Create(localFile)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ for {
+ data, err := cl.Read(fin, offset)
+ if len(data) > 0 {
+ _, err = fout.Write(data)
+ if err != nil {
+ break
+ }
+ }
+ if err != nil {
+ break
+ }
+ offset += uint64(len(data))
+ }
+ if err != nil && !errors.Is(err, io.EOF) {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ err = fout.Close()
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ err = cl.Close(fin)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ return err
+}
+
+//
+// Lstat get the file attributes based on the remote file path.
+// Unlike Stat(), the Lstat method does not follow symbolic links.
+//
+func (cl *Client) Lstat(remoteFile string) (fa *FileAttrs, err error) {
+ var (
+ logp = "Lstat"
+ req = cl.generatePacket()
+ payload = req.fxpLstat(remoteFile)
+ )
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind == packetKindFxpStatus {
+ return nil, fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+ if res.kind != packetKindFxpAttrs {
+ return nil, ErrUnexpectedResponse(packetKindFxpAttrs, res.kind)
+ }
+
+ return res.fa, nil
+}
+
+//
+// Mkdir create new directory on the server.
+//
+func (cl *Client) Mkdir(path string, fa *FileAttrs) (err error) {
+ var (
+ logp = "Mkdir"
+ req = cl.generatePacket()
+ )
+ if fa == nil {
+ fa = newFileAttrs()
+ }
+ payload := req.fxpMkdir(path, fa)
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+ return nil
+}
+
+//
+// Open the remote file for read only.
+//
+func (cl *Client) Open(remoteFile string) (fh *FileHandle, err error) {
+ pflags := ssh_FXF_READ
+ return cl.open(remoteFile, pflags, nil)
+}
+
+//
+// Opendir open the directory on the server.
+//
+func (cl *Client) Opendir(path string) (fh *FileHandle, err error) {
+ var (
+ logp = "Opendir"
+ req = cl.generatePacket()
+ )
+ payload := req.fxpOpendir(path)
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind == packetKindFxpStatus {
+ return nil, fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+ if res.kind != packetKindFxpHandle {
+ return nil, ErrUnexpectedResponse(packetKindFxpHandle, res.kind)
+ }
+ fh = res.fh
+ res.fh = nil
+ return fh, nil
+}
+
+//
+// Put local file to remote file.
+//
+func (cl *Client) Put(localFile, remoteFile string) (err error) {
+ logp := "Put"
+
+ fin, err := os.Open(localFile)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ finfo, err := fin.Stat()
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ fa := NewFileAttrs(finfo)
+
+ fout, err := cl.Create(remoteFile, fa)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ var (
+ offset uint64
+ data = make([]byte, 32000)
+ n int
+ )
+
+ n, err = fin.Read(data)
+ for n > 0 {
+ if err != nil {
+ break
+ }
+ err = cl.Write(fout, offset, data[:n])
+ if err != nil {
+ break
+ }
+ if n < len(data) {
+ break
+ }
+
+ offset += uint64(n)
+ n, err = fin.Read(data)
+ }
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ err = cl.Close(fout)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ return nil
+}
+
+//
+// Read the remote file using handle on specific offset.
+// On end-of-file it will return empty data with io.EOF.
+//
+func (cl *Client) Read(fh *FileHandle, offset uint64) (data []byte, err error) {
+ var (
+ logp = "Read"
+ req = cl.generatePacket()
+ payload = req.fxpRead(fh, offset, maxPacketRead)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+
+ if res.kind == packetKindFxpStatus {
+ if res.code == ssh_FX_EOF {
+ return nil, io.EOF
+ }
+ return nil, fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+ if res.kind != packetKindFxpData {
+ return nil, ErrUnexpectedResponse(packetKindFxpData, res.kind)
+ }
+
+ return res.data, nil
+}
+
+//
+// Readdir list files and/or directories inside the handle.
+//
+func (cl *Client) Readdir(fh *FileHandle) (nodes []*Node, err error) {
+ var (
+ logp = "Readdir"
+ req = cl.generatePacket()
+ payload = req.fxpReaddir(fh)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+
+ if res.kind == packetKindFxpStatus {
+ return nil, fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+ if res.kind != packetKindFxpName {
+ return nil, ErrUnexpectedResponse(packetKindFxpName, res.kind)
+ }
+ nodes = res.nodes
+ res.nodes = nil
+ return nodes, nil
+}
+
+//
+// Readlink read the target of a symbolic link.
+//
+func (cl *Client) Readlink(linkPath string) (node *Node, err error) {
+ var (
+ logp = "Readlink"
+ req = cl.generatePacket()
+ payload = req.fxpReadlink(linkPath)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpName {
+ return nil, ErrUnexpectedResponse(packetKindFxpName, res.kind)
+ }
+
+ return res.nodes[0], nil
+}
+
+//
+// Realpath canonicalize any given path name to an absolute path.
+// This is useful for converting path names containing ".." components or
+// relative pathnames without a leading slash into absolute paths.
+//
+func (cl *Client) Realpath(path string) (node *Node, err error) {
+ var (
+ logp = "Realpath"
+ req = cl.generatePacket()
+ payload = req.fxpRealpath(path)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpName {
+ return nil, ErrUnexpectedResponse(packetKindFxpName, res.kind)
+ }
+
+ return res.nodes[0], nil
+}
+
+//
+// Remove the remote file.
+//
+func (cl *Client) Remove(remoteFile string) (err error) {
+ var (
+ logp = "Remove"
+ req = cl.generatePacket()
+ payload = req.fxpRemove(remoteFile)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+
+ return nil
+}
+
+//
+// Rename the file, or move the file, from old path to new path.
+//
+func (cl *Client) Rename(oldPath, newPath string) (err error) {
+ var (
+ logp = "Rename"
+ req = cl.generatePacket()
+ payload = req.fxpRename(oldPath, newPath)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+
+ return nil
+}
+
+//
+// Rmdir remove the directory on the server.
+//
+func (cl *Client) Rmdir(path string) (err error) {
+ var (
+ logp = "Rmdir"
+ req = cl.generatePacket()
+ payload = req.fxpRmdir(path)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+
+ return nil
+}
+
+//
+// Setstat change the file attributes on remote file or directory.
+// These request can be used for operations such as changing the ownership,
+// permissions or access times, as well as for truncating a file.
+//
+func (cl *Client) Setstat(remoteFile string, fa *FileAttrs) (err error) {
+ var (
+ logp = "Setstat"
+ req = cl.generatePacket()
+ )
+ if len(remoteFile) == 0 || fa == nil {
+ return nil
+ }
+
+ payload := req.fxpSetstat(remoteFile, fa)
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+
+ return nil
+}
+
+//
+// Stat get the file attributes based on the remote file path.
+// This method follow symbolic links.
+//
+func (cl *Client) Stat(remoteFile string) (fa *FileAttrs, err error) {
+ var (
+ logp = "Stat"
+ req = cl.generatePacket()
+ payload = req.fxpStat(remoteFile)
+ )
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind == packetKindFxpStatus {
+ return nil, fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message)
+ }
+ if res.kind != packetKindFxpAttrs {
+ return nil, ErrUnexpectedResponse(packetKindFxpAttrs, res.kind)
+ }
+
+ return res.fa, nil
+}
+
+//
+// Symlink create a symbolic link on the server.
+// The `linkpath' specifies the path name of the symlink to be created and
+// `targetpath' specifies the target of the symlink.
+//
+func (cl *Client) Symlink(targetPath, linkPath string) (err error) {
+ var (
+ logp = "Symlink"
+ req = cl.generatePacket()
+ payload = req.fxpSymlink(targetPath, linkPath)
+ )
+ if len(targetPath) == 0 || len(linkPath) == 0 {
+ return nil
+ }
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %s", logp, res.code, res.message)
+ }
+ return nil
+}
+
+//
+// Write write the data into remote file at specific offset.
+//
+func (cl *Client) Write(fh *FileHandle, offset uint64, data []byte) (err error) {
+ var (
+ logp = "Write"
+ req = cl.generatePacket()
+ payload = req.fxpWrite(fh, offset, data)
+ )
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+ if res.kind != packetKindFxpStatus {
+ return ErrUnexpectedResponse(packetKindFxpStatus, res.kind)
+ }
+ if res.code != ssh_FX_OK {
+ return fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message)
+ }
+ return nil
+}
+
+func (cl *Client) generatePacket() (pac *packet) {
+ cl.mtxId.Lock()
+ cl.requestId++
+ pac = &packet{
+ requestId: cl.requestId,
+ }
+ cl.mtxId.Unlock()
+ return pac
+}
+
+func (cl *Client) init() (err error) {
+ logp := "init"
+
+ req := cl.generatePacket()
+ payload := req.fxpInit(defFxpVersion)
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return fmt.Errorf("%s: %w", logp, err)
+ }
+
+ cl.version = res.version
+ cl.exts = res.exts
+
+ return nil
+}
+
+func (cl *Client) open(remoteFile string, pflags uint32, fa *FileAttrs) (h *FileHandle, err error) {
+ var (
+ logp = "open"
+ req = cl.generatePacket()
+ )
+ if fa == nil {
+ fa = newFileAttrs()
+ }
+
+ payload := req.fxpOpen(remoteFile, pflags, fa)
+
+ res, err := cl.send(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+
+ if res.kind != packetKindFxpHandle {
+ err = fmt.Errorf("%s: %d %d %s", logp, res.kind, res.code, res.message)
+ return nil, err
+ }
+
+ return res.fh, nil
+}
+
+func (cl *Client) read() (res []byte, err error) {
+ var (
+ logp = "read"
+ block = make([]byte, 1024)
+ )
+
+ n, err := cl.pipeOut.Read(block)
+ for n > 0 {
+ res = append(res, block[:n]...)
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+ if n < len(block) {
+ break
+ }
+ n, err = cl.pipeOut.Read(block)
+ }
+ return res, nil
+}
+
+func (cl *Client) send(payload []byte) (res *packet, err error) {
+ var (
+ logp = "send"
+ )
+
+ _, err = cl.pipeIn.Write(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: Write: %w", logp, err)
+ }
+
+ payload, err = cl.read()
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+
+ // TODO: check the response ID.
+ res, err = unpackPacket(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", logp, err)
+ }
+
+ return res, nil
+}
diff --git a/lib/ssh/sftp/client_test.go b/lib/ssh/sftp/client_test.go
new file mode 100644
index 00000000..aed7ddd7
--- /dev/null
+++ b/lib/ssh/sftp/client_test.go
@@ -0,0 +1,281 @@
+// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package sftp
+
+import (
+ "testing"
+
+ "github.com/shuLhan/share/lib/test"
+)
+
+func TestClient_Fsetstat(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ remoteFile := "/tmp/lib-ssh-sftp-fsetstat.test"
+
+ fa := newFileAttrs()
+ fa.SetPermissions(0766)
+
+ fh, err := testClient.Create(remoteFile, fa)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fa, err = testClient.Fstat(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ exp := uint32(0o100600)
+ fa.SetPermissions(exp)
+
+ err = testClient.Fsetstat(fh, fa)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fa, err = testClient.Fstat(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ test.Assert(t, "Fsetstat", exp, fa.Permissions())
+}
+
+func TestClient_Fstat(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ remoteFile := "/etc/hosts"
+ fh, err := testClient.Open(remoteFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fa, err := testClient.Fstat(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("Fstat %s: %+v", remoteFile, fa)
+}
+
+func TestClient_Get(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ err := testClient.Get("/tmp/id_ed25519.pub", "testdata/id_ed25519.pub.get")
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestClient_Lstat(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ remoteFile := "/etc/hosts"
+ fa, err := testClient.Lstat(remoteFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("Lstat: %s: %+v", remoteFile, fa)
+}
+
+func TestClient_Mkdir(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ path := "/tmp/lib-ssh-sftp-mkdir"
+ err := testClient.Mkdir(path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = testClient.Rmdir(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestClient_Put(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ err := testClient.Put("testdata/id_ed25519.pub", "/tmp/id_ed25519.pub")
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestClient_Readdir(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ path := "/tmp"
+ fh, err := testClient.Opendir(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ nodes, err := testClient.Readdir(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("List of files inside the %s:\n", path)
+ for x, node := range nodes {
+ t.Logf("%02d: %+v\n", x, node.LongName)
+ }
+}
+
+func TestClient_Realpath(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ node, err := testClient.Realpath("../../etc/hosts")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ exp := "/etc/hosts"
+ test.Assert(t, "Realpath", exp, node.FileName)
+}
+
+func TestClient_Rename(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ oldPath := "/tmp/lib-ssh-sftp-rename-old"
+ newPath := "/tmp/lib-ssh-sftp-rename-new"
+
+ _ = testClient.Remove(newPath)
+
+ fh, err := testClient.Create(oldPath, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expAttrs, err := testClient.Fstat(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = testClient.Close(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = testClient.Rename(oldPath, newPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ gotAttrs, err := testClient.Stat(newPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ test.Assert(t, "Rename", expAttrs, gotAttrs)
+}
+
+func TestClient_Setstat(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ remoteFile := "/tmp/lib-ssh-sftp-setstat.test"
+
+ fa := newFileAttrs()
+ fa.SetPermissions(0766)
+
+ fh, err := testClient.Create(remoteFile, fa)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fa, err = testClient.Fstat(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ exp := uint32(0o100600)
+ fa.SetPermissions(exp)
+
+ err = testClient.Setstat(remoteFile, fa)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fa, err = testClient.Fstat(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ test.Assert(t, "Setstat", exp, fa.Permissions())
+}
+
+func TestClient_Stat(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ remoteFile := "/etc/hosts"
+ fh, err := testClient.Open(remoteFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fa, err := testClient.Fstat(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("Stat: %s: %+v", remoteFile, fa)
+}
+
+func TestClient_Symlink(t *testing.T) {
+ if !isTestManual {
+ t.Skipf("%s not set", envNameTestManual)
+ }
+
+ targetPath := "/tmp/lib-ssh-sftp-symlink-targetpath"
+ linkPath := "/tmp/lib-ssh-sftp-symlink-linkpath"
+
+ _ = testClient.Remove(linkPath)
+ _ = testClient.Remove(targetPath)
+
+ fh, err := testClient.Create(targetPath, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = testClient.Close(fh)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = testClient.Symlink(targetPath, linkPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ node, err := testClient.Readlink(linkPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ test.Assert(t, "Readlink", targetPath, node.FileName)
+}
diff --git a/lib/ssh/sftp/extensions.go b/lib/ssh/sftp/extensions.go
new file mode 100644
index 00000000..0fb1b247
--- /dev/null
+++ b/lib/ssh/sftp/extensions.go
@@ -0,0 +1,29 @@
+// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package sftp
+
+import "encoding/binary"
+
+//
+// extensions contains mapping of extension-pair name and data, as defined in
+// #section-4.2.
+//
+type extensions map[string]string
+
+func unpackExtensions(payload []byte) (exts extensions) {
+ exts = extensions{}
+ for len(payload) > 0 {
+ v := binary.BigEndian.Uint32(payload[:4])
+ payload = payload[4:]
+ name := string(payload[:v])
+ payload = payload[v:]
+
+ v = binary.BigEndian.Uint32(payload[:4])
+ payload = payload[4:]
+ exts[name] = string(payload[:v])
+ payload = payload[v:]
+ }
+ return exts
+}
diff --git a/lib/ssh/sftp/file_attrs.go b/lib/ssh/sftp/file_attrs.go
new file mode 100644
index 00000000..a7c14e21
--- /dev/null
+++ b/lib/ssh/sftp/file_attrs.go
@@ -0,0 +1,247 @@
+// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package sftp
+
+import (
+ "encoding/binary"
+ "io"
+ "io/fs"
+)
+
+// List of valid values for FileAttrs.flags.
+const (
+ attr_SIZE uint32 = 0x00000001
+ attr_UIDGID uint32 = 0x00000002
+ attr_PERMISSIONS uint32 = 0x00000004
+ attr_ACMODTIME uint32 = 0x00000008
+ attr_EXTENDED uint32 = 0x80000000
+)
+
+//
+// FileAttrs define the attributes for opening or creating file on the remote.
+//
+type FileAttrs struct {
+ flags uint32
+ size uint64 // attr_SIZE
+ uid uint32 // attr_UIDGID
+ gid uint32 // attr_UIDGID
+ permissions uint32 // attr_PERMISSIONS
+ atime uint32 // attr_ACMODTIME
+ mtime uint32 // attr_ACMODTIME
+ exts extensions // attr_EXTENDED
+}
+
+//
+// NewFileAttrs create and initialize FileAttrs from FileInfo.
+//
+func NewFileAttrs(fi fs.FileInfo) (fa *FileAttrs) {
+ fa = &FileAttrs{}
+
+ mode := fi.Mode()
+ mtime := fi.ModTime()
+
+ fa.SetSize(uint64(fi.Size()))
+ fa.SetPermissions(uint32(mode.Perm()))
+ fa.SetModifiedTime(uint32(mtime.Unix()))
+
+ return fa
+}
+
+func newFileAttrs() (fa *FileAttrs) {
+ return &FileAttrs{}
+}
+
+func unpackFileAttrs(payload []byte) (fa *FileAttrs, length int) {
+ fa = &FileAttrs{}
+
+ fa.flags = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+
+ if fa.flags&attr_SIZE != 0 {
+ fa.size = binary.BigEndian.Uint64(payload)
+ payload = payload[8:]
+ length += 8
+ }
+ if fa.flags&attr_UIDGID != 0 {
+ fa.uid = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+ fa.gid = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+ }
+ if fa.flags&attr_PERMISSIONS != 0 {
+ fa.permissions = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+ }
+ if fa.flags&attr_ACMODTIME != 0 {
+ fa.atime = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+ fa.mtime = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+ }
+ if fa.flags&attr_EXTENDED != 0 {
+ n := binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+
+ fa.exts = make(extensions, n)
+ for x := uint32(0); x < n; x++ {
+ size := binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+
+ name := string(payload[:size])
+ payload = payload[size:]
+ length += int(size)
+
+ size = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ length += 4
+
+ data := string(payload[:size])
+ payload = payload[size:]
+ length += int(size)
+
+ fa.exts[name] = data
+ }
+ }
+ return fa, length
+}
+
+func (fa *FileAttrs) pack(w io.Writer) {
+ _ = binary.Write(w, binary.BigEndian, fa.flags)
+
+ if fa.flags&attr_SIZE != 0 {
+ _ = binary.Write(w, binary.BigEndian, fa.size)
+ }
+ if fa.flags&attr_UIDGID != 0 {
+ _ = binary.Write(w, binary.BigEndian, fa.uid)
+ _ = binary.Write(w, binary.BigEndian, fa.gid)
+ }
+ if fa.flags&attr_PERMISSIONS != 0 {
+ _ = binary.Write(w, binary.BigEndian, fa.permissions)
+ }
+ if fa.flags&attr_ACMODTIME != 0 {
+ _ = binary.Write(w, binary.BigEndian, fa.atime)
+ _ = binary.Write(w, binary.BigEndian, fa.mtime)
+ }
+ if fa.flags&attr_EXTENDED != 0 {
+ n := uint32(len(fa.exts))
+ _ = binary.Write(w, binary.BigEndian, n)
+ for k, v := range fa.exts {
+ _ = binary.Write(w, binary.BigEndian, uint32(len(k)))
+ _, _ = w.Write([]byte(k))
+ _ = binary.Write(w, binary.BigEndian, uint32(len(v)))
+ _, _ = w.Write([]byte(v))
+ }
+ }
+}
+
+//
+// AccessTime return the remote file access time.
+//
+func (fa *FileAttrs) AccessTime() uint32 {
+ return fa.atime
+}
+
+//
+// Extensions return the remote file attribute extensions as map of type and
+// data.
+//
+func (fa *FileAttrs) Extensions() map[string]string {
+ return map[string]string(fa.exts)
+}
+
+//
+// Gid return the group ID attribute of file.
+//
+func (fa *FileAttrs) Gid() uint32 {
+ return fa.gid
+}
+
+//
+// AccessTime return the remote file modified time.
+//
+func (fa *FileAttrs) ModifiedTime() uint32 {
+ return fa.mtime
+}
+
+//
+// Permissions return the remote file permissions.
+//
+func (fa *FileAttrs) Permissions() uint32 {
+ return fa.permissions
+}
+
+//
+// SetAccessTime set the file attribute access time.
+//
+func (fa *FileAttrs) SetAccessTime(v uint32) {
+ fa.flags |= attr_ACMODTIME
+ fa.atime = v
+}
+
+//
+// SetExtension set the file attribute extension.
+//
+func (fa *FileAttrs) SetExtension(name, data string) {
+ if fa.exts == nil {
+ fa.exts = extensions{}
+ }
+ fa.flags |= attr_EXTENDED
+ fa.exts[name] = data
+}
+
+//
+// SetGid set the file attribute group ID.
+//
+func (fa *FileAttrs) SetGid(gid uint32) {
+ fa.flags |= attr_UIDGID
+ fa.gid = gid
+}
+
+//
+// SetModifiedTime set the file attribute modified time.
+//
+func (fa *FileAttrs) SetModifiedTime(v uint32) {
+ fa.flags |= attr_ACMODTIME
+ fa.mtime = v
+}
+
+//
+// SetPermissions set the remote file permission.
+//
+func (fa *FileAttrs) SetPermissions(v uint32) {
+ fa.flags |= attr_PERMISSIONS
+ fa.permissions = v
+}
+
+//
+// SetSize set the remote file size.
+//
+func (fa *FileAttrs) SetSize(v uint64) {
+ fa.flags |= attr_SIZE
+ fa.size = v
+}
+
+//
+// SetUid set the file attribute user ID.
+//
+func (fa *FileAttrs) SetUid(uid uint32) {
+ fa.flags |= attr_UIDGID
+ fa.uid = uid
+}
+
+//
+// Uid return the user ID of file.
+//
+func (fa *FileAttrs) Uid() uint32 {
+ return fa.uid
+}
diff --git a/lib/ssh/sftp/file_handle.go b/lib/ssh/sftp/file_handle.go
new file mode 100644
index 00000000..36467188
--- /dev/null
+++ b/lib/ssh/sftp/file_handle.go
@@ -0,0 +1,9 @@
+// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package sftp
+
+type FileHandle struct {
+ v []byte
+}
diff --git a/lib/ssh/sftp/node.go b/lib/ssh/sftp/node.go
new file mode 100644
index 00000000..dc038d9b
--- /dev/null
+++ b/lib/ssh/sftp/node.go
@@ -0,0 +1,14 @@
+// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package sftp
+
+//
+// Node represent the data returned from Readlink.
+//
+type Node struct {
+ FileName string
+ LongName string
+ Attrs *FileAttrs
+}
diff --git a/lib/ssh/sftp/packet.go b/lib/ssh/sftp/packet.go
new file mode 100644
index 00000000..93a658d8
--- /dev/null
+++ b/lib/ssh/sftp/packet.go
@@ -0,0 +1,389 @@
+// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package sftp
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+)
+
+const (
+ packetKindFxpInit byte = 1 + iota
+ packetKindFxpVersion
+ packetKindFxpOpen
+ packetKindFxpClose
+ packetKindFxpRead // 5
+ packetKindFxpWrite
+ packetKindFxpLstat
+ packetKindFxpFstat
+ packetKindFxpSetstat
+ packetKindFxpFsetstat // 10
+ packetKindFxpOpendir
+ packetKindFxpReaddir
+ packetKindFxpRemove
+ packetKindFxpMkdir
+ packetKindFxpRmdir // 15
+ packetKindFxpRealpath
+ packetKindFxpStat
+ packetKindFxpRename
+ packetKindFxpReadlink
+ packetKindFxpSymlink // 20
+)
+const (
+ packetKindFxpStatus = 101 + iota
+ packetKindFxpHandle
+ packetKindFxpData
+ packetKindFxpName
+ packetKindFxpAttrs
+)
+const (
+ packetKindFxpExtended = 200 + iota
+ packetKindFxpExtendedReply
+)
+
+type packet struct {
+ length uint32
+ kind byte
+ requestId uint32
+
+ // FxpVersion.
+ version uint32
+ exts extensions
+
+ // FxpStatus
+ code uint32
+ message string
+ languageTag string
+
+ // FxpHandle
+ fh *FileHandle
+
+ // FxpData
+ data []byte
+
+ // FxpName
+ nodes []*Node
+
+ // FxpAttrs
+ fa *FileAttrs
+}
+
+func unpackPacket(payload []byte) (pac *packet, err error) {
+ logp := "packetUnpack"
+ gotSize := uint32(len(payload))
+ if gotSize < 9 {
+ return nil, fmt.Errorf("%s: packet size too small %d", logp, gotSize)
+ }
+
+ pac = &packet{}
+
+ pac.length = binary.BigEndian.Uint32(payload[:4])
+ expSize := pac.length + 4
+ if expSize != gotSize {
+ return nil, fmt.Errorf("%s: expecting packet size %d, got %d", logp, expSize, gotSize)
+ }
+ pac.kind = payload[4]
+
+ v := binary.BigEndian.Uint32(payload[5:])
+ payload = payload[9:]
+ if pac.kind == packetKindFxpVersion {
+ pac.version = v
+ pac.exts = unpackExtensions(payload)
+ return pac, nil
+ }
+
+ pac.requestId = v
+
+ switch pac.kind {
+ case packetKindFxpStatus:
+ pac.code = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+
+ v = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ pac.message = string(payload[:v])
+ payload = payload[v:]
+
+ v = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ pac.languageTag = string(payload[:v])
+
+ case packetKindFxpHandle:
+ pac.fh = &FileHandle{}
+ v = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ pac.fh.v = make([]byte, v)
+ copy(pac.fh.v, payload[:v])
+
+ case packetKindFxpData:
+ v = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ pac.data = payload[:v]
+ payload = payload[v:]
+
+ case packetKindFxpName:
+ var (
+ length int
+ )
+ n := binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ for x := uint32(0); x < n; x++ {
+ node := &Node{}
+
+ v = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ node.FileName = string(payload[:v])
+ payload = payload[v:]
+
+ v = binary.BigEndian.Uint32(payload)
+ payload = payload[4:]
+ node.LongName = string(payload[:v])
+ payload = payload[v:]
+
+ node.Attrs, length = unpackFileAttrs(payload)
+ payload = payload[length:]
+
+ pac.nodes = append(pac.nodes, node)
+ }
+
+ case packetKindFxpAttrs:
+ pac.fa, _ = unpackFileAttrs(payload)
+ }
+
+ return pac, nil
+}
+
+func (pac *packet) fxpClose(fh *FileHandle) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpClose)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v)))
+ _ = binary.Write(&buf, binary.BigEndian, fh.v)
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpFsetstat(fh *FileHandle, fa *FileAttrs) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpFsetstat)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v)))
+ _ = binary.Write(&buf, binary.BigEndian, fh.v)
+ fa.pack(&buf)
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpFstat(fh *FileHandle) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpFstat)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v)))
+ _ = binary.Write(&buf, binary.BigEndian, fh.v)
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpInit(version uint32) []byte {
+ var buf bytes.Buffer
+
+ if version == 0 {
+ version = defFxpVersion
+ }
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpInit)
+ _ = binary.Write(&buf, binary.BigEndian, version)
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpLstat(remoteFile string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpLstat)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(remoteFile)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(remoteFile))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpMkdir(path string, fa *FileAttrs) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpMkdir)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(path)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(path))
+ fa.pack(&buf)
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpOpen(filename string, pflags uint32, fa *FileAttrs) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpOpen)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(filename)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(filename))
+ _ = binary.Write(&buf, binary.BigEndian, pflags)
+ fa.pack(&buf)
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpOpendir(path string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpOpendir)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(path)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(path))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpRead(fh *FileHandle, offset uint64, length uint32) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRead)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(fh.v))
+ _ = binary.Write(&buf, binary.BigEndian, offset)
+ _ = binary.Write(&buf, binary.BigEndian, length)
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpReaddir(fh *FileHandle) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpReaddir)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(fh.v))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpReadlink(linkPath string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpReadlink)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(linkPath)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(linkPath))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpRealpath(path string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRealpath)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(path)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(path))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpRemove(remoteFile string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRemove)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(remoteFile)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(remoteFile))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpRename(oldPath, newPath string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRename)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(oldPath)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(oldPath))
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(newPath)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(newPath))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpRmdir(path string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpRmdir)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(path)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(path))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpSetstat(remoteFile string, fa *FileAttrs) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpSetstat)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(remoteFile)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(remoteFile))
+ fa.pack(&buf)
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpStat(remoteFile string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpStat)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(remoteFile)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(remoteFile))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpSymlink(linkPath, targetPath string) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpSymlink)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(linkPath)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(linkPath))
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(targetPath)))
+ _ = binary.Write(&buf, binary.BigEndian, []byte(targetPath))
+
+ return sealPacket(buf.Bytes())
+}
+
+func (pac *packet) fxpWrite(fh *FileHandle, offset uint64, data []byte) []byte {
+ var buf bytes.Buffer
+
+ _ = binary.Write(&buf, binary.BigEndian, packetKindFxpWrite)
+ _ = binary.Write(&buf, binary.BigEndian, pac.requestId)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(fh.v)))
+ _ = binary.Write(&buf, binary.BigEndian, fh.v)
+ _ = binary.Write(&buf, binary.BigEndian, offset)
+ _ = binary.Write(&buf, binary.BigEndian, uint32(len(data)))
+ _ = binary.Write(&buf, binary.BigEndian, data)
+
+ return sealPacket(buf.Bytes())
+}
+
+func sealPacket(in []byte) (out []byte) {
+ lin := uint32(len(in))
+ out = make([]byte, lin+4)
+ binary.BigEndian.PutUint32(out, lin)
+ copy(out[4:], in)
+ return out
+}
diff --git a/lib/ssh/sftp/sftp.go b/lib/ssh/sftp/sftp.go
new file mode 100644
index 00000000..fb9fa32b
--- /dev/null
+++ b/lib/ssh/sftp/sftp.go
@@ -0,0 +1,47 @@
+// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//
+// Package sftp implement SSH File Transfer Protocol v3 as defined in
+// draft-ietf-secsh-filexfer-02.txt.
+//
+// The sftp package extend the golang.org/x/crypto/ssh package by
+// implementing "sftp" subsystem using the ssh.Client connection.
+//
+package sftp
+
+import "fmt"
+
+const (
+ subsystemNameSftp = "sftp"
+ defFxpVersion uint32 = 3
+ maxPacketRead uint32 = 32000
+)
+
+// List of values for FXP_OPEN pflags.
+const (
+ ssh_FXF_READ uint32 = 0x00000001
+ ssh_FXF_WRITE uint32 = 0x00000002
+ ssh_FXF_APPEND uint32 = 0x00000004
+ ssh_FXF_CREAT uint32 = 0x00000008
+ ssh_FXF_TRUNC uint32 = 0x00000010
+ ssh_FXF_EXCL uint32 = 0x00000020
+)
+
+// List of values for FXP_STATUS code.
+const (
+ ssh_FX_OK uint32 = iota
+ ssh_FX_EOF
+ ssh_FX_NO_SUCH_FILE
+ ssh_FX_PERMISSION_DENIED
+ ssh_FX_FAILURE
+ ssh_FX_BAD_MESSAGE
+ ssh_FX_NO_CONNECTION
+ ssh_FX_CONNECTION_LOST
+ ssh_FX_OP_UNSUPPORTED
+)
+
+func ErrUnexpectedResponse(exp, got byte) error {
+ return fmt.Errorf("sftp: expecting packet type %d, got %d", exp, got)
+}
diff --git a/lib/ssh/sftp/sftp_test.go b/lib/ssh/sftp/sftp_test.go
new file mode 100644
index 00000000..f048d982
--- /dev/null
+++ b/lib/ssh/sftp/sftp_test.go
@@ -0,0 +1,57 @@
+package sftp
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "testing"
+
+ "github.com/shuLhan/share/lib/ssh"
+ "github.com/shuLhan/share/lib/ssh/config"
+)
+
+const (
+ envNameTestManual = "LIB_SFTP_TEST_MANUAL"
+)
+
+var (
+ // testClient the sftp Client to test all exported functionalities,
+ // and also to test concurrent packet communication.
+ testClient *Client
+
+ // Flag to run the unit test that require SSH server.
+ // This flag is set through environment variable defined on
+ // envNameTestManual.
+ isTestManual bool
+)
+
+func TestMain(m *testing.M) {
+ isTestManual = len(os.Getenv(envNameTestManual)) > 0
+ if !isTestManual {
+ return
+ }
+
+ cfg := &config.Section{
+ User: "ms",
+ Hostname: "127.0.0.1",
+ Port: "22",
+ IdentityFile: []string{
+ "./testdata/id_ed25519",
+ },
+ }
+
+ sshClient, err := ssh.NewClientFromConfig(cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ testClient, err = NewClient(sshClient.Client)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Server version: %d\n", testClient.version)
+ fmt.Printf("Server extensions: %v\n", testClient.exts)
+
+ os.Exit(m.Run())
+}
diff --git a/lib/ssh/sftp/testdata/id_ed25519 b/lib/ssh/sftp/testdata/id_ed25519
new file mode 100644
index 00000000..579c8496
--- /dev/null
+++ b/lib/ssh/sftp/testdata/id_ed25519
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACCF4+ftKdECJKLWS6V1BO2Vc1VyJQR3mWvWTwPSVoMh5AAAAKBoVsczaFbH
+MwAAAAtzc2gtZWQyNTUxOQAAACCF4+ftKdECJKLWS6V1BO2Vc1VyJQR3mWvWTwPSVoMh5A
+AAAEA1l0ukNaCkCedaY1Mi1/c32SEdtfmZ5QfBS6cgUhnpM4Xj5+0p0QIkotZLpXUE7ZVz
+VXIlBHeZa9ZPA9JWgyHkAAAAFnNzaC10ZXN0QHNodWxoYW4ubG9jYWwBAgMEBQYH
+-----END OPENSSH PRIVATE KEY-----
diff --git a/lib/ssh/sftp/testdata/id_ed25519.pub b/lib/ssh/sftp/testdata/id_ed25519.pub
new file mode 100644
index 00000000..f27a1ed7
--- /dev/null
+++ b/lib/ssh/sftp/testdata/id_ed25519.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIXj5+0p0QIkotZLpXUE7ZVzVXIlBHeZa9ZPA9JWgyHk ssh-test@shulhan.local