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)