1 parent
47e22cac82
commit
5118e0c32a
17 files changed
+3345
No files matched your search
@@ -75,6 +75,7 @@ directories to and from different cloud storage providers.
|
||||
- Impossible Cloud [:page_facing_up:](https://rclone.org/s3/#impossible-cloud)
|
||||
- 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)
|
||||
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
_ "github.com/rclone/rclone/backend/internetarchive"
|
||||
_ "github.com/rclone/rclone/backend/internxt"
|
||||
_ "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"
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Time represents unix timestamp date and time information for the
|
||||
// kDrive API
|
||||
type Time time.Time
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ResultStatus return error details from kdrive when things go wrong
|
||||
//
|
||||
// If Status is "success" then everything is OK
|
||||
type ResultStatus struct {
|
||||
Status string `json:"result"`
|
||||
ErrorDetail struct {
|
||||
Result string `json:"code"`
|
||||
ErrorString string `json:"description"`
|
||||
Context struct {
|
||||
Values []string `json:"values"`
|
||||
} `json:"context"`
|
||||
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)
|
||||
|
||||
// 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"`
|
||||
Hash string `json:"hash"`
|
||||
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"`
|
||||
MimeType string `json:"mime_type"`
|
||||
ParentID int `json:"parent_id"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// ItemResult is returned when an item is queried
|
||||
type ItemResult struct {
|
||||
ResultStatus
|
||||
Data Item `json:"data"`
|
||||
}
|
||||
|
||||
// SearchResult is returned when a list of items is requested
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Hash string `json:"hash"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// PubLinkResult is returned from kdrive after a call to public link API
|
||||
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"`
|
||||
}
|
||||
|
||||
// PubLinkUpdateResult is returned from kdrive after a updating a public link
|
||||
type PubLinkUpdateResult struct {
|
||||
ResultStatus
|
||||
Data bool `json:"data"`
|
||||
}
|
||||
|
||||
// QuotaInfo is return from kdrive after a call get drive info
|
||||
type QuotaInfo struct {
|
||||
ResultStatus
|
||||
Data struct {
|
||||
Size int64 `json:"size"`
|
||||
UsedSize int64 `json:"used_size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// SessionStartResponse is used to start a multichunk upload session
|
||||
type SessionStartResponse struct {
|
||||
ResultStatus
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
UploadURL string `json:"upload_url"` // URL pour uploader les chunks
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ChunkUploadResponse is returned by kdrive after a chunk upload
|
||||
type ChunkUploadResponse struct {
|
||||
ResultStatus
|
||||
Data struct {
|
||||
Hash string `json:"hash,omitempty"` // Only present when requested with with:'hash'
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// SessionFinishResponse is used to end a multichunk upload session
|
||||
type SessionFinishResponse struct {
|
||||
ResultStatus
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
File Item `json:"file"`
|
||||
Result bool `json:"result"`
|
||||
Message string `json:"message"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// SessionCancelResponse is used to cancel a multichunk upload session
|
||||
type SessionCancelResponse struct {
|
||||
ResultStatus
|
||||
Data bool `json:"data"`
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,551 @@
|
||||
// Unit tests for the kdrive backend
|
||||
package kdrive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/backend/kdrive/api"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"github.com/rclone/rclone/fs/hash"
|
||||
"github.com/rclone/rclone/lib/dircache"
|
||||
"github.com/rclone/rclone/lib/pacer"
|
||||
"github.com/rclone/rclone/lib/rest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCheckChunkSize checks chunk_size validation against the API limits
|
||||
func TestCheckChunkSize(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
in fs.SizeSuffix
|
||||
wantErr string
|
||||
}{
|
||||
{in: 20 * fs.Mebi, wantErr: ""}, // default
|
||||
{in: 1000 * 1000, wantErr: ""}, // API minimum
|
||||
{in: 1000 * 1000 * 1000, wantErr: ""}, // API maximum
|
||||
{in: 0, wantErr: "too small"}, // panics without validation
|
||||
{in: 999 * 1000, wantErr: "too small"}, // below API minimum
|
||||
{in: 1000*1000*1000 + 1, wantErr: "too large"}, // above API maximum
|
||||
{in: 2 * 1000 * 1000 * 1000, wantErr: "too large"}, // way above API maximum
|
||||
} {
|
||||
t.Run(test.in.String(), func(t *testing.T) {
|
||||
err := checkChunkSize(test.in)
|
||||
if test.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenChunkWriterClampsCallerChunkSize checks that a caller
|
||||
// supplied chunk size hint below the API minimum is raised to it
|
||||
func TestOpenChunkWriterClampsCallerChunkSize(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"token":"tok","upload_url":"http://unused"}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
f.dirCache = dircache.New("", "1", f)
|
||||
src := &mockObjectInfo{remote: "file.txt", size: 4, modTime: time.Unix(1700000000, 0)}
|
||||
|
||||
info, _, err := f.OpenChunkWriter(context.Background(), "file.txt", src, &fs.ChunkOption{ChunkSize: 512 * 1024})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(minChunkSize), info.ChunkSize, "a chunk size hint below the API minimum must be raised to it")
|
||||
}
|
||||
|
||||
// TestObjectSetMetaDataResetsStaleHash checks that setMetaData always
|
||||
// replaces the hash (a chunked upload finish response carries none)
|
||||
func TestObjectSetMetaDataResetsStaleHash(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
staleHash string
|
||||
staleNested bool
|
||||
newHash string
|
||||
wantHash string
|
||||
wantIsNested bool
|
||||
wantHashKnown bool
|
||||
}{
|
||||
{
|
||||
name: "empty hash clears stale plain hash",
|
||||
staleHash: "deadbeef",
|
||||
staleNested: false,
|
||||
newHash: "",
|
||||
wantHash: "",
|
||||
wantIsNested: false,
|
||||
wantHashKnown: false,
|
||||
},
|
||||
{
|
||||
name: "empty hash clears stale nested hash",
|
||||
staleHash: "deadbeef",
|
||||
staleNested: true,
|
||||
newHash: "",
|
||||
wantHash: "",
|
||||
wantIsNested: false,
|
||||
wantHashKnown: false,
|
||||
},
|
||||
{
|
||||
name: "plain hash is set",
|
||||
staleHash: "deadbeef",
|
||||
staleNested: false,
|
||||
newHash: "xxh3:12345678",
|
||||
wantHash: "12345678",
|
||||
wantIsNested: false,
|
||||
wantHashKnown: true,
|
||||
},
|
||||
{
|
||||
name: "nested hash is set",
|
||||
staleHash: "deadbeef",
|
||||
staleNested: false,
|
||||
newHash: "N:xxh3:87654321",
|
||||
wantHash: "87654321",
|
||||
wantIsNested: true,
|
||||
wantHashKnown: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
o := &Object{
|
||||
remote: "file.txt",
|
||||
hasMetaData: true,
|
||||
xxh3: test.staleHash,
|
||||
nestedHash: test.staleNested,
|
||||
}
|
||||
info := &api.Item{
|
||||
ID: 42,
|
||||
Name: "file.txt",
|
||||
Type: "file",
|
||||
Hash: test.newHash,
|
||||
}
|
||||
|
||||
err := o.setMetaData(info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.wantHash, o.xxh3, "hash after setMetaData")
|
||||
assert.Equal(t, test.wantIsNested, o.nestedHash, "nested flag after setMetaData")
|
||||
assert.True(t, o.hasMetaData)
|
||||
|
||||
// Hash() must reflect the new state. Only checked when a
|
||||
// hash is known: with no hash Hash() would query the API.
|
||||
// Nested hashes are not comparable with plain XXH3, so
|
||||
// Hash() returns "" for them.
|
||||
if test.wantHashKnown {
|
||||
got, gotErr := o.Hash(context.Background(), hash.XXH3)
|
||||
require.NoError(t, gotErr)
|
||||
if test.wantIsNested {
|
||||
assert.Equal(t, "", got, "nested hash must not be returned as XXH3")
|
||||
} else {
|
||||
assert.Equal(t, test.wantHash, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newTestFs creates an Fs wired to a test server with a plain client
|
||||
// (no OAuth) and a fast pacer
|
||||
func newTestFs(t *testing.T, endpoint string) *Fs {
|
||||
t.Helper()
|
||||
return &Fs{
|
||||
name: "TestKdrive",
|
||||
root: "",
|
||||
opt: Options{DriveID: "1234", Endpoint: endpoint},
|
||||
srv: rest.NewClient(&http.Client{}).SetRoot(endpoint),
|
||||
pacer: fs.NewPacer(context.Background(),
|
||||
pacer.NewDefault(pacer.MinSleep(time.Millisecond), pacer.MaxSleep(2*time.Millisecond), pacer.DecayConstant(2))),
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteChunkDetectsResultError checks that a 200 with result:error
|
||||
// is reported as an error
|
||||
func TestWriteChunkDetectsResultError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"chunk_error","description":"chunk upload failed"}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
u := &uploadSession{
|
||||
f: f,
|
||||
token: "tok",
|
||||
uploadURL: ts.URL,
|
||||
fileSize: 4,
|
||||
chunkSize: 4,
|
||||
chunkHashes: make(map[int64]string),
|
||||
}
|
||||
|
||||
_, err := u.WriteChunk(context.Background(), 0, bytes.NewReader([]byte("test")))
|
||||
require.Error(t, err, "result:error with HTTP 200 must not be treated as success")
|
||||
assert.Contains(t, err.Error(), "chunk upload failed")
|
||||
}
|
||||
|
||||
// TestWriteChunkRetriesAndRewinds checks that transient failures are
|
||||
// retried with the chunk re-sent from the start of the reader
|
||||
func TestWriteChunkRetriesAndRewinds(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var attempts int
|
||||
var bodies []string
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
attempts++
|
||||
bodies = append(bodies, string(body))
|
||||
current := attempts
|
||||
mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if current == 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"internal_error","description":"transient"}}`)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"chunk_number":1,"status":"OK"}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
u := &uploadSession{
|
||||
f: f,
|
||||
token: "tok",
|
||||
uploadURL: ts.URL,
|
||||
fileSize: 4,
|
||||
chunkSize: 4,
|
||||
chunkHashes: make(map[int64]string),
|
||||
}
|
||||
|
||||
written, err := u.WriteChunk(context.Background(), 0, bytes.NewReader([]byte("test")))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(4), written)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Len(t, bodies, 2, "chunk upload must be retried once after a 5xx")
|
||||
assert.Equal(t, "test", bodies[0], "first attempt sends the chunk")
|
||||
assert.Equal(t, "test", bodies[1], "retry must re-send the chunk from the start of the reader")
|
||||
}
|
||||
|
||||
// kdriveMockItem returns a JSON body for a single item result
|
||||
func kdriveMockItem(id int, name string) string {
|
||||
return fmt.Sprintf(`{"result":"success","data":{"id":%d,"name":%q,"type":"file","path":%q,"last_modified_at":1700000000}}`, id, name, "/"+name)
|
||||
}
|
||||
|
||||
// mockObjectInfo is a minimal fs.ObjectInfo for testing
|
||||
type mockObjectInfo struct {
|
||||
remote string
|
||||
size int64
|
||||
modTime time.Time
|
||||
hash string
|
||||
}
|
||||
|
||||
func (m *mockObjectInfo) Fs() fs.Info { return nil }
|
||||
func (m *mockObjectInfo) String() string { return m.remote }
|
||||
func (m *mockObjectInfo) Remote() string { return m.remote }
|
||||
func (m *mockObjectInfo) Size() int64 { return m.size }
|
||||
func (m *mockObjectInfo) ModTime(_ context.Context) time.Time { return m.modTime }
|
||||
func (m *mockObjectInfo) Storable() bool { return true }
|
||||
func (m *mockObjectInfo) MimeType(_ context.Context) string { return "application/octet-stream" }
|
||||
|
||||
func (m *mockObjectInfo) Hash(_ context.Context, t hash.Type) (string, error) {
|
||||
if m.hash == "" || t != hash.XXH3 {
|
||||
return "", hash.ErrUnsupported
|
||||
}
|
||||
return m.hash, nil
|
||||
}
|
||||
|
||||
// TestUpdateDirectNoRetryOnServerError checks that a failed single
|
||||
// part upload is not retried here: the input must not be buffered, the
|
||||
// higher layers retry the whole transfer
|
||||
func TestUpdateDirectNoRetryOnServerError(t *testing.T) {
|
||||
var attempts int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"internal_error","description":"transient"}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
o := &Object{fs: f, remote: "file.txt"}
|
||||
src := &mockObjectInfo{remote: "file.txt", size: 4, modTime: time.Unix(1700000000, 0), hash: "12345678"}
|
||||
|
||||
err := o.updateDirect(context.Background(), bytes.NewReader([]byte("test")), "1", "file.txt", src)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&attempts), "single part uploads must not be retried here")
|
||||
}
|
||||
|
||||
// TestUpdateDirect checks the upload parameters and metadata of a
|
||||
// successful single part upload, and that total_chunk_hash is only
|
||||
// sent when the source hash is known
|
||||
func TestUpdateDirect(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
srcHash string
|
||||
wantTotalHash string
|
||||
}{
|
||||
{name: "hash known", srcHash: "12345678", wantTotalHash: "xxh3:12345678"},
|
||||
{name: "hash unknown", srcHash: "", wantTotalHash: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var gotTotalHash, gotBody string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
gotTotalHash = r.URL.Query().Get("total_chunk_hash")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(body)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"id":55,"name":"file.txt","type":"file","path":"/file.txt","hash":"xxh3:99998888","last_modified_at":1700000000}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
o := &Object{fs: f, remote: "file.txt"}
|
||||
src := &mockObjectInfo{remote: "file.txt", size: 4, modTime: time.Unix(1700000000, 0), hash: test.srcHash}
|
||||
|
||||
err := o.updateDirect(context.Background(), bytes.NewReader([]byte("test")), "1", "file.txt", src)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test", gotBody, "the content must be streamed as-is")
|
||||
assert.Equal(t, test.wantTotalHash, gotTotalHash, "total_chunk_hash")
|
||||
assert.Equal(t, "55", o.id)
|
||||
gotHash, hashErr := o.Hash(context.Background(), hash.XXH3)
|
||||
require.NoError(t, hashErr)
|
||||
assert.Equal(t, "99998888", gotHash)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFsRequiresDriveID checks that NewFs fails fast when drive_id
|
||||
// is missing
|
||||
func TestNewFsRequiresDriveID(t *testing.T) {
|
||||
m := configmap.Simple{"chunk_size": "20Mi"}
|
||||
_, err := NewFs(context.Background(), "kdrive", "", m)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "drive_id")
|
||||
}
|
||||
|
||||
// TestUploadSessionStringMasksToken checks that the upload session
|
||||
// String does not leak the session token in debug logs
|
||||
func TestUploadSessionStringMasksToken(t *testing.T) {
|
||||
u := &uploadSession{token: "very-secret-uuid"}
|
||||
assert.NotContains(t, u.String(), "very-secret-uuid")
|
||||
}
|
||||
|
||||
// TestListNonRecursiveSingleRoundTrip checks that a non recursive
|
||||
// listing needs a single API request: without depth=unlimited the API
|
||||
// returns direct children only, so neither the parent item lookup nor
|
||||
// a child filter is needed
|
||||
func TestListNonRecursiveSingleRoundTrip(t *testing.T) {
|
||||
var requests int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&requests, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/1/files"):
|
||||
// Direct children only, as returned without depth=unlimited
|
||||
_, _ = fmt.Fprintln(w, `{"result":"success","data":[`+
|
||||
`{"id":10,"name":"sub","type":"dir","path":"/sub"},`+
|
||||
`{"id":11,"name":"file.txt","type":"file","path":"/file.txt","hash":"xxh3:12345678","last_modified_at":1700000000}],"has_more":false}`)
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"unexpected","description":"unexpected request"}}`)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
f.dirCache = dircache.New("", "1", f)
|
||||
|
||||
entries, err := f.List(context.Background(), "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&requests), "non recursive listing must make a single API request")
|
||||
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Remote())
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"sub", "file.txt"}, names)
|
||||
}
|
||||
|
||||
// TestMoveConflictReturnsError checks that moving onto an occupied
|
||||
// destination reports the API conflict instead of resolving it: rclone
|
||||
// removes the destination itself before calling Move
|
||||
func TestMoveConflictReturnsError(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/files/111/move/1"):
|
||||
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"conflict_error","description":"destination already exists"}}`)
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"unexpected","description":"unexpected request"}}`)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
f.dirCache = dircache.New("", "1", f)
|
||||
srcObj := &Object{fs: f, remote: "src.txt", id: "111", hasMetaData: true}
|
||||
|
||||
_, err := f.Move(context.Background(), srcObj, "file2")
|
||||
require.Error(t, err, "a move onto an occupied destination must fail")
|
||||
assert.Contains(t, err.Error(), "conflict", "the API conflict must be reported")
|
||||
}
|
||||
|
||||
// TestMoveDetectsForeignObjectAtDestination checks that Move does not
|
||||
// return a file re-created by another writer at the destination
|
||||
func TestMoveDetectsForeignObjectAtDestination(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/files/111/move/1"):
|
||||
// The move API reports success...
|
||||
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"cancel_id":"abc","valid_until":1700000000}}`)
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/1/name"):
|
||||
// ...but the file at the destination is not the moved
|
||||
// file: another writer re-created it
|
||||
_, _ = fmt.Fprintln(w, kdriveMockItem(999, "file2"))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"unexpected","description":"unexpected request"}}`)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
f.dirCache = dircache.New("", "1", f)
|
||||
srcObj := &Object{fs: f, remote: "src.txt", id: "111", hasMetaData: true}
|
||||
|
||||
_, err := f.Move(context.Background(), srcObj, "file2")
|
||||
require.Error(t, err, "Move must not report success when the destination is not the moved file")
|
||||
}
|
||||
|
||||
// TestMoveSuccess checks the nominal move: the object returned is the
|
||||
// moved file (same ID as the source).
|
||||
func TestMoveSuccess(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/files/111/move/1"):
|
||||
_, _ = fmt.Fprintln(w, `{"result":"success","data":{"cancel_id":"abc","valid_until":1700000000}}`)
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/1/name"):
|
||||
// After the move, the moved file is at the destination
|
||||
_, _ = fmt.Fprintln(w, kdriveMockItem(111, "file2"))
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
_, _ = fmt.Fprintln(w, `{"result":"error","error":{"code":"unexpected","description":"unexpected request"}}`)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
f := newTestFs(t, ts.URL)
|
||||
f.dirCache = dircache.New("", "1", f)
|
||||
srcObj := &Object{fs: f, remote: "src.txt", id: "111", hasMetaData: true}
|
||||
|
||||
dstObj, err := f.Move(context.Background(), srcObj, "file2")
|
||||
require.NoError(t, err)
|
||||
moved, ok := dstObj.(*Object)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "111", moved.id, "the returned object must be the moved file")
|
||||
}
|
||||
|
||||
// TestFormatModTime checks that pre-1970 times format as negative
|
||||
// unix timestamps instead of wrapping through a uint64 cast
|
||||
func TestFormatModTime(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
in time.Time
|
||||
want string
|
||||
}{
|
||||
{in: time.Unix(1700000000, 0), want: "1700000000"},
|
||||
{in: time.Unix(0, 0), want: "0"},
|
||||
{in: time.Unix(-1577923200, 0), want: "-1577923200"}, // Sat Jan 2 1965
|
||||
} {
|
||||
t.Run(test.want, func(t *testing.T) {
|
||||
assert.Equal(t, test.want, formatModTime(test.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectSetMetaDataIsDir checks that directories are rejected
|
||||
func TestObjectSetMetaDataIsDir(t *testing.T) {
|
||||
o := &Object{remote: "dir"}
|
||||
info := &api.Item{ID: 42, Name: "dir", Type: "dir"}
|
||||
err := o.setMetaData(info)
|
||||
assert.Equal(t, fs.ErrorIsDir, err)
|
||||
}
|
||||
|
||||
// TestShouldRetryLockError checks that transient locks held by server
|
||||
// side copies are retried while locks held by applications and other
|
||||
// errors are not
|
||||
func TestShouldRetryLockError(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
wantRetry bool
|
||||
}{
|
||||
{
|
||||
name: "lock held by internal copy",
|
||||
body: `{"result":"error","error":{"code":"lock_error","description":"There is a lock on this file, locked by internal_copy","context":{"values":["internal_copy"]}}}`,
|
||||
status: http.StatusForbidden,
|
||||
wantRetry: true,
|
||||
},
|
||||
{
|
||||
name: "lock held by an application",
|
||||
body: `{"result":"error","error":{"code":"lock_error","description":"There is a lock on this file, locked by onlyoffice","context":{"values":["onlyoffice"]}}}`,
|
||||
status: http.StatusForbidden,
|
||||
wantRetry: false,
|
||||
},
|
||||
{
|
||||
name: "lock without context",
|
||||
body: `{"result":"error","error":{"code":"lock_error","description":"There is a lock on this file, locked by internal_copy"}}`,
|
||||
status: http.StatusForbidden,
|
||||
wantRetry: false,
|
||||
},
|
||||
{
|
||||
name: "other api error",
|
||||
body: `{"result":"error","error":{"code":"object_not_found","description":"File not found"}}`,
|
||||
status: http.StatusNotFound,
|
||||
wantRetry: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
status: http.StatusServiceUnavailable,
|
||||
wantRetry: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var apiErr error
|
||||
if test.body != "" {
|
||||
e := new(api.ResultStatus)
|
||||
require.NoError(t, json.Unmarshal([]byte(test.body), e))
|
||||
apiErr = e
|
||||
}
|
||||
resp := &http.Response{StatusCode: test.status, Header: http.Header{}}
|
||||
got, gotErr := shouldRetry(context.Background(), resp, apiErr)
|
||||
assert.Equal(t, test.wantRetry, got)
|
||||
assert.Equal(t, apiErr, gotErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Test kdrive filesystem interface
|
||||
package kdrive
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"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: (*Object)(nil),
|
||||
ChunkedUpload: fstests.ChunkedUploadConfig{
|
||||
MinChunkSize: minChunkSize,
|
||||
MaxChunkSize: maxChunkSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (f *Fs) SetUploadChunkSize(cs fs.SizeSuffix) (fs.SizeSuffix, error) {
|
||||
return f.setChunkSize(cs)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package khash provides the kDrive specific hash handling.
|
||||
//
|
||||
// kDrive uses XXH3-64 for file hashing. Files uploaded in chunks (by
|
||||
// rclone, or by the kDrive apps which each use their own chunk size)
|
||||
// get a "nested" hash: the XXH3 of the concatenated lowercase hex
|
||||
// strings of the individual chunk hashes. Such a nested hash can't be
|
||||
// compared with a plain XXH3 of the file contents.
|
||||
//
|
||||
// Hash formats returned by the kDrive API:
|
||||
// - "xxh3:HASH" for a plain XXH3 of the file contents
|
||||
// - "N:xxh3:HASH" for the nested hash of a chunked upload
|
||||
package khash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
// ParseHash parses a kDrive hash string from the API and returns the hex hash value
|
||||
// along with whether it is a nested (chunked) hash.
|
||||
func ParseHash(hashStr string) (hexHash string, isNested bool, err error) {
|
||||
if hashStr == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// Check for nested hash format: "N:xxh3:HASH"
|
||||
if strings.HasPrefix(hashStr, "N:xxh3:") {
|
||||
hashStr = strings.TrimPrefix(hashStr, "N:xxh3:")
|
||||
isNested = true
|
||||
} else {
|
||||
hashStr = strings.TrimPrefix(hashStr, "xxh3:")
|
||||
}
|
||||
|
||||
// Validate hex string
|
||||
if _, decodeErr := hex.DecodeString(hashStr); decodeErr != nil {
|
||||
return "", false, fmt.Errorf("invalid hash format: %w", decodeErr)
|
||||
}
|
||||
|
||||
return hashStr, isNested, nil
|
||||
}
|
||||
|
||||
// NestedChunkHash computes the kDrive nested hash from the hashes of the
|
||||
// file chunks, in order.
|
||||
//
|
||||
// kDrive stores the nested hash of a chunked file as the XXH3 of the
|
||||
// concatenated lowercase hex strings of the individual chunk hashes.
|
||||
// Each chunk hash may be passed with or without the "xxh3:" prefix.
|
||||
func NestedChunkHash(chunkHashes []string) (string, error) {
|
||||
if len(chunkHashes) == 0 {
|
||||
return "", errors.New("no chunk hashes to nest")
|
||||
}
|
||||
hexHashes := make([]string, len(chunkHashes))
|
||||
for i, chunkHash := range chunkHashes {
|
||||
hexHash, _, err := ParseHash(chunkHash)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid chunk hash %q: %w", chunkHash, err)
|
||||
}
|
||||
hexHashes[i] = hexHash
|
||||
}
|
||||
hasher := xxh3.New()
|
||||
_, _ = hasher.Write([]byte(strings.Join(hexHashes, "")))
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package khash
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectedHash string
|
||||
isNested bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "simple hash with prefix",
|
||||
input: "xxh3:1db797b96febd334",
|
||||
expectedHash: "1db797b96febd334",
|
||||
isNested: false,
|
||||
},
|
||||
{
|
||||
name: "nested hash",
|
||||
input: "N:xxh3:877abf3579f0a5c0",
|
||||
expectedHash: "877abf3579f0a5c0",
|
||||
isNested: true,
|
||||
},
|
||||
{
|
||||
name: "plain hex",
|
||||
input: "1db797b96febd334",
|
||||
expectedHash: "1db797b96febd334",
|
||||
isNested: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expectedHash: "",
|
||||
isNested: false,
|
||||
},
|
||||
{
|
||||
name: "invalid hex",
|
||||
input: "xxh3:notahexvalue",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash, isNested, err := ParseHash(tt.input)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if hash != tt.expectedHash {
|
||||
t.Errorf("Hash: got %q, expected %q", hash, tt.expectedHash)
|
||||
}
|
||||
if isNested != tt.isNested {
|
||||
t.Errorf("IsNested: got %t, expected %t", isNested, tt.isNested)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedChunkHash(t *testing.T) {
|
||||
// Golden values: the nested hash is the XXH3 of the concatenated
|
||||
// lowercase hex strings of the chunk hashes, in order
|
||||
chunk1 := "xxh3:d447b1ea40e6988b" // XXH3 of "hello world"
|
||||
chunk2 := "2d06800538d394c2" // XXH3 of ""
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chunkHashes []string
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "two chunks in order",
|
||||
chunkHashes: []string{chunk1, chunk2},
|
||||
expected: "6d51edccc9c05a8b",
|
||||
},
|
||||
{
|
||||
name: "order matters",
|
||||
chunkHashes: []string{chunk2, chunk1},
|
||||
expected: "596c127c7bcc8820",
|
||||
},
|
||||
{
|
||||
name: "single chunk",
|
||||
chunkHashes: []string{chunk1},
|
||||
expected: "b73081dff8fe443c",
|
||||
},
|
||||
{
|
||||
name: "mixed prefixes",
|
||||
chunkHashes: []string{chunk1, "xxh3:" + chunk2, chunk1},
|
||||
expected: "ca0b13abcdcd1661",
|
||||
},
|
||||
{
|
||||
name: "no chunks",
|
||||
chunkHashes: []string{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid chunk hash",
|
||||
chunkHashes: []string{chunk1, "notahexvalue"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nested, err := NestedChunkHash(tt.chunkHashes)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if nested != tt.expected {
|
||||
t.Errorf("NestedChunkHash: got %q, expected %q", nested, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedChunkHashAcceptsPrefixedHashes(t *testing.T) {
|
||||
plain := []string{"d447b1ea40e6988b", "2d06800538d394c2"}
|
||||
prefixed := []string{"xxh3:d447b1ea40e6988b", "N:xxh3:2d06800538d394c2"}
|
||||
|
||||
gotPlain, err := NestedChunkHash(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
gotPrefixed, err := NestedChunkHash(prefixed)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if gotPlain != gotPrefixed {
|
||||
t.Errorf("Prefixed hashes should give the same nested hash: %q != %q", gotPlain, gotPrefixed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package kdrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/backend/kdrive/api"
|
||||
"github.com/rclone/rclone/backend/kdrive/khash"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/chunksize"
|
||||
"github.com/rclone/rclone/lib/rest"
|
||||
"github.com/zeebo/xxh3"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxUploadParts is the maximum number of chunks the kDrive API accepts per file
|
||||
maxUploadParts = 10000
|
||||
|
||||
// maxChunkSize is the largest chunk size the kDrive API accepts
|
||||
maxChunkSize = 1 * 1000 * 1000 * 1000
|
||||
|
||||
// minChunkSize is the smallest chunk size the kDrive API accepts
|
||||
minChunkSize = 1 * 1000 * 1000
|
||||
)
|
||||
|
||||
// checkChunkSize checks the user supplied chunk_size against the
|
||||
// limits the kDrive API accepts
|
||||
func checkChunkSize(chunkSize fs.SizeSuffix) error {
|
||||
if chunkSize < minChunkSize {
|
||||
return fmt.Errorf("chunk_size %s is too small: the kDrive API requires chunks of at least 1 MB", chunkSize)
|
||||
}
|
||||
if chunkSize > maxChunkSize {
|
||||
return fmt.Errorf("chunk_size %s is too large: the kDrive API accepts chunks of at most 1 GB (1000000000 bytes)", chunkSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setChunkSize sets the chunk size, returning the old value
|
||||
func (f *Fs) setChunkSize(cs fs.SizeSuffix) (old fs.SizeSuffix, err error) {
|
||||
err = checkChunkSize(cs)
|
||||
if err == nil {
|
||||
old, f.opt.ChunkSize = f.opt.ChunkSize, cs
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// formatModTime formats the modification time of an upload as the
|
||||
// unix timestamp string the kDrive API expects
|
||||
func formatModTime(modTime time.Time) string {
|
||||
return fmt.Sprintf("%d", modTime.Unix())
|
||||
}
|
||||
|
||||
// uploadSession implements fs.ChunkWriter for kdrive multipart uploads
|
||||
type uploadSession struct {
|
||||
f *Fs
|
||||
token string
|
||||
uploadURL string
|
||||
fileInfo *api.Item
|
||||
fileSize int64 // total size of the file being uploaded
|
||||
chunkSize int64 // size of the chunks of this session
|
||||
|
||||
mu sync.Mutex
|
||||
chunkHashes map[int64]string // chunk number -> hash of the chunk contents
|
||||
}
|
||||
|
||||
// OpenChunkWriter returns the chunk size and a ChunkWriter
|
||||
//
|
||||
// Pass in the remote and the src object
|
||||
// You can also use options to hint at the desired chunk size
|
||||
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/start
|
||||
func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) {
|
||||
fileSize := src.Size()
|
||||
if fileSize < 0 {
|
||||
return info, nil, errors.New("kdrive can't upload files with unknown size")
|
||||
}
|
||||
|
||||
dir, leaf := path.Split(remote)
|
||||
dir = strings.TrimSuffix(dir, "/")
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
parentID, err := f.dirCache.FindDir(ctx, dir, true)
|
||||
if err != nil {
|
||||
return info, nil, fmt.Errorf("failed to find parent directory: %w", err)
|
||||
}
|
||||
|
||||
// Calculate the chunk size, honouring any fs.ChunkOption hint
|
||||
chunkSize := f.opt.ChunkSize
|
||||
for _, opt := range options {
|
||||
if chunkOpt, ok := opt.(*fs.ChunkOption); ok && chunkOpt.ChunkSize > 0 {
|
||||
chunkSize = fs.SizeSuffix(chunkOpt.ChunkSize)
|
||||
}
|
||||
}
|
||||
chunkSize = chunksize.Calculator(src, fileSize, maxUploadParts, chunkSize)
|
||||
if chunkSize < minChunkSize {
|
||||
chunkSize = minChunkSize
|
||||
}
|
||||
if chunkSize > maxChunkSize {
|
||||
chunkSize = maxChunkSize
|
||||
}
|
||||
|
||||
// Calculate the total number of chunks
|
||||
totalChunks := max((fileSize+int64(chunkSize)-1)/int64(chunkSize), 1)
|
||||
|
||||
sessionReq := struct {
|
||||
Conflict string `json:"conflict"`
|
||||
DirectoryID string `json:"directory_id"`
|
||||
FileName string `json:"file_name"`
|
||||
LastModifiedAt string `json:"last_modified_at"`
|
||||
TotalChunks int64 `json:"total_chunks"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
}{
|
||||
Conflict: "version",
|
||||
DirectoryID: parentID,
|
||||
FileName: f.opt.Enc.FromStandardName(norm.NFC.String(leaf)),
|
||||
LastModifiedAt: formatModTime(src.ModTime(ctx)),
|
||||
TotalChunks: totalChunks,
|
||||
TotalSize: fileSize,
|
||||
}
|
||||
|
||||
// https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/start
|
||||
opts := rest.Opts{
|
||||
Method: "POST",
|
||||
Path: fmt.Sprintf("/3/drive/%s/upload/session/start", f.opt.DriveID),
|
||||
}
|
||||
var sessionResp api.SessionStartResponse
|
||||
var resp *http.Response
|
||||
err = f.pacer.Call(func() (bool, error) {
|
||||
var callErr error
|
||||
resp, callErr = f.srv.CallJSON(ctx, &opts, &sessionReq, &sessionResp)
|
||||
callErr = sessionResp.ResultStatus.Update(callErr)
|
||||
return shouldRetry(ctx, resp, callErr)
|
||||
})
|
||||
if err != nil {
|
||||
return info, nil, fmt.Errorf("failed to start upload session: %w", err)
|
||||
}
|
||||
|
||||
info = fs.ChunkWriterInfo{
|
||||
ChunkSize: int64(chunkSize),
|
||||
Concurrency: f.opt.UploadConcurrency,
|
||||
}
|
||||
|
||||
writer = &uploadSession{
|
||||
f: f,
|
||||
token: sessionResp.Data.Token,
|
||||
uploadURL: sessionResp.Data.UploadURL,
|
||||
fileSize: fileSize,
|
||||
chunkSize: int64(chunkSize),
|
||||
chunkHashes: make(map[int64]string),
|
||||
}
|
||||
|
||||
fs.Debugf(f, "open chunk writer: started upload session for %d chunks of %v", totalChunks, chunkSize)
|
||||
return info, writer, nil
|
||||
}
|
||||
|
||||
// WriteChunk uploads a single chunk
|
||||
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/chunk
|
||||
func (u *uploadSession) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (bytesWritten int64, err error) {
|
||||
if chunkNumber < 0 {
|
||||
return -1, fmt.Errorf("invalid chunk number provided: %v", chunkNumber)
|
||||
}
|
||||
|
||||
// Calculate the size of this chunk from the session parameters so
|
||||
// the chunk can be streamed to the API in a single pass
|
||||
chunkLen := u.chunkSize
|
||||
if remaining := u.fileSize - int64(chunkNumber)*u.chunkSize; remaining < chunkLen {
|
||||
chunkLen = remaining
|
||||
}
|
||||
if chunkLen <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
sourceChunkNumber := chunkNumber + 1 // kDrive API uses 1-based numbering
|
||||
|
||||
// The chunk_hash parameter is optional, so the chunk is streamed
|
||||
// in a single pass, hashing the contents on the way with a
|
||||
// TeeReader. The server returns the hash of the chunk it received,
|
||||
// which is checked below. Retries seek back to the chunk start
|
||||
// and hash with a fresh hasher.
|
||||
var chunkHash string
|
||||
err = u.f.pacer.Call(func() (bool, error) {
|
||||
if _, seekErr := reader.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return false, fmt.Errorf("failed to rewind chunk %d: %w", sourceChunkNumber, seekErr)
|
||||
}
|
||||
chunkHasher := xxh3.New()
|
||||
|
||||
// https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/chunk
|
||||
chunkOpts := rest.Opts{
|
||||
Method: "POST",
|
||||
RootURL: u.uploadURL,
|
||||
Path: fmt.Sprintf("/3/drive/%s/upload/session/%s/chunk", u.f.opt.DriveID, u.token),
|
||||
Parameters: url.Values{
|
||||
"chunk_number": {fmt.Sprintf("%d", sourceChunkNumber)},
|
||||
"chunk_size": {fmt.Sprintf("%d", chunkLen)},
|
||||
"with": {"hash"},
|
||||
},
|
||||
Body: io.TeeReader(reader, chunkHasher),
|
||||
ContentLength: &chunkLen,
|
||||
}
|
||||
|
||||
var chunkResp api.ChunkUploadResponse
|
||||
resp, callErr := u.f.srv.CallJSON(ctx, &chunkOpts, nil, &chunkResp)
|
||||
callErr = chunkResp.ResultStatus.Update(callErr)
|
||||
if callErr != nil {
|
||||
return shouldRetry(ctx, resp, callErr)
|
||||
}
|
||||
|
||||
chunkHash = hex.EncodeToString(chunkHasher.Sum(nil))
|
||||
|
||||
// The server hashed the chunk it received, so compare with what we
|
||||
// sent to detect any corruption immediately
|
||||
if chunkResp.Data.Hash != "" {
|
||||
if serverHash, _, parseErr := khash.ParseHash(chunkResp.Data.Hash); parseErr == nil {
|
||||
if !strings.EqualFold(chunkHash, serverHash) {
|
||||
return false, fmt.Errorf("chunk %d hash mismatch: client=%s, server=%s", sourceChunkNumber, chunkHash, serverHash)
|
||||
}
|
||||
} else {
|
||||
fs.Debugf(u, "Failed to parse server chunk hash %q: %v", chunkResp.Data.Hash, parseErr)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
u.chunkHashes[int64(chunkNumber)] = chunkHash
|
||||
u.mu.Unlock()
|
||||
|
||||
fs.Debugf(u, "uploaded chunk %d (size: %d, hash: %s)", sourceChunkNumber, chunkLen, chunkHash)
|
||||
return chunkLen, nil
|
||||
}
|
||||
|
||||
// Close finalizes the upload session, verifies the hash of the uploaded
|
||||
// file and returns the created file info
|
||||
// @see https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/finish
|
||||
func (u *uploadSession) Close(ctx context.Context) error {
|
||||
// https://developer.infomaniak.com/docs/api/post/3/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D/finish
|
||||
opts := rest.Opts{
|
||||
Method: "POST",
|
||||
Path: fmt.Sprintf("/3/drive/%s/upload/session/%s/finish", u.f.opt.DriveID, u.token),
|
||||
}
|
||||
var resp *http.Response
|
||||
var finishResp api.SessionFinishResponse
|
||||
err := u.f.pacer.Call(func() (bool, error) {
|
||||
var callErr error
|
||||
resp, callErr = u.f.srv.CallJSON(ctx, &opts, nil, &finishResp)
|
||||
callErr = finishResp.ResultStatus.Update(callErr)
|
||||
return shouldRetry(ctx, resp, callErr)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to finish upload session: %w", err)
|
||||
}
|
||||
|
||||
u.fileInfo = &finishResp.Data.File
|
||||
fs.Debugf(u, "multipart upload completed: file id %d", finishResp.Data.File.ID)
|
||||
|
||||
return u.verifyHash(ctx)
|
||||
}
|
||||
|
||||
// verifyHash checks the hash of the uploaded file against the hashes of
|
||||
// the chunks we uploaded, deleting the file if it is corrupted
|
||||
func (u *uploadSession) verifyHash(ctx context.Context) (err error) {
|
||||
remoteHash := u.fileInfo.Hash
|
||||
if remoteHash == "" {
|
||||
// Hash might be missing in the finish response, try to fetch it
|
||||
obj := &Object{
|
||||
fs: u.f,
|
||||
id: strconv.Itoa(u.fileInfo.ID),
|
||||
}
|
||||
var hashErr error
|
||||
if remoteHash, hashErr = obj.retrieveHash(ctx); hashErr != nil {
|
||||
// skip verification
|
||||
fs.Debugf(u, "Failed to retrieve hash for verification: %v", hashErr)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
parsedRemoteHash, isNested, err := khash.ParseHash(remoteHash)
|
||||
if err != nil {
|
||||
fs.Debugf(u, "Failed to parse server hash %q: %v", remoteHash, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
// Rebuild the hash the server should have stored from the chunk
|
||||
// hashes we uploaded, in order
|
||||
chunkCount := int64(len(u.chunkHashes))
|
||||
if chunkCount == 0 {
|
||||
return nil
|
||||
}
|
||||
chunkHashes := make([]string, 0, chunkCount)
|
||||
for i := int64(0); i < chunkCount; i++ {
|
||||
chunkHash, ok := u.chunkHashes[i]
|
||||
if !ok {
|
||||
fs.Debugf(u, "Chunk %d hash missing, skipping hash verification", i)
|
||||
return nil
|
||||
}
|
||||
chunkHashes = append(chunkHashes, chunkHash)
|
||||
}
|
||||
|
||||
var localHash string
|
||||
if isNested {
|
||||
if localHash, err = khash.NestedChunkHash(chunkHashes); err != nil {
|
||||
fs.Debugf(u, "Failed to compute nested hash: %v", err)
|
||||
return nil
|
||||
}
|
||||
} else if chunkCount == 1 {
|
||||
localHash, _, _ = khash.ParseHash(chunkHashes[0])
|
||||
} else {
|
||||
// The server returned a simple hash for a multi chunk upload,
|
||||
// nothing to compare it against
|
||||
fs.Debugf(u, "Server returned a simple hash for a %d chunk upload, skipping hash verification", chunkCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.EqualFold(localHash, parsedRemoteHash) {
|
||||
err = fmt.Errorf(
|
||||
"multipart upload hash mismatch: local=%s, remote=%s",
|
||||
localHash, parsedRemoteHash,
|
||||
)
|
||||
|
||||
// Remove the corrupted file
|
||||
obj := &Object{
|
||||
fs: u.f,
|
||||
id: strconv.Itoa(u.fileInfo.ID),
|
||||
}
|
||||
if delErr := obj.Remove(ctx); delErr != nil {
|
||||
fs.Errorf(nil, "Failed to remove corrupted file after hash mismatch: %v", delErr)
|
||||
} else {
|
||||
fs.Debugf(nil, "Removed corrupted file after hash mismatch")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
fs.Debugf(u, "Multipart upload hash verified: %s", localHash)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Abort the upload session
|
||||
// @see https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D
|
||||
func (u *uploadSession) Abort(ctx context.Context) error {
|
||||
// https://developer.infomaniak.com/docs/api/delete/2/drive/%7Bdrive_id%7D/upload/session/%7Bsession_token%7D
|
||||
opts := rest.Opts{
|
||||
Method: "DELETE",
|
||||
Path: fmt.Sprintf("/2/drive/%s/upload/session/%s", u.f.opt.DriveID, u.token),
|
||||
}
|
||||
var resp *http.Response
|
||||
var cancelResp api.SessionCancelResponse
|
||||
err := u.f.pacer.Call(func() (bool, error) {
|
||||
var callErr error
|
||||
resp, callErr = u.f.srv.CallJSON(ctx, &opts, nil, &cancelResp)
|
||||
callErr = cancelResp.ResultStatus.Update(callErr)
|
||||
return shouldRetry(ctx, resp, callErr)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel upload session: %w", err)
|
||||
}
|
||||
|
||||
fs.Debugf(u, "upload session cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer
|
||||
func (u *uploadSession) String() string {
|
||||
return "kdrive upload session"
|
||||
}
|
||||
|
||||
// Verify interface compliance
|
||||
var _ fs.ChunkWriter = (*uploadSession)(nil)
|
||||
@@ -66,6 +66,7 @@ docs = [
|
||||
"internetarchive.md",
|
||||
"internxt.md",
|
||||
"jottacloud.md",
|
||||
"kdrive.md",
|
||||
"koofr.md",
|
||||
"linkbox.md",
|
||||
"mailru.md",
|
||||
|
||||
@@ -166,6 +166,7 @@ WebDAV or S3, that work out of the box.)
|
||||
{{< provider name="Impossible Cloud" home="https://www.impossiblecloud.com/" config="/s3/#impossible-cloud" >}}
|
||||
{{< 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-cloud" >}}
|
||||
|
||||
@@ -65,6 +65,7 @@ See the following for detailed instructions for
|
||||
- [Internet Archive](/internetarchive/)
|
||||
- [Internxt](/internxt/)
|
||||
- [Jottacloud](/jottacloud/)
|
||||
- [kDrive](/kdrive/)
|
||||
- [Koofr](/koofr/)
|
||||
- [Linkbox](/linkbox/)
|
||||
- [Mail.ru Cloud](/mailru/)
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
---
|
||||
title: "kDrive"
|
||||
description: "Rclone docs for kDrive"
|
||||
versionIntroduced: "v1.76"
|
||||
---
|
||||
|
||||
# kDrive
|
||||
|
||||
Paths are specified as `remote:path`
|
||||
|
||||
Paths may be as deep as required, e.g. `remote:directory/subdirectory`.
|
||||
|
||||
## Configuration
|
||||
|
||||
The initial setup for kDrive involves getting an OAuth2 token which you
|
||||
need to do in your browser. `rclone config` walks you through it.
|
||||
|
||||
kDrive uses the PKCE OAuth2 flow (RFC 7636), which means no
|
||||
`client_secret` is required. The `client_id` is optional and defaults
|
||||
to rclone's built-in application credentials.
|
||||
|
||||
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 client_id.
|
||||
OAuth Client Id.
|
||||
Leave blank normally.
|
||||
Enter a value. Press Enter to leave empty.
|
||||
client_id>
|
||||
|
||||
Option drive_id.
|
||||
ID of the kDrive to use.
|
||||
When showing a folder on kdrive, you can find the drive_id here:
|
||||
https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...
|
||||
Enter a value. Press Enter to leave empty.
|
||||
drive_id> 0654321
|
||||
|
||||
Use web browser to automatically authenticate rclone with remote?
|
||||
* Say Y if the machine running rclone has a web browser you can use
|
||||
* Say N if running rclone on a (remote) machine without web browser access
|
||||
If not sure try Y. If Y failed, try N.
|
||||
y/n> y
|
||||
|
||||
Waiting for code...
|
||||
Got code
|
||||
|
||||
Configuration complete.
|
||||
Options:
|
||||
- type: kdrive
|
||||
- drive_id: 0654321
|
||||
|
||||
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 by default the top level directory of a private kDrive
|
||||
storage is your "Private" folder. When used with a Pro account, a second
|
||||
folder "Common documents" is also available, which can be used as the
|
||||
root by setting `root_folder_id` to `common`.
|
||||
*These top-level folders cannot be modified*.
|
||||
|
||||
List all the files in your kDrive
|
||||
|
||||
```sh
|
||||
rclone ls remote:
|
||||
```
|
||||
|
||||
To copy a local directory to a kDrive directory called backup
|
||||
|
||||
```sh
|
||||
rclone copy /home/source remote: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.
|
||||
|
||||
kDrive supports the XXH3 checksum hash, so you can use the `--checksum`
|
||||
flag. XXH3 is only available for files uploaded as a single chunk:
|
||||
files uploaded in chunks (by rclone when larger than
|
||||
`--kdrive-chunk-size`, or by the kDrive apps which chunk files at their
|
||||
own sizes) have a "nested" hash which can't be compared with a plain
|
||||
XXH3, and rclone reports no hash for those files.
|
||||
|
||||
### 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 | ␠ |
|
||||
| RightSpace | 0x20 | ␠ |
|
||||
|
||||
Invalid UTF-8 bytes will also be [replaced](/overview/#invalid-utf8),
|
||||
as they can't be used in JSON strings.
|
||||
|
||||
### Overwriting files
|
||||
|
||||
When rclone overwrites an existing file, kDrive keeps the replaced
|
||||
content as a new version of the file. Old versions count against the
|
||||
drive quota, so repeatedly overwriting large files grows the storage
|
||||
usage. Manage old versions from the kDrive web app.
|
||||
|
||||
### Uploading files of unknown size
|
||||
|
||||
kDrive can't upload files of unknown size, so `rclone rcat` and
|
||||
streaming uploads are not supported.
|
||||
|
||||
### 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 numeric `Folder ID`) that rclone considers to be the
|
||||
root of your kDrive drive.
|
||||
|
||||
Normally you will leave this blank and rclone will use your private
|
||||
directory. You can also use one of the convenience shortcuts:
|
||||
|
||||
- `private`: your user private directory (default)
|
||||
- `common`: the kDrive common directory, shared among users
|
||||
|
||||
Or the numeric `Folder ID` of any directory in your drive. The root of
|
||||
the drive only contains the `Private` and `Common documents` folders,
|
||||
so a numeric ID is needed to use any other directory as root. In order
|
||||
to find the `Folder ID`, list the parent directory of the folder you
|
||||
wish rclone to use with `rclone lsf`; the first column of the output is
|
||||
the folder ID:
|
||||
|
||||
Example:
|
||||
```
|
||||
$ rclone lsf --dirs-only -Fip --csv remote:Documents
|
||||
456,Sub folder/
|
||||
789,Photos/
|
||||
```
|
||||
|
||||
So if the folder you want rclone to use is "Photos/", then use the
|
||||
returned id from ```rclone lsf``` command (ex. `789`) as the
|
||||
`root_folder_id` variable value in the config file.
|
||||
|
||||
<!-- 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 -->
|
||||
### Standard options
|
||||
|
||||
Here are the Standard options specific to kdrive (Infomaniak kDrive).
|
||||
|
||||
#### --kdrive-client-id
|
||||
|
||||
OAuth Client Id.
|
||||
|
||||
Leave blank normally.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: client_id
|
||||
- Env Var: RCLONE_KDRIVE_CLIENT_ID
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --kdrive-drive-id
|
||||
|
||||
ID of the kDrive to use.
|
||||
|
||||
When showing a folder on kdrive, you can find the drive_id here:
|
||||
https://ksuite.infomaniak.com/{account_id}/kdrive/app/drive/{drive_id}/files/...
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: drive_id
|
||||
- Env Var: RCLONE_KDRIVE_DRIVE_ID
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
### Advanced options
|
||||
|
||||
Here are the Advanced options specific to kdrive (Infomaniak kDrive).
|
||||
|
||||
#### --kdrive-token
|
||||
|
||||
OAuth Access Token as a JSON blob.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: token
|
||||
- Env Var: RCLONE_KDRIVE_TOKEN
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --kdrive-auth-url
|
||||
|
||||
Auth server URL.
|
||||
|
||||
Leave blank to use the provider defaults.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: auth_url
|
||||
- Env Var: RCLONE_KDRIVE_AUTH_URL
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --kdrive-token-url
|
||||
|
||||
Token server url.
|
||||
|
||||
Leave blank to use the provider defaults.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: token_url
|
||||
- Env Var: RCLONE_KDRIVE_TOKEN_URL
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --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,LeftSpace,RightSpace,InvalidUtf8,Dot
|
||||
|
||||
#### --kdrive-root-folder-id
|
||||
|
||||
The folder to use as root.
|
||||
|
||||
You may use either one of the convenience shortcuts [private or common]
|
||||
or an explicit folder ID.
|
||||
|
||||
- private: your user private directory
|
||||
- common: the kDrive common directory, shared among users
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: root_folder_id
|
||||
- Env Var: RCLONE_KDRIVE_ROOT_FOLDER_ID
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --kdrive-chunk-size
|
||||
|
||||
Files above this size are uploaded in chunks of this size.
|
||||
|
||||
The kDrive API accepts chunks between 1 MB and 1 GB, up to 10,000
|
||||
chunks per file and a total file size of 1 TiB. The chunk size is
|
||||
raised automatically for very large files to stay within the 10,000
|
||||
chunks limit.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: chunk_size
|
||||
- Env Var: RCLONE_KDRIVE_CHUNK_SIZE
|
||||
- Type: SizeSuffix
|
||||
- Default: 20Mi
|
||||
|
||||
#### --kdrive-upload-concurrency
|
||||
|
||||
Concurrency for chunked uploads.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: upload_concurrency
|
||||
- Env Var: RCLONE_KDRIVE_UPLOAD_CONCURRENCY
|
||||
- Type: int
|
||||
- Default: 4
|
||||
|
||||
#### --kdrive-endpoint
|
||||
|
||||
The API endpoint to use.
|
||||
|
||||
Leave blank normally. There is no reason to change the endpoint except for internal use or beta-testing.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: endpoint
|
||||
- Env Var: RCLONE_KDRIVE_ENDPOINT
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
#### --kdrive-description
|
||||
|
||||
Description of the remote.
|
||||
|
||||
Properties:
|
||||
|
||||
- Config: description
|
||||
- Env Var: RCLONE_KDRIVE_DESCRIPTION
|
||||
- Type: string
|
||||
- Required: false
|
||||
|
||||
<!-- autogenerated options stop -->
|
||||
@@ -0,0 +1,29 @@
|
||||
backend: kdrive
|
||||
name: kdrive
|
||||
tier: Tier 2
|
||||
maintainers: Community
|
||||
features_score: 5
|
||||
integration_tests: Unknown
|
||||
data_integrity: Unknown
|
||||
performance: Unknown
|
||||
adoption: Rare
|
||||
docs: Basic
|
||||
security: Unknown
|
||||
virtual: false
|
||||
remote: 'TestKdrive:'
|
||||
features:
|
||||
- About
|
||||
- CanHaveEmptyDirectories
|
||||
- CleanUp
|
||||
- Copy
|
||||
- DirCacheFlush
|
||||
- DirMove
|
||||
- ListP
|
||||
- ListR
|
||||
- Move
|
||||
- OpenChunkWriter
|
||||
- PublicLink
|
||||
- Purge
|
||||
hashes:
|
||||
- xxh3
|
||||
precision: 1000000000
|
||||
@@ -95,6 +95,7 @@
|
||||
<a class="dropdown-item" href="/internxt/">Internxt</a>
|
||||
<span class="dropdown-letter-heading">J – M</span>
|
||||
<a class="dropdown-item" href="/jottacloud/">Jottacloud</a>
|
||||
<a class="dropdown-item" href="/kdrive/">kDrive</a>
|
||||
<a class="dropdown-item" href="/koofr/">Koofr</a>
|
||||
<a class="dropdown-item" href="/linkbox/">Linkbox</a>
|
||||
<a class="dropdown-item" href="/mailru/">Mail.ru Cloud</a>
|
||||
|
||||
@@ -2181,6 +2181,11 @@ func Run(t *testing.T, opt *Opt) {
|
||||
t.Log("treating accountUpgradeRequired as success for PublicLink")
|
||||
link, err = "bogus link to "+remote, nil
|
||||
}
|
||||
// For Kdrive, link expiry is a premium feature
|
||||
if fsInfo.Name == "kdrive" && strings.Contains(err.Error(), "forbidden_error") && strings.Contains(err.Error(), "expiration date") {
|
||||
t.Log("treating forbidden on expiration date as success for PublicLink")
|
||||
link, err = "bogus link to "+remote, nil
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,3 +762,12 @@ backends:
|
||||
fastlist: false
|
||||
extratime: 2.0
|
||||
listretries: 5
|
||||
- backend: "kdrive"
|
||||
remote: "TestKdrive:"
|
||||
ignore:
|
||||
# The bisync normalization test fails as the kDrive server normalizes file names to NFC
|
||||
- TestBisyncRemoteRemote/normalization
|
||||
# the dates used in TestIndexChangedDirTime are far away in the future, this isn't handled by kdrive
|
||||
- TestIndexChangedDirTime
|
||||
fastlist: true
|
||||
extratime: 2.0
|
||||
Reference in new issue
Block a user