File 8941.patch of Package rclone

From a5e9b0d0cae37a7aace9577f2203e8add2c3d68a Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sun, 2 Nov 2025 00:15:01 +0100
Subject: [PATCH 01/16] kdrive: initial commit for new backend

Signed-off-by: Christophe Chapuis <chris.chapuis@gmail.com>
---
 backend/all/all.go            |    1 +
 backend/kdrive/api/types.go   |  327 ++++++++
 backend/kdrive/kdrive.go      | 1361 +++++++++++++++++++++++++++++++++
 backend/kdrive/kdrive_test.go |   17 +
 backend/kdrive/writer_at.go   |  261 +++++++
 5 files changed, 1967 insertions(+)
 create mode 100644 backend/kdrive/api/types.go
 create mode 100644 backend/kdrive/kdrive.go
 create mode 100644 backend/kdrive/kdrive_test.go
 create mode 100644 backend/kdrive/writer_at.go

diff --git a/backend/all/all.go b/backend/all/all.go
index 7ad63c2712ce1..41ea0152fa9e9 100644
--- a/backend/all/all.go
+++ b/backend/all/all.go
@@ -35,6 +35,7 @@ import (
 	_ "github.com/rclone/rclone/backend/imagekit"
 	_ "github.com/rclone/rclone/backend/internetarchive"
 	_ "github.com/rclone/rclone/backend/jottacloud"
+	_ "github.com/rclone/rclone/backend/kdrive"
 	_ "github.com/rclone/rclone/backend/koofr"
 	_ "github.com/rclone/rclone/backend/linkbox"
 	_ "github.com/rclone/rclone/backend/local"
diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
new file mode 100644
index 0000000000000..76463fc060fbd
--- /dev/null
+++ b/backend/kdrive/api/types.go
@@ -0,0 +1,327 @@
+// Package api has type definitions for kdrive
+//
+// Converted from the API docs with help from https://mholt.github.io/json-to-go/
+package api
+
+import (
+	"fmt"
+	"strconv"
+	"time"
+)
+
+const (
+	// Sun, 16 Mar 2014 17:26:04 +0000
+	timeFormat = `"` + time.RFC1123Z + `"`
+)
+
+// Time represents date and time information for the
+// kdrive API, by using RFC1123Z
+type Time time.Time
+
+// MarshalJSON turns a Time into JSON (in UTC)
+func (t *Time) MarshalJSON() (out []byte, err error) {
+	timeString := (*time.Time)(t).Format(timeFormat)
+	return []byte(timeString), nil
+}
+
+// UnmarshalJSON turns JSON into a Time
+func (t *Time) UnmarshalJSON(data []byte) error {
+	if string(data) == "null" {
+		return nil
+	}
+
+	timestamp, err := strconv.ParseInt(string(data), 10, 64)
+	if err != nil {
+		return err
+	}
+	newT := time.Unix(timestamp, 0)
+	*t = Time(newT)
+	return nil
+}
+
+// Error is returned from kdrive when things go wrong
+//
+// If result is 0 then everything is OK
+type ResultStatus struct {
+	Status      string `json:"result"`
+	ErrorDetail struct {
+		Result      string `json:"code"`
+		ErrorString string `json:"description"`
+		Errors      []struct {
+			Result      string `json:"code"`
+			ErrorString string `json:"description"`
+		} `json:"errors"`
+	} `json:"error"`
+}
+
+// Error returns a string for the error and satisfies the error interface
+func (e *ResultStatus) Error() string {
+	var details string
+	for i := range e.ErrorDetail.Errors {
+		details += "|" + e.ErrorDetail.Errors[i].Result
+	}
+
+	return fmt.Sprintf("kDrive error: %s (%s: %s %s)", e.Status, e.ErrorDetail.Result, e.ErrorDetail.ErrorString, details)
+}
+
+// IsError returns true if there is an error
+func (e ResultStatus) IsError() bool {
+	return e.Status != "success"
+}
+
+// Update returns err directly if it was != nil, otherwise it returns
+// an Error or nil if no error was detected
+func (e *ResultStatus) Update(err error) error {
+	if err != nil {
+		return err
+	}
+	if e.IsError() {
+		return e
+	}
+	return nil
+}
+
+// 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
+	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"`
+	Name           string `json:"name"`
+	Type           string `json:"type"`
+	FullPath       string `json:"path"`
+	Status         string `json:"status"`
+	Size           int64  `json:"size"`
+	Visibility     string `json:"visibility"`
+	DriveID        int    `json:"drive_id"`
+	Depth          int    `json:"depth"`
+	CreatedBy      int    `json:"created_by"`
+	CreatedAt      Time   `json:"created_at"`
+	AddedAt        int    `json:"added_at"`
+	LastModifiedAt Time   `json:"last_modified_at"`
+	LastModifiedBy int    `json:"last_modified_by"`
+	RevisedAt      int    `json:"revised_at"`
+	UpdatedAt      int    `json:"updated_at"`
+	ParentID       int    `json:"parent_id"`
+	Color          string `json:"color"`
+}
+
+type SearchResult struct {
+	ResultStatus
+	Data       []Item `json:"data"`
+	Cursor     string `json:"cursor"`
+	HasMore    bool   `json:"has_more"`
+	ResponseAt int    `json:"response_at"`
+}
+
+// ModTime returns the modification time of the item
+func (i *Item) ModTime() (t time.Time) {
+	t = time.Time(i.LastModifiedAt)
+	if t.IsZero() {
+		t = time.Time(i.CreatedAt)
+	}
+	return t
+}
+
+type CancelResource struct {
+	CancelID   string `json:"cancel_id"`
+	ValidUntil int    `json:"valid_until"`
+}
+
+type CancellableResponse struct {
+	ResultStatus
+	Data CancelResource `json:"data"`
+}
+
+type CreateDirResult struct {
+	ResultStatus
+	Data Item `json:"data"`
+}
+
+type FileCopyResponse struct {
+	ResultStatus
+	Data Item `json:"data"`
+}
+
+// FileTruncateResponse is the response from /file_truncate
+type FileTruncateResponse struct {
+	ResultStatus
+}
+
+// FileCloseResponse is the response from /file_close
+type FileCloseResponse struct {
+	ResultStatus
+}
+
+// FileOpenResponse is the response from /file_open
+type FileOpenResponse struct {
+	ResultStatus
+	Fileid         int64 `json:"fileid"`
+	FileDescriptor int64 `json:"fd"`
+}
+
+// FileChecksumResponse is the response from /file_checksum
+type FileChecksumResponse struct {
+	ResultStatus
+	MD5    string `json:"md5"`
+	SHA1   string `json:"sha1"`
+	SHA256 string `json:"sha256"`
+}
+
+// FilePWriteResponse is the response from /file_pwrite
+type FilePWriteResponse struct {
+	ResultStatus
+	Bytes int64 `json:"bytes"`
+}
+
+// UploadFileResponse is the response from /uploadfile
+type UploadFileResponse struct {
+	ResultStatus
+	Data Item `json:"data"`
+}
+
+// ChecksumFileResult is returned from /checksumfile
+type ChecksumFileResult struct {
+	ResultStatus
+	Data struct {
+		Hash string `json:"hash"`
+	} `json:"data"`
+}
+
+// PubLinkResult is returned from /getfilepublink and /getfolderpublink
+type PubLinkResult struct {
+	ResultStatus
+	Data struct {
+		URL          string `json:"url"`
+		FileID       int    `json:"file_id"`
+		Right        string `json:"right"`
+		ValidUntil   int    `json:"valid_until"`
+		CreatedBy    int    `json:"created_by"`
+		CreatedAt    int    `json:"created_at"`
+		UpdatedAt    int    `json:"updated_at"`
+		Capabilities struct {
+			CanEdit          bool `json:"can_edit"`
+			CanSeeStats      bool `json:"can_see_stats"`
+			CanSeeInfo       bool `json:"can_see_info"`
+			CanDownload      bool `json:"can_download"`
+			CanComment       bool `json:"can_comment"`
+			CanRequestAccess bool `json:"can_request_access"`
+		} `json:"capabilities"`
+		AccessBlocked bool `json:"access_blocked"`
+	} `json:"data"`
+}
+
+type QuotaInfo struct {
+	ResultStatus
+	Data struct {
+		Size     int64 `json:"size"`
+		UsedSize int64 `json:"used_size"`
+	} `json:"data"`
+}
+
+// UserInfo is returned from /userinfo
+type UserInfo struct {
+	ResultStatus
+	Cryptosetup           bool   `json:"cryptosetup"`
+	Plan                  int    `json:"plan"`
+	CryptoSubscription    bool   `json:"cryptosubscription"`
+	PublicLinkQuota       int64  `json:"publiclinkquota"`
+	Email                 string `json:"email"`
+	UserID                int    `json:"userid"`
+	Quota                 int64  `json:"quota"`
+	TrashRevretentionDays int    `json:"trashrevretentiondays"`
+	Premium               bool   `json:"premium"`
+	PremiumLifetime       bool   `json:"premiumlifetime"`
+	EmailVerified         bool   `json:"emailverified"`
+	UsedQuota             int64  `json:"usedquota"`
+	Language              string `json:"language"`
+	Business              bool   `json:"business"`
+	CryptoLifetime        bool   `json:"cryptolifetime"`
+	Registered            string `json:"registered"`
+	Journey               struct {
+		Claimed bool `json:"claimed"`
+		Steps   struct {
+			VerifyMail    bool `json:"verifymail"`
+			UploadFile    bool `json:"uploadfile"`
+			AutoUpload    bool `json:"autoupload"`
+			DownloadApp   bool `json:"downloadapp"`
+			DownloadDrive bool `json:"downloaddrive"`
+		} `json:"steps"`
+	} `json:"journey"`
+}
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
new file mode 100644
index 0000000000000..c89e43246f189
--- /dev/null
+++ b/backend/kdrive/kdrive.go
@@ -0,0 +1,1361 @@
+// Package kdrive provides an interface to the kdrive
+// object storage system.
+package kdrive
+
+// FIXME cleanup returns login required?
+
+// FIXME mime type? Fix overview if implement.
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/rclone/rclone/backend/kdrive/api"
+	"github.com/rclone/rclone/fs"
+	"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/fserrors"
+	"github.com/rclone/rclone/fs/fshttp"
+	"github.com/rclone/rclone/fs/hash"
+	"github.com/rclone/rclone/fs/list"
+	"github.com/rclone/rclone/lib/dircache"
+	"github.com/rclone/rclone/lib/encoder"
+	"github.com/rclone/rclone/lib/oauthutil"
+	"github.com/rclone/rclone/lib/pacer"
+	"github.com/rclone/rclone/lib/rest"
+	"golang.org/x/oauth2"
+)
+
+const (
+	rcloneClientID              = "" // unused currently
+	rcloneEncryptedClientSecret = "" // unused currently
+	minSleep                    = 10 * time.Millisecond
+	maxSleep                    = 2 * time.Second
+	decayConstant               = 2 // bigger for slower decay, exponential
+)
+
+// Globals
+var (
+	// Description of how to auth for this app
+	oauthConfig = &oauthutil.Config{
+		Scopes:       []string{"user_info", "accounts", "drive"},
+		AuthURL:      "https://login.infomaniak.com/authorize",
+		TokenURL:     "https://login.infomaniak.com/token",
+		ClientID:     rcloneClientID,
+		ClientSecret: "", //obscure.MustReveal(rcloneEncryptedClientSecret),
+		RedirectURL:  oauthutil.RedirectLocalhostURL,
+	}
+)
+
+// Register with Fs
+func init() {
+	fs.Register(&fs.RegInfo{
+		Name:        "Kdrive",
+		Description: "Infomaniak's kDrive",
+		NewFs:       NewFs,
+		Config: func(ctx context.Context, name string, m configmap.Mapper, config fs.ConfigIn) (*fs.ConfigOut, error) {
+			optc := new(Options)
+			err := configstruct.Set(m, optc)
+			if err != nil {
+				fs.Errorf(nil, "Failed to read config: %v", err)
+			}
+			checkAuth := func(oauthConfig *oauthutil.Config, auth *oauthutil.AuthResult) error {
+				if auth == nil || auth.Form == nil {
+					return errors.New("form not found in response")
+				}
+				return nil
+			}
+			return oauthutil.ConfigOut("", &oauthutil.Options{
+				OAuth2Config: oauthConfig,
+				CheckAuth:    checkAuth,
+				StateBlankOK: true, // kdrive seems to drop the state parameter now - see #4210
+			})
+		},
+		Options: append(oauthutil.SharedOptions, []fs.Option{{
+			Name:     config.ConfigEncoding,
+			Help:     config.ConfigEncodingHelp,
+			Advanced: true,
+			// Encode invalid UTF-8 bytes as json doesn't handle them properly.
+			//
+			// TODO: Investigate Unicode simplification (\ gets converted to \ server-side)
+			Default: (encoder.Display |
+				encoder.EncodeBackSlash |
+				encoder.EncodeInvalidUtf8),
+		}, {
+			Name:    "account_id",
+			Help:    "Fill the account ID that is to be considered for this kdrive.",
+			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/...`,
+			Default: "",
+		}, {
+			Name:    "access_token",
+			Help:    `Access token generated in Infomaniak profile manager.`,
+			Default: "",
+		},
+		}...),
+	})
+}
+
+// Options defines the configuration for this backend
+type Options struct {
+	Enc         encoder.MultiEncoder `config:"encoding"`
+	AccountID   string               `config:"account_id"`
+	DriveID     string               `config:"drive_id"`
+	AccessToken string               `config:"access_token"`
+}
+
+// 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
+	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
+//
+// Will definitely have info but maybe not meta
+type Object struct {
+	fs          *Fs       // what this object is part of
+	remote      string    // The remote path
+	hasMetaData bool      // whether info below has been set
+	size        int64     // size of the object
+	modTime     time.Time // modification time of the object
+	id          string    // ID of the object
+	xxh3        string    // XXH3 if known
+}
+
+// ------------------------------------------------------------
+
+// Name of the remote (as passed into NewFs)
+func (f *Fs) Name() string {
+	return f.name
+}
+
+// Root of the remote (as passed into NewFs)
+func (f *Fs) Root() string {
+	return f.root
+}
+
+// String converts this Fs to a string
+func (f *Fs) String() string {
+	return fmt.Sprintf("kdrive root '%s'", f.root)
+}
+
+// Features returns the optional features of this Fs
+func (f *Fs) Features() *fs.Features {
+	return f.features
+}
+
+// parsePath parses a kdrive 'url'
+func parsePath(path string) (root string) {
+	root = strings.Trim(path, "/")
+	return
+}
+
+// retryErrorCodes is a slice of error codes that we will retry
+var retryErrorCodes = []int{
+	429, // Too Many Requests.
+	500, // Internal Server Error
+	502, // Bad Gateway
+	503, // Service Unavailable
+	504, // Gateway Timeout
+	509, // Bandwidth Limit Exceeded
+}
+
+// shouldRetry returns a boolean as to whether this resp and err
+// deserve to be retried.  It returns the err as a convenience
+func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
+	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
+}
+
+// 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)
+	if err != nil {
+		if err == fs.ErrorDirNotFound {
+			return nil, fs.ErrorObjectNotFound
+		}
+		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
+		}
+		return false
+	})
+	if err != nil {
+		return nil, err
+	}
+	if !found {
+		return nil, fs.ErrorObjectNotFound
+	}
+	return info, nil
+}
+
+// errorHandler parses a non 2xx error response into an error
+func errorHandler(resp *http.Response) error {
+	// Decode error response
+	errResponse := new(api.ResultStatus)
+	err := rest.DecodeJSON(resp, &errResponse)
+	if err != nil {
+		fs.Debugf(nil, "Couldn't decode error response: %v", err)
+	}
+	if errResponse.ErrorDetail.ErrorString == "" {
+		errResponse.ErrorDetail.ErrorString = resp.Status
+	}
+	if errResponse.ErrorDetail.Result == "" {
+		errResponse.ErrorDetail.Result = resp.Status
+	}
+	return errResponse
+}
+
+/*
+// unused for now
+func (f *Fs) retrieveDriveIdFromName(ctx context.Context, name string) (string, error) {
+	// step 1: retrieve user id
+	// fs.Debugf(f, "retrieveDriveIdFromName(%q)\n", name)
+	var resp *http.Response
+	var resultProfile api.Profile
+	var resultDrives api.ListDrives
+	var err error
+	opts := rest.Opts{
+		Method:     "GET",
+		Path:       "/profile",
+		Parameters: url.Values{},
+	}
+	err = f.pacer.Call(func() (bool, error) {
+		resp, err = f.srv.CallJSON(ctx, &opts, nil, &resultProfile)
+		err = resultProfile.Error.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		//fmt.Printf("...Error %v\n", err)
+		return "", err
+	}
+	// fmt.Printf("...Id %q\n", *info.Id)
+	userID := resultProfile.Data.UserID
+	// step 2: retrieve the drives of that user
+	opts = rest.Opts{
+		Method:     "GET",
+		Path:       fmt.Sprintf("/2/drive/users/%s/drives", userID),
+		Parameters: url.Values{},
+	}
+	opts.Parameters.Set("account_id", f.opt.AccountID)
+	err = f.pacer.Call(func() (bool, error) {
+		resp, err = f.srv.CallJSON(ctx, &opts, nil, &resultDrives)
+		err = resultDrives.Error.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		//fmt.Printf("...Error %v\n", err)
+		return "", err
+	}
+
+	return strconv.Itoa(resultDrives.Data[0].DriveID), nil
+}
+*/
+// 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
+	opt := new(Options)
+	err := configstruct.Set(m, opt)
+	if err != nil {
+		return nil, err
+	}
+	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)
+	//oAuthClient, ts, err := oauthutil.NewClient(ctx, name, m, oauthConfig)
+	//if err != nil {
+	//	return nil, fmt.Errorf("failed to configure kdrive: %w", err)
+	//}
+
+	f := &Fs{
+		name:  name,
+		root:  root,
+		opt:   *opt,
+		ts:    ts,
+		srv:   rest.NewClient(oAuthClient).SetRoot("https://api.infomaniak.com"),
+		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.features = (&fs.Features{
+		CaseInsensitive:         false,
+		CanHaveEmptyDirectories: true,
+		PartialUploads:          true,
+	}).Fill(ctx, f)
+	f.srv.SetErrorHandler(errorHandler)
+
+	// Renew the token in the background
+	/*
+		f.tokenRenewer = oauthutil.NewRenew(f.String(), f.ts, func() error {
+			_, err := f.readMetaDataForPath(ctx, "")
+			return err
+		})
+	*/
+
+	// Get rootFolderID
+	rootID := "1" // see https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
+	f.dirCache = dircache.New(root, rootID, f)
+
+	// Find the current root
+	err = f.dirCache.FindRoot(ctx, false)
+	if err != nil {
+		// Assume it is a file
+		newRoot, remote := dircache.SplitPath(root)
+		tempF := *f
+		tempF.dirCache = dircache.New(newRoot, rootID, &tempF)
+		tempF.root = newRoot
+		// Make new Fs which is the parent
+		err = tempF.dirCache.FindRoot(ctx, false)
+		if err != nil {
+			// No root so return old f
+			return f, nil
+		}
+		_, err := tempF.newObjectWithInfo(ctx, remote, nil)
+		if err != nil {
+			if err == fs.ErrorObjectNotFound {
+				// File doesn't exist so return old f
+				return f, nil
+			}
+			return nil, err
+		}
+		// XXX: update the old f here instead of returning tempF, since
+		// `features` were already filled with functions having *f as a receiver.
+		// See https://github.com/rclone/rclone/issues/2182
+		f.dirCache = tempF.dirCache
+		f.root = tempF.root
+		// return an error with an fs which points to the parent
+		return f, fs.ErrorIsFile
+	}
+	return f, nil
+}
+
+// XOpenWriterAt opens with a handle for random access writes
+//
+// Pass in the remote desired and the size if known.
+//
+// It truncates any existing object.
+//
+// OpenWriterAt disabled because it seems to have been disabled at kdrive
+// PUT /file_open?flags=XXX&folderid=XXX&name=XXX HTTP/1.1
+//
+//	{
+//	        "result": 2003,
+//	        "error": "Access denied. You do not have permissions to perform this operation."
+//	}
+func (f *Fs) XOpenWriterAt(ctx context.Context, remote string, size int64) (fs.WriterAtCloser, error) {
+	client, err := f.newSingleConnClient(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("create client: %w", err)
+	}
+	// init an empty file
+	leaf, directoryID, err := f.dirCache.FindPath(ctx, remote, true)
+	if err != nil {
+		return nil, fmt.Errorf("resolve src: %w", err)
+	}
+	openResult, err := fileOpenNew(ctx, client, f, directoryID, leaf)
+	if err != nil {
+		return nil, fmt.Errorf("open file: %w", err)
+	}
+	if _, err := fileClose(ctx, client, f.pacer, openResult.FileDescriptor); err != nil {
+		return nil, fmt.Errorf("close file: %w", err)
+	}
+
+	writer := &writerAt{
+		ctx:    ctx,
+		fs:     f,
+		size:   size,
+		remote: remote,
+		fileID: openResult.Fileid,
+	}
+
+	return writer, nil
+}
+
+// Create a new http client, accepting keep-alive headers, limited to single connection.
+// Necessary for kdrive fileops API, as it binds the session to the underlying TCP connection.
+// File descriptors are only valid within the same connection and auto-closed when the connection is closed,
+// hence we need a separate client (with single connection) for each fd to avoid all sorts of errors and race conditions.
+func (f *Fs) newSingleConnClient(ctx context.Context) (*rest.Client, error) {
+	baseClient := fshttp.NewClient(ctx)
+	baseClient.Transport = fshttp.NewTransportCustom(ctx, func(t *http.Transport) {
+		t.MaxConnsPerHost = 1
+		t.DisableKeepAlives = false
+	})
+	// Set our own http client in the context
+	ctx = oauthutil.Context(ctx, baseClient)
+	// create a new oauth client, reuse the token source
+	oAuthClient := oauth2.NewClient(ctx, f.ts)
+	return rest.NewClient(oAuthClient).SetRoot("https://api.infomaniak.com"), nil
+}
+
+// Return an Object from a path
+//
+// If it can't be found it returns the error fs.ErrorObjectNotFound.
+func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *api.Item) (fs.Object, error) {
+	o := &Object{
+		fs:     f,
+		remote: remote,
+	}
+	var err error
+	if info != nil {
+		// Set info
+		err = o.setMetaData(info)
+	} else {
+		err = o.readMetaData(ctx) // reads info and meta, returning an error
+	}
+	if err != nil {
+		return nil, err
+	}
+	return o, nil
+}
+
+// NewObject finds the Object at remote.  If it can't be found
+// it returns the error fs.ErrorObjectNotFound.
+func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
+	return f.newObjectWithInfo(ctx, remote, nil)
+}
+
+// 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) {
+	fs.Debugf(ctx, "FindLeaf: leaf=%s", leaf)
+	// 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
+		}
+		return false
+	})
+	return pathIDOut, found, err
+}
+
+// CreateDir makes a directory with pathID as parent and name leaf
+func (f *Fs) CreateDir(ctx context.Context, pathID, leaf string) (newID string, err error) {
+	// fs.Debugf(f, "CreateDir(%q, %q)\n", pathID, leaf)
+	var resp *http.Response
+	var result api.CreateDirResult
+	opts := rest.Opts{
+		Method:     "POST",
+		Path:       fmt.Sprintf("/3/drive/%s/files/%s/directory", f.opt.DriveID, pathID),
+		Parameters: url.Values{},
+	}
+	opts.Parameters.Set("name", f.opt.Enc.FromStandardName(leaf))
+	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 {
+		//fmt.Printf("...Error %v\n", err)
+		return "", err
+	}
+	// fmt.Printf("...Id %q\n", *info.Id)
+	return strconv.Itoa(result.Data.ID), nil
+}
+
+// list the objects into the function supplied
+//
+// If directories is set it only sends directories
+// User function to process a File item from listAll
+//
+// Should return true to finish processing
+type listAllFn func(*api.Item) bool
+
+// 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
+func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, filesOnly bool, recursive bool, fn listAllFn) (found bool, err error) {
+	fs.Debugf(ctx, "Entering listAll")
+	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("with", "path")
+		if len(fromCursor) > 0 {
+			opts.Parameters.Set("cursor", fromCursor)
+		}
+
+		var result api.SearchResult
+		var resp *http.Response
+		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 {
+			return result, fmt.Errorf("couldn't list files: %w", err)
+		}
+		return result, nil
+	}
+
+	var recursiveContents func(currentDirID string, fromCursor string)
+	recursiveContents = func(currentDirID string, fromCursor string) {
+		result, err := listSomeFiles(currentDirID, fromCursor)
+		if err != nil {
+			return
+		}
+
+		// First, analyze what has been returned, and go in-depth if required
+		for i := range result.Data {
+			item := &result.Data[i]
+			if item.Type == "dir" {
+				if filesOnly {
+					continue
+				}
+			} else {
+				if directoriesOnly {
+					continue
+				}
+			}
+			if fn(item) {
+				found = true
+				break
+			}
+			if recursive && item.Type == "dir" {
+				recursiveContents(strconv.Itoa(item.ID), "" /*reset cursor*/)
+			}
+		}
+
+		// Then load the rest of the files in that folder and apply the same logic
+		if result.HasMore {
+			recursiveContents(currentDirID, result.Cursor)
+		}
+	}
+	recursiveContents(dirID, "")
+	return
+}
+
+// listHelper iterates over all items from the directory
+// and calls the callback for each element.
+func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callback func(entries fs.DirEntry) error) (err error) {
+	directoryID, err := f.dirCache.FindDir(ctx, dir, false)
+	fs.Debugf(ctx, "listHelper: root=%s dir=%s directoryID=%s", f.root, dir, directoryID)
+	if err != nil {
+		return err
+	}
+	var iErr error
+	_, err = f.listAll(ctx, directoryID, false, false, recursive, func(info *api.Item) bool {
+		fs.Debugf(ctx, "listHelper: trimming /%s out of %s", f.root, info.FullPath)
+		remote := parsePath(strings.TrimPrefix(info.FullPath, "/"+f.root))
+		if info.Type == "dir" {
+			// cache the directory ID for later lookups
+			fs.Debugf(ctx, "listAll: caching %s as %s", remote, strconv.Itoa(info.ID))
+			f.dirCache.Put(remote, strconv.Itoa(info.ID))
+
+			d := fs.NewDir(remote, info.ModTime()).SetID(strconv.Itoa(info.ID))
+			// FIXME more info from dir?
+			iErr = callback(d)
+		} else {
+			o, err := f.newObjectWithInfo(ctx, remote, info)
+			if err != nil {
+				iErr = err
+				return true
+			}
+			iErr = callback(o)
+		}
+		if iErr != nil {
+			return true
+		}
+		return false
+	})
+	if err != nil {
+		return err
+	}
+	if iErr != nil {
+		return iErr
+	}
+	return nil
+}
+
+// List the objects and directories in dir into entries.  The
+// entries can be returned in any order but should be for a
+// complete directory.
+//
+// dir should be "" to list the root, and should not have
+// trailing slashes.
+//
+// This should return ErrDirNotFound if the directory isn't
+// found.
+func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
+	return list.WithListP(ctx, dir, f)
+}
+
+// ListP lists the objects and directories of the Fs starting
+// from dir non recursively into out.
+//
+// dir should be "" to start from the root, and should not
+// have trailing slashes.
+//
+// This should return ErrDirNotFound if the directory isn't
+// found.
+//
+// It should call callback for each tranche of entries read.
+// These need not be returned in any particular order.  If
+// 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)
+	err = f.listHelper(ctx, dir, false, func(o fs.DirEntry) error {
+		return list.Add(o)
+	})
+	if err != nil {
+		return err
+	}
+	return list.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)
+	err = f.listHelper(ctx, dir, true, func(o fs.DirEntry) error {
+		return list.Add(o)
+	})
+	if err != nil {
+		return err
+	}
+	return list.Flush()
+}
+
+// Creates from the parameters passed in a half finished Object which
+// must have setMetaData called on it
+//
+// Returns the object, leaf, directoryID and error.
+//
+// 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)
+	leaf, directoryID, err = f.dirCache.FindPath(ctx, remote, true)
+	if err != nil {
+		return
+	}
+	// Temporary Object under construction
+	o = &Object{
+		fs:     f,
+		remote: remote,
+	}
+	return o, leaf, directoryID, nil
+}
+
+// Put the object into the container
+//
+// Copy the reader in to the new object which is returned.
+//
+// The new object may have been created if an error is returned
+func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
+	remote := src.Remote()
+	size := src.Size()
+	modTime := src.ModTime(ctx)
+
+	o, _, _, err := f.createObject(ctx, remote, modTime, size)
+	if err != nil {
+		return nil, err
+	}
+	return o, o.Update(ctx, in, src, options...)
+}
+
+// Mkdir creates the container if it doesn't exist
+func (f *Fs) Mkdir(ctx context.Context, dir string) error {
+	_, err := f.dirCache.FindDir(ctx, dir, true)
+	return err
+}
+
+// purgeCheck removes the root directory, if check is set then it
+// refuses to do so if it has anything in
+func (f *Fs) purgeCheck(ctx context.Context, dir string, check bool) error {
+	root := path.Join(f.root, dir)
+	if root == "" {
+		return errors.New("can't purge root directory")
+	}
+	dc := f.dirCache
+	rootID, err := dc.FindDir(ctx, dir, false)
+	if err != nil {
+		return err
+	}
+
+	opts := rest.Opts{
+		Method:     "DELETE",
+		Path:       fmt.Sprintf("/2/drive/%s/files/%s", f.opt.DriveID, rootID),
+		Parameters: url.Values{},
+	}
+	var resp *http.Response
+	var result api.CancellableResponse
+	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 {
+		return fmt.Errorf("rmdir failed: %w", err)
+	}
+	f.dirCache.FlushDir(dir)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// Rmdir deletes the root folder
+//
+// Returns an error if it isn't empty
+func (f *Fs) Rmdir(ctx context.Context, dir string) error {
+	return f.purgeCheck(ctx, dir, true)
+}
+
+// Precision return the precision of this Fs
+func (f *Fs) Precision() time.Duration {
+	return time.Second
+}
+
+// Copy src to this remote using server-side copy operations.
+//
+// This is stored with the remote path given.
+//
+// It returns the destination Object and a possible error.
+//
+// Will only be called if src.Fs().Name() == f.Name()
+//
+// If it isn't possible then return fs.ErrorCantCopy
+func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
+	srcObj, ok := src.(*Object)
+	if !ok {
+		fs.Debugf(src, "Can't copy - not same remote type")
+		return nil, fs.ErrorCantCopy
+	}
+	err := srcObj.readMetaData(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	// Create temporary object
+	dstObj, leaf, directoryID, err := f.createObject(ctx, remote, srcObj.modTime, srcObj.size)
+	if err != nil {
+		return nil, err
+	}
+
+	// Copy the object
+	opts := rest.Opts{
+		Method:     "POST",
+		Path:       fmt.Sprintf("/3/drive/%s/files/%s/copy/%s", f.opt.DriveID, srcObj.id, directoryID),
+		Parameters: url.Values{},
+	}
+	opts.Parameters.Set("name", f.opt.Enc.FromStandardName(leaf))
+	//opts.Parameters.Set("mtime", fmt.Sprintf("%d", uint64(srcObj.modTime.Unix())))
+	var resp *http.Response
+	var result api.FileCopyResponse
+	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 {
+		return nil, err
+	}
+	err = dstObj.setMetaData(&result.Data)
+	if err != nil {
+		return nil, err
+	}
+	return dstObj, nil
+}
+
+// Purge deletes all the files in the directory
+//
+// Optional interface: Only implement this if you have a way of
+// deleting all the files quicker than just running Remove() on the
+// result of List()
+func (f *Fs) Purge(ctx context.Context, dir string) error {
+	return f.purgeCheck(ctx, dir, false)
+}
+
+// CleanUp empties the trash
+func (f *Fs) CleanUp(ctx context.Context) error {
+	opts := rest.Opts{
+		Method:     "DELETE",
+		Path:       fmt.Sprintf("/2/drive/%s/trash", f.opt.DriveID),
+		Parameters: url.Values{},
+	}
+	var resp *http.Response
+	var result api.ResultStatus
+	var err error
+	return f.pacer.Call(func() (bool, error) {
+		resp, err = f.cleanupSrv.CallJSON(ctx, &opts, nil, &result)
+		err = result.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+}
+
+// Move src to this remote using server-side move operations.
+//
+// This is stored with the remote path given.
+//
+// It returns the destination Object and a possible error.
+//
+// Will only be called if src.Fs().Name() == f.Name()
+//
+// If it isn't possible then return fs.ErrorCantMove
+func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
+	srcObj, ok := src.(*Object)
+	if !ok {
+		fs.Debugf(src, "Can't move - not same remote type")
+		return nil, fs.ErrorCantMove
+	}
+
+	// Create temporary object
+	_, leaf, directoryID, err := f.createObject(ctx, remote, srcObj.modTime, srcObj.size)
+	if err != nil {
+		return nil, err
+	}
+
+	// Do the move
+	opts := rest.Opts{
+		Method:     "POST",
+		Path:       fmt.Sprintf("/3/drive/%s/files/%s/move/%s", f.opt.DriveID, srcObj.id, directoryID),
+		Parameters: url.Values{},
+	}
+	opts.Parameters.Set("name", f.opt.Enc.FromStandardName(leaf))
+	var resp *http.Response
+	var result api.CancellableResponse
+	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 {
+		return nil, err
+	}
+
+	dstObj, err := f.NewObject(ctx, remote)
+	if err != nil {
+		return nil, err
+	}
+	return dstObj, nil
+}
+
+// DirMove moves src, srcRemote to this remote at dstRemote
+// using server-side move operations.
+//
+// Will only be called if src.Fs().Name() == f.Name()
+//
+// If it isn't possible then return fs.ErrorCantDirMove
+//
+// If destination exists then return fs.ErrorDirExists
+func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error {
+	srcFs, ok := src.(*Fs)
+	if !ok {
+		fs.Debugf(srcFs, "Can't move directory - not same remote type")
+		return fs.ErrorCantDirMove
+	}
+
+	srcID, _, _, dstDirectoryID, dstLeaf, err := f.dirCache.DirMove(ctx, srcFs.dirCache, srcFs.root, srcRemote, f.root, dstRemote)
+	if err != nil {
+		return err
+	}
+
+	// Do the move
+	opts := rest.Opts{
+		Method:     "POST",
+		Path:       fmt.Sprintf("/3/drive/%s/files/%s/move/%s", f.opt.DriveID, srcID, dstDirectoryID),
+		Parameters: url.Values{},
+	}
+	opts.Parameters.Set("name", f.opt.Enc.FromStandardName(dstLeaf))
+	var resp *http.Response
+	var result api.CancellableResponse
+	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 {
+		return err
+	}
+
+	srcFs.dirCache.FlushDir(srcRemote)
+	return nil
+}
+
+// DirCacheFlush resets the directory cache - used in testing as an
+// optional interface
+func (f *Fs) DirCacheFlush() {
+	f.dirCache.ResetRoot()
+}
+
+func (f *Fs) linkDir(ctx context.Context, dirID string, expire fs.Duration) (string, error) {
+	opts := rest.Opts{
+		Method:     "GET",
+		Path:       fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, dirID),
+		Parameters: url.Values{},
+	}
+	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)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return "", err
+	}
+	return result.Data.URL, err
+}
+
+func (f *Fs) linkFile(ctx context.Context, path string, expire fs.Duration) (string, error) {
+	obj, err := f.NewObject(ctx, path)
+	if err != nil {
+		return "", err
+	}
+	o := obj.(*Object)
+	opts := rest.Opts{
+		Method:     "GET",
+		Path:       fmt.Sprintf("/2/drive/%s/files/%s/link", f.opt.DriveID, o.id),
+		Parameters: url.Values{},
+	}
+	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)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return "", err
+	}
+	return result.Data.URL, nil
+}
+
+// PublicLink adds a "readable by anyone with link" permission on the given file or folder.
+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)
+	}
+	if err != nil {
+		return "", err
+	}
+	return f.linkDir(ctx, dirID, expire)
+}
+
+// About gets quota information
+func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
+	opts := rest.Opts{
+		Method:     "GET",
+		Path:       fmt.Sprintf("/2/drive/%s", f.opt.DriveID),
+		Parameters: url.Values{},
+	}
+	opts.Parameters.Set("only", f.opt.Enc.FromStandardName("used_size,size"))
+	var resp *http.Response
+	var q api.QuotaInfo
+	err = f.pacer.Call(func() (bool, error) {
+		resp, err = f.srv.CallJSON(ctx, &opts, nil, &q)
+		err = q.ResultStatus.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return nil, err
+	}
+	free := max(q.Data.Size-q.Data.UsedSize, 0)
+	usage = &fs.Usage{
+		Total: fs.NewUsageValue(q.Data.Size),     // quota of bytes that can be used
+		Used:  fs.NewUsageValue(q.Data.UsedSize), // bytes in use
+		Free:  fs.NewUsageValue(free),            // bytes which can be uploaded before reaching the quota
+	}
+	return usage, nil
+}
+
+// Shutdown shutdown the fs
+func (f *Fs) Shutdown(ctx context.Context) error {
+	//f.tokenRenewer.Shutdown()
+	return nil
+}
+
+// Hashes returns the supported hash sets.
+func (f *Fs) Hashes() hash.Set {
+	return hash.Set(hash.XXH3)
+}
+
+// ------------------------------------------------------------
+
+// Fs returns the parent Fs
+func (o *Object) Fs() fs.Info {
+	return o.fs
+}
+
+// Return a string version
+func (o *Object) String() string {
+	if o == nil {
+		return "<nil>"
+	}
+	return o.remote
+}
+
+// Remote returns the remote path
+func (o *Object) Remote() string {
+	return o.remote
+}
+
+// getHashes fetches the hashes into the object
+func (o *Object) retrieveHash(ctx context.Context) (err error) {
+	var resp *http.Response
+	var result api.ChecksumFileResult
+
+	opts := rest.Opts{
+		Method:     "GET",
+		Path:       fmt.Sprintf("/2/drive/%s/files/%s/hash", o.fs.opt.DriveID, o.id),
+		Parameters: url.Values{},
+	}
+	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)
+	})
+	if err != nil {
+		return err
+	}
+	o.setHash(strings.TrimPrefix(result.Data.Hash, "xxh3:"))
+	return nil
+}
+
+// Hash returns the SHA-1 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 {
+	case hash.XXH3:
+		pHash = &o.xxh3
+	default:
+		return "", hash.ErrUnsupported
+	}
+	if o.xxh3 == "" {
+		err := o.retrieveHash(ctx)
+		if err != nil {
+			return "", fmt.Errorf("failed to get hash: %w", err)
+		}
+	}
+	return *pHash, nil
+}
+
+// Size returns the size of an object in bytes
+func (o *Object) Size() int64 {
+	err := o.readMetaData(context.TODO())
+	if err != nil {
+		fs.Logf(o, "Failed to read metadata: %v", err)
+		return 0
+	}
+	return o.size
+}
+
+// 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)
+	}
+	o.hasMetaData = true
+	o.size = info.Size
+	o.modTime = info.ModTime()
+	o.id = strconv.Itoa(info.ID)
+	return nil
+}
+
+// setHash sets the hashes from that passed in
+func (o *Object) setHash(hash string) {
+	o.xxh3 = hash
+}
+
+// readMetaData gets the metadata if it hasn't already been fetched
+//
+// it also sets the info
+func (o *Object) readMetaData(ctx context.Context) (err error) {
+	if o.hasMetaData {
+		return nil
+	}
+	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)
+}
+
+// ModTime returns the modification time of the object
+//
+// It attempts to read the objects mtime and if that isn't present the
+// LastModified returned in the http headers
+func (o *Object) ModTime(ctx context.Context) time.Time {
+	err := o.readMetaData(ctx)
+	if err != nil {
+		fs.Logf(o, "Failed to read metadata: %v", err)
+		return time.Now()
+	}
+	return o.modTime
+}
+
+// SetModTime sets the modification time of the local fs object
+func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
+	/*
+		// TOFE: there isn't currently any way of setting mtime on the remote !
+		filename, directoryID, err := o.fs.dirCache.FindPath(ctx, o.Remote(), true)
+		if err != nil {
+			return err
+		}
+
+		fileID := o.id
+		filename = o.fs.opt.Enc.FromStandardName(filename)
+		opts := rest.Opts{
+			Method:           "PUT",
+			Path:             "/copyfile",
+			Parameters:       url.Values{},
+			TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
+			ExtraHeaders: map[string]string{
+				"Connection": "keep-alive",
+			},
+		}
+		opts.Parameters.Set("fileid", fileID)
+		opts.Parameters.Set("folderid", dirIDtoNumber(directoryID))
+		opts.Parameters.Set("toname", filename)
+		opts.Parameters.Set("tofolderid", dirIDtoNumber(directoryID))
+		opts.Parameters.Set("ctime", strconv.FormatInt(modTime.Unix(), 10))
+		opts.Parameters.Set("mtime", strconv.FormatInt(modTime.Unix(), 10))
+
+		result := &api.ItemResult{}
+		err = o.fs.pacer.CallNoRetry(func() (bool, error) {
+			resp, err := o.fs.srv.CallJSON(ctx, &opts, nil, result)
+			err = result.Error.Update(err)
+			return shouldRetry(ctx, resp, err)
+		})
+		if err != nil {
+			return fmt.Errorf("update mtime: copyfile: %w", err)
+		}
+		if err := o.setMetaData(&result.Metadata); err != nil {
+			return err
+		}
+	*/
+
+	return nil
+}
+
+// Storable returns a boolean showing whether this object storable
+func (o *Object) Storable() bool {
+	return true
+}
+
+// downloadURL fetches the download link
+func (o *Object) downloadURL(ctx context.Context) (URL string, err error) {
+	if o.id == "" {
+		return "", errors.New("can't download - no id")
+	}
+	var resp *http.Response
+	var result api.PubLinkResult
+	opts := rest.Opts{
+		Method:     "GET",
+		Path:       fmt.Sprintf("/2/drive/%s/files/%s/link", o.fs.opt.DriveID, o.id),
+		Parameters: url.Values{},
+	}
+	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)
+	})
+	if err != nil {
+		return "", err
+	}
+	return result.Data.URL, nil
+}
+
+// Open an object for read
+func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) {
+	url, err := o.downloadURL(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var resp *http.Response
+	opts := rest.Opts{
+		Method:  "GET",
+		RootURL: url,
+		Options: options,
+	}
+	err = o.fs.pacer.Call(func() (bool, error) {
+		resp, err = o.fs.srv.Call(ctx, &opts)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return nil, err
+	}
+	return resp.Body, err
+}
+
+// Update the object with the contents of the io.Reader, modTime and size
+//
+// If existing is set then it updates the object rather than creating a new one.
+//
+// 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) {
+	//o.fs.tokenRenewer.Start()
+	//defer o.fs.tokenRenewer.Stop()
+
+	size := src.Size() // NB can upload without size
+	modTime := src.ModTime(ctx)
+	remote := o.Remote()
+
+	if size < 0 {
+		return errors.New("can't upload unknown sizes objects")
+	}
+
+	// Create the directory for the object if it doesn't exist
+	leaf, directoryID, err := o.fs.dirCache.FindPath(ctx, remote, true)
+	if err != nil {
+		return err
+	}
+
+	// This API doesn't support chunk uploads, so it's just for now
+	var resp *http.Response
+	var result api.UploadFileResponse
+	opts := rest.Opts{
+		Method:           "POST",
+		Path:             fmt.Sprintf("/3/drive/%s/upload", o.fs.opt.DriveID),
+		Body:             in,
+		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("created_at", fmt.Sprintf("%d", uint64(modTime.Unix())))
+
+	// Special treatment for a 0 length upload.  This doesn't work
+	// with PUT even with Content-Length set (by setting
+	// opts.Body=0), so upload it as a multipart form POST with
+	// Content-Length set.
+	if size == 0 {
+		formReader, contentType, overhead, err := rest.MultipartUpload(ctx, in, opts.Parameters, "content", leaf)
+		if err != nil {
+			return fmt.Errorf("failed to make multipart upload for 0 length file: %w", err)
+		}
+
+		contentLength := overhead + size
+
+		opts.ContentType = contentType
+		opts.Body = formReader
+		opts.Method = "POST"
+		opts.Parameters = nil
+		opts.ContentLength = &contentLength
+	}
+
+	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)
+	})
+	if err != nil {
+		// sometimes kdrive leaves 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)
+	}
+
+	return o.readMetaData(ctx)
+}
+
+// Remove an object
+func (o *Object) Remove(ctx context.Context) error {
+	opts := rest.Opts{
+		Method:     "DELETE",
+		Path:       fmt.Sprintf("/2/drive/%s/files/%s", o.fs.opt.DriveID, o.id),
+		Parameters: url.Values{},
+	}
+	var result api.CancellableResponse
+	return 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)
+	})
+}
+
+// ID returns the ID of the Object if known, or "" if not
+func (o *Object) ID() string {
+	return o.id
+}
+
+// Check the interfaces are satisfied
+var (
+	_ fs.Fs              = (*Fs)(nil)
+	_ fs.Purger          = (*Fs)(nil)
+	_ fs.CleanUpper      = (*Fs)(nil)
+	_ fs.Copier          = (*Fs)(nil)
+	_ fs.Mover           = (*Fs)(nil)
+	_ fs.DirMover        = (*Fs)(nil)
+	_ fs.DirCacheFlusher = (*Fs)(nil)
+	_ fs.PublicLinker    = (*Fs)(nil)
+	_ fs.ListRer         = (*Fs)(nil)
+	_ fs.ListPer         = (*Fs)(nil)
+	_ fs.Abouter         = (*Fs)(nil)
+	_ fs.Shutdowner      = (*Fs)(nil)
+	_ fs.Object          = (*Object)(nil)
+	_ fs.IDer            = (*Object)(nil)
+)
diff --git a/backend/kdrive/kdrive_test.go b/backend/kdrive/kdrive_test.go
new file mode 100644
index 0000000000000..13cff12c3a698
--- /dev/null
+++ b/backend/kdrive/kdrive_test.go
@@ -0,0 +1,17 @@
+// Test kdrive filesystem interface
+package kdrive_test
+
+import (
+	"testing"
+
+	"github.com/rclone/rclone/backend/kdrive"
+	"github.com/rclone/rclone/fstest/fstests"
+)
+
+// TestIntegration runs integration tests against the remote
+func TestIntegration(t *testing.T) {
+	fstests.Run(t, &fstests.Opt{
+		RemoteName: "TestKdrive:",
+		NilObject:  (*kdrive.Object)(nil),
+	})
+}
diff --git a/backend/kdrive/writer_at.go b/backend/kdrive/writer_at.go
new file mode 100644
index 0000000000000..54cc686098e9e
--- /dev/null
+++ b/backend/kdrive/writer_at.go
@@ -0,0 +1,261 @@
+package kdrive
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha1"
+	"encoding/hex"
+	"fmt"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/rclone/rclone/backend/kdrive/api"
+	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/lib/rest"
+)
+
+// writerAt implements fs.WriterAtCloser, adding the OpenWrtierAt feature to kdrive.
+type writerAt struct {
+	ctx    context.Context
+	fs     *Fs
+	size   int64
+	remote string
+	fileID int64
+}
+
+// Close implements WriterAt.Close.
+func (c *writerAt) Close() error {
+	// Avoiding race conditions: Depending on the tcp connection, there might be
+	// caching issues when checking the size immediately after write.
+	// Hence we try avoiding them by checking the resulting size on a different connection.
+	if c.size < 0 {
+		// Without knowing the size, we cannot do size checks.
+		// Falling back to a sleep of 1s for sake of hope.
+		time.Sleep(1 * time.Second)
+		return nil
+	}
+	sizeOk := false
+	sizeLastSeen := int64(0)
+	for retry := range 5 {
+		fs.Debugf(c.remote, "checking file size: try %d/5", retry)
+		obj, err := c.fs.NewObject(c.ctx, c.remote)
+		if err != nil {
+			return fmt.Errorf("get uploaded obj: %w", err)
+		}
+		sizeLastSeen = obj.Size()
+		if obj.Size() == c.size {
+			sizeOk = true
+			break
+		}
+		time.Sleep(1 * time.Second)
+	}
+
+	if !sizeOk {
+		return fmt.Errorf("incorrect size after upload: got %d, want %d", sizeLastSeen, c.size)
+	}
+
+	return nil
+}
+
+// WriteAt implements fs.WriteAt.
+func (c *writerAt) WriteAt(buffer []byte, offset int64) (n int, err error) {
+	contentLength := len(buffer)
+
+	inSHA1Bytes := sha1.Sum(buffer)
+	inSHA1 := hex.EncodeToString(inSHA1Bytes[:])
+
+	client, err := c.fs.newSingleConnClient(c.ctx)
+	if err != nil {
+		return 0, fmt.Errorf("create client: %w", err)
+	}
+
+	openResult, err := fileOpen(c.ctx, client, c.fs, c.fileID)
+	if err != nil {
+		return 0, fmt.Errorf("open file: %w", err)
+	}
+
+	// get target hash
+	outChecksum, err := fileChecksum(c.ctx, client, c.fs.pacer, openResult.FileDescriptor, offset, int64(contentLength))
+	if err != nil {
+		return 0, err
+	}
+	outSHA1 := outChecksum.SHA1
+
+	if outSHA1 == "" || inSHA1 == "" {
+		return 0, fmt.Errorf("expect both hashes to be filled: src: %q, target: %q", inSHA1, outSHA1)
+	}
+
+	// check hash of buffer, skip if fits
+	if inSHA1 == outSHA1 {
+		return contentLength, nil
+	}
+
+	// upload buffer with offset if necessary
+	if _, err := filePWrite(c.ctx, client, c.fs.pacer, openResult.FileDescriptor, offset, buffer); err != nil {
+		return 0, err
+	}
+
+	// close fd
+	if _, err := fileClose(c.ctx, client, c.fs.pacer, openResult.FileDescriptor); err != nil {
+		return contentLength, fmt.Errorf("close fd: %w", err)
+	}
+
+	return contentLength, nil
+}
+
+// Call kdrive file_open using folderid and name with O_CREAT and O_WRITE flags, see [API Doc.]
+// [API Doc]: https://docs.kdrive.com/methods/fileops/file_open.html
+func fileOpenNew(ctx context.Context, c *rest.Client, srcFs *Fs, directoryID, filename string) (*api.FileOpenResponse, error) {
+	opts := rest.Opts{
+		Method:           "PUT",
+		Path:             "/file_open",
+		Parameters:       url.Values{},
+		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
+		ExtraHeaders: map[string]string{
+			"Connection": "keep-alive",
+		},
+	}
+	filename = srcFs.opt.Enc.FromStandardName(filename)
+	opts.Parameters.Set("name", filename)
+	opts.Parameters.Set("folderid", directoryID)
+	opts.Parameters.Set("flags", "0x0042") // O_CREAT, O_WRITE
+
+	result := &api.FileOpenResponse{}
+	err := srcFs.pacer.CallNoRetry(func() (bool, error) {
+		resp, err := c.CallJSON(ctx, &opts, nil, result)
+		err = result.ResultStatus.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return nil, fmt.Errorf("open new file descriptor: %w", err)
+	}
+	return result, nil
+}
+
+// Call kdrive file_open using fileid with O_WRITE flags, see [API Doc.]
+// [API Doc]: https://docs.kdrive.com/methods/fileops/file_open.html
+func fileOpen(ctx context.Context, c *rest.Client, srcFs *Fs, fileID int64) (*api.FileOpenResponse, error) {
+	opts := rest.Opts{
+		Method:           "PUT",
+		Path:             "/file_open",
+		Parameters:       url.Values{},
+		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
+		ExtraHeaders: map[string]string{
+			"Connection": "keep-alive",
+		},
+	}
+	opts.Parameters.Set("fileid", strconv.FormatInt(fileID, 10))
+	opts.Parameters.Set("flags", "0x0002") // O_WRITE
+
+	result := &api.FileOpenResponse{}
+	err := srcFs.pacer.CallNoRetry(func() (bool, error) {
+		resp, err := c.CallJSON(ctx, &opts, nil, result)
+		err = result.ResultStatus.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return nil, fmt.Errorf("open new file descriptor: %w", err)
+	}
+	return result, nil
+}
+
+// Call kdrive file_checksum, see [API Doc.]
+// [API Doc]: https://docs.kdrive.com/methods/fileops/file_checksum.html
+func fileChecksum(
+	ctx context.Context,
+	client *rest.Client,
+	pacer *fs.Pacer,
+	fd, offset, count int64,
+) (*api.FileChecksumResponse, error) {
+	opts := rest.Opts{
+		Method:           "PUT",
+		Path:             "/file_checksum",
+		Parameters:       url.Values{},
+		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
+		ExtraHeaders: map[string]string{
+			"Connection": "keep-alive",
+		},
+	}
+	opts.Parameters.Set("fd", strconv.FormatInt(fd, 10))
+	opts.Parameters.Set("offset", strconv.FormatInt(offset, 10))
+	opts.Parameters.Set("count", strconv.FormatInt(count, 10))
+
+	result := &api.FileChecksumResponse{}
+	err := pacer.CallNoRetry(func() (bool, error) {
+		resp, err := client.CallJSON(ctx, &opts, nil, result)
+		err = result.ResultStatus.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return nil, fmt.Errorf("checksum of fd %d with offset %d and size %d: %w", fd, offset, count, err)
+	}
+	return result, nil
+}
+
+// Call kdrive file_pwrite, see [API Doc.]
+// [API Doc]: https://docs.kdrive.com/methods/fileops/file_pwrite.html
+func filePWrite(
+	ctx context.Context,
+	client *rest.Client,
+	pacer *fs.Pacer,
+	fd int64,
+	offset int64,
+	buf []byte,
+) (*api.FilePWriteResponse, error) {
+	contentLength := int64(len(buf))
+	opts := rest.Opts{
+		Method:           "PUT",
+		Path:             "/file_pwrite",
+		Body:             bytes.NewReader(buf),
+		ContentLength:    &contentLength,
+		Parameters:       url.Values{},
+		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
+		Close:            false,
+		ExtraHeaders: map[string]string{
+			"Connection": "keep-alive",
+		},
+	}
+	opts.Parameters.Set("fd", strconv.FormatInt(fd, 10))
+	opts.Parameters.Set("offset", strconv.FormatInt(offset, 10))
+
+	result := &api.FilePWriteResponse{}
+	err := pacer.CallNoRetry(func() (bool, error) {
+		resp, err := client.CallJSON(ctx, &opts, nil, result)
+		err = result.ResultStatus.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return nil, fmt.Errorf("write %d bytes to fd %d with offset %d: %w", contentLength, fd, offset, err)
+	}
+	return result, nil
+}
+
+// Call kdrive file_close, see [API Doc.]
+// [API Doc]: https://docs.kdrive.com/methods/fileops/file_close.html
+func fileClose(
+	ctx context.Context,
+	client *rest.Client,
+	pacer *fs.Pacer,
+	fd int64,
+) (*api.FileCloseResponse, error) {
+	opts := rest.Opts{
+		Method:           "PUT",
+		Path:             "/file_close",
+		Parameters:       url.Values{},
+		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
+		Close:            true,
+	}
+	opts.Parameters.Set("fd", strconv.FormatInt(fd, 10))
+
+	result := &api.FileCloseResponse{}
+	err := pacer.CallNoRetry(func() (bool, error) {
+		resp, err := client.CallJSON(ctx, &opts, nil, result)
+		err = result.ResultStatus.Update(err)
+		return shouldRetry(ctx, resp, err)
+	})
+	if err != nil {
+		return nil, fmt.Errorf("close file descriptor: %w", err)
+	}
+	return result, nil
+}

From 425d90e3398572325987480f41cdecfb7b26dc15 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sun, 2 Nov 2025 16:43:50 +0100
Subject: [PATCH 02/16] kdrive: Filename encoding fixes

---
 backend/kdrive/kdrive.go | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index c89e43246f189..8e35965b6d5f0 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -88,7 +88,7 @@ func init() {
 			//
 			// TODO: Investigate Unicode simplification (\ gets converted to \ server-side)
 			Default: (encoder.Display |
-				encoder.EncodeBackSlash |
+				encoder.EncodeLeftSpace | encoder.EncodeRightSpace |
 				encoder.EncodeInvalidUtf8),
 		}, {
 			Name:    "account_id",
@@ -558,6 +558,8 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
 					continue
 				}
 			}
+			item.Name = f.opt.Enc.ToStandardName(item.Name)
+			item.FullPath = f.opt.Enc.ToStandardPath(item.FullPath)
 			if fn(item) {
 				found = true
 				break
@@ -991,7 +993,7 @@ func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
 		Path:       fmt.Sprintf("/2/drive/%s", f.opt.DriveID),
 		Parameters: url.Values{},
 	}
-	opts.Parameters.Set("only", f.opt.Enc.FromStandardName("used_size,size"))
+	opts.Parameters.Set("only", "used_size,size")
 	var resp *http.Response
 	var q api.QuotaInfo
 	err = f.pacer.Call(func() (bool, error) {

From 3491cb0d4ab949a307a0c15d30a41df58703e154 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sun, 2 Nov 2025 18:50:59 +0100
Subject: [PATCH 03/16] kdrive: fix some usecases

---
 backend/kdrive/kdrive.go | 74 ++++++++++++----------------------------
 1 file changed, 21 insertions(+), 53 deletions(-)

diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 8e35965b6d5f0..f536d489c6804 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -727,6 +727,13 @@ func (f *Fs) purgeCheck(ctx context.Context, dir string, check bool) error {
 		return err
 	}
 
+	nonEmpty, err := f.listAll(ctx, rootID, false, false, false, func(i *api.Item) bool {
+		return true
+	})
+	if (nonEmpty || err != nil) && check {
+		return fmt.Errorf("rmdir failed: directory %s not empty", dir)
+	}
+
 	opts := rest.Opts{
 		Method:     "DELETE",
 		Path:       fmt.Sprintf("/2/drive/%s/files/%s", f.opt.DriveID, rootID),
@@ -1146,13 +1153,15 @@ 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 {
+	filename, directoryID, err := o.fs.dirCache.FindPath(ctx, o.Remote(), true)
+	if err != nil || len(filename) == 0 || len(directoryID) == 0 {
+		return err
+	}
+
+	o.modTime = modTime
+
 	/*
 		// TOFE: there isn't currently any way of setting mtime on the remote !
-		filename, directoryID, err := o.fs.dirCache.FindPath(ctx, o.Remote(), true)
-		if err != nil {
-			return err
-		}
-
 		fileID := o.id
 		filename = o.fs.opt.Enc.FromStandardName(filename)
 		opts := rest.Opts{
@@ -1193,39 +1202,17 @@ func (o *Object) Storable() bool {
 	return true
 }
 
-// downloadURL fetches the download link
-func (o *Object) downloadURL(ctx context.Context) (URL string, err error) {
-	if o.id == "" {
-		return "", errors.New("can't download - no id")
-	}
-	var resp *http.Response
-	var result api.PubLinkResult
-	opts := rest.Opts{
-		Method:     "GET",
-		Path:       fmt.Sprintf("/2/drive/%s/files/%s/link", o.fs.opt.DriveID, o.id),
-		Parameters: url.Values{},
-	}
-	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)
-	})
-	if err != nil {
-		return "", err
-	}
-	return result.Data.URL, nil
-}
-
 // Open an object for read
 func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) {
-	url, err := o.downloadURL(ctx)
-	if err != nil {
-		return nil, err
-	}
+	/*	url, err := o.downloadURL(ctx)
+		if err != nil {
+			return nil, err
+		}
+	*/
 	var resp *http.Response
 	opts := rest.Opts{
 		Method:  "GET",
-		RootURL: url,
+		RootURL: fmt.Sprintf("%s/2/drive/%s/files/%s/download", "https://api.infomaniak.com", o.fs.opt.DriveID, o.id),
 		Options: options,
 	}
 	err = o.fs.pacer.Call(func() (bool, error) {
@@ -1278,26 +1265,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	opts.Parameters.Set("file_name", leaf)
 	opts.Parameters.Set("directory_id", directoryID)
 	opts.Parameters.Set("total_size", fmt.Sprintf("%d", size))
-	opts.Parameters.Set("created_at", fmt.Sprintf("%d", uint64(modTime.Unix())))
-
-	// Special treatment for a 0 length upload.  This doesn't work
-	// with PUT even with Content-Length set (by setting
-	// opts.Body=0), so upload it as a multipart form POST with
-	// Content-Length set.
-	if size == 0 {
-		formReader, contentType, overhead, err := rest.MultipartUpload(ctx, in, opts.Parameters, "content", leaf)
-		if err != nil {
-			return fmt.Errorf("failed to make multipart upload for 0 length file: %w", err)
-		}
-
-		contentLength := overhead + size
-
-		opts.ContentType = contentType
-		opts.Body = formReader
-		opts.Method = "POST"
-		opts.Parameters = nil
-		opts.ContentLength = &contentLength
-	}
+	opts.Parameters.Set("last_modified_at", fmt.Sprintf("%d", uint64(modTime.Unix())))
 
 	err = o.fs.pacer.CallNoRetry(func() (bool, error) {
 		resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &result)

From a7db97dcdbf9487d93133128634ed0fa7e40c57a Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sun, 2 Nov 2025 21:30:01 +0100
Subject: [PATCH 04/16] kdrive: remove unsupported OpenWriterAt

---
 backend/kdrive/kdrive.go    |  59 --------
 backend/kdrive/writer_at.go | 261 ------------------------------------
 2 files changed, 320 deletions(-)
 delete mode 100644 backend/kdrive/writer_at.go

diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index f536d489c6804..2ffead50862ce 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -378,65 +378,6 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 	return f, nil
 }
 
-// XOpenWriterAt opens with a handle for random access writes
-//
-// Pass in the remote desired and the size if known.
-//
-// It truncates any existing object.
-//
-// OpenWriterAt disabled because it seems to have been disabled at kdrive
-// PUT /file_open?flags=XXX&folderid=XXX&name=XXX HTTP/1.1
-//
-//	{
-//	        "result": 2003,
-//	        "error": "Access denied. You do not have permissions to perform this operation."
-//	}
-func (f *Fs) XOpenWriterAt(ctx context.Context, remote string, size int64) (fs.WriterAtCloser, error) {
-	client, err := f.newSingleConnClient(ctx)
-	if err != nil {
-		return nil, fmt.Errorf("create client: %w", err)
-	}
-	// init an empty file
-	leaf, directoryID, err := f.dirCache.FindPath(ctx, remote, true)
-	if err != nil {
-		return nil, fmt.Errorf("resolve src: %w", err)
-	}
-	openResult, err := fileOpenNew(ctx, client, f, directoryID, leaf)
-	if err != nil {
-		return nil, fmt.Errorf("open file: %w", err)
-	}
-	if _, err := fileClose(ctx, client, f.pacer, openResult.FileDescriptor); err != nil {
-		return nil, fmt.Errorf("close file: %w", err)
-	}
-
-	writer := &writerAt{
-		ctx:    ctx,
-		fs:     f,
-		size:   size,
-		remote: remote,
-		fileID: openResult.Fileid,
-	}
-
-	return writer, nil
-}
-
-// Create a new http client, accepting keep-alive headers, limited to single connection.
-// Necessary for kdrive fileops API, as it binds the session to the underlying TCP connection.
-// File descriptors are only valid within the same connection and auto-closed when the connection is closed,
-// hence we need a separate client (with single connection) for each fd to avoid all sorts of errors and race conditions.
-func (f *Fs) newSingleConnClient(ctx context.Context) (*rest.Client, error) {
-	baseClient := fshttp.NewClient(ctx)
-	baseClient.Transport = fshttp.NewTransportCustom(ctx, func(t *http.Transport) {
-		t.MaxConnsPerHost = 1
-		t.DisableKeepAlives = false
-	})
-	// Set our own http client in the context
-	ctx = oauthutil.Context(ctx, baseClient)
-	// create a new oauth client, reuse the token source
-	oAuthClient := oauth2.NewClient(ctx, f.ts)
-	return rest.NewClient(oAuthClient).SetRoot("https://api.infomaniak.com"), nil
-}
-
 // Return an Object from a path
 //
 // If it can't be found it returns the error fs.ErrorObjectNotFound.
diff --git a/backend/kdrive/writer_at.go b/backend/kdrive/writer_at.go
deleted file mode 100644
index 54cc686098e9e..0000000000000
--- a/backend/kdrive/writer_at.go
+++ /dev/null
@@ -1,261 +0,0 @@
-package kdrive
-
-import (
-	"bytes"
-	"context"
-	"crypto/sha1"
-	"encoding/hex"
-	"fmt"
-	"net/url"
-	"strconv"
-	"time"
-
-	"github.com/rclone/rclone/backend/kdrive/api"
-	"github.com/rclone/rclone/fs"
-	"github.com/rclone/rclone/lib/rest"
-)
-
-// writerAt implements fs.WriterAtCloser, adding the OpenWrtierAt feature to kdrive.
-type writerAt struct {
-	ctx    context.Context
-	fs     *Fs
-	size   int64
-	remote string
-	fileID int64
-}
-
-// Close implements WriterAt.Close.
-func (c *writerAt) Close() error {
-	// Avoiding race conditions: Depending on the tcp connection, there might be
-	// caching issues when checking the size immediately after write.
-	// Hence we try avoiding them by checking the resulting size on a different connection.
-	if c.size < 0 {
-		// Without knowing the size, we cannot do size checks.
-		// Falling back to a sleep of 1s for sake of hope.
-		time.Sleep(1 * time.Second)
-		return nil
-	}
-	sizeOk := false
-	sizeLastSeen := int64(0)
-	for retry := range 5 {
-		fs.Debugf(c.remote, "checking file size: try %d/5", retry)
-		obj, err := c.fs.NewObject(c.ctx, c.remote)
-		if err != nil {
-			return fmt.Errorf("get uploaded obj: %w", err)
-		}
-		sizeLastSeen = obj.Size()
-		if obj.Size() == c.size {
-			sizeOk = true
-			break
-		}
-		time.Sleep(1 * time.Second)
-	}
-
-	if !sizeOk {
-		return fmt.Errorf("incorrect size after upload: got %d, want %d", sizeLastSeen, c.size)
-	}
-
-	return nil
-}
-
-// WriteAt implements fs.WriteAt.
-func (c *writerAt) WriteAt(buffer []byte, offset int64) (n int, err error) {
-	contentLength := len(buffer)
-
-	inSHA1Bytes := sha1.Sum(buffer)
-	inSHA1 := hex.EncodeToString(inSHA1Bytes[:])
-
-	client, err := c.fs.newSingleConnClient(c.ctx)
-	if err != nil {
-		return 0, fmt.Errorf("create client: %w", err)
-	}
-
-	openResult, err := fileOpen(c.ctx, client, c.fs, c.fileID)
-	if err != nil {
-		return 0, fmt.Errorf("open file: %w", err)
-	}
-
-	// get target hash
-	outChecksum, err := fileChecksum(c.ctx, client, c.fs.pacer, openResult.FileDescriptor, offset, int64(contentLength))
-	if err != nil {
-		return 0, err
-	}
-	outSHA1 := outChecksum.SHA1
-
-	if outSHA1 == "" || inSHA1 == "" {
-		return 0, fmt.Errorf("expect both hashes to be filled: src: %q, target: %q", inSHA1, outSHA1)
-	}
-
-	// check hash of buffer, skip if fits
-	if inSHA1 == outSHA1 {
-		return contentLength, nil
-	}
-
-	// upload buffer with offset if necessary
-	if _, err := filePWrite(c.ctx, client, c.fs.pacer, openResult.FileDescriptor, offset, buffer); err != nil {
-		return 0, err
-	}
-
-	// close fd
-	if _, err := fileClose(c.ctx, client, c.fs.pacer, openResult.FileDescriptor); err != nil {
-		return contentLength, fmt.Errorf("close fd: %w", err)
-	}
-
-	return contentLength, nil
-}
-
-// Call kdrive file_open using folderid and name with O_CREAT and O_WRITE flags, see [API Doc.]
-// [API Doc]: https://docs.kdrive.com/methods/fileops/file_open.html
-func fileOpenNew(ctx context.Context, c *rest.Client, srcFs *Fs, directoryID, filename string) (*api.FileOpenResponse, error) {
-	opts := rest.Opts{
-		Method:           "PUT",
-		Path:             "/file_open",
-		Parameters:       url.Values{},
-		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
-		ExtraHeaders: map[string]string{
-			"Connection": "keep-alive",
-		},
-	}
-	filename = srcFs.opt.Enc.FromStandardName(filename)
-	opts.Parameters.Set("name", filename)
-	opts.Parameters.Set("folderid", directoryID)
-	opts.Parameters.Set("flags", "0x0042") // O_CREAT, O_WRITE
-
-	result := &api.FileOpenResponse{}
-	err := srcFs.pacer.CallNoRetry(func() (bool, error) {
-		resp, err := c.CallJSON(ctx, &opts, nil, result)
-		err = result.ResultStatus.Update(err)
-		return shouldRetry(ctx, resp, err)
-	})
-	if err != nil {
-		return nil, fmt.Errorf("open new file descriptor: %w", err)
-	}
-	return result, nil
-}
-
-// Call kdrive file_open using fileid with O_WRITE flags, see [API Doc.]
-// [API Doc]: https://docs.kdrive.com/methods/fileops/file_open.html
-func fileOpen(ctx context.Context, c *rest.Client, srcFs *Fs, fileID int64) (*api.FileOpenResponse, error) {
-	opts := rest.Opts{
-		Method:           "PUT",
-		Path:             "/file_open",
-		Parameters:       url.Values{},
-		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
-		ExtraHeaders: map[string]string{
-			"Connection": "keep-alive",
-		},
-	}
-	opts.Parameters.Set("fileid", strconv.FormatInt(fileID, 10))
-	opts.Parameters.Set("flags", "0x0002") // O_WRITE
-
-	result := &api.FileOpenResponse{}
-	err := srcFs.pacer.CallNoRetry(func() (bool, error) {
-		resp, err := c.CallJSON(ctx, &opts, nil, result)
-		err = result.ResultStatus.Update(err)
-		return shouldRetry(ctx, resp, err)
-	})
-	if err != nil {
-		return nil, fmt.Errorf("open new file descriptor: %w", err)
-	}
-	return result, nil
-}
-
-// Call kdrive file_checksum, see [API Doc.]
-// [API Doc]: https://docs.kdrive.com/methods/fileops/file_checksum.html
-func fileChecksum(
-	ctx context.Context,
-	client *rest.Client,
-	pacer *fs.Pacer,
-	fd, offset, count int64,
-) (*api.FileChecksumResponse, error) {
-	opts := rest.Opts{
-		Method:           "PUT",
-		Path:             "/file_checksum",
-		Parameters:       url.Values{},
-		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
-		ExtraHeaders: map[string]string{
-			"Connection": "keep-alive",
-		},
-	}
-	opts.Parameters.Set("fd", strconv.FormatInt(fd, 10))
-	opts.Parameters.Set("offset", strconv.FormatInt(offset, 10))
-	opts.Parameters.Set("count", strconv.FormatInt(count, 10))
-
-	result := &api.FileChecksumResponse{}
-	err := pacer.CallNoRetry(func() (bool, error) {
-		resp, err := client.CallJSON(ctx, &opts, nil, result)
-		err = result.ResultStatus.Update(err)
-		return shouldRetry(ctx, resp, err)
-	})
-	if err != nil {
-		return nil, fmt.Errorf("checksum of fd %d with offset %d and size %d: %w", fd, offset, count, err)
-	}
-	return result, nil
-}
-
-// Call kdrive file_pwrite, see [API Doc.]
-// [API Doc]: https://docs.kdrive.com/methods/fileops/file_pwrite.html
-func filePWrite(
-	ctx context.Context,
-	client *rest.Client,
-	pacer *fs.Pacer,
-	fd int64,
-	offset int64,
-	buf []byte,
-) (*api.FilePWriteResponse, error) {
-	contentLength := int64(len(buf))
-	opts := rest.Opts{
-		Method:           "PUT",
-		Path:             "/file_pwrite",
-		Body:             bytes.NewReader(buf),
-		ContentLength:    &contentLength,
-		Parameters:       url.Values{},
-		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
-		Close:            false,
-		ExtraHeaders: map[string]string{
-			"Connection": "keep-alive",
-		},
-	}
-	opts.Parameters.Set("fd", strconv.FormatInt(fd, 10))
-	opts.Parameters.Set("offset", strconv.FormatInt(offset, 10))
-
-	result := &api.FilePWriteResponse{}
-	err := pacer.CallNoRetry(func() (bool, error) {
-		resp, err := client.CallJSON(ctx, &opts, nil, result)
-		err = result.ResultStatus.Update(err)
-		return shouldRetry(ctx, resp, err)
-	})
-	if err != nil {
-		return nil, fmt.Errorf("write %d bytes to fd %d with offset %d: %w", contentLength, fd, offset, err)
-	}
-	return result, nil
-}
-
-// Call kdrive file_close, see [API Doc.]
-// [API Doc]: https://docs.kdrive.com/methods/fileops/file_close.html
-func fileClose(
-	ctx context.Context,
-	client *rest.Client,
-	pacer *fs.Pacer,
-	fd int64,
-) (*api.FileCloseResponse, error) {
-	opts := rest.Opts{
-		Method:           "PUT",
-		Path:             "/file_close",
-		Parameters:       url.Values{},
-		TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
-		Close:            true,
-	}
-	opts.Parameters.Set("fd", strconv.FormatInt(fd, 10))
-
-	result := &api.FileCloseResponse{}
-	err := pacer.CallNoRetry(func() (bool, error) {
-		resp, err := client.CallJSON(ctx, &opts, nil, result)
-		err = result.ResultStatus.Update(err)
-		return shouldRetry(ctx, resp, err)
-	})
-	if err != nil {
-		return nil, fmt.Errorf("close file descriptor: %w", err)
-	}
-	return result, nil
-}

From de3f254d6a01f7787d381d82c809d5311a9596ac Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sun, 2 Nov 2025 22:25:45 +0100
Subject: [PATCH 05/16] kdrive: disable broken features and fix OpenRange

---
 backend/kdrive/kdrive.go | 48 ++++------------------------------------
 1 file changed, 4 insertions(+), 44 deletions(-)

diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 2ffead50862ce..b5b484ea8eea6 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -922,6 +922,7 @@ func (f *Fs) linkFile(ctx context.Context, path string, expire fs.Duration) (str
 	return result.Data.URL, nil
 }
 
+/*
 // PublicLink adds a "readable by anyone with link" permission on the given file or folder.
 func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
 	dirID, err := f.dirCache.FindDir(ctx, remote, false)
@@ -933,7 +934,7 @@ func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration,
 	}
 	return f.linkDir(ctx, dirID, expire)
 }
-
+*/
 // About gets quota information
 func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
 	opts := rest.Opts{
@@ -1094,48 +1095,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 {
-	filename, directoryID, err := o.fs.dirCache.FindPath(ctx, o.Remote(), true)
-	if err != nil || len(filename) == 0 || len(directoryID) == 0 {
-		return err
-	}
-
-	o.modTime = modTime
-
-	/*
-		// TOFE: there isn't currently any way of setting mtime on the remote !
-		fileID := o.id
-		filename = o.fs.opt.Enc.FromStandardName(filename)
-		opts := rest.Opts{
-			Method:           "PUT",
-			Path:             "/copyfile",
-			Parameters:       url.Values{},
-			TransferEncoding: []string{"identity"}, // kdrive doesn't like chunked encoding
-			ExtraHeaders: map[string]string{
-				"Connection": "keep-alive",
-			},
-		}
-		opts.Parameters.Set("fileid", fileID)
-		opts.Parameters.Set("folderid", dirIDtoNumber(directoryID))
-		opts.Parameters.Set("toname", filename)
-		opts.Parameters.Set("tofolderid", dirIDtoNumber(directoryID))
-		opts.Parameters.Set("ctime", strconv.FormatInt(modTime.Unix(), 10))
-		opts.Parameters.Set("mtime", strconv.FormatInt(modTime.Unix(), 10))
-
-		result := &api.ItemResult{}
-		err = o.fs.pacer.CallNoRetry(func() (bool, error) {
-			resp, err := o.fs.srv.CallJSON(ctx, &opts, nil, result)
-			err = result.Error.Update(err)
-			return shouldRetry(ctx, resp, err)
-		})
-		if err != nil {
-			return fmt.Errorf("update mtime: copyfile: %w", err)
-		}
-		if err := o.setMetaData(&result.Metadata); err != nil {
-			return err
-		}
-	*/
-
-	return nil
+	return fs.ErrorCantSetModTime
 }
 
 // Storable returns a boolean showing whether this object storable
@@ -1150,6 +1110,7 @@ func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.Read
 			return nil, err
 		}
 	*/
+	fs.FixRangeOption(options, o.Size())
 	var resp *http.Response
 	opts := rest.Opts{
 		Method:  "GET",
@@ -1262,7 +1223,6 @@ var (
 	_ fs.Mover           = (*Fs)(nil)
 	_ fs.DirMover        = (*Fs)(nil)
 	_ fs.DirCacheFlusher = (*Fs)(nil)
-	_ fs.PublicLinker    = (*Fs)(nil)
 	_ fs.ListRer         = (*Fs)(nil)
 	_ fs.ListPer         = (*Fs)(nil)
 	_ fs.Abouter         = (*Fs)(nil)

From c39c94923cdd65ad95e82b861069fb57114fecf9 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sun, 2 Nov 2025 22:44:39 +0100
Subject: [PATCH 06/16] kdrive: all backend tests are now OK

---
 backend/kdrive/kdrive.go | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index b5b484ea8eea6..81cde707655e0 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -1168,6 +1168,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	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("conflict", "version")
 
 	err = o.fs.pacer.CallNoRetry(func() (bool, error) {
 		resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &result)
@@ -1191,6 +1192,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		return fmt.Errorf("failed to upload %v - not sure why", o)
 	}
 
+	o.size = size
 	return o.readMetaData(ctx)
 }
 

From d7c339249012798103a0e93c671541bdea769540 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Mon, 3 Nov 2025 21:30:09 +0100
Subject: [PATCH 07/16] kdrive: cleanup

---
 backend/kdrive/api/types.go | 68 +--------------------------
 backend/kdrive/kdrive.go    | 93 +++++--------------------------------
 2 files changed, 13 insertions(+), 148 deletions(-)

diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index 76463fc060fbd..2ebe93d899b61 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -1,4 +1,4 @@
-// Package api has type definitions for kdrive
+// Package api has type definitions for kDrive
 //
 // Converted from the API docs with help from https://mholt.github.io/json-to-go/
 package api
@@ -219,44 +219,11 @@ type FileCopyResponse struct {
 	Data Item `json:"data"`
 }
 
-// FileTruncateResponse is the response from /file_truncate
-type FileTruncateResponse struct {
-	ResultStatus
-}
-
-// FileCloseResponse is the response from /file_close
-type FileCloseResponse struct {
-	ResultStatus
-}
-
-// FileOpenResponse is the response from /file_open
-type FileOpenResponse struct {
-	ResultStatus
-	Fileid         int64 `json:"fileid"`
-	FileDescriptor int64 `json:"fd"`
-}
-
-// FileChecksumResponse is the response from /file_checksum
-type FileChecksumResponse struct {
-	ResultStatus
-	MD5    string `json:"md5"`
-	SHA1   string `json:"sha1"`
-	SHA256 string `json:"sha256"`
-}
-
-// FilePWriteResponse is the response from /file_pwrite
-type FilePWriteResponse struct {
-	ResultStatus
-	Bytes int64 `json:"bytes"`
-}
-
-// UploadFileResponse is the response from /uploadfile
 type UploadFileResponse struct {
 	ResultStatus
 	Data Item `json:"data"`
 }
 
-// ChecksumFileResult is returned from /checksumfile
 type ChecksumFileResult struct {
 	ResultStatus
 	Data struct {
@@ -264,7 +231,7 @@ type ChecksumFileResult struct {
 	} `json:"data"`
 }
 
-// PubLinkResult is returned from /getfilepublink and /getfolderpublink
+// currently used, as PublicLink is disabled
 type PubLinkResult struct {
 	ResultStatus
 	Data struct {
@@ -294,34 +261,3 @@ type QuotaInfo struct {
 		UsedSize int64 `json:"used_size"`
 	} `json:"data"`
 }
-
-// UserInfo is returned from /userinfo
-type UserInfo struct {
-	ResultStatus
-	Cryptosetup           bool   `json:"cryptosetup"`
-	Plan                  int    `json:"plan"`
-	CryptoSubscription    bool   `json:"cryptosubscription"`
-	PublicLinkQuota       int64  `json:"publiclinkquota"`
-	Email                 string `json:"email"`
-	UserID                int    `json:"userid"`
-	Quota                 int64  `json:"quota"`
-	TrashRevretentionDays int    `json:"trashrevretentiondays"`
-	Premium               bool   `json:"premium"`
-	PremiumLifetime       bool   `json:"premiumlifetime"`
-	EmailVerified         bool   `json:"emailverified"`
-	UsedQuota             int64  `json:"usedquota"`
-	Language              string `json:"language"`
-	Business              bool   `json:"business"`
-	CryptoLifetime        bool   `json:"cryptolifetime"`
-	Registered            string `json:"registered"`
-	Journey               struct {
-		Claimed bool `json:"claimed"`
-		Steps   struct {
-			VerifyMail    bool `json:"verifymail"`
-			UploadFile    bool `json:"uploadfile"`
-			AutoUpload    bool `json:"autoupload"`
-			DownloadApp   bool `json:"downloadapp"`
-			DownloadDrive bool `json:"downloaddrive"`
-		} `json:"steps"`
-	} `json:"journey"`
-}
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 81cde707655e0..eef763bef74e1 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -1,11 +1,7 @@
-// Package kdrive provides an interface to the kdrive
+// Package kdrive provides an interface to the kDrive
 // object storage system.
 package kdrive
 
-// FIXME cleanup returns login required?
-
-// FIXME mime type? Fix overview if implement.
-
 import (
 	"context"
 	"errors"
@@ -77,7 +73,6 @@ func init() {
 			return oauthutil.ConfigOut("", &oauthutil.Options{
 				OAuth2Config: oauthConfig,
 				CheckAuth:    checkAuth,
-				StateBlankOK: true, // kdrive seems to drop the state parameter now - see #4210
 			})
 		},
 		Options: append(oauthutil.SharedOptions, []fs.Option{{
@@ -85,8 +80,6 @@ func init() {
 			Help:     config.ConfigEncodingHelp,
 			Advanced: true,
 			// Encode invalid UTF-8 bytes as json doesn't handle them properly.
-			//
-			// TODO: Investigate Unicode simplification (\ gets converted to \ server-side)
 			Default: (encoder.Display |
 				encoder.EncodeLeftSpace | encoder.EncodeRightSpace |
 				encoder.EncodeInvalidUtf8),
@@ -253,51 +246,6 @@ func errorHandler(resp *http.Response) error {
 	return errResponse
 }
 
-/*
-// unused for now
-func (f *Fs) retrieveDriveIdFromName(ctx context.Context, name string) (string, error) {
-	// step 1: retrieve user id
-	// fs.Debugf(f, "retrieveDriveIdFromName(%q)\n", name)
-	var resp *http.Response
-	var resultProfile api.Profile
-	var resultDrives api.ListDrives
-	var err error
-	opts := rest.Opts{
-		Method:     "GET",
-		Path:       "/profile",
-		Parameters: url.Values{},
-	}
-	err = f.pacer.Call(func() (bool, error) {
-		resp, err = f.srv.CallJSON(ctx, &opts, nil, &resultProfile)
-		err = resultProfile.Error.Update(err)
-		return shouldRetry(ctx, resp, err)
-	})
-	if err != nil {
-		//fmt.Printf("...Error %v\n", err)
-		return "", err
-	}
-	// fmt.Printf("...Id %q\n", *info.Id)
-	userID := resultProfile.Data.UserID
-	// step 2: retrieve the drives of that user
-	opts = rest.Opts{
-		Method:     "GET",
-		Path:       fmt.Sprintf("/2/drive/users/%s/drives", userID),
-		Parameters: url.Values{},
-	}
-	opts.Parameters.Set("account_id", f.opt.AccountID)
-	err = f.pacer.Call(func() (bool, error) {
-		resp, err = f.srv.CallJSON(ctx, &opts, nil, &resultDrives)
-		err = resultDrives.Error.Update(err)
-		return shouldRetry(ctx, resp, err)
-	})
-	if err != nil {
-		//fmt.Printf("...Error %v\n", err)
-		return "", err
-	}
-
-	return strconv.Itoa(resultDrives.Data[0].DriveID), nil
-}
-*/
 // 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
@@ -312,10 +260,6 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 	staticToken := oauth2.Token{AccessToken: opt.AccessToken}
 	ts := oauth2.StaticTokenSource(&staticToken)
 	oAuthClient := oauth2.NewClient(ctx, ts)
-	//oAuthClient, ts, err := oauthutil.NewClient(ctx, name, m, oauthConfig)
-	//if err != nil {
-	//	return nil, fmt.Errorf("failed to configure kdrive: %w", err)
-	//}
 
 	f := &Fs{
 		name:  name,
@@ -333,14 +277,6 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 	}).Fill(ctx, f)
 	f.srv.SetErrorHandler(errorHandler)
 
-	// Renew the token in the background
-	/*
-		f.tokenRenewer = oauthutil.NewRenew(f.String(), f.ts, func() error {
-			_, err := f.readMetaDataForPath(ctx, "")
-			return err
-		})
-	*/
-
 	// Get rootFolderID
 	rootID := "1" // see https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
 	f.dirCache = dircache.New(root, rootID, f)
@@ -407,7 +343,6 @@ 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) {
-	fs.Debugf(ctx, "FindLeaf: leaf=%s", leaf)
 	// Find the leaf in pathID
 	found, err = f.listAll(ctx, pathID, true, false, false, func(item *api.Item) bool {
 		if item.Name == leaf {
@@ -439,7 +374,7 @@ func (f *Fs) CreateDir(ctx context.Context, pathID, leaf string) (newID string,
 		//fmt.Printf("...Error %v\n", err)
 		return "", err
 	}
-	// fmt.Printf("...Id %q\n", *info.Id)
+	// fmt.Printf("...Id %d\n", result.Data.ID)
 	return strconv.Itoa(result.Data.ID), nil
 }
 
@@ -455,7 +390,6 @@ 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.Debugf(ctx, "Entering listAll")
 	listSomeFiles := func(currentDirID string, fromCursor string) (api.SearchResult, error) {
 		opts := rest.Opts{
 			Method:     "GET",
@@ -523,17 +457,15 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
 // and calls the callback for each element.
 func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callback func(entries fs.DirEntry) error) (err error) {
 	directoryID, err := f.dirCache.FindDir(ctx, dir, false)
-	fs.Debugf(ctx, "listHelper: root=%s dir=%s directoryID=%s", f.root, dir, directoryID)
+	//fs.Debugf(ctx, "listHelper: root=%s dir=%s directoryID=%s", f.root, dir, directoryID)
 	if err != nil {
 		return err
 	}
 	var iErr error
 	_, err = f.listAll(ctx, directoryID, false, false, recursive, func(info *api.Item) bool {
-		fs.Debugf(ctx, "listHelper: trimming /%s out of %s", f.root, info.FullPath)
 		remote := parsePath(strings.TrimPrefix(info.FullPath, "/"+f.root))
 		if info.Type == "dir" {
 			// cache the directory ID for later lookups
-			fs.Debugf(ctx, "listAll: caching %s as %s", remote, strconv.Itoa(info.ID))
 			f.dirCache.Put(remote, strconv.Itoa(info.ID))
 
 			d := fs.NewDir(remote, info.ModTime()).SetID(strconv.Itoa(info.ID))
@@ -881,6 +813,8 @@ 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) {
 	opts := rest.Opts{
 		Method:     "GET",
@@ -922,7 +856,6 @@ func (f *Fs) linkFile(ctx context.Context, path string, expire fs.Duration) (str
 	return result.Data.URL, nil
 }
 
-/*
 // PublicLink adds a "readable by anyone with link" permission on the given file or folder.
 func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
 	dirID, err := f.dirCache.FindDir(ctx, remote, false)
@@ -964,12 +897,12 @@ func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
 
 // Shutdown shutdown the fs
 func (f *Fs) Shutdown(ctx context.Context) error {
-	//f.tokenRenewer.Shutdown()
 	return nil
 }
 
 // Hashes returns the supported hash sets.
 func (f *Fs) Hashes() hash.Set {
+	// kDrive only supports xxh3
 	return hash.Set(hash.XXH3)
 }
 
@@ -1105,11 +1038,6 @@ func (o *Object) Storable() bool {
 
 // Open an object for read
 func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) {
-	/*	url, err := o.downloadURL(ctx)
-		if err != nil {
-			return nil, err
-		}
-	*/
 	fs.FixRangeOption(options, o.Size())
 	var resp *http.Response
 	opts := rest.Opts{
@@ -1133,9 +1061,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) {
-	//o.fs.tokenRenewer.Start()
-	//defer o.fs.tokenRenewer.Stop()
-
 	size := src.Size() // NB can upload without size
 	modTime := src.ModTime(ctx)
 	remote := o.Remote()
@@ -1151,6 +1076,8 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	}
 
 	// 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
 	var resp *http.Response
 	var result api.UploadFileResponse
 	opts := rest.Opts{
@@ -1175,8 +1102,10 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 		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 {
-		// sometimes kdrive leaves a half complete file on
+		// 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)

From fc7f5762de6c1507013a38b7fca5da62291412a9 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Mon, 3 Nov 2025 23:10:36 +0100
Subject: [PATCH 09/16] kdrive: add documentation

Signed-off-by: Christophe Chapuis <chris.chapuis@gmail.com>
---
 bin/make_manual.py              |   1 +
 docs/content/_index.md          |   1 +
 docs/content/docs.md            |   1 +
 docs/content/kdrive.md          | 203 ++++++++++++++++++++++++++++++++
 docs/content/overview.md        |   2 +
 docs/layouts/chrome/navbar.html |   1 +
 6 files changed, 209 insertions(+)
 create mode 100644 docs/content/kdrive.md

diff --git a/bin/make_manual.py b/bin/make_manual.py
index e4593b213b0f4..e7abc986c7b22 100755
--- a/bin/make_manual.py
+++ b/bin/make_manual.py
@@ -61,6 +61,7 @@
     "iclouddrive.md",
     "internetarchive.md",
     "jottacloud.md",
+    "kdrive.md",
     "koofr.md",
     "linkbox.md",
     "mailru.md",
diff --git a/docs/content/_index.md b/docs/content/_index.md
index 16cc1d9e49ec2..caae5bb1824f9 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -157,6 +157,7 @@ WebDAV or S3, that work out of the box.)
 {{< provider name="IDrive e2" home="https://www.idrive.com/e2/?refer=rclone" config="/s3/#idrive-e2" >}}
 {{< provider name="Intercolo Object Storage" home="https://intercolo.de/object-storage" config="/s3/#intercolo" >}}
 {{< provider name="IONOS Cloud" home="https://cloud.ionos.com/storage/object-storage" config="/s3/#ionos" >}}
+{{< provider name="kDrive" home="https://www.infomaniak.com/en/ksuite/kdrive" config="/kdrive/" >}}
 {{< provider name="Koofr" home="https://koofr.eu/" config="/koofr/" >}}
 {{< provider name="Leviia Object Storage" home="https://www.leviia.com/object-storage" config="/s3/#leviia" >}}
 {{< provider name="Liara Object Storage" home="https://liara.ir/landing/object-storage" config="/s3/#liara-object-storage" >}}
diff --git a/docs/content/docs.md b/docs/content/docs.md
index 780573626edc8..62d968ea28fcc 100644
--- a/docs/content/docs.md
+++ b/docs/content/docs.md
@@ -61,6 +61,7 @@ See the following for detailed instructions for
 - [iCloud Drive](/iclouddrive/)
 - [Internet Archive](/internetarchive/)
 - [Jottacloud](/jottacloud/)
+- [kDrive](/kdrive/)
 - [Koofr](/koofr/)
 - [Linkbox](/linkbox/)
 - [Mail.ru Cloud](/mailru/)
diff --git a/docs/content/kdrive.md b/docs/content/kdrive.md
new file mode 100644
index 0000000000000..bffd9fd8e09e9
--- /dev/null
+++ b/docs/content/kdrive.md
@@ -0,0 +1,203 @@
+---
+title: "kDrive"
+description: "Rclone docs for kDrive"
+versionIntroduced: "v1.72"
+---
+
+# {{< icon "fa fa-cloud" >}} kDrive
+
+Paths are specified as `remote:path`
+
+Paths may be as deep as required, e.g. `remote:directory/subdirectory`.
+
+## Configuration
+
+The initial setup for pCloud involves getting a token from kDrive which you
+need to do in your browser.  `rclone config` walks you through it.
+
+Here is an example of how to make a remote called `remote`.  First run:
+
+```sh
+rclone config
+```
+
+This will guide you through an interactive setup process:
+
+```text
+No remotes found, make a new one?
+n) New remote
+s) Set configuration password
+q) Quit config
+n/s/q> n
+name> remote
+Type of storage to configure.
+Choose a number from below, or type in your own value
+[snip]
+XX / Infomaniak kDrive
+   \ "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:
+https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...
+Enter a value. Press Enter to leave empty.
+drive_id> 0654321
+
+Option access_token.
+Access token generated in Infomaniak profile manager.
+Enter a value. Press Enter to leave empty.
+access_token> ThisIsAVeryLong-Token
+
+Edit advanced config?
+y) Yes
+n) No (default)
+y/n> 
+
+Configuration complete.
+Options:
+- type: kdrive
+- account_id: 12345678
+- drive_id: 0654321
+- access_token: ThisIsAVeryLong-Token
+
+Keep this "remote" remote?
+y) Yes this is OK
+e) Edit this remote
+d) Delete this remote
+y/e/d> y
+```
+
+Once configured you can then use `rclone` like this (replace `remote` with the name you gave your remote):
+
+List directories in top level of your rClone
+
+```sh
+rclone lsd remote:
+```
+
+**Note** that the top level directory of a private kDrive storage is always called "Private".
+When used in with a Pro account, a second folder "Common documents" is also present.
+*These top-level folders cannot be modified*.
+
+List all the files in your rClone
+
+```sh
+rclone ls remote:
+```
+
+To copy a local directory to a rClone directory called backup
+
+```sh
+rclone copy /home/source remote:/Private/backup
+```
+
+### Modification times and hashes
+
+kDrive allows modification times to be set on objects accurate to 1
+second.  These will be used to detect whether objects need syncing or
+not.  In order to set a Modification time kDrive requires the object
+be re-uploaded.
+
+kDrive supports the XXH3 checksum hash, so you can use the `--checksum` flag.
+
+### Restricted filename characters
+
+In addition to the [default restricted characters set](/overview/#restricted-characters)
+the following characters are also replaced:
+
+|  Character | Value | Replacement |
+| ---------- |:-----:|:-----------:|
+| LeftSpace  | 0x20  | ␠           |
+| RighSpace  | 0x20  | ␠           |
+
+Invalid UTF-8 bytes will also be [replaced](/overview/#invalid-utf8),
+as they can't be used in JSON strings.
+
+### Deleting files
+
+Deleted files will be moved to the trash.  Your subscription level
+will determine how long items stay in the trash.  `rclone cleanup` can
+be used to empty the trash.
+
+### Root folder ID
+
+You can set the `root_folder_id` for rclone.  This is the directory
+(identified by its `Folder ID`) that rclone considers to be the root
+of your pCloud drive.
+
+Normally you will leave this blank and rclone will determine the
+correct root to use itself.
+
+However you can set this to restrict rclone to a specific folder
+hierarchy.
+
+In order to do this you will have to find the `Folder ID` of the
+directory you wish rclone to display. This can be accomplished by executing
+the ```rclone lsf``` command using a basic configuration setup that does not
+include the ```root_folder_id``` parameter.
+
+The command will enumerate available directories, allowing you to locate the
+appropriate Folder ID for subsequent use.
+
+Example:
+```
+$ rclone lsf --dirs-only -Fip --csv TestkDrive:
+dxxxxxxxx2,My Music/
+dxxxxxxxx3,My Pictures/
+dxxxxxxxx4,My Videos/
+```
+
+So if the folder you want rclone to use your is "My Music/", then use the returned id from ```rclone lsf``` command (ex. `dxxxxxxxx2`) as
+the `root_folder_id` variable value in the config file.
+
+{{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/pcloud/pcloud.go then run make backenddocs" >}}
+
+### Advanced options
+
+Here are the Advanced options specific to pcloud (Pcloud).
+
+#### --kdrive-encoding
+
+The encoding for the backend.
+
+See the [encoding section in the overview](/overview/#encoding) for more info.
+
+Properties:
+
+- Config:      encoding
+- Env Var:     RCLONE_KDRIVE_ENCODING
+- Type:        Encoding
+- Default:     Slash,Del,Ctl,InvalidUtf8,Dot,LeftSpace,RightSpace
+
+#### --kdrive-root-folder-id
+
+Fill in for rclone to use a non root folder as its starting point.
+
+Properties:
+
+- Config:      root_folder_id
+- Env Var:     RCLONE_KDRIVE_ROOT_FOLDER_ID
+- Type:        string
+- Default:     "1"
+
+#### --kdrive-description
+
+Description of the remote.
+
+Properties:
+
+- Config:      description
+- Env Var:     RCLONE_KDRIVE_DESCRIPTION
+- Type:        string
+- Required:    false
+
+{{< rem autogenerated options stop >}}
diff --git a/docs/content/overview.md b/docs/content/overview.md
index 52a244e91d11b..6ab57b66c44a9 100644
--- a/docs/content/overview.md
+++ b/docs/content/overview.md
@@ -39,6 +39,7 @@ Here is an overview of the major features of each cloud storage system.
 | iCloud Drive                 | -                 | R       | No               | No              | -         | -        |
 | Internet Archive             | MD5, SHA1, CRC32  | R/W ¹¹  | No               | No              | -         | RWU      |
 | Jottacloud                   | MD5               | R/W     | Yes              | No              | R         | RW       |
+| kDrive                       | XXH3              | R       | No               | No              | -         | -        |
 | Koofr                        | MD5               | -       | Yes              | No              | -         | -        |
 | Linkbox                      | -                 | R       | No               | No              | -         | -        |
 | Mail.ru Cloud                | Mailru ⁶          | R/W     | Yes              | No              | -         | -        |
@@ -533,6 +534,7 @@ upon backend-specific capabilities.
 | ImageKit                     | Yes   | No   | Yes  | No      | No      | No    | No           | No                | No           | No    | Yes      |
 | Internet Archive             | No    | Yes  | No   | No      | Yes     | Yes   | No           | No                | Yes          | Yes   | No       |
 | Jottacloud                   | Yes   | Yes  | Yes  | Yes     | Yes     | Yes   | No           | No                | Yes          | Yes   | Yes      |
+| kDrive                       | Yes   | Yes  | Yes  | Yes     | Yes     | Yes   | No           | No                | No           | Yes   | Yes      |
 | Koofr                        | Yes   | Yes  | Yes  | Yes     | No      | No    | Yes          | No                | Yes          | Yes   | Yes      |
 | Mail.ru Cloud                | Yes   | Yes  | Yes  | Yes     | Yes     | No    | No           | No                | Yes          | Yes   | Yes      |
 | Mega                         | Yes   | No   | Yes  | Yes     | Yes     | No    | No           | No                | Yes          | Yes   | Yes      |
diff --git a/docs/layouts/chrome/navbar.html b/docs/layouts/chrome/navbar.html
index b6b617ea38cae..fcba2ba9a2de2 100644
--- a/docs/layouts/chrome/navbar.html
+++ b/docs/layouts/chrome/navbar.html
@@ -85,6 +85,7 @@
           <a class="dropdown-item" href="/imagekit/"><i class="fa fa-cloud fa-fw"></i> ImageKit</a>
           <a class="dropdown-item" href="/internetarchive/"><i class="fa fa-archive fa-fw"></i> Internet Archive</a>
           <a class="dropdown-item" href="/jottacloud/"><i class="fa fa-cloud fa-fw"></i> Jottacloud</a>
+          <a class="dropdown-item" href="/kdrive/"><i class="fa fa-suitcase fa-fw"></i> kDrive</a>
           <a class="dropdown-item" href="/koofr/"><i class="fa fa-suitcase fa-fw"></i> Koofr</a>
           <a class="dropdown-item" href="/linkbox/"><i class="fa fa-infinity fa-fw"></i> Linkbox</a>
           <a class="dropdown-item" href="/mailru/"><i class="fa fa-at fa-fw"></i> Mail.ru Cloud</a>

From 9f60f03cd0155428fa84f87633c244fa4b145b79 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Tue, 4 Nov 2025 19:50:41 +0100
Subject: [PATCH 10/16] kdrive: cleanup unused oauth

---
 backend/kdrive/kdrive.go | 71 ++++++++++++++--------------------------
 1 file changed, 24 insertions(+), 47 deletions(-)

diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index eef763bef74e1..3eada54988ad6 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -25,57 +25,24 @@ import (
 	"github.com/rclone/rclone/fs/list"
 	"github.com/rclone/rclone/lib/dircache"
 	"github.com/rclone/rclone/lib/encoder"
-	"github.com/rclone/rclone/lib/oauthutil"
 	"github.com/rclone/rclone/lib/pacer"
 	"github.com/rclone/rclone/lib/rest"
 	"golang.org/x/oauth2"
 )
 
 const (
-	rcloneClientID              = "" // unused currently
-	rcloneEncryptedClientSecret = "" // unused currently
-	minSleep                    = 10 * time.Millisecond
-	maxSleep                    = 2 * time.Second
-	decayConstant               = 2 // bigger for slower decay, exponential
-)
-
-// Globals
-var (
-	// Description of how to auth for this app
-	oauthConfig = &oauthutil.Config{
-		Scopes:       []string{"user_info", "accounts", "drive"},
-		AuthURL:      "https://login.infomaniak.com/authorize",
-		TokenURL:     "https://login.infomaniak.com/token",
-		ClientID:     rcloneClientID,
-		ClientSecret: "", //obscure.MustReveal(rcloneEncryptedClientSecret),
-		RedirectURL:  oauthutil.RedirectLocalhostURL,
-	}
+	minSleep      = 10 * time.Millisecond
+	maxSleep      = 2 * time.Second
+	decayConstant = 2 // bigger for slower decay, exponential
 )
 
 // Register with Fs
 func init() {
 	fs.Register(&fs.RegInfo{
-		Name:        "Kdrive",
-		Description: "Infomaniak's kDrive",
+		Name:        "kdrive",
+		Description: "Infomaniak kDrive",
 		NewFs:       NewFs,
-		Config: func(ctx context.Context, name string, m configmap.Mapper, config fs.ConfigIn) (*fs.ConfigOut, error) {
-			optc := new(Options)
-			err := configstruct.Set(m, optc)
-			if err != nil {
-				fs.Errorf(nil, "Failed to read config: %v", err)
-			}
-			checkAuth := func(oauthConfig *oauthutil.Config, auth *oauthutil.AuthResult) error {
-				if auth == nil || auth.Form == nil {
-					return errors.New("form not found in response")
-				}
-				return nil
-			}
-			return oauthutil.ConfigOut("", &oauthutil.Options{
-				OAuth2Config: oauthConfig,
-				CheckAuth:    checkAuth,
-			})
-		},
-		Options: append(oauthutil.SharedOptions, []fs.Option{{
+		Options: []fs.Option{{
 			Name:     config.ConfigEncoding,
 			Help:     config.ConfigEncodingHelp,
 			Advanced: true,
@@ -84,8 +51,17 @@ func init() {
 				encoder.EncodeLeftSpace | encoder.EncodeRightSpace |
 				encoder.EncodeInvalidUtf8),
 		}, {
-			Name:    "account_id",
-			Help:    "Fill the account ID that is to be considered for this kdrive.",
+			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",
+			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",
@@ -98,16 +74,17 @@ https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...
 			Help:    `Access token generated in Infomaniak profile manager.`,
 			Default: "",
 		},
-		}...),
+		},
 	})
 }
 
 // Options defines the configuration for this backend
 type Options struct {
-	Enc         encoder.MultiEncoder `config:"encoding"`
-	AccountID   string               `config:"account_id"`
-	DriveID     string               `config:"drive_id"`
-	AccessToken string               `config:"access_token"`
+	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"`
 }
 
 // Fs represents a remote kdrive
