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

openSUSE Build Service is sponsored by