diff --git a/backend/b2/b2.go b/backend/b2/b2.go index df8e9fc3f..5698c672b 100644 --- a/backend/b2/b2.go +++ b/backend/b2/b2.go @@ -1357,7 +1357,7 @@ func (f *Fs) getDownloadAuthorization(ctx context.Context, bucket, remote string } // PublicLink returns a link for downloading without account -func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { bucket, bucketPath := f.split(remote) var RootURL string if f.opt.DownloadURL == "" { diff --git a/backend/box/box.go b/backend/box/box.go index 7b5bed948..b720047aa 100644 --- a/backend/box/box.go +++ b/backend/box/box.go @@ -1024,7 +1024,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string } // PublicLink adds a "readable by anyone with link" permission on the given file or folder. -func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) { id, err := f.dirCache.FindDir(ctx, remote, false) var opts rest.Opts if err == nil { diff --git a/backend/crypt/crypt.go b/backend/crypt/crypt.go index 16735ac57..592760560 100644 --- a/backend/crypt/crypt.go +++ b/backend/crypt/crypt.go @@ -656,7 +656,7 @@ func (f *Fs) DirCacheFlush() { } // PublicLink generates a public link to the remote path (usually readable by anyone) -func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) { do := f.Fs.Features().PublicLink if do == nil { return "", errors.New("PublicLink not supported") @@ -664,9 +664,9 @@ func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) { o, err := f.NewObject(ctx, remote) if err != nil { // assume it is a directory - return do(ctx, f.cipher.EncryptDirName(remote)) + return do(ctx, f.cipher.EncryptDirName(remote), expire, unlink) } - return do(ctx, o.(*Object).Object.Remote()) + return do(ctx, o.(*Object).Object.Remote(), expire, unlink) } // ChangeNotify calls the passed function with a path diff --git a/backend/drive/drive.go b/backend/drive/drive.go index 2115fe559..81446cdba 100755 --- a/backend/drive/drive.go +++ b/backend/drive/drive.go @@ -2488,7 +2488,7 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, } // PublicLink adds a "readable by anyone with link" permission on the given file or folder. -func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { id, err := f.dirCache.FindDir(ctx, remote, false) if err == nil { fs.Debugf(f, "attempting to share directory '%s'", remote) diff --git a/backend/dropbox/dropbox.go b/backend/dropbox/dropbox.go index 7af321da3..ca07db62d 100755 --- a/backend/dropbox/dropbox.go +++ b/backend/dropbox/dropbox.go @@ -782,11 +782,14 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, } // PublicLink adds a "readable by anyone with link" permission on the given file or folder. -func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { absPath := f.opt.Enc.FromStandardPath(path.Join(f.slashRoot, remote)) fs.Debugf(f, "attempting to share '%s' (absolute path: %s)", remote, absPath) createArg := sharing.CreateSharedLinkWithSettingsArg{ Path: absPath, + Settings: &sharing.SharedLinkSettings{ + Expires: time.Now().Add(time.Duration(expire)), + }, } var linkRes sharing.IsSharedLinkMetadata err = f.pacer.Call(func() (bool, error) { diff --git a/backend/jottacloud/jottacloud.go b/backend/jottacloud/jottacloud.go index 1103c3926..3fa81866a 100644 --- a/backend/jottacloud/jottacloud.go +++ b/backend/jottacloud/jottacloud.go @@ -150,11 +150,6 @@ func init() { Help: "Delete files permanently rather than putting them into the trash.", Default: false, Advanced: true, - }, { - Name: "unlink", - Help: "Remove existing public link to file/folder with link command rather than creating.\nDefault is false, meaning link command will create or retrieve public link.", - Default: false, - Advanced: true, }, { Name: "upload_resume_limit", Help: "Files bigger than this can be resumed if the upload fail's.", @@ -181,7 +176,6 @@ type Options struct { MD5MemoryThreshold fs.SizeSuffix `config:"md5_memory_limit"` TrashedOnly bool `config:"trashed_only"` HardDelete bool `config:"hard_delete"` - Unlink bool `config:"unlink"` UploadThreshold fs.SizeSuffix `config:"upload_resume_limit"` Enc encoder.MultiEncoder `config:"encoding"` } @@ -1002,14 +996,14 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string } // PublicLink generates a public link to the remote path (usually readable by anyone) -func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { opts := rest.Opts{ Method: "GET", Path: f.filePath(remote), Parameters: url.Values{}, } - if f.opt.Unlink { + if unlink { opts.Parameters.Set("mode", "disableShare") } else { opts.Parameters.Set("mode", "enableShare") @@ -1029,12 +1023,12 @@ func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err er } } if err != nil { - if f.opt.Unlink { + if unlink { return "", errors.Wrap(err, "couldn't remove public link") } return "", errors.Wrap(err, "couldn't create public link") } - if f.opt.Unlink { + if unlink { if result.PublicSharePath != "" { return "", errors.Errorf("couldn't remove public link - %q", result.PublicSharePath) } diff --git a/backend/koofr/koofr.go b/backend/koofr/koofr.go index 32b7374ca..f51368513 100644 --- a/backend/koofr/koofr.go +++ b/backend/koofr/koofr.go @@ -603,7 +603,7 @@ func createLink(c *koofrclient.KoofrClient, mountID string, path string) (*link, } // PublicLink creates a public link to the remote path -func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) { linkData, err := createLink(f.client, f.mountID, f.fullPath(remote)) if err != nil { return "", translateErrorsDir(err) diff --git a/backend/mailru/mailru.go b/backend/mailru/mailru.go index 5f317953e..b1a61b76e 100644 --- a/backend/mailru/mailru.go +++ b/backend/mailru/mailru.go @@ -1450,7 +1450,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string } // PublicLink generates a public link to the remote path (usually readable by anyone) -func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { // fs.Debugf(f, ">>> PublicLink %q", remote) token, err := f.accessToken() diff --git a/backend/mega/mega.go b/backend/mega/mega.go index 925bd3b59..efb23ff9d 100644 --- a/backend/mega/mega.go +++ b/backend/mega/mega.go @@ -836,7 +836,7 @@ func (f *Fs) Hashes() hash.Set { } // PublicLink generates a public link to the remote path (usually readable by anyone) -func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { root, err := f.findRoot(false) if err != nil { return "", errors.Wrap(err, "PublicLink failed to find root node") diff --git a/backend/onedrive/onedrive.go b/backend/onedrive/onedrive.go index 8e764543e..202433742 100755 --- a/backend/onedrive/onedrive.go +++ b/backend/onedrive/onedrive.go @@ -1311,7 +1311,7 @@ func (f *Fs) Hashes() hash.Set { } // PublicLink returns a link for downloading without account. -func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { info, _, err := f.readMetaDataForPath(ctx, f.rootPath(remote)) if err != nil { return "", err diff --git a/backend/premiumizeme/premiumizeme.go b/backend/premiumizeme/premiumizeme.go index f2ec858d5..b80f5a749 100644 --- a/backend/premiumizeme/premiumizeme.go +++ b/backend/premiumizeme/premiumizeme.go @@ -822,7 +822,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string } // PublicLink adds a "readable by anyone with link" permission on the given file or folder. -func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) { _, err := f.dirCache.FindDir(ctx, remote, false) if err == nil { return "", fs.ErrorCantShareDirectories diff --git a/backend/seafile/seafile.go b/backend/seafile/seafile.go index 75ab5afad..544e72a4e 100644 --- a/backend/seafile/seafile.go +++ b/backend/seafile/seafile.go @@ -972,7 +972,7 @@ func (f *Fs) UserInfo(ctx context.Context) (map[string]string, error) { // ==================== Optional Interface fs.PublicLinker ==================== // PublicLink generates a public link to the remote path (usually readable by anyone) -func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) { libraryName, filePath := f.splitPath(remote) if libraryName == "" { // We cannot share the whole seafile server, we need at least a library diff --git a/backend/sugarsync/sugarsync.go b/backend/sugarsync/sugarsync.go index 7507de3ec..8c22d680f 100644 --- a/backend/sugarsync/sugarsync.go +++ b/backend/sugarsync/sugarsync.go @@ -1099,7 +1099,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string } // PublicLink adds a "readable by anyone with link" permission on the given file or folder. -func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) { obj, err := f.NewObject(ctx, remote) if err != nil { return "", err diff --git a/backend/yandex/yandex.go b/backend/yandex/yandex.go index b5b638588..570f3891d 100644 --- a/backend/yandex/yandex.go +++ b/backend/yandex/yandex.go @@ -73,11 +73,6 @@ func init() { }, { Name: config.ConfigClientSecret, Help: "Yandex Client Secret\nLeave blank normally.", - }, { - Name: "unlink", - Help: "Remove existing public link to file/folder with link command rather than creating.\nDefault is false, meaning link command will create or retrieve public link.", - Default: false, - Advanced: true, }, { Name: config.ConfigEncoding, Help: config.ConfigEncodingHelp, @@ -92,9 +87,8 @@ func init() { // Options defines the configuration for this backend type Options struct { - Token string `config:"token"` - Unlink bool `config:"unlink"` - Enc encoder.MultiEncoder `config:"encoding"` + Token string `config:"token"` + Enc encoder.MultiEncoder `config:"encoding"` } // Fs represents a remote yandex @@ -801,9 +795,9 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string } // PublicLink generates a public link to the remote path (usually readable by anyone) -func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) { +func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { var path string - if f.opt.Unlink { + if unlink { path = "/resources/unpublish" } else { path = "/resources/publish" @@ -830,7 +824,7 @@ func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err er } } if err != nil { - if f.opt.Unlink { + if unlink { return "", errors.Wrap(err, "couldn't remove public link") } return "", errors.Wrap(err, "couldn't create public link") diff --git a/cmd/link/link.go b/cmd/link/link.go index 4ea470f85..bf72b659e 100644 --- a/cmd/link/link.go +++ b/cmd/link/link.go @@ -3,35 +3,57 @@ package link import ( "context" "fmt" + "time" "github.com/rclone/rclone/cmd" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config/flags" "github.com/rclone/rclone/fs/operations" "github.com/spf13/cobra" ) +var ( + expire = fs.Duration(time.Hour * 24 * 365 * 100) + unlink = false +) + func init() { cmd.Root.AddCommand(commandDefinition) + cmdFlags := commandDefinition.Flags() + flags.FVarP(cmdFlags, &expire, "expire", "", "The amount of time that the link will be valid") + flags.BoolVarP(cmdFlags, &unlink, "unlink", "", unlink, "Remove existing public link to file/folder") } var commandDefinition = &cobra.Command{ Use: "link remote:path", Short: `Generate public link to file/folder.`, - Long: ` -rclone link will create or retrieve a public link to the given file or folder. + Long: `rclone link will create, retrieve or remove a public link to the given +file or folder. rclone link remote:path/to/file rclone link remote:path/to/folder/ + rclone link --unlink remote:path/to/folder/ + rclone link --expire 1d remote:path/to/file -If successful, the last line of the output will contain the link. Exact -capabilities depend on the remote, but the link will always be created with -the least constraints – e.g. no expiry, no password protection, accessible -without account. +If you supply the --expire flag, it will set the expiration time +otherwise it will use the default (100 years). **Note** not all +backends support the --expire flag - if the backend doesn't support it +then the link returned won't expire. + +Use the --unlink flag to remove existing public links to the file or +folder. **Note** not all backends support "--unlink" flag - those that +don't will just ignore it. + +If successful, the last line of the output will contain the +link. Exact capabilities depend on the remote, but the link will +always by default be created with the least constraints – e.g. no +expiry, no password protection, accessible without account. `, Run: func(command *cobra.Command, args []string) { cmd.CheckArgs(1, 1, command, args) fsrc, remote := cmd.NewFsFile(args[0]) cmd.Run(false, false, command, func() error { - link, err := operations.PublicLink(context.Background(), fsrc, remote) + link, err := operations.PublicLink(context.Background(), fsrc, remote, expire, unlink) if err != nil { return err } diff --git a/fs/fs.go b/fs/fs.go index 6fd55099c..121898a97 100644 --- a/fs/fs.go +++ b/fs/fs.go @@ -575,7 +575,7 @@ type Features struct { DirCacheFlush func() // PublicLink generates a public link to the remote path (usually readable by anyone) - PublicLink func(ctx context.Context, remote string) (string, error) + PublicLink func(ctx context.Context, remote string, expire Duration, unlink bool) (string, error) // Put in to the remote path with the modTime given of the given size // @@ -988,7 +988,7 @@ type PutStreamer interface { // PublicLinker is an optional interface for Fs type PublicLinker interface { // PublicLink generates a public link to the remote path (usually readable by anyone) - PublicLink(ctx context.Context, remote string) (string, error) + PublicLink(ctx context.Context, remote string, expire Duration, unlink bool) (string, error) } // MergeDirser is an option interface for Fs diff --git a/fs/operations/operations.go b/fs/operations/operations.go index adf32e072..02b0c8c71 100644 --- a/fs/operations/operations.go +++ b/fs/operations/operations.go @@ -1416,12 +1416,12 @@ func Rcat(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadCloser, } // PublicLink adds a "readable by anyone with link" permission on the given file or folder. -func PublicLink(ctx context.Context, f fs.Fs, remote string) (string, error) { +func PublicLink(ctx context.Context, f fs.Fs, remote string, expire fs.Duration, unlink bool) (string, error) { doPublicLink := f.Features().PublicLink if doPublicLink == nil { return "", errors.Errorf("%v doesn't support public links", f) } - return doPublicLink(ctx, remote) + return doPublicLink(ctx, remote, expire, unlink) } // Rmdirs removes any empty directories (or directories only diff --git a/fs/operations/rc.go b/fs/operations/rc.go index 291124902..6482612bc 100644 --- a/fs/operations/rc.go +++ b/fs/operations/rc.go @@ -271,6 +271,8 @@ func init() { - fs - a remote name string eg "drive:" - remote - a path within that remote eg "dir" +- unlink - boolean - if set removes the link rather than adding it (optional) +- expire - string - the expiry time of the link eg "1d" (optional) Returns @@ -287,7 +289,12 @@ func rcPublicLink(ctx context.Context, in rc.Params) (out rc.Params, err error) if err != nil { return nil, err } - url, err := PublicLink(ctx, f, remote) + unlink, _ := in.GetBool("unlink") + expire, err := in.GetDuration("expire") + if err != nil && !rc.IsErrParamNotFound(err) { + return nil, err + } + url, err := PublicLink(ctx, f, remote, fs.Duration(expire), unlink) if err != nil { return nil, err } diff --git a/fs/operations/rc_test.go b/fs/operations/rc_test.go index 36117c284..3f3dd99bd 100644 --- a/fs/operations/rc_test.go +++ b/fs/operations/rc_test.go @@ -192,7 +192,7 @@ func TestRcDeletefile(t *testing.T) { fstest.CheckItems(t, r.Fremote, file2) } -// operations/list: List the given remote and path in JSON format +// operations/list: List the given remote and path in JSON format. func TestRcList(t *testing.T) { r, call := rcNewRun(t, "operations/list") defer r.Finalise() @@ -402,6 +402,8 @@ func TestRcPublicLink(t *testing.T) { in := rc.Params{ "fs": r.FremoteName, "remote": "", + "expire": "5m", + "unlink": false, } _, err := call.Fn(context.Background(), in) require.Error(t, err) diff --git a/fs/rc/params.go b/fs/rc/params.go index cf8bf95d3..415cb4e32 100644 --- a/fs/rc/params.go +++ b/fs/rc/params.go @@ -7,8 +7,11 @@ import ( "fmt" "math" "strconv" + "time" "github.com/pkg/errors" + + "github.com/rclone/rclone/fs" ) // Params is the input and output type for the Func @@ -212,3 +215,16 @@ func (p Params) GetStructMissingOK(key string, out interface{}) error { } return p.GetStruct(key, out) } + +// GetDuration get the duration parameters from in +func (p Params) GetDuration(key string) (time.Duration, error) { + s, err := p.GetString(key) + if err != nil { + return 0, err + } + duration, err := fs.ParseDuration(s) + if err != nil { + return 0, ErrParamInvalid{errors.Wrap(err, "parse duration")} + } + return duration, nil +} diff --git a/fs/rc/params_test.go b/fs/rc/params_test.go index 4b7a25349..27529f70b 100644 --- a/fs/rc/params_test.go +++ b/fs/rc/params_test.go @@ -3,10 +3,13 @@ package rc import ( "fmt" "testing" + "time" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/rclone/rclone/fs" ) func TestErrParamNotFoundError(t *testing.T) { @@ -173,6 +176,57 @@ func TestParamsGetFloat64(t *testing.T) { assert.Equal(t, true, IsErrParamInvalid(e3), e3.Error()) } +func TestParamsGetDuration(t *testing.T) { + for _, test := range []struct { + value interface{} + result time.Duration + errString string + }{ + {"86400", time.Hour * 24, ""}, + {"1y", time.Hour * 24 * 365, ""}, + {"60", time.Minute * 1, ""}, + {"0", 0, ""}, + {"-45", -time.Second * 45, ""}, + {"2", time.Second * 2, ""}, + {"2h4m7s", time.Hour*2 + 4*time.Minute + 7*time.Second, ""}, + {"3d", time.Hour * 24 * 3, ""}, + {"off", time.Duration(fs.DurationOff), ""}, + {"", 0, "parse duration"}, + {12, 0, "expecting string"}, + {"34y", time.Hour * 24 * 365 * 34, ""}, + {"30d", time.Hour * 24 * 30, ""}, + {"2M", time.Hour * 24 * 60, ""}, + {"wrong", 0, "parse duration"}, + } { + t.Run(fmt.Sprintf("%T=%v", test.value, test.value), func(t *testing.T) { + in := Params{ + "key": test.value, + } + v1, e1 := in.GetDuration("key") + if test.errString == "" { + require.NoError(t, e1) + assert.Equal(t, test.result, v1) + } else { + require.NotNil(t, e1) + require.Error(t, e1) + assert.Contains(t, e1.Error(), test.errString) + assert.Equal(t, time.Duration(0), v1) + } + }) + } + in := Params{ + "notDuration": []string{"a", "b"}, + } + v2, e2 := in.GetDuration("notOK") + assert.Error(t, e2) + assert.Equal(t, time.Duration(0), v2) + assert.Equal(t, ErrParamNotFound("notOK"), e2) + v3, e3 := in.GetDuration("notDuration") + assert.Error(t, e3) + assert.Equal(t, time.Duration(0), v3) + assert.Equal(t, true, IsErrParamInvalid(e3), e3.Error()) +} + func TestParamsGetBool(t *testing.T) { for _, test := range []struct { value interface{} diff --git a/fstest/fstests/fstests.go b/fstest/fstests/fstests.go index d8701ee61..be5bff060 100644 --- a/fstest/fstests/fstests.go +++ b/fstest/fstests/fstests.go @@ -1467,29 +1467,29 @@ func Run(t *testing.T, opt *Opt) { } // if object not found - link, err := doPublicLink(ctx, file1.Path+"_does_not_exist") + link, err := doPublicLink(ctx, file1.Path+"_does_not_exist", fs.Duration(0), false) require.Error(t, err, "Expected to get error when file doesn't exist") require.Equal(t, "", link, "Expected link to be empty on error") // sharing file for the first time - link1, err := doPublicLink(ctx, file1.Path) + link1, err := doPublicLink(ctx, file1.Path, fs.Duration(0), false) require.NoError(t, err) require.NotEqual(t, "", link1, "Link should not be empty") - link2, err := doPublicLink(ctx, file2.Path) + link2, err := doPublicLink(ctx, file2.Path, fs.Duration(0), false) require.NoError(t, err) require.NotEqual(t, "", link2, "Link should not be empty") require.NotEqual(t, link1, link2, "Links to different files should differ") // sharing file for the 2nd time - link1, err = doPublicLink(ctx, file1.Path) + link1, err = doPublicLink(ctx, file1.Path, fs.Duration(0), false) require.NoError(t, err) require.NotEqual(t, "", link1, "Link should not be empty") // sharing directory for the first time path := path.Dir(file2.Path) - link3, err := doPublicLink(ctx, path) + link3, err := doPublicLink(ctx, path, fs.Duration(0), false) if err != nil && errors.Cause(err) == fs.ErrorCantShareDirectories { t.Log("skipping directory tests as not supported on this backend") } else { @@ -1497,7 +1497,7 @@ func Run(t *testing.T, opt *Opt) { require.NotEqual(t, "", link3, "Link should not be empty") // sharing directory for the second time - link3, err = doPublicLink(ctx, path) + link3, err = doPublicLink(ctx, path, fs.Duration(0), false) require.NoError(t, err) require.NotEqual(t, "", link3, "Link should not be empty") @@ -1511,7 +1511,7 @@ func Run(t *testing.T, opt *Opt) { _, err = subRemote.Put(ctx, buf, obji) require.NoError(t, err) - link4, err := subRemote.Features().PublicLink(ctx, "") + link4, err := subRemote.Features().PublicLink(ctx, "", fs.Duration(0), false) require.NoError(t, err, "Sharing root in a sub-remote should work") require.NotEqual(t, "", link4, "Link should not be empty") }