File 1.patch of Package rclone
From 9b6655fd25a11393da58ddb70b678eec277ad669 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Tue, 17 Feb 2026 14:05:35 +0100
Subject: [PATCH 01/32] kdrive: add multipart upload and internal tests
---
backend/kdrive/api/types.go | 31 +++
backend/kdrive/kdrive.go | 101 ++++++--
backend/kdrive/kdrive_internal_test.go | 217 +++++++++++++++++
backend/kdrive/multipart.go | 225 ++++++++++++++++++
.../kdrive/test/test-list/test-list-file1.txt | 0
.../kdrive/test/test-list/test-list-file2.txt | 0
.../test-list-subfolder/test-list-subfile.txt | 0
.../test-list-subsubfile.txt | 0
8 files changed, 550 insertions(+), 24 deletions(-)
create mode 100644 backend/kdrive/kdrive_internal_test.go
create mode 100644 backend/kdrive/multipart.go
create mode 100644 backend/kdrive/test/test-list/test-list-file1.txt
create mode 100644 backend/kdrive/test/test-list/test-list-file2.txt
create mode 100644 backend/kdrive/test/test-list/test-list-subfolder/test-list-subfile.txt
create mode 100644 backend/kdrive/test/test-list/test-list-subfolder/test-list-subsubfolder/test-list-subsubfile.txt
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index ce96979ab..6fb4a8da5 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -271,3 +271,34 @@ type QuotaInfo struct {
UsedSize int64 `json:"used_size"`
} `json:"data"`
}
+
+type SessionStartResponse struct {
+ Result string `json:"result"`
+ Data struct {
+ Token string `json:"token"`
+ UploadURL string `json:"upload_url"` // URL pour uploader les chunks
+ } `json:"data"`
+}
+
+type ChunkUploadResponse struct {
+ Result string `json:"result"`
+ Data struct {
+ ReceivedBytes int64 `json:"received_bytes"`
+ Hash string `json:"hash,omitempty"` // Only present when requested with with:'hash'
+ } `json:"data"`
+}
+
+type SessionFinishResponse struct {
+ Result string `json:"result"`
+ Data struct {
+ Token string `json:"token"`
+ File Item `json:"file"`
+ Result bool `json:"result"`
+ Message string `json:"message"`
+ } `json:"data"`
+}
+
+type SessionCancelResponse struct {
+ Result string `json:"result"`
+ Data Item `json:"data"`
+}
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 7c94ed9c8..b4a4131b6 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -3,6 +3,7 @@
package kdrive
import (
+ "bytes"
"context"
"errors"
"fmt"
@@ -25,16 +26,20 @@ import (
"github.com/rclone/rclone/fs/list"
"github.com/rclone/rclone/lib/dircache"
"github.com/rclone/rclone/lib/encoder"
+ "github.com/rclone/rclone/lib/multipart"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
+ "github.com/zeebo/xxh3"
"golang.org/x/oauth2"
"golang.org/x/text/unicode/norm"
)
const (
- minSleep = 10 * time.Millisecond
- maxSleep = 2 * time.Second
- decayConstant = 2 // bigger for slower decay, exponential
+ defaultEndpoint = "https://api.infomaniak.com"
+ minSleep = 10 * time.Millisecond
+ maxSleep = 2 * time.Second
+ decayConstant = 2 // bigger for slower decay, exponential
+ uploadThreshold = 20 * 1024 * 1024 // 20 Mo
)
// Register with Fs
@@ -55,25 +60,24 @@ func init() {
Name: "root_folder_id",
Help: "Fill in for rclone to use a non root folder as its starting point.",
// for default root, see https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
- Default: "1",
+ Default: "5",
Advanced: true,
Sensitive: true,
- }, {
- Name: "account_id",
- Help: `Fill the account ID that is to be considered for this kdrive.
-When showing a folder on kdrive, you can find the account_id here:
-https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/...`,
- Default: "",
}, {
Name: "drive_id",
Help: `Fill the drive ID for this kdrive.
-When showing a folder on kdrive, you can find the drive_id here:
-https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...`,
+ When showing a folder on kdrive, you can find the drive_id here:
+ https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...`,
Default: "",
}, {
Name: "access_token",
Help: `Access token generated in Infomaniak profile manager.`,
Default: "",
+ }, {
+ Name: "endpoint",
+ Help: "By default, pointing to the production API.",
+ Default: defaultEndpoint,
+ Advanced: true,
},
},
})
@@ -83,9 +87,9 @@ https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...
type Options struct {
Enc encoder.MultiEncoder `config:"encoding"`
RootFolderID string `config:"root_folder_id"`
- AccountID string `config:"account_id"`
DriveID string `config:"drive_id"`
AccessToken string `config:"access_token"`
+ Endpoint string `config:"endpoint"`
}
// Fs represents a remote kdrive
@@ -247,10 +251,10 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
root: root,
opt: *opt,
ts: ts,
- srv: rest.NewClient(oAuthClient).SetRoot("https://api.infomaniak.com"),
+ srv: rest.NewClient(oAuthClient).SetRoot(opt.Endpoint),
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
}
- f.cleanupSrv = rest.NewClient(fshttp.NewClient(ctx)).SetRoot("https://api.infomaniak.com")
+ f.cleanupSrv = rest.NewClient(fshttp.NewClient(ctx)).SetRoot(opt.Endpoint)
f.features = (&fs.Features{
CaseInsensitive: false,
CanHaveEmptyDirectories: true,
@@ -944,7 +948,7 @@ func (o *Object) retrieveHash(ctx context.Context) (err error) {
return nil
}
-// Hash returns the SHA-1 of an object returning a lowercase hex string
+// Hash returns the XXH3 of an object returning a lowercase hex string
func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) {
var pHash *string
switch t {
@@ -964,7 +968,7 @@ func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) {
// Size returns the size of an object in bytes
func (o *Object) Size() int64 {
- err := o.readMetaData(context.TODO())
+ err := o.readMetaData(context.Background())
if err != nil {
fs.Logf(o, "Failed to read metadata: %v", err)
return 0
@@ -1041,7 +1045,7 @@ func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.Read
var resp *http.Response
opts := rest.Opts{
Method: "GET",
- RootURL: fmt.Sprintf("%s/2/drive/%s/files/%s/download", "https://api.infomaniak.com", o.fs.opt.DriveID, o.id),
+ RootURL: fmt.Sprintf("%s/2/drive/%s/files/%s/download", o.fs.opt.Endpoint, o.fs.opt.DriveID, o.id),
Options: options,
}
err = o.fs.pacer.Call(func() (bool, error) {
@@ -1061,7 +1065,6 @@ func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.Read
// The new object may have been created if an error is returned
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
size := src.Size() // NB can upload without size
- modTime := src.ModTime(ctx)
remote := o.Remote()
if size < 0 {
@@ -1074,34 +1077,56 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
return err
}
- // This API doesn't support chunk uploads, so it's just for now
- // NOTE: It will fail for any file larger than 1GB
- // TODO: implement the session multi-chunk API approach
+ // if file size is less than the threshold, upload direct
+ if size <= uploadThreshold {
+ return o.updateDirect(ctx, in, directoryID, leaf, src, options...)
+ }
+ // else, use multipart upload with parallelism
+ return o.updateMultipart(ctx, in, src, options...)
+}
+
+func (o *Object) updateDirect(ctx context.Context, in io.Reader, directoryID, leaf string, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
+
var resp *http.Response
var result api.UploadFileResponse
+
+ // Read the content to calculate hash (files are small, under 20MB)
+ content, err := io.ReadAll(in)
+ if err != nil {
+ return fmt.Errorf("failed to read file content: %w", err)
+ }
+ // Calculate xxh3 hash
+ hasher := xxh3.New()
+ hasher.Write(content)
+ totalHash := fmt.Sprintf("xxh3:%x", hasher.Sum(nil))
+
+ size := src.Size()
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/upload", o.fs.opt.DriveID),
- Body: in,
+ Body: bytes.NewReader(content),
ContentType: fs.MimeType(ctx, src),
ContentLength: &size,
Parameters: url.Values{},
TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
Options: options,
}
+
leaf = o.fs.opt.Enc.FromStandardName(leaf)
opts.Parameters.Set("file_name", leaf)
opts.Parameters.Set("directory_id", directoryID)
opts.Parameters.Set("total_size", fmt.Sprintf("%d", size))
- opts.Parameters.Set("last_modified_at", fmt.Sprintf("%d", uint64(modTime.Unix())))
+ opts.Parameters.Set("last_modified_at", fmt.Sprintf("%d", uint64(src.ModTime(ctx).Unix())))
opts.Parameters.Set("conflict", "version")
opts.Parameters.Set("with", "hash")
+ opts.Parameters.Set("total_chunk_hash", totalHash)
err = o.fs.pacer.CallNoRetry(func() (bool, error) {
resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &result)
err = result.ResultStatus.Update(err)
return shouldRetry(ctx, resp, err)
})
+
// TODO: check if the following erroneous behavior also happens on kDrive
// (this workaround comes from the pcloud backend implementation)
if err != nil {
@@ -1126,6 +1151,34 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
return o.readMetaData(ctx)
}
+// updateMultipart uploads large files using chunked upload with parallel streaming
+func (o *Object) updateMultipart(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
+ f := o.fs
+
+ chunkWriter, err := multipart.UploadMultipart(ctx, src, in, multipart.UploadMultipartOptions{
+ Open: f,
+ OpenOptions: options,
+ })
+ if err != nil {
+ return err
+ }
+
+ // Extract the file info from the chunk writer
+ session, ok := chunkWriter.(*uploadSession)
+ if !ok {
+ return fmt.Errorf("unexpected chunk writer type")
+ }
+
+ if session.fileInfo == nil {
+ return fmt.Errorf("upload failed: no file info returned")
+ }
+
+ // Use hash from chunk uploads (stored in session)
+ o.setHash(strings.TrimPrefix(session.hash, "xxh3:"))
+
+ return o.setMetaData(session.fileInfo)
+}
+
// Remove an object
func (o *Object) Remove(ctx context.Context) error {
opts := rest.Opts{
diff --git a/backend/kdrive/kdrive_internal_test.go b/backend/kdrive/kdrive_internal_test.go
new file mode 100644
index 000000000..cd26f442f
--- /dev/null
+++ b/backend/kdrive/kdrive_internal_test.go
@@ -0,0 +1,217 @@
+package kdrive
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "flag"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ _ "github.com/rclone/rclone/backend/local"
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/object"
+ "github.com/rclone/rclone/fs/operations"
+ "github.com/rclone/rclone/fs/sync"
+ "github.com/rclone/rclone/fstest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ // Set individual mode to prevent automatic cleanup of entire remote
+ *fstest.Individual = true
+ // Parse flags first
+ flag.Parse()
+ // Initialise fstest (setup verbose logging, etc.)
+ fstest.Initialise()
+ // Run tests
+ rc := m.Run()
+ os.Exit(rc)
+}
+
+// setupTestFs creates an isolated test filesystem in a unique subdirectory
+// This prevents tests from deleting user's personal files
+func setupTestFs(t *testing.T) *Fs {
+ ctx := context.Background()
+ fs.GetConfig(ctx).LogLevel = fs.LogLevelDebug
+
+ // Create a unique test directory name
+ testDir := fmt.Sprintf("rclone-test-%d", time.Now().UnixNano())
+
+ // Step 1: Create fs pointing to root of drive
+ fRoot, err := fs.NewFs(ctx, "TestKdrive:")
+ require.NoError(t, err, "Failed to create root fs")
+
+ // Step 2: Create the test directory in the root fs
+ err = fRoot.Mkdir(ctx, testDir)
+ require.NoError(t, err, "Failed to create test directory")
+
+ // Step 3: Create fs pointing specifically to the test subdirectory
+ fTest, err := fs.NewFs(ctx, fmt.Sprintf("TestKdrive:%s", testDir))
+ require.NoError(t, err, "Failed to create test fs")
+
+ // Step 4: Cleanup - delete the test directory from the root fs
+ t.Cleanup(func() {
+ // Use Rmdir to delete the test directory and its contents
+ err := operations.Purge(ctx, fRoot, testDir)
+ if err != nil {
+ t.Logf("Failed to remove test directory: %v", err)
+ }
+ })
+
+ // Cast fTest to *Fs
+ fKdrive, ok := fTest.(*Fs)
+ require.True(t, ok, "Expected *Fs type")
+
+ return fKdrive
+}
+
+// TestPutSmallFile tests the updateDirect path (file < uploadThreshold of 20MB)
+// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestPutSmallFile -verbose
+func TestPutSmallFile(t *testing.T) {
+ ctx := context.Background()
+ fRemote := setupTestFs(t)
+
+ // File of 1KB
+ size := int64(1024)
+ data := make([]byte, size)
+ _, err := rand.Read(data)
+ require.NoError(t, err)
+
+ remote := fmt.Sprintf("small-file-%d.bin", time.Now().UnixNano())
+ src := object.NewStaticObjectInfo(remote, time.Now(), size, true, nil, fRemote)
+
+ obj, err := fRemote.Put(ctx, bytes.NewReader(data), src)
+ require.NoError(t, err)
+ require.NotNil(t, obj)
+
+ // Verify that the object exists
+ obj2, err := fRemote.NewObject(ctx, remote)
+ require.NoError(t, err)
+ assert.Equal(t, size, obj2.Size())
+ assert.Equal(t, remote, obj2.Remote())
+}
+
+// TestPutLargeFile tests the updateMultipart path (file > uploadThreshold of 20MB)
+// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestPutLargeFile -verbose
+func TestPutLargeFile(t *testing.T) {
+ ctx := context.Background()
+ fRemote := setupTestFs(t)
+
+ // File of 50MB to force chunked mode
+ size := int64(50 * 1024 * 1024)
+ data := make([]byte, size)
+ _, err := rand.Read(data)
+ require.NoError(t, err)
+
+ remote := fmt.Sprintf("large-file-%d.bin", time.Now().UnixNano())
+ src := object.NewStaticObjectInfo(remote, time.Now(), size, true, nil, fRemote)
+
+ obj, err := fRemote.Put(ctx, bytes.NewReader(data), src)
+ require.NoError(t, err)
+ require.NotNil(t, obj)
+
+ // Verify that the object exists
+ obj2, err := fRemote.NewObject(ctx, remote)
+ require.NoError(t, err)
+ assert.Equal(t, size, obj2.Size())
+ assert.Equal(t, remote, obj2.Remote())
+}
+
+func prepareListing(t *testing.T) fs.Fs {
+ ctx := context.Background()
+
+ // Use the same isolated test fs setup
+ fRemote := setupTestFs(t)
+
+ // Copies the test/test-list folder to the remote (recursive)
+ testDirPath := "./test/test-list"
+ fLocal, err := fs.NewFs(ctx, testDirPath)
+ require.NoError(t, err)
+
+ err = sync.CopyDir(ctx, fRemote, fLocal, true)
+ require.NoError(t, err)
+
+ return fRemote
+}
+
+// TestListFiles test List without recursion
+// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestListFiles -verbose
+func TestListFiles(t *testing.T) {
+ ctx := context.Background()
+ fRemote := prepareListing(t)
+
+ entries, err := fRemote.List(ctx, "")
+ require.NoError(t, err)
+
+ // Verify that we have listed the files/directories
+ assert.NotEmpty(t, entries)
+ assert.Len(t, entries, 3)
+
+ var remoteList []string
+ for _, item := range entries {
+ fs.Debugf(nil, "Remote file : %s", item.Remote())
+ remoteList = append(remoteList, item.Remote())
+ }
+
+ assert.Contains(t, remoteList, "test-list-subfolder")
+ assert.Contains(t, remoteList, "test-list-file1.txt")
+ assert.Contains(t, remoteList, "test-list-file2.txt")
+ assert.NotContains(t, remoteList, "test-list-subfolder/test-list-subsubfolder")
+
+ // List subfolder
+ entriesSub, err := fRemote.List(ctx, "/test-list-subfolder")
+ require.NoError(t, err)
+
+ // Verify that we have listed the files/directories
+ assert.NotEmpty(t, entriesSub)
+ assert.Len(t, entriesSub, 2)
+
+ var remoteListSub []string
+ for _, item := range entriesSub {
+ fs.Debugf(nil, "Remote file sub : %s", item.Remote())
+ remoteListSub = append(remoteListSub, item.Remote())
+ }
+
+ assert.Contains(t, remoteListSub, "/test-list-subfolder/test-list-subsubfolder")
+ assert.Contains(t, remoteListSub, "/test-list-subfolder/test-list-subfile.txt")
+ assert.NotContains(t, remoteListSub, "test-list-file1.txt")
+}
+
+// TestListFiles test List with recursion
+// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestListFiles -verbose
+func TestListRecursive(t *testing.T) {
+ ctx := context.Background()
+ fRemote := prepareListing(t)
+
+ if fRemote.Features().ListR == nil {
+ t.Skip("ListR not supported")
+ }
+
+ var entries fs.DirEntries
+ err := fRemote.Features().ListR(ctx, "", func(entry fs.DirEntries) error {
+ entries = append(entries, entry...)
+ return nil
+ })
+ require.NoError(t, err)
+
+ // Verify that we have listed the files/directories
+ assert.NotEmpty(t, entries)
+ assert.Len(t, entries, 6)
+
+ var remoteList []string
+ for _, item := range entries {
+ fs.Debugf(nil, "Remote file %s", item.Remote())
+ remoteList = append(remoteList, item.Remote())
+ }
+
+ assert.Contains(t, remoteList, "test-list-subfolder")
+ assert.Contains(t, remoteList, "test-list-file1.txt")
+ assert.Contains(t, remoteList, "test-list-file2.txt")
+ assert.Contains(t, remoteList, "test-list-subfolder/test-list-subfile.txt")
+ assert.Contains(t, remoteList, "test-list-subfolder/test-list-subsubfolder")
+ assert.Contains(t, remoteList, "test-list-subfolder/test-list-subsubfolder/test-list-subsubfile.txt")
+}
diff --git a/backend/kdrive/multipart.go b/backend/kdrive/multipart.go
new file mode 100644
index 000000000..62240af4f
--- /dev/null
+++ b/backend/kdrive/multipart.go
@@ -0,0 +1,225 @@
+//go:build !plan9 && !js
+
+package kdrive
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "hash"
+ "io"
+ "math"
+ "net/url"
+ "path"
+ "strings"
+
+ "github.com/rclone/rclone/backend/kdrive/api"
+ "github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/lib/rest"
+ "github.com/zeebo/xxh3"
+)
+
+// uploadSession implements fs.ChunkWriter for kdrive multipart uploads
+type uploadSession struct {
+ f *Fs
+ parentID string
+ fileName string
+ token string
+ uploadURL string
+ fileInfo *api.Item
+ chunkCount int
+ hash string // Hash from the last chunk upload
+ totalHash hash.Hash // Accumulates all chunks for total hash
+ chunkHashes []string // Stores individual chunk hashes
+}
+
+const (
+ maxChunkSize = 1 * 1024 * 1024 * 1024 // 1 Go (max API)
+ defaultChunkSize = 20 * 1024 * 1024 // 20 Mo
+ maxChunks = 10000 // Limite API
+)
+
+func calculateChunkSize(fileSize int64) int64 {
+ if fileSize <= defaultChunkSize*maxChunks {
+ return defaultChunkSize
+ }
+ // Pour les très gros fichiers, augmenter la taille des chunks
+ chunkSize := fileSize / maxChunks
+ if chunkSize > maxChunkSize {
+ return maxChunkSize
+ }
+ return chunkSize
+}
+
+func calculateTotalChunks(fileSize int64, chunkSize int64) int64 {
+ totalChunks := math.Ceil(float64(fileSize) / float64(chunkSize))
+
+ return int64(totalChunks)
+}
+
+// OpenChunkWriter returns chunk writer info and the upload session
+func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
+ dir, leaf := path.Split(remote)
+ dir = strings.TrimSuffix(dir, "/")
+
+ // Create parent directories if they don't exist
+ parentID, err := f.dirCache.FindDir(ctx, dir, true)
+ if err != nil {
+ return info, nil, fmt.Errorf("failed to find parent directory: %w", err)
+ }
+
+ fileSize := src.Size()
+ chunkSize := calculateChunkSize(fileSize)
+ totalChunks := calculateTotalChunks(fileSize, chunkSize)
+
+ sessionReq := struct {
+ Conflict string `json:"conflict"`
+ DirectoryID string `json:"directory_id"`
+ FileName string `json:"file_name"`
+ TotalChunks int64 `json:"total_chunks"`
+ TotalSize int64 `json:"total_size"`
+ }{
+ Conflict: "version",
+ DirectoryID: parentID,
+ FileName: leaf,
+ TotalChunks: totalChunks,
+ TotalSize: fileSize,
+ }
+
+ opts := rest.Opts{
+ Method: "POST",
+ Path: fmt.Sprintf("/3/drive/%s/upload/session/start", f.opt.DriveID),
+ }
+ var sessionResp api.SessionStartResponse
+ _, err = f.srv.CallJSON(ctx, &opts, &sessionReq, &sessionResp)
+ if err != nil {
+ return info, nil, fmt.Errorf("failed to start upload session: %w", err)
+ }
+
+ chunkWriter := &uploadSession{
+ f: f,
+ parentID: parentID,
+ fileName: leaf,
+ token: sessionResp.Data.Token,
+ uploadURL: sessionResp.Data.UploadURL,
+ totalHash: xxh3.New(),
+ chunkHashes: make([]string, 0, totalChunks),
+ }
+
+ info = fs.ChunkWriterInfo{
+ ChunkSize: chunkSize,
+ Concurrency: 4,
+ }
+
+ fs.Debugf(&Object{fs: f, remote: remote}, "open chunk writer: started upload session: %v", sessionResp.Data.Token)
+ return info, chunkWriter, nil
+}
+
+// WriteChunk uploads a single chunk
+func (u *uploadSession) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (bytesWritten int64, err error) {
+ if chunkNumber < 0 {
+ return -1, fmt.Errorf("invalid chunk number provided: %v", chunkNumber)
+ }
+
+ // Read the chunk data
+ var buf bytes.Buffer
+ n, err := io.Copy(&buf, reader)
+ if err != nil {
+ return -1, fmt.Errorf("failed to read chunk data: %w", err)
+ }
+
+ if n == 0 {
+ return 0, nil
+ }
+
+ chunkData := buf.Bytes()
+ sourceChunkNumber := chunkNumber + 1 // KDive API uses 1-based numbering
+
+ // Calculate chunk hash
+ chunkHasher := xxh3.New()
+ chunkHasher.Write(chunkData)
+ chunkHash := fmt.Sprintf("xxh3:%x", chunkHasher.Sum(nil))
+
+ // Accumulate in total hash
+ u.totalHash.Write(chunkData)
+ u.chunkHashes = append(u.chunkHashes, chunkHash)
+
+ uploadPath := fmt.Sprintf("/3/drive/%s/upload/session/%s/chunk", u.f.opt.DriveID, u.token)
+ chunkOpts := rest.Opts{
+ Method: "POST",
+ RootURL: u.uploadURL,
+ Path: uploadPath,
+ Parameters: url.Values{
+ "chunk_number": {fmt.Sprintf("%d", sourceChunkNumber)},
+ "chunk_size": {fmt.Sprintf("%d", n)},
+ "with": {"hash"},
+ "chunk_hash": {chunkHash},
+ },
+ Body: bytes.NewReader(chunkData),
+ }
+
+ var chunkResp api.ChunkUploadResponse
+ _, err = u.f.srv.CallJSON(ctx, &chunkOpts, nil, &chunkResp)
+ if err != nil {
+ return -1, fmt.Errorf("failed to upload chunk %d: %w", sourceChunkNumber, err)
+ }
+
+ // Verify server returned matching hash (optional but good for debugging)
+ if chunkResp.Data.Hash != "" {
+ serverHash := strings.TrimPrefix(chunkResp.Data.Hash, "xxh3:")
+ clientHash := strings.TrimPrefix(chunkHash, "xxh3:")
+ if serverHash != clientHash {
+ fs.Debugf(u, "chunk %d hash mismatch: client=%s, server=%s", sourceChunkNumber, clientHash, serverHash)
+ }
+ u.hash = serverHash
+ }
+
+ u.chunkCount++
+ fs.Debugf(u, "uploaded chunk %d (size: %d, hash: %s)", sourceChunkNumber, n, chunkHash)
+ return n, nil
+}
+
+// Close finalizes the upload session and returns the created file info
+func (u *uploadSession) Close(ctx context.Context) error {
+ // Calculate total hash (hash of concatenation of all chunks)
+ totalHashValue := fmt.Sprintf("xxh3:%x", u.totalHash.Sum(nil))
+
+ opts := rest.Opts{
+ Method: "POST",
+ Path: fmt.Sprintf("/3/drive/%s/upload/session/%s/finish", u.f.opt.DriveID, u.token),
+ Parameters: url.Values{
+ "total_chunk_hash": {totalHashValue},
+ },
+ }
+ var resp api.SessionFinishResponse
+ _, err := u.f.srv.CallJSON(ctx, &opts, nil, &resp)
+ if err != nil {
+ return fmt.Errorf("failed to finish upload session: %w", err)
+ }
+
+ u.fileInfo = &resp.Data.File
+ fs.Debugf(u, "multipart upload completed: file id %d, total hash: %s", resp.Data.File.ID, totalHashValue)
+ return nil
+}
+
+// Abort cancels the upload session
+func (u *uploadSession) Abort(ctx context.Context) error {
+ opts := rest.Opts{
+ Method: "POST",
+ Path: fmt.Sprintf("/3/drive/%s/upload/session/%s/cancel", u.f.opt.DriveID, u.token),
+ }
+ var resp api.SessionCancelResponse
+ _, err := u.f.srv.CallJSON(ctx, &opts, nil, &resp)
+ if err != nil {
+ fs.Debugf(u, "failed to cancel upload session: %v", err)
+ return fmt.Errorf("failed to cancel upload session: %w", err)
+ }
+
+ fs.Debugf(u, "upload session cancelled")
+ return nil
+}
+
+// String implements fmt.Stringer
+func (u *uploadSession) String() string {
+ return fmt.Sprintf("kdrive upload session %s", u.token)
+}
diff --git a/backend/kdrive/test/test-list/test-list-file1.txt b/backend/kdrive/test/test-list/test-list-file1.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/kdrive/test/test-list/test-list-file2.txt b/backend/kdrive/test/test-list/test-list-file2.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/kdrive/test/test-list/test-list-subfolder/test-list-subfile.txt b/backend/kdrive/test/test-list/test-list-subfolder/test-list-subfile.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/kdrive/test/test-list/test-list-subfolder/test-list-subsubfolder/test-list-subsubfile.txt b/backend/kdrive/test/test-list/test-list-subfolder/test-list-subsubfolder/test-list-subsubfile.txt
new file mode 100644
index 000000000..e69de29bb
--
2.51.1
From 890b691f9970afb88626144eb00265c5b671c3cc Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Tue, 17 Feb 2026 14:54:22 +0100
Subject: [PATCH 02/32] kdrive: improve error management
---
backend/kdrive/kdrive.go | 39 +++++++++++++--------------------------
1 file changed, 13 insertions(+), 26 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index b4a4131b6..2d92019c7 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -149,7 +149,8 @@ func parsePath(path string) (root string) {
// retryErrorCodes is a slice of error codes that we will retry
var retryErrorCodes = []int{
- 429, // Too Many Requests.
+ 408, // Request Timeout
+ 429, // Too Many Requests
500, // Internal Server Error
502, // Bad Gateway
503, // Service Unavailable
@@ -163,24 +164,8 @@ func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, err
if fserrors.ContextError(ctx, &err) {
return false, err
}
- doRetry := false
- // Check if it is an api.Error
- if _, ok := err.(*api.ResultStatus); ok {
- // Errors are classified as 1xx, 2xx, etc.
- switch resp.StatusCode / 100 {
- case 4: // 4xxx: rate limiting
- doRetry = true
- case 5: // 5xxx: internal errors
- doRetry = true
- }
- }
-
- if resp != nil && resp.StatusCode == 401 && len(resp.Header["Www-Authenticate"]) == 1 && strings.Contains(resp.Header["Www-Authenticate"][0], "expired_token") {
- doRetry = true
- fs.Debugf(nil, "Should retry: %v", err)
- }
- return doRetry || fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
+ return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
}
// readMetaDataForPath reads the metadata from the path
@@ -399,10 +384,15 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
return result, nil
}
+ var listErr error
var recursiveContents func(currentDirID string, currentSubDir string, fromCursor string)
recursiveContents = func(currentDirID string, currentSubDir string, fromCursor string) {
+ if listErr != nil {
+ return
+ }
result, err := listSomeFiles(currentDirID, fromCursor)
if err != nil {
+ listErr = err
return
}
@@ -435,6 +425,9 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
}
}
recursiveContents(dirID, "", "")
+ if listErr != nil {
+ return found, listErr
+ }
return
}
@@ -608,9 +601,7 @@ func (f *Fs) purgeCheck(ctx context.Context, dir string, check bool) error {
return fmt.Errorf("rmdir failed: %w", err)
}
f.dirCache.FlushDir(dir)
- if err != nil {
- return err
- }
+
return nil
}
@@ -1164,11 +1155,7 @@ func (o *Object) updateMultipart(ctx context.Context, in io.Reader, src fs.Objec
}
// Extract the file info from the chunk writer
- session, ok := chunkWriter.(*uploadSession)
- if !ok {
- return fmt.Errorf("unexpected chunk writer type")
- }
-
+ session := chunkWriter.(*uploadSession)
if session.fileInfo == nil {
return fmt.Errorf("upload failed: no file info returned")
}
--
2.51.1
From 7ce4a3fadff0ae048d16ce640c4bbdc3f8758379 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Tue, 17 Feb 2026 15:11:51 +0100
Subject: [PATCH 03/32] kdrive: bearer token instead of oauth2
---
backend/kdrive/kdrive.go | 11 ++---------
1 file changed, 2 insertions(+), 9 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 2d92019c7..7f086f561 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -30,7 +30,6 @@ import (
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
"github.com/zeebo/xxh3"
- "golang.org/x/oauth2"
"golang.org/x/text/unicode/norm"
)
@@ -98,12 +97,10 @@ type Fs struct {
root string // the path we are working on
opt Options // parsed options
features *fs.Features // optional features
- ts oauth2.TokenSource // the token source, used to create new clients
srv *rest.Client // the connection to the server
cleanupSrv *rest.Client // the connection used for the cleanup method
dirCache *dircache.DirCache // Map of directory path to directory id
pacer *fs.Pacer // pacer for API calls
- // tokenRenewer *oauthutil.Renew // renew the token on expiry
}
// Object describes a kdrive object
@@ -227,18 +224,14 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
root = parsePath(root)
fs.Debugf(ctx, "NewFs: for root=%s", root)
- staticToken := oauth2.Token{AccessToken: opt.AccessToken}
- ts := oauth2.StaticTokenSource(&staticToken)
- oAuthClient := oauth2.NewClient(ctx, ts)
-
f := &Fs{
name: name,
root: root,
opt: *opt,
- ts: ts,
- srv: rest.NewClient(oAuthClient).SetRoot(opt.Endpoint),
+ srv: rest.NewClient(fshttp.NewClient(ctx)).SetRoot(opt.Endpoint),
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
}
+ f.srv.SetHeader("Authorization", "Bearer "+opt.AccessToken)
f.cleanupSrv = rest.NewClient(fshttp.NewClient(ctx)).SetRoot(opt.Endpoint)
f.features = (&fs.Features{
CaseInsensitive: false,
--
2.51.1
From 6b8126e068d23acdf2496e49712f21e4d08e582b Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Tue, 17 Feb 2026 17:30:40 +0100
Subject: [PATCH 04/32] kdrive: fix PublicLink
---
backend/kdrive/kdrive.go | 121 ++++++++++++++++++++-----
backend/kdrive/kdrive_internal_test.go | 91 +++++++++++++++++++
2 files changed, 187 insertions(+), 25 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 7f086f561..09dbf548f 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -796,61 +796,131 @@ func (f *Fs) DirCacheFlush() {
f.dirCache.ResetRoot()
}
-/*
-// The PublicLink method is currently disabled, not sure which API should be used here
-func (f *Fs) linkDir(ctx context.Context, dirID string, expire fs.Duration) (string, error) {
+// getFileIDFromPath returns the file/directory ID from a remote path
+func (f *Fs) getFileIDFromPath(ctx context.Context, remote string) (string, error) {
+ // Try to find as directory first
+ directoryID, err := f.dirCache.FindDir(ctx, remote, false)
+ if err == nil {
+ return directoryID, nil
+ }
+
+ // If not a directory, try to find as file
+ info, err := f.readMetaDataForPath(ctx, remote)
+ if err != nil {
+ return "", err
+ }
+ return strconv.Itoa(info.ID), nil
+}
+
+func (f *Fs) getPublicLink(ctx context.Context, fileID string) (string, error) {
opts := rest.Opts{
- Method: "GET",
- Path: fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, dirID),
- Parameters: url.Values{},
+ Method: "GET",
+ Path: fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, fileID),
}
var result api.PubLinkResult
err := f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, nil, &result)
- err = result.ResultStatus.Update(err)
+ err = result.Update(err)
return shouldRetry(ctx, resp, err)
})
+
if err != nil {
- return "", err
+ return "", fmt.Errorf("failed to get public link: %w", err)
+ }
+
+ // check if the shared link is blocked
+ if result.Data.AccessBlocked == true {
+ return "", nil
+ }
+
+ // check if the shared link is expired
+ if result.Data.ValidUntil > 0 {
+ // ValidUntil is a Unix timestamp (UTC)
+ if time.Now().Unix() > int64(result.Data.ValidUntil) {
+ // link is expired
+ return "", nil
+ }
}
- return result.Data.URL, err
+
+ return result.Data.URL, nil
}
-func (f *Fs) linkFile(ctx context.Context, path string, expire fs.Duration) (string, error) {
- obj, err := f.NewObject(ctx, path)
+func (f *Fs) deletePublicLink(ctx context.Context, fileID string) error {
+ opts := rest.Opts{
+ Method: "DELETE",
+ Path: fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, fileID),
+ }
+ var result api.ResultStatus
+ err := f.pacer.Call(func() (bool, error) {
+ resp, err := f.srv.CallJSON(ctx, &opts, nil, &result)
+ err = result.Update(err)
+ return shouldRetry(ctx, resp, err)
+ })
+
if err != nil {
- return "", err
+ return fmt.Errorf("failed to remove public link: %w", err)
+ }
+
+ return nil
+}
+
+func (f *Fs) createPublicLink(ctx context.Context, fileID string, expire fs.Duration) (string, error) {
+ createReq := struct {
+ Right string `json:"right"` // true for read access
+ ValidUntil int `json:"valid_until,omitempty"`
+ }{
+ Right: "public",
}
- o := obj.(*Object)
+
+ // Set expiry if provided
+ if expire > 0 {
+ createReq.ValidUntil = int(time.Now().Add(time.Duration(expire)).Unix())
+ }
+
opts := rest.Opts{
- Method: "GET",
- Path: fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, o.id),
- Parameters: url.Values{},
+ Method: "POST",
+ Path: fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, fileID),
}
+
var result api.PubLinkResult
- err = f.pacer.Call(func() (bool, error) {
- resp, err := f.srv.CallJSON(ctx, &opts, nil, &result)
+ err := f.pacer.Call(func() (bool, error) {
+ resp, err := f.srv.CallJSON(ctx, &opts, createReq, &result)
err = result.ResultStatus.Update(err)
return shouldRetry(ctx, resp, err)
})
+
if err != nil {
- return "", err
+ return "", fmt.Errorf("failed to create public link: %w", err)
}
+
return result.Data.URL, nil
}
// PublicLink adds a "readable by anyone with link" permission on the given file or folder.
+// If unlink is true, it removes the existing public link.
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
- dirID, err := f.dirCache.FindDir(ctx, remote, false)
- if err == fs.ErrorDirNotFound {
- return f.linkFile(ctx, remote, expire)
- }
+ fileID, err := f.getFileIDFromPath(ctx, remote)
if err != nil {
return "", err
}
- return f.linkDir(ctx, dirID, expire)
+
+ if unlink {
+ // Remove existing public link
+ err = f.deletePublicLink(ctx, fileID)
+ if err != nil {
+ return "", err
+ }
+ }
+
+ // Check if link exists
+ url, err := f.getPublicLink(ctx, fileID)
+ if url != "" {
+ return url, nil
+ }
+
+ // Create or get existing public link
+ return f.createPublicLink(ctx, fileID, expire)
}
-*/
// About gets quota information
func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
@@ -1192,6 +1262,7 @@ var (
_ fs.ListPer = (*Fs)(nil)
_ fs.Abouter = (*Fs)(nil)
_ fs.Shutdowner = (*Fs)(nil)
+ _ fs.PublicLinker = (*Fs)(nil)
_ fs.Object = (*Object)(nil)
_ fs.IDer = (*Object)(nil)
)
diff --git a/backend/kdrive/kdrive_internal_test.go b/backend/kdrive/kdrive_internal_test.go
index cd26f442f..96cd56ef0 100644
--- a/backend/kdrive/kdrive_internal_test.go
+++ b/backend/kdrive/kdrive_internal_test.go
@@ -215,3 +215,94 @@ func TestListRecursive(t *testing.T) {
assert.Contains(t, remoteList, "test-list-subfolder/test-list-subsubfolder")
assert.Contains(t, remoteList, "test-list-subfolder/test-list-subsubfolder/test-list-subsubfile.txt")
}
+
+// TestPublicLink tests the creation and deletion of public links
+// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestPublicLink -verbose
+func TestPublicLink(t *testing.T) {
+ ctx := context.Background()
+ fRemote := setupTestFs(t)
+
+ if fRemote.Features().PublicLink == nil {
+ t.Skip("PublicLink not supported")
+ }
+
+ // Create a test file
+ testContent := []byte("Test content for public link")
+ testFile := fmt.Sprintf("test-link-file-%d.txt", time.Now().UnixNano())
+ src := object.NewStaticObjectInfo(testFile, time.Now(), int64(len(testContent)), true, nil, fRemote)
+
+ obj, err := fRemote.Put(ctx, bytes.NewReader(testContent), src)
+ require.NoError(t, err, "Failed to create test file")
+ require.NotNil(t, obj)
+
+ testFile2 := fmt.Sprintf("test-link-file-%d.txt", time.Now().UnixNano())
+ src = object.NewStaticObjectInfo(testFile2, time.Now(), int64(len(testContent)), true, nil, fRemote)
+
+ obj, err = fRemote.Put(ctx, bytes.NewReader(testContent), src)
+ require.NoError(t, err, "Failed to create test file")
+ require.NotNil(t, obj)
+
+ // Test 1: Create public link for file
+ t.Run("Create link for file", func(t *testing.T) {
+ link, err := fRemote.Features().PublicLink(ctx, testFile, 0, false)
+ require.NoError(t, err)
+ assert.NotEmpty(t, link)
+ assert.Contains(t, link, "infomaniak")
+ fs.Debugf(nil, "Created public link: %s", link)
+ })
+
+ // Test 2: Get existing link (should return the same link)
+ t.Run("Get existing link for file", func(t *testing.T) {
+ link, err := fRemote.Features().PublicLink(ctx, testFile, 0, false)
+ require.NoError(t, err)
+ assert.NotEmpty(t, link)
+ fs.Debugf(nil, "Retrieved public link: %s", link)
+ })
+
+ // Test 3: Create public link with expiration
+ t.Run("Create link with expiration", func(t *testing.T) {
+ expire := fs.Duration(24 * time.Hour)
+ link, err := fRemote.Features().PublicLink(ctx, testFile, expire, false)
+ require.NoError(t, err)
+ assert.NotEmpty(t, link)
+ fs.Debugf(nil, "Created public link with expiration: %s", link)
+ })
+
+ // Test 4: Test with directory
+ t.Run("Create link for directory", func(t *testing.T) {
+ testDir := fmt.Sprintf("test-link-dir-%d", time.Now().UnixNano())
+ err := fRemote.Mkdir(ctx, testDir)
+ require.NoError(t, err)
+
+ link, err := fRemote.Features().PublicLink(ctx, testDir, 0, false)
+ require.NoError(t, err)
+ assert.NotEmpty(t, link)
+ assert.Contains(t, link, "infomaniak")
+ fs.Debugf(nil, "Created public link for directory: %s", link)
+
+ // Clean up the directory using Rmdir instead of dirCache
+ err = fRemote.Rmdir(ctx, testDir)
+ if err != nil {
+ t.Logf("Warning: failed to remove test directory: %v", err)
+ }
+ })
+
+ // Test 5: Remove public link
+ t.Run("Remove public link", func(t *testing.T) {
+ _, err := fRemote.Features().PublicLink(ctx, testFile, 0, true)
+ require.NoError(t, err)
+ fs.Debugf(nil, "Removed public link for: %s", testFile)
+ })
+
+ // Test 6: Try to remove link for non-existent file (should error)
+ t.Run("Remove link for non-existent file fails", func(t *testing.T) {
+ _, err := fRemote.Features().PublicLink(ctx, "non-existent-file.txt", 0, true)
+ assert.Error(t, err)
+ })
+
+ // Test 7: Try to non existent link (should error)
+ t.Run("Remove non-existent link fails", func(t *testing.T) {
+ _, err := fRemote.Features().PublicLink(ctx, testFile2, 0, true)
+ assert.Error(t, err)
+ })
+}
--
2.51.1
From 6937c327fa8ea2b285cd63d8ee131aa7a2acbced Mon Sep 17 00:00:00 2001
From: "ruben.pericas-moya" <ruben.pericas-moya@infomaniak.com>
Date: Wed, 18 Feb 2026 13:10:46 +0100
Subject: [PATCH 05/32] kdrive: remove unused Profile
---
backend/kdrive/api/types.go | 45 -------------------------------------
1 file changed, 45 deletions(-)
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index 6fb4a8da5..501295b33 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -84,51 +84,6 @@ func (e *ResultStatus) Update(err error) error {
// Check ResultStatus satisfies the error interface
var _ error = (*ResultStatus)(nil)
-// Profile describes a profile, as returned by the "/profile" root API
-type Profile struct {
- ResultStatus
- Data struct {
- ID int `json:"id"`
- UserID int `json:"user_id"`
- Login string `json:"login"`
- Firstname string `json:"firstname"`
- Lastname string `json:"lastname"`
- DisplayName string `json:"display_name"`
- DateLastChangePassword int `json:"date_last_change_password"`
- Otp bool `json:"otp"`
- Sms bool `json:"sms"`
- SmsPhone any `json:"sms_phone"`
- Yubikey bool `json:"yubikey"`
- InfomaniakApplication bool `json:"infomaniak_application"`
- DoubleAuth bool `json:"double_auth"`
- DoubleAuthMethod string `json:"double_auth_method"`
- RemainingRescueCode int `json:"remaining_rescue_code"`
- SecurityAssistant int `json:"security_assistant"`
- SecurityCheck bool `json:"security_check"`
- OpenRenewalWarrantyInvoiceGroupID []any `json:"open_renewal_warranty_invoice_group_id"`
- AuthDevices []any `json:"auth_devices"`
- ValidatedAt any `json:"validated_at"`
- LastLoginAt int `json:"last_login_at"`
- AdministrationLastLoginAt int `json:"administration_last_login_at"`
- InvalidEmail bool `json:"invalid_email"`
- Avatar string `json:"avatar"`
- Locale string `json:"locale"`
- LanguageID int `json:"language_id"`
- Timezone string `json:"timezone"`
- Country struct {
- ID int `json:"id"`
- Short string `json:"short"`
- Name string `json:"name"`
- Enabled bool `json:"enabled"`
- } `json:"country"`
- UnsuccessfulConnexionLimit bool `json:"unsuccessful_connexion_limit"`
- UnsuccessfulConnexionRateLimit int `json:"unsuccessful_connexion_rate_limit"`
- UnsuccessfulConnexionNotification bool `json:"unsuccessful_connexion_notification"`
- SuccessfulConnexionNotification bool `json:"successful_connexion_notification"`
- CurrentAccountID int `json:"current_account_id"`
- } `json:"data"`
-}
-
// ListDrives describes a list of available drives for a user
type ListDrives struct {
ResultStatus
--
2.51.1
From 2107447925b100f7662dabd93351a0db86456911 Mon Sep 17 00:00:00 2001
From: ikbuben <ruben.pericas-moya@infomaniak.com>
Date: Wed, 18 Feb 2026 13:18:53 +0100
Subject: [PATCH 06/32] kdrive: remove some unused types
---
backend/kdrive/api/types.go | 31 -------------------------------
1 file changed, 31 deletions(-)
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index 501295b33..7bc67162b 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -84,37 +84,6 @@ func (e *ResultStatus) Update(err error) error {
// Check ResultStatus satisfies the error interface
var _ error = (*ResultStatus)(nil)
-// ListDrives describes a list of available drives for a user
-type ListDrives struct {
- ResultStatus
- Data []struct {
- ID int `json:"id"`
- DisplayName string `json:"display_name"`
- FirstName string `json:"first_name"`
- LastName string `json:"last_name"`
- Email string `json:"email"`
- IsSso bool `json:"is_sso"`
- Avatar string `json:"avatar"`
- DeletedAt any `json:"deleted_at"`
- DriveID int `json:"drive_id"`
- DriveName string `json:"drive_name"`
- AccountID int `json:"account_id"`
- CreatedAt int `json:"created_at"`
- UpdatedAt int `json:"updated_at"`
- LastConnectionAt int `json:"last_connection_at"`
- ProductID int `json:"product_id"`
- Status string `json:"status"`
- Role string `json:"role"`
- Type string `json:"type"`
- Preference struct {
- Color string `json:"color"`
- Hide bool `json:"hide"`
- Default bool `json:"default"`
- DefaultPage string `json:"default_page"`
- } `json:"preference"`
- } `json:"data"`
-}
-
// Item describes a folder or a file as returned by Get Folder Items and others
type Item struct {
ID int `json:"id"`
--
2.51.1
From b0151164c3c8c7155eebe997cb2f99a5a5067076 Mon Sep 17 00:00:00 2001
From: ikbuben <ruben.pericas-moya@infomaniak.com>
Date: Wed, 18 Feb 2026 13:20:11 +0100
Subject: [PATCH 07/32] kdrive: consistent ResultStatus receiver type
---
backend/kdrive/api/types.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index 7bc67162b..9e32eb258 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -65,7 +65,7 @@ func (e *ResultStatus) Error() string {
}
// IsError returns true if there is an error
-func (e ResultStatus) IsError() bool {
+func (e *ResultStatus) IsError() bool {
return e.Status != "success"
}
--
2.51.1
From b04b16a904b6a4dee76d56125b35df6a90706ce2 Mon Sep 17 00:00:00 2001
From: ikbuben <ruben.pericas-moya@infomaniak.com>
Date: Wed, 18 Feb 2026 14:37:32 +0100
Subject: [PATCH 08/32] kdrive: quick cleanup
---
backend/kdrive/kdrive.go | 40 ++++++++++++++++++-------------------
backend/kdrive/multipart.go | 4 ++--
2 files changed, 22 insertions(+), 22 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 09dbf548f..11f53ab19 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -52,9 +52,7 @@ func init() {
Help: config.ConfigEncodingHelp,
Advanced: true,
// Encode invalid UTF-8 bytes as json doesn't handle them properly.
- Default: (encoder.Display |
- encoder.EncodeLeftSpace | encoder.EncodeRightSpace |
- encoder.EncodeInvalidUtf8),
+ Default: encoder.Display | encoder.EncodeLeftSpace | encoder.EncodeRightSpace | encoder.EncodeInvalidUtf8,
}, {
Name: "root_folder_id",
Help: "Fill in for rclone to use a non root folder as its starting point.",
@@ -171,7 +169,7 @@ func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.It
// defer fs.Trace(f, "path=%q", path)("info=%+v, err=%v", &info, &err)
leaf, directoryID, err := f.dirCache.FindPath(ctx, path, false)
if err != nil {
- if err == fs.ErrorDirNotFound {
+ if errors.Is(err, fs.ErrorDirNotFound) {
return nil, fs.ErrorObjectNotFound
}
return nil, err
@@ -258,9 +256,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
// No root so return old f
return f, nil
}
- _, err := tempF.newObjectWithInfo(ctx, remote, nil)
+ _, err = tempF.newObjectWithInfo(ctx, remote, nil)
if err != nil {
- if err == fs.ErrorObjectNotFound {
+ if errors.Is(err, fs.ErrorObjectNotFound) {
// File doesn't exist so return old f
return f, nil
}
@@ -491,27 +489,27 @@ func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err e
// callback returns an error then the listing will stop
// immediately.
func (f *Fs) ListP(ctx context.Context, dir string, callback fs.ListRCallback) (err error) {
- list := list.NewHelper(callback)
+ l := list.NewHelper(callback)
err = f.listHelper(ctx, dir, false, func(o fs.DirEntry) error {
- return list.Add(o)
+ return l.Add(o)
})
if err != nil {
return err
}
- return list.Flush()
+ return l.Flush()
}
// ListR lists the objects and directories of the Fs starting
// from dir recursively into out.
func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (err error) {
- list := list.NewHelper(callback)
+ l := list.NewHelper(callback)
err = f.listHelper(ctx, dir, true, func(o fs.DirEntry) error {
- return list.Add(o)
+ return l.Add(o)
})
if err != nil {
return err
}
- return list.Flush()
+ return l.Flush()
}
// Creates from the parameters passed in a half finished Object which
@@ -529,8 +527,10 @@ func (f *Fs) createObject(ctx context.Context, remote string, modTime time.Time,
}
// Temporary Object under construction
o = &Object{
- fs: f,
- remote: remote,
+ fs: f,
+ remote: remote,
+ size: size,
+ modTime: modTime,
}
return o, leaf, directoryID, nil
}
@@ -913,9 +913,9 @@ func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration,
}
// Check if link exists
- url, err := f.getPublicLink(ctx, fileID)
- if url != "" {
- return url, nil
+ link, err := f.getPublicLink(ctx, fileID)
+ if link != "" {
+ return link, nil
}
// Create or get existing public link
@@ -950,7 +950,7 @@ func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
}
// Shutdown shutdown the fs
-func (f *Fs) Shutdown(ctx context.Context) error {
+func (f *Fs) Shutdown(_ context.Context) error {
return nil
}
@@ -1084,7 +1084,7 @@ func (o *Object) ModTime(ctx context.Context) time.Time {
}
// SetModTime sets the modification time of the local fs object
-func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
+func (o *Object) SetModTime(_ context.Context, _ time.Time) error {
return fs.ErrorCantSetModTime
}
@@ -1151,7 +1151,7 @@ func (o *Object) updateDirect(ctx context.Context, in io.Reader, directoryID, le
}
// Calculate xxh3 hash
hasher := xxh3.New()
- hasher.Write(content)
+ _, _ = hasher.Write(content)
totalHash := fmt.Sprintf("xxh3:%x", hasher.Sum(nil))
size := src.Size()
diff --git a/backend/kdrive/multipart.go b/backend/kdrive/multipart.go
index 62240af4f..43be17160 100644
--- a/backend/kdrive/multipart.go
+++ b/backend/kdrive/multipart.go
@@ -58,7 +58,7 @@ func calculateTotalChunks(fileSize int64, chunkSize int64) int64 {
}
// OpenChunkWriter returns chunk writer info and the upload session
-func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
+func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, _ ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
dir, leaf := path.Split(remote)
dir = strings.TrimSuffix(dir, "/")
@@ -137,7 +137,7 @@ func (u *uploadSession) WriteChunk(ctx context.Context, chunkNumber int, reader
// Calculate chunk hash
chunkHasher := xxh3.New()
- chunkHasher.Write(chunkData)
+ _, _ = chunkHasher.Write(chunkData)
chunkHash := fmt.Sprintf("xxh3:%x", chunkHasher.Sum(nil))
// Accumulate in total hash
--
2.51.1
From 87636246dbdf44e241cb77ba1c89c52399aeb4b5 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Wed, 18 Feb 2026 15:57:40 +0100
Subject: [PATCH 09/32] kdrive: add ChunkSize option to multipart and refuse
files with no size
---
backend/kdrive/kdrive_internal_test.go | 17 +++++++
backend/kdrive/multipart.go | 69 +++++++++++++++++++++-----
2 files changed, 74 insertions(+), 12 deletions(-)
diff --git a/backend/kdrive/kdrive_internal_test.go b/backend/kdrive/kdrive_internal_test.go
index 96cd56ef0..e8e141a64 100644
--- a/backend/kdrive/kdrive_internal_test.go
+++ b/backend/kdrive/kdrive_internal_test.go
@@ -119,6 +119,23 @@ func TestPutLargeFile(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, size, obj2.Size())
assert.Equal(t, remote, obj2.Remote())
+
+ // Test with ChunkSize option
+ remoteChunksize := fmt.Sprintf("large-file-chunksize-%d.bin", time.Now().UnixNano())
+ srcChunksize := object.NewStaticObjectInfo(remoteChunksize, time.Now(), size, true, nil, fRemote)
+
+ size5MBs := 10 * 1024 * 1024
+ objChunksize, err := fRemote.Put(ctx, bytes.NewReader(data), srcChunksize, &fs.ChunkOption{
+ ChunkSize: int64(size5MBs),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, objChunksize)
+
+ // Verify that the object exists
+ objChunksize2, err := fRemote.NewObject(ctx, remoteChunksize)
+ require.NoError(t, err)
+ assert.Equal(t, size, objChunksize2.Size())
+ assert.Equal(t, remoteChunksize, objChunksize2.Remote())
}
func prepareListing(t *testing.T) fs.Fs {
diff --git a/backend/kdrive/multipart.go b/backend/kdrive/multipart.go
index 43be17160..5e9e45fe2 100644
--- a/backend/kdrive/multipart.go
+++ b/backend/kdrive/multipart.go
@@ -5,6 +5,7 @@ package kdrive
import (
"bytes"
"context"
+ "errors"
"fmt"
"hash"
"io"
@@ -36,18 +37,46 @@ type uploadSession struct {
const (
maxChunkSize = 1 * 1024 * 1024 * 1024 // 1 Go (max API)
defaultChunkSize = 20 * 1024 * 1024 // 20 Mo
- maxChunks = 10000 // Limite API
+ maxChunks = 10000 // Limit API
+ mebi = 1024 * 1024
)
-func calculateChunkSize(fileSize int64) int64 {
- if fileSize <= defaultChunkSize*maxChunks {
- return defaultChunkSize
+func calculateChunkSize(fileSize int64, preferredChunkSize int64) int64 {
+ // Use preferred chunk size
+ chunkSize := preferredChunkSize
+ if chunkSize <= 0 {
+ chunkSize = defaultChunkSize
}
- // Pour les très gros fichiers, augmenter la taille des chunks
- chunkSize := fileSize / maxChunks
+
+ // Round to greater MiB
+ if chunkSize%mebi != 0 {
+ chunkSize += mebi - (chunkSize % mebi)
+ }
+
+ // Limit chunk size to 1 Go
if chunkSize > maxChunkSize {
- return maxChunkSize
+ chunkSize = maxChunkSize
}
+
+ // For large files, use a bigger chunk size
+ requiredChunks := calculateTotalChunks(fileSize, chunkSize)
+ if requiredChunks > maxChunks {
+ chunkSize = fileSize / maxChunks
+ if fileSize%maxChunks != 0 {
+ chunkSize++
+ }
+
+ // Round to greater MiB
+ if chunkSize%mebi != 0 {
+ chunkSize += mebi - (chunkSize % mebi)
+ }
+
+ // Limit chunk size to 1 Go
+ if chunkSize > maxChunkSize {
+ chunkSize = maxChunkSize
+ }
+ }
+
return chunkSize
}
@@ -58,7 +87,8 @@ func calculateTotalChunks(fileSize int64, chunkSize int64) int64 {
}
// OpenChunkWriter returns chunk writer info and the upload session
-func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, _ ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
+// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/start
+func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
dir, leaf := path.Split(remote)
dir = strings.TrimSuffix(dir, "/")
@@ -69,7 +99,18 @@ func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectIn
}
fileSize := src.Size()
- chunkSize := calculateChunkSize(fileSize)
+ if fileSize < 0 {
+ return info, nil, errors.New("kdrive can't upload files with unknown size")
+ }
+
+ var preferredChunkSize int64
+ for _, opt := range options {
+ if chunkOpt, ok := opt.(*fs.ChunkOption); ok {
+ preferredChunkSize = chunkOpt.ChunkSize
+ }
+ }
+
+ chunkSize := calculateChunkSize(fileSize, preferredChunkSize)
totalChunks := calculateTotalChunks(fileSize, chunkSize)
sessionReq := struct {
@@ -93,6 +134,7 @@ func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectIn
var sessionResp api.SessionStartResponse
_, err = f.srv.CallJSON(ctx, &opts, &sessionReq, &sessionResp)
if err != nil {
+ fs.Debugf(nil, "REQUEST : %s %w", opts.Path, &sessionReq)
return info, nil, fmt.Errorf("failed to start upload session: %w", err)
}
@@ -116,6 +158,7 @@ func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectIn
}
// WriteChunk uploads a single chunk
+// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/chunk
func (u *uploadSession) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (bytesWritten int64, err error) {
if chunkNumber < 0 {
return -1, fmt.Errorf("invalid chunk number provided: %v", chunkNumber)
@@ -180,6 +223,7 @@ func (u *uploadSession) WriteChunk(ctx context.Context, chunkNumber int, reader
}
// Close finalizes the upload session and returns the created file info
+// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/finish
func (u *uploadSession) Close(ctx context.Context) error {
// Calculate total hash (hash of concatenation of all chunks)
totalHashValue := fmt.Sprintf("xxh3:%x", u.totalHash.Sum(nil))
@@ -202,11 +246,12 @@ func (u *uploadSession) Close(ctx context.Context) error {
return nil
}
-// Abort cancels the upload session
+// Abort the upload session
+// @see https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D
func (u *uploadSession) Abort(ctx context.Context) error {
opts := rest.Opts{
- Method: "POST",
- Path: fmt.Sprintf("/3/drive/%s/upload/session/%s/cancel", u.f.opt.DriveID, u.token),
+ Method: "DELETE",
+ Path: fmt.Sprintf("/2/drive/%s/upload/session/%s", u.f.opt.DriveID, u.token),
}
var resp api.SessionCancelResponse
_, err := u.f.srv.CallJSON(ctx, &opts, nil, &resp)
--
2.51.1
From 47be890de577e4b89e9ee8fff8cc549e901a0e3a Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Thu, 19 Feb 2026 10:24:47 +0100
Subject: [PATCH 10/32] kdrive: fix hash control on large file upload
---
backend/kdrive/kdrive.go | 5 +----
backend/kdrive/multipart.go | 43 ++++++++++++-------------------------
docs/content/kdrive.md | 7 ------
3 files changed, 15 insertions(+), 40 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 11f53ab19..6f78dd48a 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -57,7 +57,7 @@ func init() {
Name: "root_folder_id",
Help: "Fill in for rclone to use a non root folder as its starting point.",
// for default root, see https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
- Default: "5",
+ Default: "1",
Advanced: true,
Sensitive: true,
}, {
@@ -1223,9 +1223,6 @@ func (o *Object) updateMultipart(ctx context.Context, in io.Reader, src fs.Objec
return fmt.Errorf("upload failed: no file info returned")
}
- // Use hash from chunk uploads (stored in session)
- o.setHash(strings.TrimPrefix(session.hash, "xxh3:"))
-
return o.setMetaData(session.fileInfo)
}
diff --git a/backend/kdrive/multipart.go b/backend/kdrive/multipart.go
index 5e9e45fe2..3e2ffbbf3 100644
--- a/backend/kdrive/multipart.go
+++ b/backend/kdrive/multipart.go
@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
- "hash"
"io"
"math"
"net/url"
@@ -22,16 +21,14 @@ import (
// uploadSession implements fs.ChunkWriter for kdrive multipart uploads
type uploadSession struct {
- f *Fs
- parentID string
- fileName string
- token string
- uploadURL string
- fileInfo *api.Item
- chunkCount int
- hash string // Hash from the last chunk upload
- totalHash hash.Hash // Accumulates all chunks for total hash
- chunkHashes []string // Stores individual chunk hashes
+ f *Fs
+ parentID string
+ fileName string
+ token string
+ uploadURL string
+ fileInfo *api.Item
+ chunkCount int
+ hash string // Hash from the last chunk upload
}
const (
@@ -139,13 +136,11 @@ func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectIn
}
chunkWriter := &uploadSession{
- f: f,
- parentID: parentID,
- fileName: leaf,
- token: sessionResp.Data.Token,
- uploadURL: sessionResp.Data.UploadURL,
- totalHash: xxh3.New(),
- chunkHashes: make([]string, 0, totalChunks),
+ f: f,
+ parentID: parentID,
+ fileName: leaf,
+ token: sessionResp.Data.Token,
+ uploadURL: sessionResp.Data.UploadURL,
}
info = fs.ChunkWriterInfo{
@@ -183,10 +178,6 @@ func (u *uploadSession) WriteChunk(ctx context.Context, chunkNumber int, reader
_, _ = chunkHasher.Write(chunkData)
chunkHash := fmt.Sprintf("xxh3:%x", chunkHasher.Sum(nil))
- // Accumulate in total hash
- u.totalHash.Write(chunkData)
- u.chunkHashes = append(u.chunkHashes, chunkHash)
-
uploadPath := fmt.Sprintf("/3/drive/%s/upload/session/%s/chunk", u.f.opt.DriveID, u.token)
chunkOpts := rest.Opts{
Method: "POST",
@@ -225,15 +216,9 @@ func (u *uploadSession) WriteChunk(ctx context.Context, chunkNumber int, reader
// Close finalizes the upload session and returns the created file info
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/finish
func (u *uploadSession) Close(ctx context.Context) error {
- // Calculate total hash (hash of concatenation of all chunks)
- totalHashValue := fmt.Sprintf("xxh3:%x", u.totalHash.Sum(nil))
-
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/upload/session/%s/finish", u.f.opt.DriveID, u.token),
- Parameters: url.Values{
- "total_chunk_hash": {totalHashValue},
- },
}
var resp api.SessionFinishResponse
_, err := u.f.srv.CallJSON(ctx, &opts, nil, &resp)
@@ -242,7 +227,7 @@ func (u *uploadSession) Close(ctx context.Context) error {
}
u.fileInfo = &resp.Data.File
- fs.Debugf(u, "multipart upload completed: file id %d, total hash: %s", resp.Data.File.ID, totalHashValue)
+ fs.Debugf(u, "multipart upload completed: file id %d", resp.Data.File.ID)
return nil
}
diff --git a/docs/content/kdrive.md b/docs/content/kdrive.md
index 65705c418..aa2a1ae34 100644
--- a/docs/content/kdrive.md
+++ b/docs/content/kdrive.md
@@ -38,13 +38,6 @@ XX / Infomaniak kDrive
[snip]
Storage> kdrive
-Option account_id.
-Fill the account ID that is to be considered for this kdrive.
-When showing a folder on kdrive, you can find the account_id here:
-https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/...
-Enter a value. Press Enter to leave empty.
-account_id> 12345678
-
Option drive_id.
Fill the drive ID for this kdrive.
When showing a folder on kdrive, you can find the drive_id here:
--
2.51.1
From a03e26f55cf61eefc2bcfbbda98dea05d950b330 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Thu, 19 Feb 2026 21:18:15 +0100
Subject: [PATCH 11/32] kdrive: use get file by path api
---
backend/kdrive/api/types.go | 5 +
backend/kdrive/kdrive.go | 157 ++++++++++++++++---------
backend/kdrive/kdrive_internal_test.go | 19 ++-
3 files changed, 126 insertions(+), 55 deletions(-)
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index 9e32eb258..62034dadc 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -108,6 +108,11 @@ type Item struct {
Color string `json:"color"`
}
+type ItemResult struct {
+ ResultStatus
+ Data Item `json:"data"`
+}
+
// SearchResult is returned when a list of items is requested
type SearchResult struct {
ResultStatus
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 6f78dd48a..85e0fd10c 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -30,7 +30,6 @@ import (
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
"github.com/zeebo/xxh3"
- "golang.org/x/text/unicode/norm"
)
const (
@@ -163,11 +162,58 @@ func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, err
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
}
-// readMetaDataForPath reads the metadata from the path
-func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.Item, err error) {
- fs.Debugf(ctx, "readMetaDataForPath: path=%s", path)
- // defer fs.Trace(f, "path=%q", path)("info=%+v, err=%v", &info, &err)
- leaf, directoryID, err := f.dirCache.FindPath(ctx, path, false)
+// findItemInDir retrieves a file or directory by its name in a specific directory using the API
+// This avoids listing the entire directory. It takes the directoryID directly.
+func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string) (*api.Item, error) {
+ fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
+
+ opts := rest.Opts{
+ Method: "GET",
+ Path: fmt.Sprintf("/3/drive/%s/files/%s/path", f.opt.DriveID, directoryID),
+ Parameters: url.Values{},
+ }
+ opts.Parameters.Set("name", f.opt.Enc.FromStandardName(leaf))
+ opts.Parameters.Set("with", "path,hash")
+
+ var result api.ItemResult
+ var resp *http.Response
+ var err error
+ err = f.pacer.Call(func() (bool, error) {
+ resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
+ err = result.ResultStatus.Update(err)
+ return shouldRetry(ctx, resp, err)
+ })
+ if err != nil {
+ // Check if it's a "not found" error
+ if apiErr, ok := err.(*api.ResultStatus); ok {
+ if apiErr.ErrorDetail.Result == "object_not_found" {
+ return nil, fs.ErrorObjectNotFound
+ }
+ }
+ return nil, fmt.Errorf("couldn't find item in dir: %w", err)
+ }
+
+ item := result.Data
+ // Check if item is valid (has an ID)
+ if item.ID == 0 {
+ return nil, fs.ErrorObjectNotFound
+ }
+ // Normalize the name
+ item.Name = f.opt.Enc.ToStandardName(item.Name)
+
+ return &item, nil
+}
+
+// findItemByPath retrieves a file or directory by its path using the API
+// This avoids listing the entire directory
+func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, error) {
+ fs.Infof(ctx, "findItemByPath: remote=%s", remote)
+
+ // Get the directoryID of the parent directory
+ directory, leaf := path.Split(remote)
+ directory = strings.TrimSuffix(directory, "/")
+
+ directoryID, err := f.dirCache.FindDir(ctx, directory, false)
if err != nil {
if errors.Is(err, fs.ErrorDirNotFound) {
return nil, fs.ErrorObjectNotFound
@@ -175,22 +221,19 @@ func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.It
return nil, err
}
- found, err := f.listAll(ctx, directoryID, false, true, false, func(item *api.Item) bool {
- if item.Name == leaf {
- info = item
- return true
- } else if norm.NFC.String(item.Name) == norm.NFC.String(leaf) {
- info = item
- return true
- }
- return false
- })
+ return f.findItemInDir(ctx, directoryID, leaf)
+}
+
+// readMetaDataForPath reads the metadata from the path
+func (f *Fs) readMetaDataForPath(ctx context.Context, remote string) (*api.Item, error) {
+ fs.Debugf(ctx, "readMetaDataForPath: remote=%s", remote)
+
+ // Try the new API endpoint first
+ info, err := f.findItemByPath(ctx, remote)
if err != nil {
return nil, err
}
- if !found {
- return nil, fs.ErrorObjectNotFound
- }
+
return info, nil
}
@@ -304,15 +347,21 @@ func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
// FindLeaf finds a directory of name leaf in the folder with ID pathID
func (f *Fs) FindLeaf(ctx context.Context, pathID, leaf string) (pathIDOut string, found bool, err error) {
- // Find the leaf in pathID
- found, err = f.listAll(ctx, pathID, true, false, false, func(item *api.Item) bool {
- if item.Name == leaf {
- pathIDOut = strconv.Itoa(item.ID)
- return true
+ // Use API endpoint to find item directly by name
+ item, err := f.findItemInDir(ctx, pathID, leaf)
+ if err != nil {
+ if errors.Is(err, fs.ErrorObjectNotFound) {
+ return "", false, nil
}
- return false
- })
- return pathIDOut, found, err
+ return "", false, err
+ }
+
+ if item.Type == "dir" {
+ return strconv.Itoa(item.ID), true, nil
+ }
+
+ // Not a directory
+ return "", false, nil
}
// CreateDir makes a directory with pathID as parent and name leaf
@@ -351,12 +400,14 @@ type listAllFn func(*api.Item) bool
//
// If the user fn ever returns true then it early exits with found = true
func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, filesOnly bool, recursive bool, fn listAllFn) (found bool, err error) {
+ // fs.Infof(nil, "Stacktrace : %s", string(debug.Stack()))
listSomeFiles := func(currentDirID string, fromCursor string) (api.SearchResult, error) {
opts := rest.Opts{
Method: "GET",
Path: fmt.Sprintf("/3/drive/%s/files/%s/files", f.opt.DriveID, currentDirID),
Parameters: url.Values{},
}
+ opts.Parameters.Set("limit", "1000")
opts.Parameters.Set("with", "path,hash")
if len(fromCursor) > 0 {
opts.Parameters.Set("cursor", fromCursor)
@@ -431,8 +482,23 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
return err
}
var iErr error
+
_, err = f.listAll(ctx, directoryID, false, false, recursive, func(info *api.Item) bool {
remote := path.Join(dir, info.FullPath)
+
+ // When not recursive, only return direct children
+ if !recursive {
+ itemDir := path.Dir(remote)
+ // Normalize: "." becomes "" for root
+ if itemDir == "." {
+ itemDir = ""
+ }
+ if itemDir != dir {
+ // Skip - not a direct child
+ return false
+ }
+ }
+
if info.Type == "dir" {
// cache the directory ID for later lookups
f.dirCache.Put(remote, strconv.Itoa(info.ID))
@@ -796,26 +862,10 @@ func (f *Fs) DirCacheFlush() {
f.dirCache.ResetRoot()
}
-// getFileIDFromPath returns the file/directory ID from a remote path
-func (f *Fs) getFileIDFromPath(ctx context.Context, remote string) (string, error) {
- // Try to find as directory first
- directoryID, err := f.dirCache.FindDir(ctx, remote, false)
- if err == nil {
- return directoryID, nil
- }
-
- // If not a directory, try to find as file
- info, err := f.readMetaDataForPath(ctx, remote)
- if err != nil {
- return "", err
- }
- return strconv.Itoa(info.ID), nil
-}
-
-func (f *Fs) getPublicLink(ctx context.Context, fileID string) (string, error) {
+func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, error) {
opts := rest.Opts{
Method: "GET",
- Path: fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, fileID),
+ Path: fmt.Sprintf("/2/drive/%s/files/%d/link", f.opt.DriveID, fileID),
}
var result api.PubLinkResult
err := f.pacer.Call(func() (bool, error) {
@@ -845,10 +895,10 @@ func (f *Fs) getPublicLink(ctx context.Context, fileID string) (string, error) {
return result.Data.URL, nil
}
-func (f *Fs) deletePublicLink(ctx context.Context, fileID string) error {
+func (f *Fs) deletePublicLink(ctx context.Context, fileID int) error {
opts := rest.Opts{
Method: "DELETE",
- Path: fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, fileID),
+ Path: fmt.Sprintf("/2/drive/%s/files/%d/link", f.opt.DriveID, fileID),
}
var result api.ResultStatus
err := f.pacer.Call(func() (bool, error) {
@@ -864,7 +914,7 @@ func (f *Fs) deletePublicLink(ctx context.Context, fileID string) error {
return nil
}
-func (f *Fs) createPublicLink(ctx context.Context, fileID string, expire fs.Duration) (string, error) {
+func (f *Fs) createPublicLink(ctx context.Context, fileID int, expire fs.Duration) (string, error) {
createReq := struct {
Right string `json:"right"` // true for read access
ValidUntil int `json:"valid_until,omitempty"`
@@ -879,7 +929,7 @@ func (f *Fs) createPublicLink(ctx context.Context, fileID string, expire fs.Dura
opts := rest.Opts{
Method: "POST",
- Path: fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, fileID),
+ Path: fmt.Sprintf("/2/drive/%s/files/%d/link", f.opt.DriveID, fileID),
}
var result api.PubLinkResult
@@ -899,27 +949,28 @@ func (f *Fs) createPublicLink(ctx context.Context, fileID string, expire fs.Dura
// PublicLink adds a "readable by anyone with link" permission on the given file or folder.
// If unlink is true, it removes the existing public link.
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
- fileID, err := f.getFileIDFromPath(ctx, remote)
+ fs.Infof("PublicLink PATH REMOTE", remote)
+ item, err := f.findItemByPath(ctx, remote)
if err != nil {
return "", err
}
if unlink {
// Remove existing public link
- err = f.deletePublicLink(ctx, fileID)
+ err = f.deletePublicLink(ctx, item.ID)
if err != nil {
return "", err
}
}
// Check if link exists
- link, err := f.getPublicLink(ctx, fileID)
+ link, err := f.getPublicLink(ctx, item.ID)
if link != "" {
return link, nil
}
// Create or get existing public link
- return f.createPublicLink(ctx, fileID, expire)
+ return f.createPublicLink(ctx, item.ID, expire)
}
// About gets quota information
diff --git a/backend/kdrive/kdrive_internal_test.go b/backend/kdrive/kdrive_internal_test.go
index e8e141a64..6051f35e2 100644
--- a/backend/kdrive/kdrive_internal_test.go
+++ b/backend/kdrive/kdrive_internal_test.go
@@ -7,11 +7,13 @@ import (
"flag"
"fmt"
"os"
+ "strings"
"testing"
"time"
_ "github.com/rclone/rclone/backend/local"
"github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/object"
"github.com/rclone/rclone/fs/operations"
"github.com/rclone/rclone/fs/sync"
@@ -41,8 +43,21 @@ func setupTestFs(t *testing.T) *Fs {
// Create a unique test directory name
testDir := fmt.Sprintf("rclone-test-%d", time.Now().UnixNano())
+ // Use -remote flag if provided, otherwise default to TestKdrive
+ remoteName := *fstest.RemoteName
+ if remoteName == "" {
+ remoteName = "TestKdrive:"
+ }
+
+ // Check if root_folder_id is "1" in config, if so prepend Private/ to path
+ remoteSection := strings.TrimSuffix(remoteName, ":")
+ rootFolderID := config.GetValue(remoteSection, "root_folder_id")
+ if rootFolderID == "1" {
+ remoteName = remoteName + "Private/"
+ }
+
// Step 1: Create fs pointing to root of drive
- fRoot, err := fs.NewFs(ctx, "TestKdrive:")
+ fRoot, err := fs.NewFs(ctx, remoteName)
require.NoError(t, err, "Failed to create root fs")
// Step 2: Create the test directory in the root fs
@@ -50,7 +65,7 @@ func setupTestFs(t *testing.T) *Fs {
require.NoError(t, err, "Failed to create test directory")
// Step 3: Create fs pointing specifically to the test subdirectory
- fTest, err := fs.NewFs(ctx, fmt.Sprintf("TestKdrive:%s", testDir))
+ fTest, err := fs.NewFs(ctx, fmt.Sprintf("%s%s", remoteName, testDir))
require.NoError(t, err, "Failed to create test fs")
// Step 4: Cleanup - delete the test directory from the root fs
--
2.51.1
From 4035f16dd055fa2fc138642eae2c0b2daf5a0c5f Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Fri, 20 Feb 2026 16:54:03 +0100
Subject: [PATCH 12/32] kdrive: SetModTime implementation
---
backend/kdrive/kdrive.go | 38 +++++++++++++++++++++++++++++++++++---
1 file changed, 35 insertions(+), 3 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 85e0fd10c..1d04a2801 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -1134,9 +1134,41 @@ func (o *Object) ModTime(ctx context.Context) time.Time {
return o.modTime
}
-// SetModTime sets the modification time of the local fs object
-func (o *Object) SetModTime(_ context.Context, _ time.Time) error {
- return fs.ErrorCantSetModTime
+// SetModTime sets the modification time of the object
+func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
+ if modTime.Unix() == 0 {
+ return fs.ErrorCantSetModTime
+ }
+
+ var result api.ResultStatus
+
+ modTimeReq := struct {
+ LastModifiedAt int64 `json:"last_modified_at"`
+ }{
+ LastModifiedAt: modTime.Unix(),
+ }
+
+ opts := rest.Opts{
+ Method: "POST",
+ RootURL: fmt.Sprintf("%s/3/drive/%s/files/%s/last-modified", o.fs.opt.Endpoint, o.fs.opt.DriveID, o.id),
+ }
+
+ err := o.fs.pacer.Call(func() (bool, error) {
+ resp, err := o.fs.srv.CallJSON(ctx, &opts, modTimeReq, &result)
+ err = result.Update(err)
+ return shouldRetry(ctx, resp, err)
+ })
+ if err != nil {
+ return fmt.Errorf("SetModTime: %w", err)
+ }
+
+ if result.Status != "success" {
+ return fs.ErrorCantSetModTime
+ }
+
+ // Update Object modTime
+ o.modTime = modTime
+ return nil
}
// Storable returns a boolean showing whether this object storable
--
2.51.1
From fdbb153fd34eeec5205ce7a64d515852363c2cf2 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Mon, 23 Feb 2026 11:18:41 +0100
Subject: [PATCH 13/32] kdrive: fix options and code fixme
---
backend/kdrive/kdrive.go | 43 ++++++++++++----------------------------
docs/content/kdrive.md | 1 -
2 files changed, 13 insertions(+), 31 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 1d04a2801..a3dc9a937 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -66,9 +66,11 @@ func init() {
https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...`,
Default: "",
}, {
- Name: "access_token",
- Help: `Access token generated in Infomaniak profile manager.`,
- Default: "",
+ Name: "access_token",
+ Help: `Access token generated in Infomaniak profile manager.`,
+ Required: true,
+ IsPassword: true,
+ Sensitive: true,
}, {
Name: "endpoint",
Help: "By default, pointing to the production API.",
@@ -277,7 +279,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
f.features = (&fs.Features{
CaseInsensitive: false,
CanHaveEmptyDirectories: true,
- PartialUploads: true,
+ PartialUploads: false,
}).Fill(ctx, f)
f.srv.SetErrorHandler(errorHandler)
@@ -504,7 +506,8 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
f.dirCache.Put(remote, strconv.Itoa(info.ID))
d := fs.NewDir(remote, info.ModTime()).SetID(strconv.Itoa(info.ID))
- // FIXME more info from dir?
+ d.SetParentID(strconv.Itoa(info.ParentID))
+ d.SetSize(info.Size)
iErr = callback(d)
} else {
o, err := f.newObjectWithInfo(ctx, remote, info)
@@ -514,17 +517,21 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
}
iErr = callback(o)
}
+
if iErr != nil {
return true
}
return false
})
+
if err != nil {
return err
}
+
if iErr != nil {
return iErr
}
+
return nil
}
@@ -1110,12 +1117,6 @@ func (o *Object) readMetaData(ctx context.Context) (err error) {
}
info, err := o.fs.readMetaDataForPath(ctx, o.remote)
if err != nil {
- //if apiErr, ok := err.(*api.Error); ok {
- // FIXME
- // if apiErr.Code == "not_found" || apiErr.Code == "trashed" {
- // return fs.ErrorObjectNotFound
- // }
- //}
return err
}
return o.setMetaData(info)
@@ -1158,11 +1159,8 @@ func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
err = result.Update(err)
return shouldRetry(ctx, resp, err)
})
- if err != nil {
- return fmt.Errorf("SetModTime: %w", err)
- }
- if result.Status != "success" {
+ if err != nil {
return fs.ErrorCantSetModTime
}
@@ -1264,24 +1262,9 @@ func (o *Object) updateDirect(ctx context.Context, in io.Reader, directoryID, le
return shouldRetry(ctx, resp, err)
})
- // TODO: check if the following erroneous behavior also happens on kDrive
- // (this workaround comes from the pcloud backend implementation)
if err != nil {
- // sometimes we get a half complete file on
- // error, so delete it if it exists, trying a few times
- for range 5 {
- delObj, delErr := o.fs.NewObject(ctx, o.remote)
- if delErr == nil && delObj != nil {
- _ = delObj.Remove(ctx)
- break
- }
- time.Sleep(time.Second)
- }
return err
}
- if result.ResultStatus.IsError() {
- return fmt.Errorf("failed to upload %v - not sure why", o)
- }
o.size = size
o.setHash(strings.TrimPrefix(result.Data.Hash, "xxh3:"))
diff --git a/docs/content/kdrive.md b/docs/content/kdrive.md
index aa2a1ae34..b83bf7794 100644
--- a/docs/content/kdrive.md
+++ b/docs/content/kdrive.md
@@ -58,7 +58,6 @@ y/n>
Configuration complete.
Options:
- type: kdrive
-- account_id: 12345678
- drive_id: 0654321
- access_token: ThisIsAVeryLong-Token
--
2.51.1
From f8af69646c353a9a837c5e0b5caa8b621a612678 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Mon, 23 Feb 2026 12:51:48 +0100
Subject: [PATCH 14/32] kdrive: fix type cancel session + change path
findItemInDir
---
backend/kdrive/api/types.go | 2 +-
backend/kdrive/kdrive.go | 10 ++++++++--
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index 62034dadc..d01283014 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -229,5 +229,5 @@ type SessionFinishResponse struct {
type SessionCancelResponse struct {
Result string `json:"result"`
- Data Item `json:"data"`
+ Data bool `json:"data"`
}
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index a3dc9a937..e42ff69b8 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -171,7 +171,7 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
opts := rest.Opts{
Method: "GET",
- Path: fmt.Sprintf("/3/drive/%s/files/%s/path", f.opt.DriveID, directoryID),
+ Path: fmt.Sprintf("/3/drive/%s/files/%s/name", f.opt.DriveID, directoryID),
Parameters: url.Values{},
}
opts.Parameters.Set("name", f.opt.Enc.FromStandardName(leaf))
@@ -404,9 +404,15 @@ type listAllFn func(*api.Item) bool
func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, filesOnly bool, recursive bool, fn listAllFn) (found bool, err error) {
// fs.Infof(nil, "Stacktrace : %s", string(debug.Stack()))
listSomeFiles := func(currentDirID string, fromCursor string) (api.SearchResult, error) {
+
+ apiPath := fmt.Sprintf("/3/drive/%s/files/%s/listing/full", f.opt.DriveID, currentDirID)
+ if currentDirID == "1" {
+ apiPath = fmt.Sprintf("/3/drive/%s/files/%s/files", f.opt.DriveID, currentDirID)
+ }
+
opts := rest.Opts{
Method: "GET",
- Path: fmt.Sprintf("/3/drive/%s/files/%s/files", f.opt.DriveID, currentDirID),
+ Path: apiPath,
Parameters: url.Values{},
}
opts.Parameters.Set("limit", "1000")
--
2.51.1
From 5aebe93acc7d0360d3e9deec0f5cdce891852cb8 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Mon, 23 Feb 2026 16:08:16 +0100
Subject: [PATCH 15/32] kdrive: Add item hasChildren on listing
---
backend/kdrive/api/types.go | 1 +
backend/kdrive/kdrive.go | 49 +++++++++++++++++++++++--------------
2 files changed, 31 insertions(+), 19 deletions(-)
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index d01283014..b60efa241 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -106,6 +106,7 @@ type Item struct {
MimeType string `json:"mime_type"`
ParentID int `json:"parent_id"`
Color string `json:"color"`
+ HasChildren bool `json:"has_children,omitempty"`
}
type ItemResult struct {
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index e42ff69b8..b67829f89 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -167,7 +167,7 @@ func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, err
// findItemInDir retrieves a file or directory by its name in a specific directory using the API
// This avoids listing the entire directory. It takes the directoryID directly.
func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string) (*api.Item, error) {
- fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
+ // fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
opts := rest.Opts{
Method: "GET",
@@ -209,7 +209,7 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
// findItemByPath retrieves a file or directory by its path using the API
// This avoids listing the entire directory
func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, error) {
- fs.Infof(ctx, "findItemByPath: remote=%s", remote)
+ // fs.Infof(ctx, "findItemByPath: remote=%s", remote)
// Get the directoryID of the parent directory
directory, leaf := path.Split(remote)
@@ -228,7 +228,7 @@ func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, erro
// readMetaDataForPath reads the metadata from the path
func (f *Fs) readMetaDataForPath(ctx context.Context, remote string) (*api.Item, error) {
- fs.Debugf(ctx, "readMetaDataForPath: remote=%s", remote)
+ // fs.Debugf(ctx, "readMetaDataForPath: remote=%s", remote)
// Try the new API endpoint first
info, err := f.findItemByPath(ctx, remote)
@@ -404,15 +404,9 @@ type listAllFn func(*api.Item) bool
func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, filesOnly bool, recursive bool, fn listAllFn) (found bool, err error) {
// fs.Infof(nil, "Stacktrace : %s", string(debug.Stack()))
listSomeFiles := func(currentDirID string, fromCursor string) (api.SearchResult, error) {
-
- apiPath := fmt.Sprintf("/3/drive/%s/files/%s/listing/full", f.opt.DriveID, currentDirID)
- if currentDirID == "1" {
- apiPath = fmt.Sprintf("/3/drive/%s/files/%s/files", f.opt.DriveID, currentDirID)
- }
-
opts := rest.Opts{
Method: "GET",
- Path: apiPath,
+ Path: fmt.Sprintf("/3/drive/%s/files/%s/files", f.opt.DriveID, currentDirID),
Parameters: url.Values{},
}
opts.Parameters.Set("limit", "1000")
@@ -435,19 +429,22 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
}
var listErr error
- var recursiveContents func(currentDirID string, currentSubDir string, fromCursor string)
- recursiveContents = func(currentDirID string, currentSubDir string, fromCursor string) {
+ var recursiveContents func(currentDirID string, currentSubDir string, fromCursor string) bool
+
+ recursiveContents = func(currentDirID string, currentSubDir string, fromCursor string) bool {
if listErr != nil {
- return
+ return false
}
result, err := listSomeFiles(currentDirID, fromCursor)
if err != nil {
listErr = err
- return
+ return false
}
+ hasChildren := false
// First, analyze what has been returned, and go in-depth if required
for i := range result.Data {
+ hasChildren = true
item := &result.Data[i]
if item.Type == "dir" {
if filesOnly {
@@ -460,21 +457,30 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
}
item.Name = f.opt.Enc.ToStandardName(item.Name)
item.FullPath = path.Join(currentSubDir, item.Name)
+
+ item.HasChildren = false
+ if recursive && item.Type == "dir" {
+ subDirHasChildren := recursiveContents(strconv.Itoa(item.ID), path.Join(currentSubDir, item.Name), "" /*reset cursor*/)
+ // fs.Infof(nil, "DIR %s SUBDIR %s subDirHasChildren %w", strconv.Itoa(item.ID), path.Join(currentSubDir, item.Name), subDirHasChildren)
+ item.HasChildren = subDirHasChildren
+ }
+
if fn(item) {
found = true
break
}
- if recursive && item.Type == "dir" {
- recursiveContents(strconv.Itoa(item.ID), path.Join(currentSubDir, item.Name), "" /*reset cursor*/)
- }
}
// Then load the rest of the files in that folder and apply the same logic
if result.HasMore {
recursiveContents(currentDirID, currentSubDir, result.Cursor)
}
+
+ return hasChildren
}
+
recursiveContents(dirID, "", "")
+
if listErr != nil {
return found, listErr
}
@@ -514,6 +520,8 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
d := fs.NewDir(remote, info.ModTime()).SetID(strconv.Itoa(info.ID))
d.SetParentID(strconv.Itoa(info.ParentID))
d.SetSize(info.Size)
+
+ // fs.Infof(nil, "CALL CALLBACK WITH %s ", info.Name)
iErr = callback(d)
} else {
o, err := f.newObjectWithInfo(ctx, remote, info)
@@ -521,6 +529,8 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
iErr = err
return true
}
+
+ fs.Infof(nil, "CALL CALLBACK WITH %s ", info.Name)
iErr = callback(o)
}
@@ -583,6 +593,7 @@ func (f *Fs) ListP(ctx context.Context, dir string, callback fs.ListRCallback) (
func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (err error) {
l := list.NewHelper(callback)
err = f.listHelper(ctx, dir, true, func(o fs.DirEntry) error {
+ fs.Debugf(nil, "ADD OBJECT %w", o.Remote())
return l.Add(o)
})
if err != nil {
@@ -599,7 +610,7 @@ func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (
// Used to create new objects
func (f *Fs) createObject(ctx context.Context, remote string, modTime time.Time, size int64) (o *Object, leaf string, directoryID string, err error) {
// Create the directory for the object if it doesn't exist
- fs.Debugf(ctx, "createObject: remote = %s", remote)
+ // fs.Debugf(ctx, "createObject: remote = %s", remote)
leaf, directoryID, err = f.dirCache.FindPath(ctx, remote, true)
if err != nil {
return
@@ -962,7 +973,7 @@ func (f *Fs) createPublicLink(ctx context.Context, fileID int, expire fs.Duratio
// PublicLink adds a "readable by anyone with link" permission on the given file or folder.
// If unlink is true, it removes the existing public link.
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
- fs.Infof("PublicLink PATH REMOTE", remote)
+ // fs.Infof("PublicLink PATH REMOTE", remote)
item, err := f.findItemByPath(ctx, remote)
if err != nil {
return "", err
--
2.51.1
From c389755d55ea379e3e832d53c5e2e2fd6f66ef48 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Mon, 23 Feb 2026 20:26:53 +0100
Subject: [PATCH 16/32] kdrive: fix directory in double en walk.GetAll
---
backend/kdrive/kdrive.go | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index b67829f89..633993ff8 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -517,11 +517,16 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
// cache the directory ID for later lookups
f.dirCache.Put(remote, strconv.Itoa(info.ID))
+ // In recursive mode, don't return directories - they'll be created
+ // implicitly by checkParent from the file paths
+ if recursive {
+ return false
+ }
+
d := fs.NewDir(remote, info.ModTime()).SetID(strconv.Itoa(info.ID))
d.SetParentID(strconv.Itoa(info.ParentID))
d.SetSize(info.Size)
- // fs.Infof(nil, "CALL CALLBACK WITH %s ", info.Name)
iErr = callback(d)
} else {
o, err := f.newObjectWithInfo(ctx, remote, info)
@@ -530,7 +535,6 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
return true
}
- fs.Infof(nil, "CALL CALLBACK WITH %s ", info.Name)
iErr = callback(o)
}
--
2.51.1
From d8d9702f16f084ddf557df53607320906210debb Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Mon, 23 Feb 2026 22:37:18 +0100
Subject: [PATCH 17/32] kdrive: fix directory in double en walk.GetAll
---
backend/kdrive/kdrive.go | 60 +++++++++++++++++++++++++++++--------
backend/kdrive/multipart.go | 10 +++----
2 files changed, 52 insertions(+), 18 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 633993ff8..025ee10a7 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -206,11 +206,51 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
return &item, nil
}
+// getItem retrieves a file or directory by its ID
+func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
+ opts := rest.Opts{
+ Method: "GET",
+ Path: fmt.Sprintf("/2/drive/%s/files/%s", f.opt.DriveID, id),
+ }
+
+ var result api.ItemResult
+ var resp *http.Response
+ var err error
+ err = f.pacer.Call(func() (bool, error) {
+ resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
+ err = result.ResultStatus.Update(err)
+ return shouldRetry(ctx, resp, err)
+ })
+ if err != nil {
+ if apiErr, ok := err.(*api.ResultStatus); ok {
+ if apiErr.ErrorDetail.Result == "object_not_found" {
+ return nil, fs.ErrorObjectNotFound
+ }
+ }
+ return nil, fmt.Errorf("couldn't get item: %w", err)
+ }
+
+ item := result.Data
+ if item.ID == 0 {
+ return nil, fs.ErrorObjectNotFound
+ }
+ item.Name = f.opt.Enc.ToStandardName(item.Name)
+ return &item, nil
+}
+
// findItemByPath retrieves a file or directory by its path using the API
// This avoids listing the entire directory
func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, error) {
// fs.Infof(ctx, "findItemByPath: remote=%s", remote)
+ if remote == "" || remote == "." {
+ rootID, err := f.dirCache.FindDir(ctx, "", false)
+ if err != nil {
+ return nil, err
+ }
+ return f.getItem(ctx, rootID)
+ }
+
// Get the directoryID of the parent directory
directory, leaf := path.Split(remote)
directory = strings.TrimSuffix(directory, "/")
@@ -458,17 +498,17 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
item.Name = f.opt.Enc.ToStandardName(item.Name)
item.FullPath = path.Join(currentSubDir, item.Name)
+ if fn(item) {
+ found = true
+ break
+ }
+
item.HasChildren = false
if recursive && item.Type == "dir" {
subDirHasChildren := recursiveContents(strconv.Itoa(item.ID), path.Join(currentSubDir, item.Name), "" /*reset cursor*/)
// fs.Infof(nil, "DIR %s SUBDIR %s subDirHasChildren %w", strconv.Itoa(item.ID), path.Join(currentSubDir, item.Name), subDirHasChildren)
item.HasChildren = subDirHasChildren
}
-
- if fn(item) {
- found = true
- break
- }
}
// Then load the rest of the files in that folder and apply the same logic
@@ -517,12 +557,6 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
// cache the directory ID for later lookups
f.dirCache.Put(remote, strconv.Itoa(info.ID))
- // In recursive mode, don't return directories - they'll be created
- // implicitly by checkParent from the file paths
- if recursive {
- return false
- }
-
d := fs.NewDir(remote, info.ModTime()).SetID(strconv.Itoa(info.ID))
d.SetParentID(strconv.Itoa(info.ParentID))
d.SetSize(info.Size)
@@ -597,7 +631,7 @@ func (f *Fs) ListP(ctx context.Context, dir string, callback fs.ListRCallback) (
func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (err error) {
l := list.NewHelper(callback)
err = f.listHelper(ctx, dir, true, func(o fs.DirEntry) error {
- fs.Debugf(nil, "ADD OBJECT %w", o.Remote())
+ // fs.Debugf(nil, "ADD OBJECT %s", o.Remote())
return l.Add(o)
})
if err != nil {
@@ -1112,7 +1146,7 @@ func (o *Object) Size() int64 {
// setMetaData sets the metadata from info
func (o *Object) setMetaData(info *api.Item) (err error) {
if info.Type == "dir" {
- return fmt.Errorf("%q is a folder: %w", o.remote, fs.ErrorNotAFile)
+ return fs.ErrorIsDir
}
o.hasMetaData = true
o.size = info.Size
diff --git a/backend/kdrive/multipart.go b/backend/kdrive/multipart.go
index 3e2ffbbf3..dc1483232 100644
--- a/backend/kdrive/multipart.go
+++ b/backend/kdrive/multipart.go
@@ -86,6 +86,11 @@ func calculateTotalChunks(fileSize int64, chunkSize int64) int64 {
// OpenChunkWriter returns chunk writer info and the upload session
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/start
func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
+ fileSize := src.Size()
+ if fileSize < 0 {
+ return info, nil, errors.New("kdrive can't upload files with unknown size")
+ }
+
dir, leaf := path.Split(remote)
dir = strings.TrimSuffix(dir, "/")
@@ -95,11 +100,6 @@ func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectIn
return info, nil, fmt.Errorf("failed to find parent directory: %w", err)
}
- fileSize := src.Size()
- if fileSize < 0 {
- return info, nil, errors.New("kdrive can't upload files with unknown size")
- }
-
var preferredChunkSize int64
for _, opt := range options {
if chunkOpt, ok := opt.(*fs.ChunkOption); ok {
--
2.51.1
From f64f806d9b28db1a5bde58733d5838ad61bb3cb0 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Tue, 24 Feb 2026 09:38:25 +0100
Subject: [PATCH 18/32] kdrive: use private folder as default root
---
backend/kdrive/kdrive.go | 32 ++++++++++++++++++++++++++++++--
1 file changed, 30 insertions(+), 2 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 025ee10a7..25b3c3c4b 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -56,9 +56,14 @@ func init() {
Name: "root_folder_id",
Help: "Fill in for rclone to use a non root folder as its starting point.",
// for default root, see https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
- Default: "1",
+ Default: "private",
Advanced: true,
Sensitive: true,
+ Examples: []fs.OptionExample{
+ {Value: "private", Help: "My Folder"},
+ {Value: "common", Help: "Organisation Folder"},
+ {Value: "10", Help: "Explicit folder id"},
+ },
}, {
Name: "drive_id",
Help: `Fill the drive ID for this kdrive.
@@ -324,7 +329,11 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
f.srv.SetErrorHandler(errorHandler)
// Get rootFolderID
- rootID := f.opt.RootFolderID
+ rootID, err := f.determineRootID()
+ if err != nil {
+ return nil, err
+ }
+
f.dirCache = dircache.New(root, rootID, f)
// Find the current root
@@ -360,6 +369,25 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
return f, nil
}
+func (f *Fs) determineRootID() (string, error) {
+ ctx := context.Background()
+
+ var rootID string
+ var err error
+ switch f.opt.RootFolderID {
+ case "private":
+ rootID, _, err = f.FindLeaf(ctx, "1", "Private")
+ case "common":
+ rootID, _, err = f.FindLeaf(ctx, "1", "Common documents")
+ case "shared":
+ rootID, _, err = f.FindLeaf(ctx, "1", "Shared")
+ default:
+ rootID = f.opt.RootFolderID
+ }
+
+ return rootID, err
+}
+
// Return an Object from a path
//
// If it can't be found it returns the error fs.ErrorObjectNotFound.
--
2.51.1
From 49c4346a05d0f5c49e0adcbc77aea7fc26896b63 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Tue, 24 Feb 2026 11:05:09 +0100
Subject: [PATCH 19/32] kdrive: delete OpenChunkWriter implementation because
kdrive can't manage file without a size
---
backend/kdrive/kdrive.go | 6 +-
backend/kdrive/multipart.go | 112 +++++++++++++++++++++++++++++++++++-
2 files changed, 111 insertions(+), 7 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 25b3c3c4b..60a2b1cb5 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -26,7 +26,6 @@ import (
"github.com/rclone/rclone/fs/list"
"github.com/rclone/rclone/lib/dircache"
"github.com/rclone/rclone/lib/encoder"
- "github.com/rclone/rclone/lib/multipart"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
"github.com/zeebo/xxh3"
@@ -1358,10 +1357,7 @@ func (o *Object) updateDirect(ctx context.Context, in io.Reader, directoryID, le
func (o *Object) updateMultipart(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
f := o.fs
- chunkWriter, err := multipart.UploadMultipart(ctx, src, in, multipart.UploadMultipartOptions{
- Open: f,
- OpenOptions: options,
- })
+ chunkWriter, err := f.UploadMultipart(ctx, src, in, options)
if err != nil {
return err
}
diff --git a/backend/kdrive/multipart.go b/backend/kdrive/multipart.go
index dc1483232..a3e75a034 100644
--- a/backend/kdrive/multipart.go
+++ b/backend/kdrive/multipart.go
@@ -15,8 +15,13 @@ import (
"github.com/rclone/rclone/backend/kdrive/api"
"github.com/rclone/rclone/fs"
+ "github.com/rclone/rclone/fs/accounting"
+ "github.com/rclone/rclone/lib/atexit"
+ "github.com/rclone/rclone/lib/pacer"
+ "github.com/rclone/rclone/lib/pool"
"github.com/rclone/rclone/lib/rest"
"github.com/zeebo/xxh3"
+ "golang.org/x/sync/errgroup"
)
// uploadSession implements fs.ChunkWriter for kdrive multipart uploads
@@ -83,9 +88,9 @@ func calculateTotalChunks(fileSize int64, chunkSize int64) int64 {
return int64(totalChunks)
}
-// OpenChunkWriter returns chunk writer info and the upload session
+// newChunkWriter returns chunk writer info and the upload session
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/start
-func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
+func (f *Fs) newChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
fileSize := src.Size()
if fileSize < 0 {
return info, nil, errors.New("kdrive can't upload files with unknown size")
@@ -253,3 +258,106 @@ func (u *uploadSession) Abort(ctx context.Context) error {
func (u *uploadSession) String() string {
return fmt.Sprintf("kdrive upload session %s", u.token)
}
+
+func NewRW() *pool.RW {
+ return pool.NewRW(pool.Global())
+}
+
+// UploadMultipart does a generic multipart upload from src using f as newChunkWriter.
+//
+// in is read seqentially and chunks from it are uploaded in parallel.
+//
+// It returns the chunkWriter used in case the caller needs to extract any private info from it.
+func (f *Fs) UploadMultipart(ctx context.Context, src fs.ObjectInfo, in io.Reader, opt []fs.OpenOption) (chunkWriterOut fs.ChunkWriter, err error) {
+ info, chunkWriter, err := f.newChunkWriter(ctx, src.Remote(), src, opt...)
+ if err != nil {
+ return nil, fmt.Errorf("multipart upload failed to initialise: %w", err)
+ }
+
+ // make concurrency machinery
+ concurrency := max(info.Concurrency, 1)
+ tokens := pacer.NewTokenDispenser(concurrency)
+
+ uploadCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ defer atexit.OnError(&err, func() {
+ cancel()
+ if info.LeavePartsOnError {
+ return
+ }
+ fs.Debugf(src, "Cancelling multipart upload")
+ errCancel := chunkWriter.Abort(ctx)
+ if errCancel != nil {
+ fs.Debugf(src, "Failed to cancel multipart upload: %v", errCancel)
+ }
+ })()
+
+ var (
+ g, gCtx = errgroup.WithContext(uploadCtx)
+ finished = false
+ off int64
+ size = src.Size()
+ chunkSize = info.ChunkSize
+ )
+
+ // Do the accounting manually
+ in, acc := accounting.UnWrapAccounting(in)
+
+ for partNum := int64(0); !finished; partNum++ {
+ // Get a block of memory from the pool and token which limits concurrency.
+ tokens.Get()
+ rw := NewRW().Reserve(chunkSize)
+ if acc != nil {
+ rw.SetAccounting(acc.AccountRead)
+ }
+
+ free := func() {
+ // return the memory and token
+ _ = rw.Close() // Can't return an error
+ tokens.Put()
+ }
+
+ // Fail fast, in case an errgroup managed function returns an error
+ // gCtx is cancelled. There is no point in uploading all the other parts.
+ if gCtx.Err() != nil {
+ free()
+ break
+ }
+
+ // Read the chunk
+ var n int64
+ n, err = io.CopyN(rw, in, chunkSize)
+ if err == io.EOF {
+ if n == 0 && partNum != 0 { // end if no data and if not first chunk
+ free()
+ break
+ }
+ finished = true
+ } else if err != nil {
+ free()
+ return nil, fmt.Errorf("multipart upload: failed to read source: %w", err)
+ }
+
+ partNum := partNum
+ partOff := off
+ off += n
+ g.Go(func() (err error) {
+ defer free()
+ fs.Debugf(src, "multipart upload: starting chunk %d size %v offset %v/%v", partNum, fs.SizeSuffix(n), fs.SizeSuffix(partOff), fs.SizeSuffix(size))
+ _, err = chunkWriter.WriteChunk(gCtx, int(partNum), rw)
+ return err
+ })
+ }
+
+ err = g.Wait()
+ if err != nil {
+ return nil, err
+ }
+
+ err = chunkWriter.Close(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("multipart upload: failed to finalise: %w", err)
+ }
+
+ return chunkWriter, nil
+}
--
2.51.1
From f873f480817739e1ef4ffaef7e562a1695c67309 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Tue, 24 Feb 2026 13:25:52 +0100
Subject: [PATCH 20/32] kdrive: optimize update and reorder code
---
backend/kdrive/kdrive.go | 135 +++++++++++++++++++++++----------------
1 file changed, 81 insertions(+), 54 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 60a2b1cb5..ca86054c9 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -242,47 +242,6 @@ func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
return &item, nil
}
-// findItemByPath retrieves a file or directory by its path using the API
-// This avoids listing the entire directory
-func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, error) {
- // fs.Infof(ctx, "findItemByPath: remote=%s", remote)
-
- if remote == "" || remote == "." {
- rootID, err := f.dirCache.FindDir(ctx, "", false)
- if err != nil {
- return nil, err
- }
- return f.getItem(ctx, rootID)
- }
-
- // Get the directoryID of the parent directory
- directory, leaf := path.Split(remote)
- directory = strings.TrimSuffix(directory, "/")
-
- directoryID, err := f.dirCache.FindDir(ctx, directory, false)
- if err != nil {
- if errors.Is(err, fs.ErrorDirNotFound) {
- return nil, fs.ErrorObjectNotFound
- }
- return nil, err
- }
-
- return f.findItemInDir(ctx, directoryID, leaf)
-}
-
-// readMetaDataForPath reads the metadata from the path
-func (f *Fs) readMetaDataForPath(ctx context.Context, remote string) (*api.Item, error) {
- // fs.Debugf(ctx, "readMetaDataForPath: remote=%s", remote)
-
- // Try the new API endpoint first
- info, err := f.findItemByPath(ctx, remote)
- if err != nil {
- return nil, err
- }
-
- return info, nil
-}
-
// errorHandler parses a non 2xx error response into an error
func errorHandler(resp *http.Response) error {
// Decode error response
@@ -368,6 +327,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
return f, nil
}
+// determineRootID retriece the real RootId of the configured RootFolderID
func (f *Fs) determineRootID() (string, error) {
ctx := context.Background()
@@ -387,6 +347,47 @@ func (f *Fs) determineRootID() (string, error) {
return rootID, err
}
+// findItemByPath retrieves a file or directory by its path using the API
+// This avoids listing the entire directory
+func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, error) {
+ // fs.Infof(ctx, "findItemByPath: remote=%s", remote)
+
+ if remote == "" || remote == "." {
+ rootID, err := f.dirCache.FindDir(ctx, "", false)
+ if err != nil {
+ return nil, err
+ }
+ return f.getItem(ctx, rootID)
+ }
+
+ // Get the directoryID of the parent directory
+ directory, leaf := path.Split(remote)
+ directory = strings.TrimSuffix(directory, "/")
+
+ directoryID, err := f.dirCache.FindDir(ctx, directory, false)
+ if err != nil {
+ if errors.Is(err, fs.ErrorDirNotFound) {
+ return nil, fs.ErrorObjectNotFound
+ }
+ return nil, err
+ }
+
+ return f.findItemInDir(ctx, directoryID, leaf)
+}
+
+// readMetaDataForPath reads the metadata from the path
+func (f *Fs) readMetaDataForPath(ctx context.Context, remote string) (*api.Item, error) {
+ // fs.Debugf(ctx, "readMetaDataForPath: remote=%s", remote)
+
+ // Try the new API endpoint first
+ info, err := f.findItemByPath(ctx, remote)
+ if err != nil {
+ return nil, err
+ }
+
+ return info, nil
+}
+
// Return an Object from a path
//
// If it can't be found it returns the error fs.ErrorObjectNotFound.
@@ -700,11 +701,11 @@ func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options .
size := src.Size()
modTime := src.ModTime(ctx)
- o, _, _, err := f.createObject(ctx, remote, modTime, size)
+ o, leaf, directoryID, err := f.createObject(ctx, remote, modTime, size)
if err != nil {
return nil, err
}
- return o, o.Update(ctx, in, src, options...)
+ return o, o.update(ctx, in, src, directoryID, leaf, options...)
}
// Mkdir creates the container if it doesn't exist
@@ -942,6 +943,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
}
srcFs.dirCache.FlushDir(srcRemote)
+ srcFs.dirCache.FlushDir(dstRemote)
return nil
}
@@ -1294,6 +1296,16 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
return err
}
+ return o.update(ctx, in, src, directoryID, leaf, options...)
+}
+
+func (o *Object) update(ctx context.Context, in io.Reader, src fs.ObjectInfo, directoryID, leaf string, options ...fs.OpenOption) (err error) {
+ size := src.Size() // NB can upload without size
+
+ if size < 0 {
+ return errors.New("can't upload unknown sizes objects")
+ }
+
// if file size is less than the threshold, upload direct
if size <= uploadThreshold {
return o.updateDirect(ctx, in, directoryID, leaf, src, options...)
@@ -1307,21 +1319,36 @@ func (o *Object) updateDirect(ctx context.Context, in io.Reader, directoryID, le
var resp *http.Response
var result api.UploadFileResponse
- // Read the content to calculate hash (files are small, under 20MB)
- content, err := io.ReadAll(in)
- if err != nil {
- return fmt.Errorf("failed to read file content: %w", err)
+ // Attempt to get the hash from the source object without reading the content
+ totalHash, err := src.Hash(ctx, hash.XXH3)
+ var body io.Reader
+ size := src.Size()
+
+ if err == nil && totalHash != "" {
+ // Hash is already known (e.g., local file)
+ // Stream directly without loading into memory
+ body = in
+ totalHash = "xxh3:" + totalHash
+ } else {
+ // Hash unknown, need to read content to calculate it
+ content, err := io.ReadAll(in)
+ if err != nil {
+ return fmt.Errorf("failed to read file content: %w", err)
+ }
+
+ // Calculate xxh3 hash
+ hasher := xxh3.New()
+ _, _ = hasher.Write(content)
+ sum := hasher.Sum(nil)
+ totalHash = fmt.Sprintf("xxh3:%x", sum)
+
+ body = bytes.NewReader(content)
}
- // Calculate xxh3 hash
- hasher := xxh3.New()
- _, _ = hasher.Write(content)
- totalHash := fmt.Sprintf("xxh3:%x", hasher.Sum(nil))
- size := src.Size()
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/upload", o.fs.opt.DriveID),
- Body: bytes.NewReader(content),
+ Body: body,
ContentType: fs.MimeType(ctx, src),
ContentLength: &size,
Parameters: url.Values{},
@@ -1338,7 +1365,7 @@ func (o *Object) updateDirect(ctx context.Context, in io.Reader, directoryID, le
opts.Parameters.Set("with", "hash")
opts.Parameters.Set("total_chunk_hash", totalHash)
- err = o.fs.pacer.CallNoRetry(func() (bool, error) {
+ err = o.fs.pacer.Call(func() (bool, error) {
resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &result)
err = result.ResultStatus.Update(err)
return shouldRetry(ctx, resp, err)
--
2.51.1
From e432ccea1c4fe2ad51b2ccefc059926de9e27834 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Thu, 26 Feb 2026 15:43:47 +0100
Subject: [PATCH 21/32] kdrive: fix options
---
backend/kdrive/kdrive.go | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index ca86054c9..514ced96d 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -20,6 +20,7 @@ import (
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/config/configstruct"
+ "github.com/rclone/rclone/fs/config/obscure"
"github.com/rclone/rclone/fs/fserrors"
"github.com/rclone/rclone/fs/fshttp"
"github.com/rclone/rclone/fs/hash"
@@ -52,17 +53,11 @@ func init() {
// Encode invalid UTF-8 bytes as json doesn't handle them properly.
Default: encoder.Display | encoder.EncodeLeftSpace | encoder.EncodeRightSpace | encoder.EncodeInvalidUtf8,
}, {
- Name: "root_folder_id",
- Help: "Fill in for rclone to use a non root folder as its starting point.",
- // for default root, see https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
+ Name: "root_folder_id",
+ Help: "Fill in for rclone to use a non root folder as its starting point. (directory id)",
Default: "private",
Advanced: true,
Sensitive: true,
- Examples: []fs.OptionExample{
- {Value: "private", Help: "My Folder"},
- {Value: "common", Help: "Organisation Folder"},
- {Value: "10", Help: "Explicit folder id"},
- },
}, {
Name: "drive_id",
Help: `Fill the drive ID for this kdrive.
@@ -267,6 +262,12 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
if err != nil {
return nil, err
}
+
+ accessToken, err := obscure.Reveal(opt.AccessToken)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't decrypt access token: %w", err)
+ }
+
root = parsePath(root)
fs.Debugf(ctx, "NewFs: for root=%s", root)
@@ -277,7 +278,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
srv: rest.NewClient(fshttp.NewClient(ctx)).SetRoot(opt.Endpoint),
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
}
- f.srv.SetHeader("Authorization", "Bearer "+opt.AccessToken)
+ f.srv.SetHeader("Authorization", "Bearer "+accessToken)
f.cleanupSrv = rest.NewClient(fshttp.NewClient(ctx)).SetRoot(opt.Endpoint)
f.features = (&fs.Features{
CaseInsensitive: false,
--
2.51.1
From 46e08b816bd8a20ad842e0f650e20a9eff9d56af Mon Sep 17 00:00:00 2001
From: ikbuben <ruben.pericas-moya@infomaniak.com>
Date: Fri, 27 Feb 2026 10:40:47 +0100
Subject: [PATCH 22/32] kdrive: doc cleanup
---
backend/kdrive/kdrive.go | 124 +++++++++++++++++++++++++--------------
1 file changed, 81 insertions(+), 43 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 514ced96d..6b1804285 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -53,14 +53,32 @@ func init() {
// Encode invalid UTF-8 bytes as json doesn't handle them properly.
Default: encoder.Display | encoder.EncodeLeftSpace | encoder.EncodeRightSpace | encoder.EncodeInvalidUtf8,
}, {
- Name: "root_folder_id",
- Help: "Fill in for rclone to use a non root folder as its starting point. (directory id)",
- Default: "private",
+ Name: "root_folder_id",
+ Help: `The folder to use as root.
+
+You may use either one of the convenience shortcuts [private|common|shared]
+or an explicit folder ID.`,
+ Default: "private",
+ Examples: []fs.OptionExample{
+ {
+ Value: "private",
+ Help: "Your user private directory.",
+ },
+ {
+ Value: "common",
+ Help: "The kDrive common directory, shared among users.",
+ },
+ {
+ Value: "shared",
+ Help: "The folder with the files shared with you.",
+ },
+ },
Advanced: true,
Sensitive: true,
}, {
Name: "drive_id",
- Help: `Fill the drive ID for this kdrive.
+ Help: `ID of the kDrive to use.
+
When showing a folder on kdrive, you can find the drive_id here:
https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...`,
Default: "",
@@ -71,9 +89,10 @@ func init() {
IsPassword: true,
Sensitive: true,
}, {
- Name: "endpoint",
- Help: "By default, pointing to the production API.",
- Default: defaultEndpoint,
+ Name: "endpoint",
+ Help: `The API endpoint to use.
+
+Leave blank normally. There is no reason to change the endpoint except for internal use or beta-testing.`,
Advanced: true,
},
},
@@ -163,11 +182,12 @@ func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, err
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
}
-// findItemInDir retrieves a file or directory by its name in a specific directory using the API
+// findItemInDir retrieves a file or directory by its name in a specific directory using the API.
// This avoids listing the entire directory. It takes the directoryID directly.
func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string) (*api.Item, error) {
// fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
+ // https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/name
opts := rest.Opts{
Method: "GET",
Path: fmt.Sprintf("/3/drive/%s/files/%s/name", f.opt.DriveID, directoryID),
@@ -185,11 +205,8 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
return shouldRetry(ctx, resp, err)
})
if err != nil {
- // Check if it's a "not found" error
- if apiErr, ok := err.(*api.ResultStatus); ok {
- if apiErr.ErrorDetail.Result == "object_not_found" {
- return nil, fs.ErrorObjectNotFound
- }
+ if isNotFoundError(err) {
+ return nil, fs.ErrorObjectNotFound
}
return nil, fmt.Errorf("couldn't find item in dir: %w", err)
}
@@ -205,8 +222,9 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
return &item, nil
}
-// getItem retrieves a file or directory by its ID
+// getItem retrieves a file or directory by its ID.
func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
+ // https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
opts := rest.Opts{
Method: "GET",
Path: fmt.Sprintf("/2/drive/%s/files/%s", f.opt.DriveID, id),
@@ -221,10 +239,8 @@ func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
return shouldRetry(ctx, resp, err)
})
if err != nil {
- if apiErr, ok := err.(*api.ResultStatus); ok {
- if apiErr.ErrorDetail.Result == "object_not_found" {
- return nil, fs.ErrorObjectNotFound
- }
+ if isNotFoundError(err) {
+ return nil, fs.ErrorObjectNotFound
}
return nil, fmt.Errorf("couldn't get item: %w", err)
}
@@ -254,6 +270,13 @@ func errorHandler(resp *http.Response) error {
return errResponse
}
+// isNotFoundError checks if the given error is a standard kDrive API "Not Found" error.
+func isNotFoundError(err error) bool {
+ var apiErr *api.ResultStatus
+
+ return errors.As(err, &apiErr) && apiErr.ErrorDetail.Result == "object_not_found"
+}
+
// NewFs constructs an Fs from the path, container:path
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
// Parse config into Options struct
@@ -263,6 +286,13 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
return nil, err
}
+ // Use the default API endpoint unless explicitly defined;
+ // not defined as option default to keep it hidden from end user,
+ // since there is little to no reason to change it.
+ if opt.Endpoint == "" {
+ opt.Endpoint = defaultEndpoint
+ }
+
accessToken, err := obscure.Reveal(opt.AccessToken)
if err != nil {
return nil, fmt.Errorf("couldn't decrypt access token: %w", err)
@@ -287,8 +317,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
}).Fill(ctx, f)
f.srv.SetErrorHandler(errorHandler)
- // Get rootFolderID
- rootID, err := f.determineRootID()
+ rootID, err := f.computeRootID()
if err != nil {
return nil, err
}
@@ -328,12 +357,10 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
return f, nil
}
-// determineRootID retriece the real RootId of the configured RootFolderID
-func (f *Fs) determineRootID() (string, error) {
+// computeRootID finds the real RootId of the configured RootFolderID.
+func (f *Fs) computeRootID() (rootID string, err error) {
ctx := context.Background()
- var rootID string
- var err error
switch f.opt.RootFolderID {
case "private":
rootID, _, err = f.FindLeaf(ctx, "1", "Private")
@@ -345,11 +372,11 @@ func (f *Fs) determineRootID() (string, error) {
rootID = f.opt.RootFolderID
}
- return rootID, err
+ return
}
-// findItemByPath retrieves a file or directory by its path using the API
-// This avoids listing the entire directory
+// findItemByPath retrieves a file or directory by its path using the API.
+// This avoids listing the entire directory.
func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, error) {
// fs.Infof(ctx, "findItemByPath: remote=%s", remote)
@@ -440,6 +467,8 @@ func (f *Fs) CreateDir(ctx context.Context, pathID, leaf string) (newID string,
// fs.Debugf(f, "CreateDir(%q, %q)\n", pathID, leaf)
var resp *http.Response
var result api.CreateDirResult
+
+ // https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/directory
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/files/%s/directory", f.opt.DriveID, pathID),
@@ -452,10 +481,8 @@ func (f *Fs) CreateDir(ctx context.Context, pathID, leaf string) (newID string,
return shouldRetry(ctx, resp, err)
})
if err != nil {
- //fmt.Printf("...Error %v\n", err)
return "", err
}
- // fmt.Printf("...Id %d\n", result.Data.ID)
return strconv.Itoa(result.Data.ID), nil
}
@@ -467,12 +494,13 @@ func (f *Fs) CreateDir(ctx context.Context, pathID, leaf string) (newID string,
// Should return true to finish processing
type listAllFn func(*api.Item) bool
-// Lists the directory required calling the user function on each item found
+// Lists the directory required calling the user function on each item found.
//
-// If the user fn ever returns true then it early exits with found = true
+// If the user fn ever returns true then it early exits with found = true.
func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, filesOnly bool, recursive bool, fn listAllFn) (found bool, err error) {
// fs.Infof(nil, "Stacktrace : %s", string(debug.Stack()))
listSomeFiles := func(currentDirID string, fromCursor string) (api.SearchResult, error) {
+ // https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/files
opts := rest.Opts{
Method: "GET",
Path: fmt.Sprintf("/3/drive/%s/files/%s/files", f.opt.DriveID, currentDirID),
@@ -735,6 +763,7 @@ func (f *Fs) purgeCheck(ctx context.Context, dir string, check bool) error {
return fmt.Errorf("rmdir failed: directory %s not empty", dir)
}
+ // https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
opts := rest.Opts{
Method: "DELETE",
Path: fmt.Sprintf("/2/drive/%s/files/%s", f.opt.DriveID, rootID),
@@ -793,7 +822,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
return nil, err
}
- // Copy the object
+ // https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/copy/%7Bdestination_directory_id%7D
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/files/%s/copy/%s", f.opt.DriveID, srcObj.id, directoryID),
@@ -829,6 +858,7 @@ func (f *Fs) Purge(ctx context.Context, dir string) error {
// CleanUp empties the trash
func (f *Fs) CleanUp(ctx context.Context) error {
+ // https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/trash
opts := rest.Opts{
Method: "DELETE",
Path: fmt.Sprintf("/2/drive/%s/trash", f.opt.DriveID),
@@ -866,7 +896,7 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
return nil, err
}
- // Do the move
+ // https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/move/%7Bdestination_directory_id%7D
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/files/%s/move/%s", f.opt.DriveID, srcObj.id, directoryID),
@@ -925,7 +955,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
return err
}
- // Do the move
+ // https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/move/%7Bdestination_directory_id%7D
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/files/%s/move/%s", f.opt.DriveID, srcID, dstDirectoryID),
@@ -955,6 +985,7 @@ func (f *Fs) DirCacheFlush() {
}
func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, error) {
+ // https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/link
opts := rest.Opts{
Method: "GET",
Path: fmt.Sprintf("/2/drive/%s/files/%d/link", f.opt.DriveID, fileID),
@@ -988,6 +1019,7 @@ func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, error) {
}
func (f *Fs) deletePublicLink(ctx context.Context, fileID int) error {
+ // https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/link
opts := rest.Opts{
Method: "DELETE",
Path: fmt.Sprintf("/2/drive/%s/files/%d/link", f.opt.DriveID, fileID),
@@ -1019,6 +1051,7 @@ func (f *Fs) createPublicLink(ctx context.Context, fileID int, expire fs.Duratio
createReq.ValidUntil = int(time.Now().Add(time.Duration(expire)).Unix())
}
+ // https://developer.infomaniak.com/docs/api/post/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/link
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/2/drive/%s/files/%d/link", f.opt.DriveID, fileID),
@@ -1067,6 +1100,7 @@ func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration,
// About gets quota information
func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
+ // https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D
opts := rest.Opts{
Method: "GET",
Path: fmt.Sprintf("/2/drive/%s", f.opt.DriveID),
@@ -1128,10 +1162,10 @@ func (o *Object) retrieveHash(ctx context.Context) (err error) {
var resp *http.Response
var result api.ChecksumFileResult
+ // https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/hash
opts := rest.Opts{
- Method: "GET",
- Path: fmt.Sprintf("/2/drive/%s/files/%s/hash", o.fs.opt.DriveID, o.id),
- Parameters: url.Values{},
+ Method: "GET",
+ Path: fmt.Sprintf("/2/drive/%s/files/%s/hash", o.fs.opt.DriveID, o.id),
}
err = o.fs.pacer.Call(func() (bool, error) {
resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &result)
@@ -1234,9 +1268,10 @@ func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
LastModifiedAt: modTime.Unix(),
}
+ // https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/last-modified
opts := rest.Opts{
- Method: "POST",
- RootURL: fmt.Sprintf("%s/3/drive/%s/files/%s/last-modified", o.fs.opt.Endpoint, o.fs.opt.DriveID, o.id),
+ Method: "POST",
+ Path: fmt.Sprintf("/3/drive/%s/files/%s/last-modified", o.fs.opt.DriveID, o.id),
}
err := o.fs.pacer.Call(func() (bool, error) {
@@ -1263,9 +1298,11 @@ func (o *Object) Storable() bool {
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) {
fs.FixRangeOption(options, o.Size())
var resp *http.Response
+
+ // https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/download
opts := rest.Opts{
Method: "GET",
- RootURL: fmt.Sprintf("%s/2/drive/%s/files/%s/download", o.fs.opt.Endpoint, o.fs.opt.DriveID, o.id),
+ Path: fmt.Sprintf("/2/drive/%s/files/%s/download", o.fs.opt.DriveID, o.id),
Options: options,
}
err = o.fs.pacer.Call(func() (bool, error) {
@@ -1346,6 +1383,7 @@ func (o *Object) updateDirect(ctx context.Context, in io.Reader, directoryID, le
body = bytes.NewReader(content)
}
+ // https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload
opts := rest.Opts{
Method: "POST",
Path: fmt.Sprintf("/3/drive/%s/upload", o.fs.opt.DriveID),
@@ -1401,10 +1439,10 @@ func (o *Object) updateMultipart(ctx context.Context, in io.Reader, src fs.Objec
// Remove an object
func (o *Object) Remove(ctx context.Context) error {
+ // https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
opts := rest.Opts{
- Method: "DELETE",
- Path: fmt.Sprintf("/2/drive/%s/files/%s", o.fs.opt.DriveID, o.id),
- Parameters: url.Values{},
+ Method: "DELETE",
+ Path: fmt.Sprintf("/2/drive/%s/files/%s", o.fs.opt.DriveID, o.id),
}
var result api.CancellableResponse
return o.fs.pacer.Call(func() (bool, error) {
--
2.51.1
From a0f80b9975787f9e2d43d38e5cf237b750b17f67 Mon Sep 17 00:00:00 2001
From: ikbuben <ruben.pericas-moya@infomaniak.com>
Date: Fri, 27 Feb 2026 11:16:35 +0100
Subject: [PATCH 23/32] kdrive: fix root_folder_id option
---
backend/kdrive/kdrive.go | 22 ++++++----------------
1 file changed, 6 insertions(+), 16 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 6b1804285..771bdffdf 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -57,22 +57,12 @@ func init() {
Help: `The folder to use as root.
You may use either one of the convenience shortcuts [private|common|shared]
-or an explicit folder ID.`,
- Default: "private",
- Examples: []fs.OptionExample{
- {
- Value: "private",
- Help: "Your user private directory.",
- },
- {
- Value: "common",
- Help: "The kDrive common directory, shared among users.",
- },
- {
- Value: "shared",
- Help: "The folder with the files shared with you.",
- },
- },
+or an explicit folder ID.
+
+- private: your user private directory
+- common: the kDrive common directory, shared among users
+- shared: the folder with the files shared with you`,
+ Default: "private",
Advanced: true,
Sensitive: true,
}, {
--
2.51.1
From 93fb55c97f2e98d1055d4eecf01857d8e59c1665 Mon Sep 17 00:00:00 2001
From: ikbuben <ruben.pericas-moya@infomaniak.com>
Date: Fri, 27 Feb 2026 11:19:13 +0100
Subject: [PATCH 24/32] kdrive: fix doc indent
---
backend/kdrive/kdrive.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 771bdffdf..ae64a7222 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -69,8 +69,8 @@ or an explicit folder ID.
Name: "drive_id",
Help: `ID of the kDrive to use.
- When showing a folder on kdrive, you can find the drive_id here:
- https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...`,
+When showing a folder on kdrive, you can find the drive_id here:
+https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...`,
Default: "",
}, {
Name: "access_token",
--
2.51.1
From 453e98c7a2aea7df55fee0e89780153a8d52b29f Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Fri, 27 Feb 2026 10:45:11 +0100
Subject: [PATCH 25/32] kdrive: fix conflicts
---
backend/kdrive/kdrive.go | 169 ++++++++++++++++++++-------------------
1 file changed, 87 insertions(+), 82 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index ae64a7222..061c79a15 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -62,7 +62,6 @@ or an explicit folder ID.
- private: your user private directory
- common: the kDrive common directory, shared among users
- shared: the folder with the files shared with you`,
- Default: "private",
Advanced: true,
Sensitive: true,
}, {
@@ -172,77 +171,6 @@ func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, err
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
}
-// findItemInDir retrieves a file or directory by its name in a specific directory using the API.
-// This avoids listing the entire directory. It takes the directoryID directly.
-func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string) (*api.Item, error) {
- // fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
-
- // https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/name
- opts := rest.Opts{
- Method: "GET",
- Path: fmt.Sprintf("/3/drive/%s/files/%s/name", f.opt.DriveID, directoryID),
- Parameters: url.Values{},
- }
- opts.Parameters.Set("name", f.opt.Enc.FromStandardName(leaf))
- opts.Parameters.Set("with", "path,hash")
-
- var result api.ItemResult
- var resp *http.Response
- var err error
- err = f.pacer.Call(func() (bool, error) {
- resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
- err = result.ResultStatus.Update(err)
- return shouldRetry(ctx, resp, err)
- })
- if err != nil {
- if isNotFoundError(err) {
- return nil, fs.ErrorObjectNotFound
- }
- return nil, fmt.Errorf("couldn't find item in dir: %w", err)
- }
-
- item := result.Data
- // Check if item is valid (has an ID)
- if item.ID == 0 {
- return nil, fs.ErrorObjectNotFound
- }
- // Normalize the name
- item.Name = f.opt.Enc.ToStandardName(item.Name)
-
- return &item, nil
-}
-
-// getItem retrieves a file or directory by its ID.
-func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
- // https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
- opts := rest.Opts{
- Method: "GET",
- Path: fmt.Sprintf("/2/drive/%s/files/%s", f.opt.DriveID, id),
- }
-
- var result api.ItemResult
- var resp *http.Response
- var err error
- err = f.pacer.Call(func() (bool, error) {
- resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
- err = result.ResultStatus.Update(err)
- return shouldRetry(ctx, resp, err)
- })
- if err != nil {
- if isNotFoundError(err) {
- return nil, fs.ErrorObjectNotFound
- }
- return nil, fmt.Errorf("couldn't get item: %w", err)
- }
-
- item := result.Data
- if item.ID == 0 {
- return nil, fs.ErrorObjectNotFound
- }
- item.Name = f.opt.Enc.ToStandardName(item.Name)
- return &item, nil
-}
-
// errorHandler parses a non 2xx error response into an error
func errorHandler(resp *http.Response) error {
// Decode error response
@@ -351,22 +279,99 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
func (f *Fs) computeRootID() (rootID string, err error) {
ctx := context.Background()
- switch f.opt.RootFolderID {
- case "private":
- rootID, _, err = f.FindLeaf(ctx, "1", "Private")
- case "common":
- rootID, _, err = f.FindLeaf(ctx, "1", "Common documents")
- case "shared":
- rootID, _, err = f.FindLeaf(ctx, "1", "Shared")
- default:
+ if _, err := strconv.Atoi(f.opt.RootFolderID); err == nil {
rootID = f.opt.RootFolderID
+ } else {
+ switch f.opt.RootFolderID {
+ case "private":
+ rootID, _, err = f.FindLeaf(ctx, "1", "Private")
+ case "common":
+ rootID, _, err = f.FindLeaf(ctx, "1", "Common documents")
+ case "shared":
+ rootID, _, err = f.FindLeaf(ctx, "1", "Shared")
+ case "":
+ rootID, _, err = f.FindLeaf(ctx, "1", "Private")
+ default:
+ rootID, _, err = f.FindLeaf(ctx, "1", f.opt.RootFolderID)
+ }
}
+ fs.Debugf(nil, "ROOTFOLDERID %w ROOTID %s", f.opt.RootFolderID, rootID)
+
return
}
-// findItemByPath retrieves a file or directory by its path using the API.
-// This avoids listing the entire directory.
+// getItem retrieves a file or directory by its ID.
+func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
+ // https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
+ opts := rest.Opts{
+ Method: "GET",
+ Path: fmt.Sprintf("/3/drive/%s/files/%s", f.opt.DriveID, id),
+ }
+
+ var result api.ItemResult
+ var resp *http.Response
+ var err error
+ err = f.pacer.Call(func() (bool, error) {
+ resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
+ err = result.ResultStatus.Update(err)
+ return shouldRetry(ctx, resp, err)
+ })
+ if err != nil {
+ if isNotFoundError(err) {
+ return nil, fs.ErrorObjectNotFound
+ }
+ return nil, fmt.Errorf("couldn't get item: %w", err)
+ }
+
+ item := result.Data
+ if item.ID == 0 {
+ return nil, fs.ErrorObjectNotFound
+ }
+ item.Name = f.opt.Enc.ToStandardName(item.Name)
+ return &item, nil
+}
+
+// findItemInDir retrieves a file or directory by its name in a specific directory using the API.
+func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string) (*api.Item, error) {
+ // fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
+
+ // https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/name
+ opts := rest.Opts{
+ Method: "GET",
+ Path: fmt.Sprintf("/3/drive/%s/files/%s/name", f.opt.DriveID, directoryID),
+ Parameters: url.Values{},
+ }
+ opts.Parameters.Set("name", f.opt.Enc.FromStandardName(leaf))
+ opts.Parameters.Set("with", "path,hash")
+
+ var result api.ItemResult
+ var resp *http.Response
+ var err error
+ err = f.pacer.Call(func() (bool, error) {
+ resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
+ err = result.ResultStatus.Update(err)
+ return shouldRetry(ctx, resp, err)
+ })
+ if err != nil {
+ if isNotFoundError(err) {
+ return nil, fs.ErrorObjectNotFound
+ }
+ return nil, fmt.Errorf("couldn't find item in dir: %w", err)
+ }
+
+ item := result.Data
+ // Check if item is valid (has an ID)
+ if item.ID == 0 {
+ return nil, fs.ErrorObjectNotFound
+ }
+ // Normalize the name
+ item.Name = f.opt.Enc.ToStandardName(item.Name)
+
+ return &item, nil
+}
+
+// findItemByPath retrieves a file or directory by its path
func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, error) {
// fs.Infof(ctx, "findItemByPath: remote=%s", remote)
--
2.51.1
From d57fd953d871495a556de7aea2514d6eda8dec07 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Fri, 27 Feb 2026 14:55:34 +0100
Subject: [PATCH 26/32] kdrive: optimize api call on init with context cache
---
backend/kdrive/kdrive.go | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 061c79a15..aa6fa3dc2 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -240,8 +240,9 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
return nil, err
}
- f.dirCache = dircache.New(root, rootID, f)
+ ctx = context.WithValue(ctx, "kdriveInitCache", make(map[string]*api.Item))
+ f.dirCache = dircache.New(root, rootID, f)
// Find the current root
err = f.dirCache.FindRoot(ctx, false)
if err != nil {
@@ -334,7 +335,13 @@ func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
// findItemInDir retrieves a file or directory by its name in a specific directory using the API.
func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string) (*api.Item, error) {
- // fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
+ fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
+
+ cacheKey := directoryID + "|" + leaf
+ cache, cacheExist := ctx.Value("kdriveInitCache").(map[string]*api.Item)
+ if cacheExist && cache[cacheKey] != nil {
+ return cache[cacheKey], nil
+ }
// https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/name
opts := rest.Opts{
@@ -368,6 +375,10 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
// Normalize the name
item.Name = f.opt.Enc.ToStandardName(item.Name)
+ if cacheExist {
+ cache[cacheKey] = &item
+ }
+
return &item, nil
}
--
2.51.1
From d26602541f419bed69607484389823758f003322 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Fri, 27 Feb 2026 15:33:21 +0100
Subject: [PATCH 27/32] kdrive: cache not found on init
---
backend/kdrive/kdrive.go | 24 +++++++++++++++++++-----
1 file changed, 19 insertions(+), 5 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index aa6fa3dc2..bc7daacf8 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -122,6 +122,11 @@ type Object struct {
xxh3 string // XXH3 if known
}
+type cacheEntry struct {
+ item *api.Item
+ err error
+}
+
// ------------------------------------------------------------
// Name of the remote (as passed into NewFs)
@@ -240,7 +245,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
return nil, err
}
- ctx = context.WithValue(ctx, "kdriveInitCache", make(map[string]*api.Item))
+ ctx = context.WithValue(ctx, "kdriveInitCache", make(map[string]cacheEntry))
f.dirCache = dircache.New(root, rootID, f)
// Find the current root
@@ -336,11 +341,15 @@ func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
// findItemInDir retrieves a file or directory by its name in a specific directory using the API.
func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string) (*api.Item, error) {
fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
+ //fs.Infof(nil, "Stacktrace : %s", string(debug.Stack()))
cacheKey := directoryID + "|" + leaf
- cache, cacheExist := ctx.Value("kdriveInitCache").(map[string]*api.Item)
- if cacheExist && cache[cacheKey] != nil {
- return cache[cacheKey], nil
+ cache, cacheExist := ctx.Value("kdriveInitCache").(map[string]cacheEntry)
+ if cacheExist {
+ if entry, entryExists := cache[cacheKey]; entryExists {
+ fs.Infof(nil, "USE CACHE")
+ return entry.item, entry.err
+ }
}
// https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/name
@@ -362,6 +371,9 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
})
if err != nil {
if isNotFoundError(err) {
+ if cacheExist {
+ cache[cacheKey] = cacheEntry{nil, fs.ErrorObjectNotFound}
+ }
return nil, fs.ErrorObjectNotFound
}
return nil, fmt.Errorf("couldn't find item in dir: %w", err)
@@ -376,7 +388,7 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
item.Name = f.opt.Enc.ToStandardName(item.Name)
if cacheExist {
- cache[cacheKey] = &item
+ cache[cacheKey] = cacheEntry{&item, nil}
}
return &item, nil
@@ -1057,6 +1069,8 @@ func (f *Fs) createPublicLink(ctx context.Context, fileID int, expire fs.Duratio
createReq.ValidUntil = int(time.Now().Add(time.Duration(expire)).Unix())
}
+ // fs.Infof(createReq.ValidUntil);
+
// https://developer.infomaniak.com/docs/api/post/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/link
opts := rest.Opts{
Method: "POST",
--
2.51.1
From 4d3a3c8cff8140d48e62bafdb95fb5471f1b3a95 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Fri, 27 Feb 2026 16:01:12 +0100
Subject: [PATCH 28/32] kdrive: fix Public link unlink and expiration
---
backend/kdrive/kdrive.go | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index bc7daacf8..1c4e9d641 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -347,7 +347,6 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
cache, cacheExist := ctx.Value("kdriveInitCache").(map[string]cacheEntry)
if cacheExist {
if entry, entryExists := cache[cacheKey]; entryExists {
- fs.Infof(nil, "USE CACHE")
return entry.item, entry.err
}
}
@@ -1029,6 +1028,7 @@ func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, error) {
// ValidUntil is a Unix timestamp (UTC)
if time.Now().Unix() > int64(result.Data.ValidUntil) {
// link is expired
+ f.deletePublicLink(ctx, fileID)
return "", nil
}
}
@@ -1065,7 +1065,7 @@ func (f *Fs) createPublicLink(ctx context.Context, fileID int, expire fs.Duratio
}
// Set expiry if provided
- if expire > 0 {
+ if expire != fs.DurationOff {
createReq.ValidUntil = int(time.Now().Add(time.Duration(expire)).Unix())
}
@@ -1103,9 +1103,7 @@ func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration,
if unlink {
// Remove existing public link
err = f.deletePublicLink(ctx, item.ID)
- if err != nil {
- return "", err
- }
+ return "", err
}
// Check if link exists
--
2.51.1
From 4908adcfa94b0afa7e1b0a2d66e8164632b5695f Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Fri, 27 Feb 2026 16:18:15 +0100
Subject: [PATCH 29/32] kdrive: fix CleanUp
---
backend/kdrive/kdrive.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 1c4e9d641..04175c953 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -232,7 +232,10 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
}
f.srv.SetHeader("Authorization", "Bearer "+accessToken)
+
f.cleanupSrv = rest.NewClient(fshttp.NewClient(ctx)).SetRoot(opt.Endpoint)
+ f.cleanupSrv.SetHeader("Authorization", "Bearer "+accessToken)
+
f.features = (&fs.Features{
CaseInsensitive: false,
CanHaveEmptyDirectories: true,
--
2.51.1
From 13e6d91b11c2a191813b3d03d40f24aae61f1caa Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Mon, 2 Mar 2026 16:32:22 +0100
Subject: [PATCH 30/32] kdrive: use depth unlimited for ListR
---
backend/kdrive/api/types.go | 1 -
backend/kdrive/kdrive.go | 40 +++++++++++++++++++++----------------
2 files changed, 23 insertions(+), 18 deletions(-)
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index b60efa241..d01283014 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -106,7 +106,6 @@ type Item struct {
MimeType string `json:"mime_type"`
ParentID int `json:"parent_id"`
Color string `json:"color"`
- HasChildren bool `json:"has_children,omitempty"`
}
type ItemResult struct {
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 04175c953..d4ba85c46 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -314,9 +314,11 @@ func (f *Fs) computeRootID() (rootID string, err error) {
func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
// https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
opts := rest.Opts{
- Method: "GET",
- Path: fmt.Sprintf("/3/drive/%s/files/%s", f.opt.DriveID, id),
+ Method: "GET",
+ Path: fmt.Sprintf("/3/drive/%s/files/%s", f.opt.DriveID, id),
+ Parameters: url.Values{},
}
+ opts.Parameters.Set("with", "path")
var result api.ItemResult
var resp *http.Response
@@ -518,6 +520,12 @@ type listAllFn func(*api.Item) bool
//
// If the user fn ever returns true then it early exits with found = true.
func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, filesOnly bool, recursive bool, fn listAllFn) (found bool, err error) {
+ rootItem, err := f.getItem(ctx, dirID)
+ if err != nil {
+ return false, err
+ }
+ rootPath := rootItem.FullPath + "/"
+
// fs.Infof(nil, "Stacktrace : %s", string(debug.Stack()))
listSomeFiles := func(currentDirID string, fromCursor string) (api.SearchResult, error) {
// https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/files
@@ -527,7 +535,11 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
Parameters: url.Values{},
}
opts.Parameters.Set("limit", "1000")
- opts.Parameters.Set("with", "path,hash")
+ opts.Parameters.Set("with", "path")
+ if recursive {
+ opts.Parameters.Set("depth", "unlimited")
+ }
+
if len(fromCursor) > 0 {
opts.Parameters.Set("cursor", fromCursor)
}
@@ -546,22 +558,20 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
}
var listErr error
- var recursiveContents func(currentDirID string, currentSubDir string, fromCursor string) bool
+ var recursiveContents func(currentDirID string, currentSubDir string, fromCursor string)
- recursiveContents = func(currentDirID string, currentSubDir string, fromCursor string) bool {
+ recursiveContents = func(currentDirID string, currentSubDir string, fromCursor string) {
if listErr != nil {
- return false
+ return
}
result, err := listSomeFiles(currentDirID, fromCursor)
if err != nil {
listErr = err
- return false
+ return
}
- hasChildren := false
// First, analyze what has been returned, and go in-depth if required
for i := range result.Data {
- hasChildren = true
item := &result.Data[i]
if item.Type == "dir" {
if filesOnly {
@@ -572,19 +582,17 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
continue
}
}
+
item.Name = f.opt.Enc.ToStandardName(item.Name)
- item.FullPath = path.Join(currentSubDir, item.Name)
+ item.FullPath = f.opt.Enc.ToStandardPath(strings.TrimPrefix(item.FullPath, rootPath))
if fn(item) {
found = true
break
}
- item.HasChildren = false
- if recursive && item.Type == "dir" {
- subDirHasChildren := recursiveContents(strconv.Itoa(item.ID), path.Join(currentSubDir, item.Name), "" /*reset cursor*/)
- // fs.Infof(nil, "DIR %s SUBDIR %s subDirHasChildren %w", strconv.Itoa(item.ID), path.Join(currentSubDir, item.Name), subDirHasChildren)
- item.HasChildren = subDirHasChildren
+ if recursive && currentDirID == "1" && item.Type == "dir" {
+ recursiveContents(strconv.Itoa(item.ID), path.Join(currentSubDir, item.Name), "" /*reset cursor*/)
}
}
@@ -592,8 +600,6 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
if result.HasMore {
recursiveContents(currentDirID, currentSubDir, result.Cursor)
}
-
- return hasChildren
}
recursiveContents(dirID, "", "")
--
2.51.1
From 00e718ebc52a74e49e3dd78a812c0f7ab2c7b983 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Wed, 4 Mar 2026 13:48:47 +0100
Subject: [PATCH 31/32] kdrive: Public Link, update expiration date if provided
---
backend/kdrive/api/types.go | 6 +++-
backend/kdrive/kdrive.go | 68 +++++++++++++++++++++++++++++++------
2 files changed, 62 insertions(+), 12 deletions(-)
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index d01283014..1945837c4 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -169,7 +169,6 @@ type ChecksumFileResult struct {
} `json:"data"`
}
-// PubLinkResult is currently unused, as PublicLink is disabled
type PubLinkResult struct {
ResultStatus
Data struct {
@@ -192,6 +191,11 @@ type PubLinkResult struct {
} `json:"data"`
}
+type PubLinkUpdateResult struct {
+ ResultStatus
+ Data bool `json:"data"`
+}
+
// QuotaInfo is return from kdrive after a call get drive info
type QuotaInfo struct {
ResultStatus
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index d4ba85c46..8fce1a036 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -1010,7 +1010,7 @@ func (f *Fs) DirCacheFlush() {
f.dirCache.ResetRoot()
}
-func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, error) {
+func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, bool, error) {
// https://developer.infomaniak.com/docs/api/get/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/link
opts := rest.Opts{
Method: "GET",
@@ -1022,14 +1022,15 @@ func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, error) {
err = result.Update(err)
return shouldRetry(ctx, resp, err)
})
-
+ fs.Infof(nil, "VALID UNTIL %d", result.Data.ValidUntil)
+ expired := false
if err != nil {
- return "", fmt.Errorf("failed to get public link: %w", err)
+ return "", expired, fmt.Errorf("failed to get public link: %w", err)
}
// check if the shared link is blocked
if result.Data.AccessBlocked == true {
- return "", nil
+ return "", expired, nil
}
// check if the shared link is expired
@@ -1037,12 +1038,11 @@ func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, error) {
// ValidUntil is a Unix timestamp (UTC)
if time.Now().Unix() > int64(result.Data.ValidUntil) {
// link is expired
- f.deletePublicLink(ctx, fileID)
- return "", nil
+ expired = true
}
}
- return result.Data.URL, nil
+ return result.Data.URL, expired, nil
}
func (f *Fs) deletePublicLink(ctx context.Context, fileID int) error {
@@ -1078,8 +1078,6 @@ func (f *Fs) createPublicLink(ctx context.Context, fileID int, expire fs.Duratio
createReq.ValidUntil = int(time.Now().Add(time.Duration(expire)).Unix())
}
- // fs.Infof(createReq.ValidUntil);
-
// https://developer.infomaniak.com/docs/api/post/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/link
opts := rest.Opts{
Method: "POST",
@@ -1100,6 +1098,36 @@ func (f *Fs) createPublicLink(ctx context.Context, fileID int, expire fs.Duratio
return result.Data.URL, nil
}
+func (f *Fs) updatePublicLink(ctx context.Context, fileID int, expire fs.Duration) error {
+ createReq := struct {
+ ValidUntil int `json:"valid_until,omitempty"`
+ }{}
+
+ // Set expiry if provided
+ if expire != fs.DurationOff {
+ createReq.ValidUntil = int(time.Now().Add(time.Duration(expire)).Unix())
+ }
+
+ // https://developer.infomaniak.com/docs/api/put/2/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/link
+ opts := rest.Opts{
+ Method: "PUT",
+ Path: fmt.Sprintf("/2/drive/%s/files/%d/link", f.opt.DriveID, fileID),
+ }
+
+ var result api.PubLinkUpdateResult
+ err := f.pacer.Call(func() (bool, error) {
+ resp, err := f.srv.CallJSON(ctx, &opts, createReq, &result)
+ err = result.ResultStatus.Update(err)
+ return shouldRetry(ctx, resp, err)
+ })
+
+ if err != nil {
+ return fmt.Errorf("failed to update public link: %w", err)
+ }
+
+ return nil
+}
+
// PublicLink adds a "readable by anyone with link" permission on the given file or folder.
// If unlink is true, it removes the existing public link.
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
@@ -1116,10 +1144,28 @@ func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration,
}
// Check if link exists
- link, err := f.getPublicLink(ctx, item.ID)
+ link, expired, err := f.getPublicLink(ctx, item.ID)
if link != "" {
- return link, nil
+ // link exist
+ if expire != fs.DurationOff {
+ // update valid until and return the same link
+ fs.Infof(nil, "UPDATE LINK")
+ err = f.updatePublicLink(ctx, item.ID, expire)
+ if err != nil {
+ return "", err
+ }
+ return link, nil
+ } else if expired == true {
+ fs.Infof(nil, "DELETE LINK")
+ // delete and recreate after
+ f.deletePublicLink(ctx, item.ID)
+ } else {
+ fs.Infof(nil, "RETURN LINK")
+ // return link
+ return link, nil
+ }
}
+ fs.Infof(nil, "CREATE LINK")
// Create or get existing public link
return f.createPublicLink(ctx, item.ID, expire)
--
2.51.1
From d50211386020089f7ed6c4ca45120ffedac8e923 Mon Sep 17 00:00:00 2001
From: Guillaume MALLERET <guillaume.malleret@infomaniak.com>
Date: Thu, 5 Mar 2026 08:38:13 +0100
Subject: [PATCH 32/32] kdrive: Add cache on not found and clear logs
---
backend/kdrive/kdrive.go | 77 +++---
backend/kdrive/kdrive_internal_test.go | 340 -------------------------
2 files changed, 44 insertions(+), 373 deletions(-)
delete mode 100644 backend/kdrive/kdrive_internal_test.go
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 8fce1a036..3db4139d9 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -99,14 +99,15 @@ type Options struct {
// Fs represents a remote kdrive
type Fs struct {
- name string // name of this remote
- root string // the path we are working on
- opt Options // parsed options
- features *fs.Features // optional features
- srv *rest.Client // the connection to the server
- cleanupSrv *rest.Client // the connection used for the cleanup method
- dirCache *dircache.DirCache // Map of directory path to directory id
- pacer *fs.Pacer // pacer for API calls
+ name string // name of this remote
+ root string // the path we are working on
+ opt Options // parsed options
+ features *fs.Features // optional features
+ srv *rest.Client // the connection to the server
+ cleanupSrv *rest.Client // the connection used for the cleanup method
+ dirCache *dircache.DirCache // Map of directory path to directory id
+ pacer *fs.Pacer // pacer for API calls
+ cacheNotFound map[string]cacheEntry
}
// Object describes a kdrive object
@@ -149,6 +150,11 @@ func (f *Fs) Features() *fs.Features {
return f.features
}
+// clearNotFoundCache removes all entries from the not-found cache
+func (f *Fs) clearNotFoundCache() {
+ f.cacheNotFound = make(map[string]cacheEntry)
+}
+
// parsePath parses a kdrive 'url'
func parsePath(path string) (root string) {
root = strings.Trim(path, "/")
@@ -225,11 +231,12 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
fs.Debugf(ctx, "NewFs: for root=%s", root)
f := &Fs{
- name: name,
- root: root,
- opt: *opt,
- srv: rest.NewClient(fshttp.NewClient(ctx)).SetRoot(opt.Endpoint),
- pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
+ name: name,
+ root: root,
+ opt: *opt,
+ srv: rest.NewClient(fshttp.NewClient(ctx)).SetRoot(opt.Endpoint),
+ pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
+ cacheNotFound: make(map[string]cacheEntry),
}
f.srv.SetHeader("Authorization", "Bearer "+accessToken)
@@ -345,13 +352,15 @@ func (f *Fs) getItem(ctx context.Context, id string) (*api.Item, error) {
// findItemInDir retrieves a file or directory by its name in a specific directory using the API.
func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string) (*api.Item, error) {
- fs.Infof(ctx, "findItemInDir: directoryID=%s leaf=%s", directoryID, leaf)
- //fs.Infof(nil, "Stacktrace : %s", string(debug.Stack()))
-
cacheKey := directoryID + "|" + leaf
- cache, cacheExist := ctx.Value("kdriveInitCache").(map[string]cacheEntry)
- if cacheExist {
- if entry, entryExists := cache[cacheKey]; entryExists {
+
+ if entry, entryExists := f.cacheNotFound[cacheKey]; entryExists {
+ return entry.item, entry.err
+ }
+
+ cacheInit, cacheInitExist := ctx.Value("kdriveInitCache").(map[string]cacheEntry)
+ if cacheInitExist {
+ if entry, entryExists := cacheInit[cacheKey]; entryExists {
return entry.item, entry.err
}
}
@@ -375,9 +384,7 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
})
if err != nil {
if isNotFoundError(err) {
- if cacheExist {
- cache[cacheKey] = cacheEntry{nil, fs.ErrorObjectNotFound}
- }
+ f.cacheNotFound[cacheKey] = cacheEntry{nil, fs.ErrorObjectNotFound}
return nil, fs.ErrorObjectNotFound
}
return nil, fmt.Errorf("couldn't find item in dir: %w", err)
@@ -391,8 +398,8 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
// Normalize the name
item.Name = f.opt.Enc.ToStandardName(item.Name)
- if cacheExist {
- cache[cacheKey] = cacheEntry{&item, nil}
+ if cacheInitExist {
+ cacheInit[cacheKey] = cacheEntry{&item, nil}
}
return &item, nil
@@ -400,8 +407,6 @@ func (f *Fs) findItemInDir(ctx context.Context, directoryID string, leaf string)
// findItemByPath retrieves a file or directory by its path
func (f *Fs) findItemByPath(ctx context.Context, remote string) (*api.Item, error) {
- // fs.Infof(ctx, "findItemByPath: remote=%s", remote)
-
if remote == "" || remote == "." {
rootID, err := f.dirCache.FindDir(ctx, "", false)
if err != nil {
@@ -505,6 +510,8 @@ func (f *Fs) CreateDir(ctx context.Context, pathID, leaf string) (newID string,
if err != nil {
return "", err
}
+
+ f.clearNotFoundCache()
return strconv.Itoa(result.Data.ID), nil
}
@@ -526,7 +533,6 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
}
rootPath := rootItem.FullPath + "/"
- // fs.Infof(nil, "Stacktrace : %s", string(debug.Stack()))
listSomeFiles := func(currentDirID string, fromCursor string) (api.SearchResult, error) {
// https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D/files
opts := rest.Opts{
@@ -870,6 +876,8 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object,
if err != nil {
return nil, err
}
+
+ f.clearNotFoundCache()
return dstObj, nil
}
@@ -945,6 +953,8 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
if err != nil {
return false, err
}
+
+ f.clearNotFoundCache()
return true, nil
}
}
@@ -958,6 +968,8 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
if err != nil {
return nil, err
}
+
+ f.clearNotFoundCache()
return dstObj, nil
}
@@ -1001,6 +1013,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
srcFs.dirCache.FlushDir(srcRemote)
srcFs.dirCache.FlushDir(dstRemote)
+ f.clearNotFoundCache()
return nil
}
@@ -1022,7 +1035,7 @@ func (f *Fs) getPublicLink(ctx context.Context, fileID int) (string, bool, error
err = result.Update(err)
return shouldRetry(ctx, resp, err)
})
- fs.Infof(nil, "VALID UNTIL %d", result.Data.ValidUntil)
+
expired := false
if err != nil {
return "", expired, fmt.Errorf("failed to get public link: %w", err)
@@ -1131,7 +1144,6 @@ func (f *Fs) updatePublicLink(ctx context.Context, fileID int, expire fs.Duratio
// PublicLink adds a "readable by anyone with link" permission on the given file or folder.
// If unlink is true, it removes the existing public link.
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
- // fs.Infof("PublicLink PATH REMOTE", remote)
item, err := f.findItemByPath(ctx, remote)
if err != nil {
return "", err
@@ -1149,23 +1161,19 @@ func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration,
// link exist
if expire != fs.DurationOff {
// update valid until and return the same link
- fs.Infof(nil, "UPDATE LINK")
err = f.updatePublicLink(ctx, item.ID, expire)
if err != nil {
return "", err
}
return link, nil
} else if expired == true {
- fs.Infof(nil, "DELETE LINK")
// delete and recreate after
f.deletePublicLink(ctx, item.ID)
} else {
- fs.Infof(nil, "RETURN LINK")
// return link
return link, nil
}
}
- fs.Infof(nil, "CREATE LINK")
// Create or get existing public link
return f.createPublicLink(ctx, item.ID, expire)
@@ -1489,6 +1497,8 @@ func (o *Object) updateDirect(ctx context.Context, in io.Reader, directoryID, le
o.size = size
o.setHash(strings.TrimPrefix(result.Data.Hash, "xxh3:"))
+
+ o.fs.clearNotFoundCache()
return o.readMetaData(ctx)
}
@@ -1507,6 +1517,7 @@ func (o *Object) updateMultipart(ctx context.Context, in io.Reader, src fs.Objec
return fmt.Errorf("upload failed: no file info returned")
}
+ o.fs.clearNotFoundCache()
return o.setMetaData(session.fileInfo)
}
diff --git a/backend/kdrive/kdrive_internal_test.go b/backend/kdrive/kdrive_internal_test.go
deleted file mode 100644
index 6051f35e2..000000000
--- a/backend/kdrive/kdrive_internal_test.go
+++ /dev/null
@@ -1,340 +0,0 @@
-package kdrive
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "flag"
- "fmt"
- "os"
- "strings"
- "testing"
- "time"
-
- _ "github.com/rclone/rclone/backend/local"
- "github.com/rclone/rclone/fs"
- "github.com/rclone/rclone/fs/config"
- "github.com/rclone/rclone/fs/object"
- "github.com/rclone/rclone/fs/operations"
- "github.com/rclone/rclone/fs/sync"
- "github.com/rclone/rclone/fstest"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestMain(m *testing.M) {
- // Set individual mode to prevent automatic cleanup of entire remote
- *fstest.Individual = true
- // Parse flags first
- flag.Parse()
- // Initialise fstest (setup verbose logging, etc.)
- fstest.Initialise()
- // Run tests
- rc := m.Run()
- os.Exit(rc)
-}
-
-// setupTestFs creates an isolated test filesystem in a unique subdirectory
-// This prevents tests from deleting user's personal files
-func setupTestFs(t *testing.T) *Fs {
- ctx := context.Background()
- fs.GetConfig(ctx).LogLevel = fs.LogLevelDebug
-
- // Create a unique test directory name
- testDir := fmt.Sprintf("rclone-test-%d", time.Now().UnixNano())
-
- // Use -remote flag if provided, otherwise default to TestKdrive
- remoteName := *fstest.RemoteName
- if remoteName == "" {
- remoteName = "TestKdrive:"
- }
-
- // Check if root_folder_id is "1" in config, if so prepend Private/ to path
- remoteSection := strings.TrimSuffix(remoteName, ":")
- rootFolderID := config.GetValue(remoteSection, "root_folder_id")
- if rootFolderID == "1" {
- remoteName = remoteName + "Private/"
- }
-
- // Step 1: Create fs pointing to root of drive
- fRoot, err := fs.NewFs(ctx, remoteName)
- require.NoError(t, err, "Failed to create root fs")
-
- // Step 2: Create the test directory in the root fs
- err = fRoot.Mkdir(ctx, testDir)
- require.NoError(t, err, "Failed to create test directory")
-
- // Step 3: Create fs pointing specifically to the test subdirectory
- fTest, err := fs.NewFs(ctx, fmt.Sprintf("%s%s", remoteName, testDir))
- require.NoError(t, err, "Failed to create test fs")
-
- // Step 4: Cleanup - delete the test directory from the root fs
- t.Cleanup(func() {
- // Use Rmdir to delete the test directory and its contents
- err := operations.Purge(ctx, fRoot, testDir)
- if err != nil {
- t.Logf("Failed to remove test directory: %v", err)
- }
- })
-
- // Cast fTest to *Fs
- fKdrive, ok := fTest.(*Fs)
- require.True(t, ok, "Expected *Fs type")
-
- return fKdrive
-}
-
-// TestPutSmallFile tests the updateDirect path (file < uploadThreshold of 20MB)
-// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestPutSmallFile -verbose
-func TestPutSmallFile(t *testing.T) {
- ctx := context.Background()
- fRemote := setupTestFs(t)
-
- // File of 1KB
- size := int64(1024)
- data := make([]byte, size)
- _, err := rand.Read(data)
- require.NoError(t, err)
-
- remote := fmt.Sprintf("small-file-%d.bin", time.Now().UnixNano())
- src := object.NewStaticObjectInfo(remote, time.Now(), size, true, nil, fRemote)
-
- obj, err := fRemote.Put(ctx, bytes.NewReader(data), src)
- require.NoError(t, err)
- require.NotNil(t, obj)
-
- // Verify that the object exists
- obj2, err := fRemote.NewObject(ctx, remote)
- require.NoError(t, err)
- assert.Equal(t, size, obj2.Size())
- assert.Equal(t, remote, obj2.Remote())
-}
-
-// TestPutLargeFile tests the updateMultipart path (file > uploadThreshold of 20MB)
-// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestPutLargeFile -verbose
-func TestPutLargeFile(t *testing.T) {
- ctx := context.Background()
- fRemote := setupTestFs(t)
-
- // File of 50MB to force chunked mode
- size := int64(50 * 1024 * 1024)
- data := make([]byte, size)
- _, err := rand.Read(data)
- require.NoError(t, err)
-
- remote := fmt.Sprintf("large-file-%d.bin", time.Now().UnixNano())
- src := object.NewStaticObjectInfo(remote, time.Now(), size, true, nil, fRemote)
-
- obj, err := fRemote.Put(ctx, bytes.NewReader(data), src)
- require.NoError(t, err)
- require.NotNil(t, obj)
-
- // Verify that the object exists
- obj2, err := fRemote.NewObject(ctx, remote)
- require.NoError(t, err)
- assert.Equal(t, size, obj2.Size())
- assert.Equal(t, remote, obj2.Remote())
-
- // Test with ChunkSize option
- remoteChunksize := fmt.Sprintf("large-file-chunksize-%d.bin", time.Now().UnixNano())
- srcChunksize := object.NewStaticObjectInfo(remoteChunksize, time.Now(), size, true, nil, fRemote)
-
- size5MBs := 10 * 1024 * 1024
- objChunksize, err := fRemote.Put(ctx, bytes.NewReader(data), srcChunksize, &fs.ChunkOption{
- ChunkSize: int64(size5MBs),
- })
- require.NoError(t, err)
- require.NotNil(t, objChunksize)
-
- // Verify that the object exists
- objChunksize2, err := fRemote.NewObject(ctx, remoteChunksize)
- require.NoError(t, err)
- assert.Equal(t, size, objChunksize2.Size())
- assert.Equal(t, remoteChunksize, objChunksize2.Remote())
-}
-
-func prepareListing(t *testing.T) fs.Fs {
- ctx := context.Background()
-
- // Use the same isolated test fs setup
- fRemote := setupTestFs(t)
-
- // Copies the test/test-list folder to the remote (recursive)
- testDirPath := "./test/test-list"
- fLocal, err := fs.NewFs(ctx, testDirPath)
- require.NoError(t, err)
-
- err = sync.CopyDir(ctx, fRemote, fLocal, true)
- require.NoError(t, err)
-
- return fRemote
-}
-
-// TestListFiles test List without recursion
-// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestListFiles -verbose
-func TestListFiles(t *testing.T) {
- ctx := context.Background()
- fRemote := prepareListing(t)
-
- entries, err := fRemote.List(ctx, "")
- require.NoError(t, err)
-
- // Verify that we have listed the files/directories
- assert.NotEmpty(t, entries)
- assert.Len(t, entries, 3)
-
- var remoteList []string
- for _, item := range entries {
- fs.Debugf(nil, "Remote file : %s", item.Remote())
- remoteList = append(remoteList, item.Remote())
- }
-
- assert.Contains(t, remoteList, "test-list-subfolder")
- assert.Contains(t, remoteList, "test-list-file1.txt")
- assert.Contains(t, remoteList, "test-list-file2.txt")
- assert.NotContains(t, remoteList, "test-list-subfolder/test-list-subsubfolder")
-
- // List subfolder
- entriesSub, err := fRemote.List(ctx, "/test-list-subfolder")
- require.NoError(t, err)
-
- // Verify that we have listed the files/directories
- assert.NotEmpty(t, entriesSub)
- assert.Len(t, entriesSub, 2)
-
- var remoteListSub []string
- for _, item := range entriesSub {
- fs.Debugf(nil, "Remote file sub : %s", item.Remote())
- remoteListSub = append(remoteListSub, item.Remote())
- }
-
- assert.Contains(t, remoteListSub, "/test-list-subfolder/test-list-subsubfolder")
- assert.Contains(t, remoteListSub, "/test-list-subfolder/test-list-subfile.txt")
- assert.NotContains(t, remoteListSub, "test-list-file1.txt")
-}
-
-// TestListFiles test List with recursion
-// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestListFiles -verbose
-func TestListRecursive(t *testing.T) {
- ctx := context.Background()
- fRemote := prepareListing(t)
-
- if fRemote.Features().ListR == nil {
- t.Skip("ListR not supported")
- }
-
- var entries fs.DirEntries
- err := fRemote.Features().ListR(ctx, "", func(entry fs.DirEntries) error {
- entries = append(entries, entry...)
- return nil
- })
- require.NoError(t, err)
-
- // Verify that we have listed the files/directories
- assert.NotEmpty(t, entries)
- assert.Len(t, entries, 6)
-
- var remoteList []string
- for _, item := range entries {
- fs.Debugf(nil, "Remote file %s", item.Remote())
- remoteList = append(remoteList, item.Remote())
- }
-
- assert.Contains(t, remoteList, "test-list-subfolder")
- assert.Contains(t, remoteList, "test-list-file1.txt")
- assert.Contains(t, remoteList, "test-list-file2.txt")
- assert.Contains(t, remoteList, "test-list-subfolder/test-list-subfile.txt")
- assert.Contains(t, remoteList, "test-list-subfolder/test-list-subsubfolder")
- assert.Contains(t, remoteList, "test-list-subfolder/test-list-subsubfolder/test-list-subsubfile.txt")
-}
-
-// TestPublicLink tests the creation and deletion of public links
-// go test -v ./backend/kdrive/ -remote TestKdrive: -run TestPublicLink -verbose
-func TestPublicLink(t *testing.T) {
- ctx := context.Background()
- fRemote := setupTestFs(t)
-
- if fRemote.Features().PublicLink == nil {
- t.Skip("PublicLink not supported")
- }
-
- // Create a test file
- testContent := []byte("Test content for public link")
- testFile := fmt.Sprintf("test-link-file-%d.txt", time.Now().UnixNano())
- src := object.NewStaticObjectInfo(testFile, time.Now(), int64(len(testContent)), true, nil, fRemote)
-
- obj, err := fRemote.Put(ctx, bytes.NewReader(testContent), src)
- require.NoError(t, err, "Failed to create test file")
- require.NotNil(t, obj)
-
- testFile2 := fmt.Sprintf("test-link-file-%d.txt", time.Now().UnixNano())
- src = object.NewStaticObjectInfo(testFile2, time.Now(), int64(len(testContent)), true, nil, fRemote)
-
- obj, err = fRemote.Put(ctx, bytes.NewReader(testContent), src)
- require.NoError(t, err, "Failed to create test file")
- require.NotNil(t, obj)
-
- // Test 1: Create public link for file
- t.Run("Create link for file", func(t *testing.T) {
- link, err := fRemote.Features().PublicLink(ctx, testFile, 0, false)
- require.NoError(t, err)
- assert.NotEmpty(t, link)
- assert.Contains(t, link, "infomaniak")
- fs.Debugf(nil, "Created public link: %s", link)
- })
-
- // Test 2: Get existing link (should return the same link)
- t.Run("Get existing link for file", func(t *testing.T) {
- link, err := fRemote.Features().PublicLink(ctx, testFile, 0, false)
- require.NoError(t, err)
- assert.NotEmpty(t, link)
- fs.Debugf(nil, "Retrieved public link: %s", link)
- })
-
- // Test 3: Create public link with expiration
- t.Run("Create link with expiration", func(t *testing.T) {
- expire := fs.Duration(24 * time.Hour)
- link, err := fRemote.Features().PublicLink(ctx, testFile, expire, false)
- require.NoError(t, err)
- assert.NotEmpty(t, link)
- fs.Debugf(nil, "Created public link with expiration: %s", link)
- })
-
- // Test 4: Test with directory
- t.Run("Create link for directory", func(t *testing.T) {
- testDir := fmt.Sprintf("test-link-dir-%d", time.Now().UnixNano())
- err := fRemote.Mkdir(ctx, testDir)
- require.NoError(t, err)
-
- link, err := fRemote.Features().PublicLink(ctx, testDir, 0, false)
- require.NoError(t, err)
- assert.NotEmpty(t, link)
- assert.Contains(t, link, "infomaniak")
- fs.Debugf(nil, "Created public link for directory: %s", link)
-
- // Clean up the directory using Rmdir instead of dirCache
- err = fRemote.Rmdir(ctx, testDir)
- if err != nil {
- t.Logf("Warning: failed to remove test directory: %v", err)
- }
- })
-
- // Test 5: Remove public link
- t.Run("Remove public link", func(t *testing.T) {
- _, err := fRemote.Features().PublicLink(ctx, testFile, 0, true)
- require.NoError(t, err)
- fs.Debugf(nil, "Removed public link for: %s", testFile)
- })
-
- // Test 6: Try to remove link for non-existent file (should error)
- t.Run("Remove link for non-existent file fails", func(t *testing.T) {
- _, err := fRemote.Features().PublicLink(ctx, "non-existent-file.txt", 0, true)
- assert.Error(t, err)
- })
-
- // Test 7: Try to non existent link (should error)
- t.Run("Remove non-existent link fails", func(t *testing.T) {
- _, err := fRemote.Features().PublicLink(ctx, testFile2, 0, true)
- assert.Error(t, err)
- })
-}
--
2.51.1