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

@ -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.",
@ -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"`
@ -575,6 +589,8 @@ type Object struct {
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)
} }
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) 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

@ -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.

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