@@ -278,7 +255,7 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e
 	f.srv.SetErrorHandler(errorHandler)
 
 	// Get rootFolderID
-	rootID := "1" // see https://developer.infomaniak.com/docs/api/get/3/drive/%7Bdrive_id%7D/files/%7Bfile_id%7D
+	rootID := f.opt.RootFolderID
 	f.dirCache = dircache.New(root, rootID, f)
 
 	// Find the current root

From 5effd5a9fde8ae0d28ea8ef4690e34f64c22cc7e Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Wed, 5 Nov 2025 22:03:18 +0100
Subject: [PATCH 11/16] kdrive: fix remaining tests

---
 backend/kdrive/api/types.go |  2 ++
 backend/kdrive/kdrive.go    | 26 ++++++++++++++++++++++++--
 2 files changed, 26 insertions(+), 2 deletions(-)

diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index 2ebe93d899b61..55a599066d8e6 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -167,6 +167,7 @@ type Item struct {
 	Type           string `json:"type"`
 	FullPath       string `json:"path"`
 	Status         string `json:"status"`
+	Hash           string `json:"hash"`
 	Size           int64  `json:"size"`
 	Visibility     string `json:"visibility"`
 	DriveID        int    `json:"drive_id"`
@@ -178,6 +179,7 @@ type Item struct {
 	LastModifiedBy int    `json:"last_modified_by"`
 	RevisedAt      int    `json:"revised_at"`
 	UpdatedAt      int    `json:"updated_at"`
+	MimeType       string `json:"mime_type"`
 	ParentID       int    `json:"parent_id"`
 	Color          string `json:"color"`
 }
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 3eada54988ad6..e4661c0c53c28 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -28,6 +28,7 @@ import (
 	"github.com/rclone/rclone/lib/pacer"
 	"github.com/rclone/rclone/lib/rest"
 	"golang.org/x/oauth2"
+	"golang.org/x/text/unicode/norm"
 )
 
 const (
@@ -194,6 +195,9 @@ func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.It
 		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
 	})
