sftp: add symlink support - fixes #5011

Add new flag (--sftp-links) for backend sftp to recreate symlink from source to
destination.
This commit is contained in:
Nikita COEUR 2024-08-29 23:41:00 +02:00
parent be448c9e13
commit 773620ca0b
No known key found for this signature in database
4 changed files with 189 additions and 36 deletions

View file

@ -178,13 +178,13 @@ E.g. if shared folders can be found in directories representing volumes:
E.g. if home directory can be found in a shared folder called "home": E.g. if home directory can be found in a shared folder called "home":
rclone sync /home/local/directory remote:/home/directory --sftp-path-override /volume1/homes/USER/directory rclone sync /home/local/directory remote:/home/directory --sftp-path-override /volume1/homes/USER/directory
To specify only the path to the SFTP remote's root, and allow rclone to add any relative subpaths automatically (including unwrapping/decrypting remotes as necessary), add the '@' character to the beginning of the path. To specify only the path to the SFTP remote's root, and allow rclone to add any relative subpaths automatically (including unwrapping/decrypting remotes as necessary), add the '@' character to the beginning of the path.
E.g. the first example above could be rewritten as: E.g. the first example above could be rewritten as:
rclone sync /home/local/directory remote:/directory --sftp-path-override @/volume2 rclone sync /home/local/directory remote:/directory --sftp-path-override @/volume2
Note that when using this method with Synology "home" folders, the full "/homes/USER" path should be specified instead of "/home". Note that when using this method with Synology "home" folders, the full "/homes/USER" path should be specified instead of "/home".
E.g. the second example above should be rewritten as: E.g. the second example above should be rewritten as:
@ -232,6 +232,19 @@ E.g. the second example above should be rewritten as:
Help: "Set to skip any symlinks and any other non regular files.", Help: "Set to skip any symlinks and any other non regular files.",
Advanced: true, Advanced: true,
}, { }, {
Name: "links",
Default: false,
Help: `Copy symlinks instead of following them.
This permit to recreate the same symlink structure on the destination.
Only works between two remotes with symlink support. [Currently only supported between two SFTP remotes].
Symlink is validate if target file on destination and on source have same size and same hash. [Except if target is a directory, size and hash are not checked].
`,
Advanced: true,
}, {
Name: "subsystem", Name: "subsystem",
Default: "sftp", Default: "sftp",
Help: "Specifies the SSH2 subsystem on the remote host.", Help: "Specifies the SSH2 subsystem on the remote host.",
@ -243,7 +256,7 @@ E.g. the second example above should be rewritten as:
The subsystem option is ignored when server_command is defined. The subsystem option is ignored when server_command is defined.
If adding server_command to the configuration file please note that If adding server_command to the configuration file please note that
it should not be enclosed in quotes, since that will make rclone fail. it should not be enclosed in quotes, since that will make rclone fail.
A working example is: A working example is:
@ -469,7 +482,7 @@ connection for every hash it calculates.
Name: "socks_proxy", Name: "socks_proxy",
Default: "", Default: "",
Help: `Socks 5 proxy host. Help: `Socks 5 proxy host.
Supports the format user:pass@host:port, user@host:port, host:port. Supports the format user:pass@host:port, user@host:port, host:port.
Example: Example:
@ -523,6 +536,7 @@ type Options struct {
Md5sumCommand string `config:"md5sum_command"` Md5sumCommand string `config:"md5sum_command"`
Sha1sumCommand string `config:"sha1sum_command"` Sha1sumCommand string `config:"sha1sum_command"`
SkipLinks bool `config:"skip_links"` SkipLinks bool `config:"skip_links"`
TranslateSymlinks bool `config:"links"`
Subsystem string `config:"subsystem"` Subsystem string `config:"subsystem"`
ServerCommand string `config:"server_command"` ServerCommand string `config:"server_command"`
UseFstat bool `config:"use_fstat"` UseFstat bool `config:"use_fstat"`
@ -568,13 +582,15 @@ type Fs struct {
// Object is a remote SFTP file that has been stat'd (so it exists, but is not necessarily open for reading) // Object is a remote SFTP file that has been stat'd (so it exists, but is not necessarily open for reading)
type Object struct { type Object struct {
fs *Fs fs *Fs
remote string remote string
size int64 // size of the object size int64 // size of the object
modTime uint32 // modification time of the object as unix time modTime uint32 // modification time of the object as unix time
mode os.FileMode // mode bits from the file mode os.FileMode // mode bits from the file
md5sum *string // Cached MD5 checksum md5sum *string // Cached MD5 checksum
sha1sum *string // Cached SHA1 checksum sha1sum *string // Cached SHA1 checksum
linkTarget string // If object isSymlink, this is the target
linkTargetIsDir bool // If object isSymlink, this is true if the target is a directory
} }
// conn encapsulates an ssh client and corresponding sftp client // conn encapsulates an ssh client and corresponding sftp client
@ -852,6 +868,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
if len(opt.SSH) != 0 && ((opt.User != currentUser && opt.User != "") || opt.Host != "" || (opt.Port != "22" && opt.Port != "")) { if len(opt.SSH) != 0 && ((opt.User != currentUser && opt.User != "") || opt.Host != "" || (opt.Port != "22" && opt.Port != "")) {
fs.Logf(name, "--sftp-ssh is in use - ignoring user/host/port from config - set in the parameters to --sftp-ssh (remove them from the config to silence this warning)") fs.Logf(name, "--sftp-ssh is in use - ignoring user/host/port from config - set in the parameters to --sftp-ssh (remove them from the config to silence this warning)")
} }
if opt.TranslateSymlinks && opt.SkipLinks {
return nil, errors.New("can't use --sftp-links and --sftp-skip-links together")
}
f.tokens = pacer.NewTokenDispenser(opt.Connections) f.tokens = pacer.NewTokenDispenser(opt.Connections)
if opt.User == "" { if opt.User == "" {
@ -1115,6 +1134,12 @@ func NewFsWithConnection(ctx context.Context, f *Fs, name string, root string, m
// Disable server side copy unless --sftp-copy-is-hardlink is set // Disable server side copy unless --sftp-copy-is-hardlink is set
f.features.Copy = nil f.features.Copy = nil
} }
if opt.TranslateSymlinks {
// Enable symlink translation Feature when --sftp-links is set
// Not used yet but may be used in the future on shared backend operations.
// Maybe to check if src and dst backend support this feature before proceeding with the operation.
f.features.TranslateSymlink = true
}
// Make a connection and pool it to return errors early // Make a connection and pool it to return errors early
c, err := f.getSftpConnection(ctx) c, err := f.getSftpConnection(ctx)
if err != nil { if err != nil {
@ -1331,6 +1356,18 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
remote: remote, remote: remote,
} }
o.setMetadata(info) o.setMetadata(info)
if o.IsSymlink() {
// Read the link target if it is a symlink to get path to real object and its size
linkTarget, linkTargetIsDir, sizeTarget, err := f.Readlink(ctx, o.path())
if err != nil {
// If we can't read the link target, log the error and continue
fs.Errorf(remote, "Readlink failed: %v", err)
continue
}
o.size = sizeTarget
o.linkTarget = linkTarget
o.linkTargetIsDir = linkTargetIsDir
}
entries = append(entries, o) entries = append(entries, o)
} }
} }
@ -1340,14 +1377,36 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
// Put data from <in> into a new remote sftp file object described by <src.Remote()> and <src.ModTime(ctx)> // Put data from <in> into a new remote sftp file object described by <src.Remote()> and <src.ModTime(ctx)>
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
err := f.mkParentDir(ctx, src.Remote()) err := f.mkParentDir(ctx, src.Remote())
// Init two variables to store symlink object and check if source object is a symlink
// If TranslateSymlinks is set
var srcSymlinkObject fs.Object
isSymlink := false
if err != nil { if err != nil {
return nil, fmt.Errorf("Put mkParentDir failed: %w", err) return nil, fmt.Errorf("Put mkParentDir failed: %w", err)
} }
if f.opt.TranslateSymlinks {
// If TranslateSymlinks is set, we need to check if source object is a symlink
if or, ok := src.(*fs.OverrideRemote); ok {
srcSymlinkObject = or.UnWrap()
isSymlink = srcSymlinkObject.(*Object).IsSymlink()
}
}
// Temporary object under construction // Temporary object under construction
o := &Object{ o := &Object{
fs: f, fs: f,
remote: src.Remote(), remote: src.Remote(),
} }
// if source file is a symlink, we need to specify target path to temporary object
if isSymlink {
o.linkTarget = srcSymlinkObject.(*Object).linkTarget
o.size = srcSymlinkObject.(*Object).size
o.mode = srcSymlinkObject.(*Object).mode
o.linkTargetIsDir = srcSymlinkObject.(*Object).linkTargetIsDir
}
err = o.Update(ctx, in, src, options...) err = o.Update(ctx, in, src, options...)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1822,7 +1881,9 @@ func (o *Object) Remote() string {
// Hash returns the selected checksum of the file // Hash returns the selected checksum of the file
// If no checksum is available it returns "" // If no checksum is available it returns ""
func (o *Object) Hash(ctx context.Context, r hash.Type) (string, error) { func (o *Object) Hash(ctx context.Context, r hash.Type) (string, error) {
if o.fs.opt.DisableHashCheck { // Check if HashCheck is disabled or
// if TranslateSymlinks is enabled and the target is a directory
if o.fs.opt.DisableHashCheck || (o.fs.opt.TranslateSymlinks && o.linkTargetIsDir) {
return "", nil return "", nil
} }
_ = o.fs.Hashes() _ = o.fs.Hashes()
@ -1987,6 +2048,11 @@ func (o *Object) setMetadata(info os.FileInfo) {
o.mode = info.Mode() o.mode = info.Mode()
} }
// IsSymlink returns true if the remote sftp file is a symlink
func (o *Object) IsSymlink() bool {
return o.mode&os.ModeSymlink != 0
}
// statRemote stats the file or directory at the remote given // statRemote stats the file or directory at the remote given
func (f *Fs) stat(ctx context.Context, remote string) (info os.FileInfo, err error) { func (f *Fs) stat(ctx context.Context, remote string) (info os.FileInfo, err error) {
absPath := remote absPath := remote
@ -1997,7 +2063,13 @@ func (f *Fs) stat(ctx context.Context, remote string) (info os.FileInfo, err err
if err != nil { if err != nil {
return nil, fmt.Errorf("stat: %w", err) return nil, fmt.Errorf("stat: %w", err)
} }
info, err = c.sftpClient.Stat(absPath) if f.opt.TranslateSymlinks {
// Lstat is used to get the info of the symlink itself instead of the target
// We use Lstat only if the user has requested --sftp-links flag
info, err = c.sftpClient.Lstat(absPath)
} else {
info, err = c.sftpClient.Stat(absPath)
}
f.putSftpConnection(&c, err) f.putSftpConnection(&c, err)
return info, err return info, err
} }
@ -2041,8 +2113,13 @@ func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
return nil return nil
} }
// Storable returns whether the remote sftp file is a regular file (not a directory, symbolic link, block device, character device, named pipe, etc.) // Storable returns whether the remote sftp file is a regular file (not a directory, block device, character device, named pipe, etc.)
// if TranslateSymlinks is set, symlinks are also allowed
func (o *Object) Storable() bool { func (o *Object) Storable() bool {
if o.fs.opt.TranslateSymlinks {
// If TranslateSymlinks is set, we also allow symlinks
return o.mode.IsRegular() || o.IsSymlink()
}
return o.mode.IsRegular() return o.mode.IsRegular()
} }
@ -2156,12 +2233,6 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
if err != nil { if err != nil {
return fmt.Errorf("Update: %w", err) return fmt.Errorf("Update: %w", err)
} }
// Hang on to the connection for the whole upload so it doesn't get reused while we are uploading
file, err := c.sftpClient.OpenFile(o.path(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil {
o.fs.putSftpConnection(&c, err)
return fmt.Errorf("Update Create failed: %w", err)
}
// remove the file if upload failed // remove the file if upload failed
remove := func() { remove := func() {
c, removeErr := o.fs.getSftpConnection(ctx) c, removeErr := o.fs.getSftpConnection(ctx)
@ -2177,6 +2248,44 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
fs.Debugf(src, "Removed after failed upload: %v", err) fs.Debugf(src, "Removed after failed upload: %v", err)
} }
} }
// Check if TranslateSymlinks flag is set and if temporary object given by Put() is Symlink
if o.fs.opt.TranslateSymlinks && o.IsSymlink() {
// Create or update symlink
err = o.fs.Symlink(ctx, o.linkTarget, o.path())
if err != nil {
o.fs.putSftpConnection(&c, err)
return fmt.Errorf("Update Symlink failed: %w", err)
}
_, linkTargetIsDir, sizeTarget, err := o.fs.Readlink(ctx, o.path())
if o.linkTargetIsDir == linkTargetIsDir {
// if symlink target is a directory, in will be closed with ErrorReadIsDirectory
// FIXME : Needed to bypass closeErr := inAcc.Close() in updateOrPut function in copy.go <== Need Help here if bad practice
err = in.(*accounting.Account).Close()
if err == fs.ErrorReadIsDirectory {
fs.Debugf(o, "Readlink returned directory, Continue normally")
err = nil
}
}
if err != nil {
o.fs.putSftpConnection(&c, err)
remove()
return fmt.Errorf("Update Readlink failed: %w", err)
}
if sizeTarget != src.Size() {
o.fs.putSftpConnection(&c, err)
remove()
return fmt.Errorf("Update Readlink target's size mismatch: %d != %d", sizeTarget, src.Size())
}
o.fs.putSftpConnection(&c, err)
o.size = sizeTarget
return nil
}
// Hang on to the connection for the whole upload so it doesn't get reused while we are uploading
file, err := c.sftpClient.OpenFile(o.path(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil {
o.fs.putSftpConnection(&c, err)
return fmt.Errorf("Update Create failed: %w", err)
}
_, err = file.ReadFrom(&sizeReader{Reader: in, size: src.Size()}) _, err = file.ReadFrom(&sizeReader{Reader: in, size: src.Size()})
if err != nil { if err != nil {
o.fs.putSftpConnection(&c, err) o.fs.putSftpConnection(&c, err)
@ -2227,6 +2336,33 @@ func (o *Object) Remove(ctx context.Context) error {
return err return err
} }
// Symlink creates a symbolic link on remote.
func (f *Fs) Symlink(ctx context.Context, targetPath, linkName string) error {
c, err := f.getSftpConnection(ctx)
if err != nil {
return fmt.Errorf("Symlink: %w", err)
}
err = c.sftpClient.Symlink(targetPath, linkName)
f.putSftpConnection(&c, err)
return err
}
// Readlink reads the target of a symbolic link.
func (f *Fs) Readlink(ctx context.Context, path string) (string, bool, int64, error) {
c, err := f.getSftpConnection(ctx)
if err != nil {
return "", false, 0, err
}
target, err := c.sftpClient.Stat(path)
if err != nil {
f.putSftpConnection(&c, err)
return "", false, 0, err
}
link, err := c.sftpClient.ReadLink(path)
f.putSftpConnection(&c, err)
return link, target.IsDir(), target.Size(), err
}
// Check the interfaces are satisfied // Check the interfaces are satisfied
var ( var (
_ fs.Fs = &Fs{} _ fs.Fs = &Fs{}

View file

@ -21,9 +21,9 @@ SSH installations.
Paths are specified as `remote:path`. If the path does not begin with Paths are specified as `remote:path`. If the path does not begin with
a `/` it is relative to the home directory of the user. An empty path a `/` it is relative to the home directory of the user. An empty path
`remote:` refers to the user's home directory. For example, `rclone lsd remote:` `remote:` refers to the user's home directory. For example, `rclone lsd remote:`
would list the home directory of the user configured in the rclone remote config would list the home directory of the user configured in the rclone remote config
(`i.e /home/sftpuser`). However, `rclone lsd remote:/` would list the root (`i.e /home/sftpuser`). However, `rclone lsd remote:/` would list the root
directory for remote machine (i.e. `/`) directory for remote machine (i.e. `/`)
Note that some SFTP servers will need the leading / - Synology is a Note that some SFTP servers will need the leading / - Synology is a
@ -128,7 +128,7 @@ The SFTP remote supports three authentication methods:
Key files should be PEM-encoded private key files. For instance `/home/$USER/.ssh/id_rsa`. Key files should be PEM-encoded private key files. For instance `/home/$USER/.ssh/id_rsa`.
Only unencrypted OpenSSH or PEM encrypted files are supported. Only unencrypted OpenSSH or PEM encrypted files are supported.
The key file can be specified in either an external file (key_file) or contained within the The key file can be specified in either an external file (key_file) or contained within the
rclone config file (key_pem). If using key_pem in the config file, the entry should be on a rclone config file (key_pem). If using key_pem in the config file, the entry should be on a
single line with new line ('\n' or '\r\n') separating lines. i.e. single line with new line ('\n' or '\r\n') separating lines. i.e.
@ -199,7 +199,7 @@ e.g. using the OpenSSH `known_hosts` file:
type = sftp type = sftp
host = example.com host = example.com
user = sftpuser user = sftpuser
pass = pass =
known_hosts_file = ~/.ssh/known_hosts known_hosts_file = ~/.ssh/known_hosts
```` ````
@ -593,7 +593,7 @@ Properties:
- Config: ssh - Config: ssh
- Env Var: RCLONE_SFTP_SSH - Env Var: RCLONE_SFTP_SSH
- Type: SpaceSepList - Type: SpaceSepList
- Default: - Default:
### Advanced options ### Advanced options
@ -647,13 +647,13 @@ E.g. if shared folders can be found in directories representing volumes:
E.g. if home directory can be found in a shared folder called "home": E.g. if home directory can be found in a shared folder called "home":
rclone sync /home/local/directory remote:/home/directory --sftp-path-override /volume1/homes/USER/directory rclone sync /home/local/directory remote:/home/directory --sftp-path-override /volume1/homes/USER/directory
To specify only the path to the SFTP remote's root, and allow rclone to add any relative subpaths automatically (including unwrapping/decrypting remotes as necessary), add the '@' character to the beginning of the path. To specify only the path to the SFTP remote's root, and allow rclone to add any relative subpaths automatically (including unwrapping/decrypting remotes as necessary), add the '@' character to the beginning of the path.
E.g. the first example above could be rewritten as: E.g. the first example above could be rewritten as:
rclone sync /home/local/directory remote:/directory --sftp-path-override @/volume2 rclone sync /home/local/directory remote:/directory --sftp-path-override @/volume2
Note that when using this method with Synology "home" folders, the full "/homes/USER" path should be specified instead of "/home". Note that when using this method with Synology "home" folders, the full "/homes/USER" path should be specified instead of "/home".
E.g. the second example above should be rewritten as: E.g. the second example above should be rewritten as:
@ -730,6 +730,8 @@ Properties:
Set to skip any symlinks and any other non regular files. Set to skip any symlinks and any other non regular files.
Mutually exclusive with `--sftp-links`.
Properties: Properties:
- Config: skip_links - Config: skip_links
@ -737,6 +739,19 @@ Properties:
- Type: bool - Type: bool
- Default: false - Default: false
#### --sftp-links
Set to follow symlinks. Recreate or update symlinks as symlinks and not copy the underlying file/directory.
Mutually exclusive with `--sftp-skip-links`.
Properties:
- Config: links
- Env Var: RCLONE_SFTP_LINKS
- Type: bool
- Default: false
#### --sftp-subsystem #### --sftp-subsystem
Specifies the SSH2 subsystem on the remote host. Specifies the SSH2 subsystem on the remote host.
@ -754,7 +769,7 @@ Specifies the path or command to run a sftp server on the remote host.
The subsystem option is ignored when server_command is defined. The subsystem option is ignored when server_command is defined.
If adding server_command to the configuration file please note that If adding server_command to the configuration file please note that
it should not be enclosed in quotes, since that will make rclone fail. it should not be enclosed in quotes, since that will make rclone fail.
A working example is: A working example is:
@ -946,7 +961,7 @@ Properties:
- Config: set_env - Config: set_env
- Env Var: RCLONE_SFTP_SET_ENV - Env Var: RCLONE_SFTP_SET_ENV
- Type: SpaceSepList - Type: SpaceSepList
- Default: - Default:
#### --sftp-ciphers #### --sftp-ciphers
@ -966,7 +981,7 @@ Properties:
- Config: ciphers - Config: ciphers
- Env Var: RCLONE_SFTP_CIPHERS - Env Var: RCLONE_SFTP_CIPHERS
- Type: SpaceSepList - Type: SpaceSepList
- Default: - Default:
#### --sftp-key-exchange #### --sftp-key-exchange
@ -986,7 +1001,7 @@ Properties:
- Config: key_exchange - Config: key_exchange
- Env Var: RCLONE_SFTP_KEY_EXCHANGE - Env Var: RCLONE_SFTP_KEY_EXCHANGE
- Type: SpaceSepList - Type: SpaceSepList
- Default: - Default:
#### --sftp-macs #### --sftp-macs
@ -1004,7 +1019,7 @@ Properties:
- Config: macs - Config: macs
- Env Var: RCLONE_SFTP_MACS - Env Var: RCLONE_SFTP_MACS
- Type: SpaceSepList - Type: SpaceSepList
- Default: - Default:
#### --sftp-host-key-algorithms #### --sftp-host-key-algorithms
@ -1024,18 +1039,18 @@ Properties:
- Config: host_key_algorithms - Config: host_key_algorithms
- Env Var: RCLONE_SFTP_HOST_KEY_ALGORITHMS - Env Var: RCLONE_SFTP_HOST_KEY_ALGORITHMS
- Type: SpaceSepList - Type: SpaceSepList
- Default: - Default:
#### --sftp-socks-proxy #### --sftp-socks-proxy
Socks 5 proxy host. Socks 5 proxy host.
Supports the format user:pass@host:port, user@host:port, host:port. Supports the format user:pass@host:port, user@host:port, host:port.
Example: Example:
myUser:myPass@localhost:9005 myUser:myPass@localhost:9005
Properties: Properties:

View file

@ -39,6 +39,7 @@ type Features struct {
NoMultiThreading bool // set if can't have multiplethreads on one download open NoMultiThreading bool // set if can't have multiplethreads on one download open
Overlay bool // this wraps one or more backends to add functionality Overlay bool // this wraps one or more backends to add functionality
ChunkWriterDoesntSeek bool // set if the chunk writer doesn't need to read the data more than once ChunkWriterDoesntSeek bool // set if the chunk writer doesn't need to read the data more than once
TranslateSymlink bool // set if backend can Read and Write symlinks
// Purge all files in the directory specified // Purge all files in the directory specified
// //

View file

@ -48,6 +48,7 @@ var (
ErrorNotImplemented = errors.New("optional feature not implemented") ErrorNotImplemented = errors.New("optional feature not implemented")
ErrorCommandNotFound = errors.New("command not found") ErrorCommandNotFound = errors.New("command not found")
ErrorFileNameTooLong = errors.New("file name too long") ErrorFileNameTooLong = errors.New("file name too long")
ErrorReadIsDirectory = errors.New("read is a directory")
) )
// CheckClose is a utility function used to check the return from // CheckClose is a utility function used to check the return from