@@ -373,7 +377,7 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
 			Path:       fmt.Sprintf("/3/drive/%s/files/%s/files", f.opt.DriveID, currentDirID),
 			Parameters: url.Values{},
 		}
-		opts.Parameters.Set("with", "path")
+		opts.Parameters.Set("with", "path,hash")
 		if len(fromCursor) > 0 {
 			opts.Parameters.Set("cursor", fromCursor)
 		}
@@ -712,7 +716,7 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
 	}
 
 	// Create temporary object
-	_, leaf, directoryID, err := f.createObject(ctx, remote, srcObj.modTime, srcObj.size)
+	moveDst, leaf, directoryID, err := f.createObject(ctx, remote, srcObj.modTime, srcObj.size)
 	if err != nil {
 		return nil, err
 	}
@@ -729,6 +733,20 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
 	err = f.pacer.Call(func() (bool, error) {
 		resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
 		err = result.ResultStatus.Update(err)
+		if err != nil && err.(*api.ResultStatus) != nil {
+			if err.(*api.ResultStatus).ErrorDetail.Result == "conflict_error" {
+				// Destination already exists => remove if and retry
+				err = moveDst.readMetaData(ctx)
+				if err != nil {
+					return false, err
+				}
+				err = moveDst.Remove(ctx)
+				if err != nil {
+					return false, err
+				}
+				return true, nil
+			}
+		}
 		return shouldRetry(ctx, resp, err)
 	})
 	if err != nil {
@@ -962,6 +980,9 @@ func (o *Object) setMetaData(info *api.Item) (err error) {
 	o.size = info.Size
 	o.modTime = info.ModTime()
 	o.id = strconv.Itoa(info.ID)
+	if len(o.xxh3) == 0 && len(info.Hash) > 0 {
+		o.xxh3 = strings.TrimPrefix(info.Hash, "xxh3:")
+	}
 	return nil
 }
 
@@ -1073,6 +1094,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	opts.Parameters.Set("total_size", fmt.Sprintf("%d", size))
 	opts.Parameters.Set("last_modified_at", fmt.Sprintf("%d", uint64(modTime.Unix())))
 	opts.Parameters.Set("conflict", "version")
+	opts.Parameters.Set("with", "hash")
 
 	err = o.fs.pacer.CallNoRetry(func() (bool, error) {
 		resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &result)

From 2884c5e5788207228b3e9b7596dfa567b9ec16fd Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sat, 8 Nov 2025 21:43:40 +0100
Subject: [PATCH 12/16] kdrive: fix lint errors

---
 backend/kdrive/api/types.go | 12 ++++++++++--
 backend/kdrive/kdrive.go    |  3 ++-
 2 files changed, 12 insertions(+), 3 deletions(-)

diff --git a/backend/kdrive/api/types.go b/backend/kdrive/api/types.go
index 55a599066d8e6..ce96979ab301d 100644
--- a/backend/kdrive/api/types.go
+++ b/backend/kdrive/api/types.go
@@ -39,7 +39,7 @@ func (t *Time) UnmarshalJSON(data []byte) error {
 	return nil
 }
 
-// Error is returned from kdrive when things go wrong
+// ResultStatus return error details from kdrive when things go wrong
 //
 // If result is 0 then everything is OK
 type ResultStatus struct {
@@ -184,6 +184,7 @@ type Item struct {
 	Color          string `json:"color"`
 }
 
+// SearchResult is returned when a list of items is requested
 type SearchResult struct {
 	ResultStatus
 	Data       []Item `json:"data"`
@@ -201,31 +202,37 @@ func (i *Item) ModTime() (t time.Time) {
 	return t
 }
 
+// CancelResource is a kdrive resource that can be cancelled after some action
 type CancelResource struct {
 	CancelID   string `json:"cancel_id"`
 	ValidUntil int    `json:"valid_until"`
 }
 
+// CancellableResponse is returned from kdrive when an action can be cancelled afterwards
 type CancellableResponse struct {
 	ResultStatus
 	Data CancelResource `json:"data"`
 }
 
+// CreateDirResult is returned from kdrive after a call to MkDir
 type CreateDirResult struct {
 	ResultStatus
 	Data Item `json:"data"`
 }
 
+// FileCopyResponse is returned from kdrive after a call to Copy
 type FileCopyResponse struct {
 	ResultStatus
 	Data Item `json:"data"`
 }
 
+// UploadFileResponse is returned from kdrive after a call to Upload
 type UploadFileResponse struct {
 	ResultStatus
 	Data Item `json:"data"`
 }
 
+// ChecksumFileResult is returned from kdrive after a call to Hash
 type ChecksumFileResult struct {
 	ResultStatus
 	Data struct {
@@ -233,7 +240,7 @@ type ChecksumFileResult struct {
 	} `json:"data"`
 }
 
-// currently used, as PublicLink is disabled
+// PubLinkResult is currently unused, as PublicLink is disabled
 type PubLinkResult struct {
 	ResultStatus
 	Data struct {
@@ -256,6 +263,7 @@ type PubLinkResult struct {
 	} `json:"data"`
 }
 
+// QuotaInfo is return from kdrive after a call get drive info
 type QuotaInfo struct {
 	ResultStatus
 	Data struct {
diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index e4661c0c53c28..3db2e52f020b7 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -808,8 +808,8 @@ func (f *Fs) DirCacheFlush() {
 	f.dirCache.ResetRoot()
 }
 
-// The PublicLink method is currently disabled, not sure which API should be used here
 /*
+// 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) {
 	opts := rest.Opts{
 		Method:     "GET",
@@ -863,6 +863,7 @@ func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration,
 	return f.linkDir(ctx, dirID, expire)
 }
 */
+
 // About gets quota information
 func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
 	opts := rest.Opts{

From e928f836b4c3d312d5467e86ee49ac9269bf89ee Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sat, 10 Jan 2026 15:28:21 +0100
Subject: [PATCH 13/16] kdrive: fix path of items when using root id folder

---
 backend/kdrive/kdrive.go | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index 3db2e52f020b7..cb6185d5422d7 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -395,8 +395,8 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
 		return result, nil
 	}
 
-	var recursiveContents func(currentDirID string, fromCursor string)
-	recursiveContents = func(currentDirID string, fromCursor string) {
+	var recursiveContents func(currentDirID string, currentSubDir string, fromCursor string)
+	recursiveContents = func(currentDirID string, currentSubDir string, fromCursor string) {
 		result, err := listSomeFiles(currentDirID, fromCursor)
 		if err != nil {
 			return
@@ -415,22 +415,22 @@ func (f *Fs) listAll(ctx context.Context, dirID string, directoriesOnly bool, fi
 				}
 			}
 			item.Name = f.opt.Enc.ToStandardName(item.Name)
-			item.FullPath = f.opt.Enc.ToStandardPath(item.FullPath)
+			item.FullPath = path.Join(currentSubDir, item.Name)
 			if fn(item) {
 				found = true
 				break
 			}
 			if recursive && item.Type == "dir" {
-				recursiveContents(strconv.Itoa(item.ID), "" /*reset cursor*/)
+				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, result.Cursor)
+			recursiveContents(currentDirID, currentSubDir, result.Cursor)
 		}
 	}
-	recursiveContents(dirID, "")
+	recursiveContents(dirID, "", "")
 	return
 }
 
@@ -444,7 +444,7 @@ func (f *Fs) listHelper(ctx context.Context, dir string, recursive bool, callbac
 	}
 	var iErr error
 	_, err = f.listAll(ctx, directoryID, false, false, recursive, func(info *api.Item) bool {
-		remote := parsePath(strings.TrimPrefix(info.FullPath, "/"+f.root))
+		remote := path.Join(dir, info.FullPath)
 		if info.Type == "dir" {
 			// cache the directory ID for later lookups
 			f.dirCache.Put(remote, strconv.Itoa(info.ID))

From 3182e97ebf674c891768fc499d6f1210b49aabf4 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Sat, 10 Jan 2026 16:05:06 +0100
Subject: [PATCH 14/16] kdrive: fix hash update after content change

---
 backend/kdrive/kdrive.go | 1 +
 1 file changed, 1 insertion(+)

diff --git a/backend/kdrive/kdrive.go b/backend/kdrive/kdrive.go
index cb6185d5422d7..7c94ed9c8cff7 100644
--- a/backend/kdrive/kdrive.go
+++ b/backend/kdrive/kdrive.go
@@ -1122,6 +1122,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
 	}
 
 	o.size = size
+	o.setHash(strings.TrimPrefix(result.Data.Hash, "xxh3:"))
 	return o.readMetaData(ctx)
 }
 

From e359d294569a174cfde21826c866a1e42664ca34 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Mon, 12 Jan 2026 11:34:28 +0100
Subject: [PATCH 15/16] kdrive: docs: fix incorrect names

There were pending references to "pcloud", it should be "kdrive".
---
 docs/content/kdrive.md | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/docs/content/kdrive.md b/docs/content/kdrive.md
index bffd9fd8e09e9..65705c4183f3b 100644
--- a/docs/content/kdrive.md
+++ b/docs/content/kdrive.md
@@ -12,7 +12,7 @@ Paths may be as deep as required, e.g. `remote:directory/subdirectory`.
 
 ## Configuration
 
-The initial setup for pCloud involves getting a token from kDrive which you
+The initial setup for kDrive involves getting a token which you
 need to do in your browser.  `rclone config` walks you through it.
 
 Here is an example of how to make a remote called `remote`.  First run:
@@ -132,7 +132,7 @@ be used to empty the trash.
 
 You can set the `root_folder_id` for rclone.  This is the directory
 (identified by its `Folder ID`) that rclone considers to be the root
-of your pCloud drive.
+of your kDrive drive.
 
 Normally you will leave this blank and rclone will determine the
 correct root to use itself.
@@ -159,11 +159,11 @@ dxxxxxxxx4,My Videos/
 So if the folder you want rclone to use your is "My Music/", then use the returned id from ```rclone lsf``` command (ex. `dxxxxxxxx2`) as
 the `root_folder_id` variable value in the config file.
 
-{{< rem autogenerated options start" - DO NOT EDIT - instead edit fs.RegInfo in backend/pcloud/pcloud.go then run make backenddocs" >}}
+<!-- autogenerated options start - DO NOT EDIT - instead edit fs.RegInfo in backend/kdrive/kdrive.go and run make backenddocs to verify --> <!-- markdownlint-disable-line line-length -->
 
 ### Advanced options
 
-Here are the Advanced options specific to pcloud (Pcloud).
+Here are the Advanced options specific to kdrive (kDrive).
 
 #### --kdrive-encoding
 

From ac6c0dc178f04a4a4eee9bd587cc116959548fd3 Mon Sep 17 00:00:00 2001
From: Christophe Chapuis <chris.chapuis@gmail.com>
Date: Mon, 12 Jan 2026 14:54:58 +0100
Subject: [PATCH 16/16] kdrive: add reference in main README file

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index 2c99a640b829f..77e5501600f5e 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,7 @@ directories to and from different cloud storage providers.
 - IBM COS S3 [:page_facing_up:](https://rclone.org/s3/#ibm-cos-s3)
 - Intercolo Object Storage [:page_facing_up:](https://rclone.org/s3/#intercolo)
 - IONOS Cloud [:page_facing_up:](https://rclone.org/s3/#ionos)
+- kDrive [:page_facing_up:](https://rclone.org/kdrive/)
 - Koofr [:page_facing_up:](https://rclone.org/koofr/)
 - Leviia Object Storage [:page_facing_up:](https://rclone.org/s3/#leviia)
 - Liara Object Storage [:page_facing_up:](https://rclone.org/s3/#liara-object-storage)
openSUSE Build Service is sponsored by