Files
rclone/backend/huaweidrive/huaweidrive.go
T
18899a58f3 Add Huawei Drive support
Add Huawei Drive backend implementation and tests

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Nick Craig-Wood <nick@craig-wood.com>
2026-05-01 13:41:07 +01:00

2472 lines
72 KiB
Go

// Package huaweidrive implements the Huawei Drive backend for rclone
package huaweidrive
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/rclone/rclone/backend/huaweidrive/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/config/obscure"
"github.com/rclone/rclone/fs/fserrors"
"github.com/rclone/rclone/fs/hash"
"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 = "115505059"
rcloneEncryptedClientSecret = "EHQ1exJbwabjEMrIYc81zcWaSXg6n_SA_vzeiC34_Dfyfwys2RoNBbuDGwlNFbBFCrb_RYdU_DMLtaI5NnM9F9FriQAVHeRaN1oROHFZtCU"
minSleep = 10 * time.Millisecond
maxSleep = 2 * time.Second
decayConstant = 2 // bigger for slower decay, exponential
rootURL = "https://driveapis.cloud.huawei.com.cn/drive/v1"
uploadURL = "https://driveapis.cloud.huawei.com.cn/upload/drive/v1"
defaultChunkSize = 8 * fs.Mebi
)
// OAuth2 configuration
var oauthConfig = &oauthutil.Config{
Scopes: []string{
"openid",
"profile",
"https://www.huawei.com/auth/drive",
"https://www.huawei.com/auth/drive.file",
},
AuthURL: "https://oauth-login.cloud.huawei.com/oauth2/v3/authorize",
TokenURL: "https://oauth-login.cloud.huawei.com/oauth2/v3/token",
ClientID: rcloneClientID,
ClientSecret: obscure.MustReveal(rcloneEncryptedClientSecret),
RedirectURL: oauthutil.RedirectURL,
AuthStyle: oauth2.AuthStyleInParams,
EndpointParams: url.Values{
"access_type": {"offline"},
},
}
// Register with Fs
func init() {
fs.Register(&fs.RegInfo{
Name: "huaweidrive",
Description: "Huawei Drive",
NewFs: NewFs,
MetadataInfo: &fs.MetadataInfo{
System: map[string]fs.MetadataHelp{
"content-type": {
Help: "MIME type of the object",
Type: "string",
ReadOnly: true,
},
"description": {
Help: "Description of the file",
Type: "string",
ReadOnly: false,
},
"sha256": {
Help: "SHA256 hash of the file",
Type: "string",
ReadOnly: true,
},
"btime": {
Help: "Time when the file was created (RFC 3339)",
Type: "RFC 3339",
ReadOnly: true,
},
"mtime": {
Help: "Time when the file content was last modified (RFC 3339)",
Type: "RFC 3339",
ReadOnly: true,
},
"utime": {
Help: "Time when the file was last edited by the current user (RFC 3339)",
Type: "RFC 3339",
ReadOnly: true,
},
"favorite": {
Help: "Whether the file is marked as favorite (true/false)",
Type: "string",
ReadOnly: false,
},
"recycled": {
Help: "Whether the file is in recycle bin (true/false)",
Type: "string",
ReadOnly: true,
},
"has-thumbnail": {
Help: "Whether the file has a thumbnail (true/false)",
Type: "string",
ReadOnly: true,
},
},
Help: `Huawei Drive supports reading and writing custom metadata.
User metadata can be set using the "properties" field in the API.
System metadata includes standard file information such as:
- content-type: MIME type of the object
- description: User-provided description
- favorite: Whether file is marked as favorite
- btime/mtime/utime: Various timestamps
- sha256: File hash
- recycled/has-thumbnail: File status flags
Custom metadata keys can be any string and will be stored in the file's properties.`,
},
Config: func(ctx context.Context, name string, m configmap.Mapper, config fs.ConfigIn) (*fs.ConfigOut, error) {
return oauthutil.ConfigOut("", &oauthutil.Options{
OAuth2Config: oauthConfig,
})
},
Options: append(oauthutil.SharedOptions, []fs.Option{{
Name: "root_folder_id",
Help: "ID of the root folder.\n\nNormally this is auto-detected, but if it fails or you want to speed up startup,\nyou can set it manually.\n\nLeave blank normally.",
Default: "",
Advanced: true,
}, {
Name: "chunk_size",
Help: "Upload chunk size.\n\nMust be a power of 2 >= 256k and <= 64MB.",
Default: defaultChunkSize,
Advanced: true,
}, {
Name: "list_chunk",
Default: 1000,
Help: "Size of listing chunk 1-1000.",
Advanced: true,
}, {
Name: "upload_cutoff",
Help: "Cutoff for switching to resumable upload.\n\nAny files larger than this will be uploaded using resumable upload.\nThe minimum is 0 and the maximum is 20 MiB (Huawei Drive API limit for single request uploads).",
Default: 20 * fs.Mebi,
Advanced: true,
}, {
Name: config.ConfigEncoding,
Help: config.ConfigEncodingHelp,
Advanced: true,
// Huawei Drive has strict filename restrictions
// API error: "The fileName can not be blank and can not contain '<>|:\"*?/\\', cannot equal .. or . and not exceed max limit."
// Additionally encode slash and some Unicode characters that might cause issues
Default: (encoder.Display |
encoder.EncodeBackSlash |
encoder.EncodeSlash |
encoder.EncodeInvalidUtf8 |
encoder.EncodeRightSpace |
encoder.EncodeLeftSpace |
encoder.EncodeLeftTilde |
encoder.EncodeRightPeriod |
encoder.EncodeLeftPeriod |
encoder.EncodeColon |
encoder.EncodePipe |
encoder.EncodeDoubleQuote |
encoder.EncodeLtGt |
encoder.EncodeQuestion |
encoder.EncodeAsterisk |
encoder.EncodeCtl |
encoder.EncodeDot),
}}...),
})
}
// Options defines the configuration for this backend
type Options struct {
RootFolderID string `config:"root_folder_id"`
ChunkSize fs.SizeSuffix `config:"chunk_size"`
ListChunk int `config:"list_chunk"`
UploadCutoff fs.SizeSuffix `config:"upload_cutoff"`
Enc encoder.MultiEncoder `config:"encoding"`
}
// itemMeta stores cached metadata for an item, used to resolve old paths
// in ChangeNotify events (e.g. when a file is moved/renamed/deleted).
type itemMeta struct {
parentID string // ID of the parent directory
name string // leaf name of the item
isDir bool // whether this is a directory
}
// Fs represents a remote Huawei Drive
type Fs struct {
name string // name of this remote
root string // root path in the remote
opt Options // parsed options
features *fs.Features // optional features
srv *rest.Client // the connection to the server
pacer *fs.Pacer // pacer for API calls
dirCache *dircache.DirCache // Map of directory path to directory id
rootFolderID string // ID of the root folder (detected at runtime)
domainURL string // regional domain URL from About:get
uploadURL string // upload URL (may be updated after domain detection)
itemMetaCacheMu sync.Mutex // protects itemMetaCache
itemMetaCache map[string]itemMeta // map of item ID to metadata for ChangeNotify
}
// Object describes a Huawei Drive object
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
sha256 string // SHA256 hash of the object
mimeType string // Content-Type of object from server (may not be file extension)
}
// ------------------------------------------------------------
// 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("Huawei Drive root '%s'", f.root)
}
// Precision return the precision of this Fs
func (f *Fs) Precision() time.Duration {
// Huawei Drive API does NOT preserve file modification times
// Despite accepting editedTime/createdTime parameters, the API
// always sets file times to the server's current timestamp
return fs.ModTimeNotSupported
}
// Hashes returns the supported hash sets.
func (f *Fs) Hashes() hash.Set {
return hash.Set(hash.SHA256)
}
// Features returns the optional features of this Fs
func (f *Fs) Features() *fs.Features {
return f.features
}
// About gets quota information
func (f *Fs) About(ctx context.Context) (usage *fs.Usage, err error) {
opts := rest.Opts{
Method: "GET",
Path: "/about",
Parameters: url.Values{
"fields": []string{"*"},
},
}
var about api.About
err = f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, nil, &about)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("failed to get about info: %w", err)
}
// Convert string values to int64
used, err := strconv.ParseInt(about.StorageQuota.UsedSpace, 10, 64)
if err != nil {
fs.Debugf(f, "Failed to parse used space %q: %v", about.StorageQuota.UsedSpace, err)
used = 0
}
total, err := strconv.ParseInt(about.StorageQuota.UserCapacity, 10, 64)
if err != nil {
fs.Debugf(f, "Failed to parse total capacity %q: %v", about.StorageQuota.UserCapacity, err)
total = 0
}
usage = &fs.Usage{
Used: fs.NewUsageValue(used), // bytes in use
Total: fs.NewUsageValue(total), // bytes total
}
return usage, nil
}
// parsePath parses a Huawei Drive 'url'
func parsePath(path string) (root string) {
root = strings.Trim(path, "/")
return
}
// rootAlias is the special file ID alias understood by the Huawei Drive API
// to mean "this user's drive root". GET /files/root returns the metadata of
// the real root folder, including its concrete id.
const rootAlias = "root"
// detectRootID discovers the user's drive root folder ID deterministically
// by calling GET /files/root. The Huawei Drive API treats the literal
// string "root" as an alias for the user's drive root, so this is the
// authoritative way to obtain the real root id without any guessing.
func (f *Fs) detectRootID(ctx context.Context) error {
opts := rest.Opts{
Method: "GET",
Path: "/files/" + rootAlias,
Parameters: url.Values{
"fields": []string{"id,fileName,mimeType,parentFolder"},
},
}
var info api.File
err := f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, nil, &info)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("failed to resolve drive root via /files/root: %w", err)
}
if info.ID == "" {
return fmt.Errorf("drive root response did not include an id")
}
f.rootFolderID = info.ID
fs.Debugf(f, "Resolved drive root folder ID: %s", f.rootFolderID)
return nil
}
// detectDomain calls About:get to discover the regional domain for better performance.
func (f *Fs) detectDomain(ctx context.Context) {
opts := rest.Opts{
Method: "GET",
Path: "/about",
Parameters: url.Values{
"fields": []string{"domain"},
},
}
var about api.About
err := f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, nil, &about)
return shouldRetry(ctx, resp, err)
})
if err != nil {
fs.Debugf(f, "Failed to detect regional domain: %v", err)
return
}
if about.Domain != "" {
if api.GlobalDomains[about.Domain] {
// Already using the global domain, no switch needed
fs.Debugf(f, "Using global domain %q", about.Domain)
} else if regional, ok := api.DomainToRootURL[about.Domain]; ok {
f.domainURL = regional
f.srv.SetRoot(regional + "/drive/v1")
f.uploadURL = regional + "/upload/drive/v1"
fs.Debugf(f, "Switched to regional domain %q -> %s", about.Domain, regional)
} else {
fs.Debugf(f, "Unknown domain %q, keeping global domain", about.Domain)
}
}
}
// 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)
// Create OAuth2 client - oauthutil.NewClient handles credential overrides automatically
client, _, err := oauthutil.NewClient(ctx, name, m, oauthConfig)
if err != nil {
return nil, fmt.Errorf("failed to configure Huawei Drive OAuth: %w", err)
}
// Create REST client with global domain initially
srv := rest.NewClient(client).SetRoot(rootURL)
f := &Fs{
name: name,
root: root,
opt: *opt,
srv: srv,
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
uploadURL: uploadURL,
itemMetaCache: make(map[string]itemMeta),
}
f.features = (&fs.Features{
CaseInsensitive: false,
DuplicateFiles: false,
ReadMimeType: true,
WriteMimeType: true,
CanHaveEmptyDirectories: true,
ReadMetadata: true,
WriteMetadata: true,
UserMetadata: true,
}).Fill(ctx, f)
// Set root folder ID
if f.opt.RootFolderID != "" {
f.rootFolderID = f.opt.RootFolderID
} else {
// Detect root folder ID by listing files
err = f.detectRootID(ctx)
if err != nil {
return nil, fmt.Errorf("failed to detect root folder ID: %w", err)
}
fs.Debugf(f, "'root_folder_id = %s' - save this in the config to speed up startup", f.rootFolderID)
}
// Detect regional domain via About:get
f.detectDomain(ctx)
// Create directory cache with detected root folder ID
f.dirCache = dircache.New(root, f.rootFolderID, 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 := &Fs{
name: f.name,
root: newRoot,
opt: f.opt,
features: f.features,
srv: f.srv,
pacer: f.pacer,
rootFolderID: f.rootFolderID,
domainURL: f.domainURL,
uploadURL: f.uploadURL,
}
tempF.dirCache = dircache.New(newRoot, f.rootFolderID, tempF)
_ = tempF.dirCache.FindRoot(ctx, false)
_, fileErr := tempF.NewObject(ctx, remote)
if fileErr == nil {
f.dirCache = tempF.dirCache
f.root = tempF.root
return f, fs.ErrorIsFile
}
return f, nil
}
return f, 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.File) (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) {
// Encode the leaf name to match the stored (encoded) name in the API
encodedLeaf := f.opt.Enc.FromStandardName(leaf)
// Find the leaf in pathID
found, err = f.listAll(ctx, pathID, func(item *api.File) bool {
if item.FileName == encodedLeaf && item.IsDir() {
pathIDOut = 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) {
// Encode the directory name
encodedLeaf := f.opt.Enc.FromStandardName(leaf)
if encodedLeaf == "" {
return "", fmt.Errorf("invalid directory name: cannot be empty")
}
// Create the directory
var req = api.CreateFolderRequest{
FileName: encodedLeaf,
MimeType: api.FolderMimeType,
Description: "folder",
}
// Set parent folder for the new directory
if pathID != "" {
req.ParentFolder = []string{pathID}
}
opts := rest.Opts{
Method: "POST",
Path: "/files",
Parameters: url.Values{
"fields": []string{"*"},
},
}
var info *api.File
err = f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, &req, &info)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return "", fmt.Errorf("couldn't create directory %q: %w", leaf, err)
}
if info == nil || info.ID == "" {
return "", fmt.Errorf("invalid response when creating directory %q", leaf)
}
return info.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.File) 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, fn listAllFn) (found bool, err error) {
// For non-root directories, we can directly query with parentFolder filter
if dirID != "" {
return f.listDirectory(ctx, dirID, fn)
}
// For root directory (empty dirID), use detected root folder ID.
// detectRootID always resolves a concrete id via /files/root, so this
// must be set before any list call reaches us.
if f.rootFolderID == "" {
return false, fmt.Errorf("listAll called with empty dirID and no root folder ID resolved")
}
return f.listDirectory(ctx, f.rootFolderID, fn)
}
// listDirectory lists files in a specific directory using parentFolder filter
func (f *Fs) listDirectory(ctx context.Context, dirID string, fn listAllFn) (found bool, err error) {
return f.listDirectoryWithFilter(ctx, dirID, nil, fn)
}
// listDirectoryWithFilter lists files in a specific directory with optional additional filters
func (f *Fs) listDirectoryWithFilter(ctx context.Context, dirID string, extraFilter *QueryFilter, fn listAllFn) (found bool, err error) {
// Create base query filter for parent folder
filter := NewQueryFilter()
filter.AddParentFolder(dirID)
// Add any extra filters
if extraFilter != nil {
filter.Conditions = append(filter.Conditions, extraFilter.Conditions...)
}
opts := rest.Opts{
Method: "GET",
Path: "/files",
Parameters: url.Values{
"fields": []string{"*"},
"containers": []string{"drive"},
},
}
// Add query filter if we have conditions
queryParam := filter.String()
if queryParam != "" {
opts.Parameters.Set("queryParam", queryParam)
}
// Set page size if specified
if f.opt.ListChunk > 0 && f.opt.ListChunk <= 1000 {
opts.Parameters.Set("pageSize", strconv.Itoa(f.opt.ListChunk))
}
fs.Debugf(f, "Listing directory %q", dirID)
for {
var result api.FileList
err = f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return false, fmt.Errorf("couldn't list directory %q: %w", dirID, err)
}
// Process files directly - no need for further filtering since API does it for us
for _, item := range result.Files {
if fn(&item) {
found = true
return found, nil
}
}
// Check for more pages
if result.NextPageToken == "" {
break
}
opts.Parameters.Set("pageToken", result.NextPageToken)
}
return found, nil
}
// QueryFilter represents a Huawei Drive query filter
type QueryFilter struct {
Conditions []string
}
// NewQueryFilter creates a new empty query filter
func NewQueryFilter() *QueryFilter {
return &QueryFilter{}
}
// AddParentFolder adds a parent folder filter condition
func (q *QueryFilter) AddParentFolder(parentID string) {
if parentID != "" {
q.Conditions = append(q.Conditions, fmt.Sprintf("'%s' in parentFolder", parentID))
}
}
// AddMimeType adds a mime type filter condition
func (q *QueryFilter) AddMimeType(mimeType string) {
if mimeType != "" {
q.Conditions = append(q.Conditions, fmt.Sprintf("mimeType = '%s'", mimeType))
}
}
// AddMimeTypeNot adds a negative mime type filter condition
func (q *QueryFilter) AddMimeTypeNot(mimeType string) {
if mimeType != "" {
q.Conditions = append(q.Conditions, fmt.Sprintf("mimeType != '%s'", mimeType))
}
}
// AddFileName adds a file name filter condition
func (q *QueryFilter) AddFileName(fileName string) {
if fileName != "" {
// Escape single quotes in filename
escapedName := strings.ReplaceAll(fileName, "'", "\\'")
q.Conditions = append(q.Conditions, fmt.Sprintf("fileName = '%s'", escapedName))
}
}
// AddFileNameContains adds a file name contains filter condition
func (q *QueryFilter) AddFileNameContains(fileName string) {
if fileName != "" {
// Escape single quotes in filename
escapedName := strings.ReplaceAll(fileName, "'", "\\'")
q.Conditions = append(q.Conditions, fmt.Sprintf("fileName contains '%s'", escapedName))
}
}
// AddRecycled adds a recycled status filter condition
func (q *QueryFilter) AddRecycled(recycled bool) {
q.Conditions = append(q.Conditions, fmt.Sprintf("recycled = %t", recycled))
}
// AddDirectlyRecycled adds a directly recycled status filter condition
func (q *QueryFilter) AddDirectlyRecycled(directlyRecycled bool) {
q.Conditions = append(q.Conditions, fmt.Sprintf("directlyRecycled = %t", directlyRecycled))
}
// AddFavorite adds a favorite status filter condition
func (q *QueryFilter) AddFavorite(favorite bool) {
q.Conditions = append(q.Conditions, fmt.Sprintf("favorite = %t", favorite))
}
// AddEditedTimeRange adds an edited time range filter condition
func (q *QueryFilter) AddEditedTimeRange(operator string, editedTime time.Time) {
if !editedTime.IsZero() {
timeStr := editedTime.Format(time.RFC3339)
q.Conditions = append(q.Conditions, fmt.Sprintf("editedTime %s '%s'", operator, timeStr))
}
}
// String returns the query filter as a string for the API
func (q *QueryFilter) String() string {
if len(q.Conditions) == 0 {
return ""
}
return strings.Join(q.Conditions, " and ")
}
// 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) {
directoryID, err := f.dirCache.FindDir(ctx, dir, false)
if err != nil {
return nil, err
}
directoryID, err = f.ensureDirectoryID(ctx, dir, directoryID)
if err != nil {
return nil, err
}
var iErr error
_, err = f.listAll(ctx, directoryID, func(info *api.File) bool {
remote := path.Join(dir, f.opt.Enc.ToStandardName(info.FileName))
if info.IsDir() {
// cache the directory ID for later lookups
f.dirCache.Put(remote, info.ID)
d := fs.NewDir(remote, info.EditedTime).SetID(info.ID)
d.SetItems(0) // Huawei Drive doesn't return item count
entries = append(entries, d)
} else {
o, err := f.newObjectWithInfo(ctx, remote, info)
if err != nil {
iErr = err
return true
}
entries = append(entries, o)
}
// Cache metadata for ChangeNotify old path resolution
f.itemMetaCacheMu.Lock()
f.itemMetaCache[info.ID] = itemMeta{
parentID: directoryID,
name: info.FileName,
isDir: info.IsDir(),
}
f.itemMetaCacheMu.Unlock()
return false
})
if err != nil {
return nil, err
}
if iErr != nil {
return nil, iErr
}
return entries, nil
}
func (f *Fs) ensureDirectoryID(ctx context.Context, dir, directoryID string) (string, error) {
if f.root == "" || f.rootFolderID == "" || directoryID != f.rootFolderID {
return directoryID, nil
}
f.dirCache.ResetRoot()
return f.dirCache.FindDir(ctx, dir, false)
}
// 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
leaf, directoryID, err = f.dirCache.FindPath(ctx, remote, true)
if err != nil {
return
}
// Temporary Object under construction
o = &Object{
fs: f,
remote: remote,
modTime: modTime, // Store the modification time in the object
size: size, // Store the size in the object
}
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) {
existingObj, err := f.newObjectWithInfo(ctx, src.Remote(), nil)
if err == nil {
err = existingObj.Update(ctx, in, src, options...)
if err != nil {
return nil, err
}
return existingObj, nil
}
if err != fs.ErrorObjectNotFound {
return nil, err
}
// The object doesn't exist so create it
o, err := f.PutUnchecked(ctx, in, src, options...)
if err == nil {
return o, nil
}
// PutUnchecked may fail because a sibling goroutine raced us and a file
// with the same name already exists on the server (the server rejects
// auto-rename, see uploadMultipart). Recover by locating the existing
// object and updating it.
if !isFileExistsErr(err) {
return nil, err
}
existingObj, lookupErr := f.newObjectWithInfo(ctx, src.Remote(), nil)
if lookupErr != nil {
return nil, err
}
if updateErr := existingObj.Update(ctx, in, src, options...); updateErr != nil {
return nil, updateErr
}
return existingObj, nil
}
// isFileExistsErr reports whether err is the Huawei Drive "file already
// exists" / "auto-rename rejected" error returned when we set autoRename=3.
func isFileExistsErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// 21004014 / FILENAME_EXISTED is the documented duplicate-name code.
return strings.Contains(msg, "21004014") ||
strings.Contains(msg, "FILENAME_EXISTED") ||
strings.Contains(msg, "FILE_NAME_EXISTED") ||
strings.Contains(msg, "FILE_EXIST") ||
strings.Contains(msg, "FILE_ALREADY_EXIST") ||
strings.Contains(msg, "DUPLICATE_FILE_NAME")
}
// PutUnchecked the object into the container
//
// This will produce duplicates if we haven't checked that there
// is an existing object first.
//
// 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) PutUnchecked(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
}
err = o.Update(ctx, in, src, options...)
if err != nil {
return nil, err
}
return o, nil
}
// 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
}
if check {
found, err := f.listAll(ctx, rootID, func(item *api.File) bool {
return true // return found
})
if err != nil {
return err
}
if found {
return fs.ErrorDirectoryNotEmpty
}
}
opts := rest.Opts{
Method: "DELETE",
Path: "/files/" + rootID,
}
err = f.pacer.Call(func() (bool, error) {
resp, err := f.srv.Call(ctx, &opts)
return shouldRetry(ctx, resp, err)
})
if err == nil {
// Clear the directory cache after successful deletion
f.dirCache.FlushDir(dir)
}
return err
}
// 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)
}
// 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)
}
// 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
}
srcPath := srcObj.fs.rootSlash() + srcObj.remote
dstPath := f.rootSlash() + remote
if srcPath == dstPath {
return nil, fmt.Errorf("can't copy %q -> %q as they are same name", srcPath, dstPath)
}
// Check if destination file already exists
dstObj, err := f.NewObject(ctx, remote)
if err == nil {
// Destination exists, we need to delete it first to ensure proper overwrite
fs.Debugf(f, "Copy: destination file exists, deleting before copy")
err = dstObj.Remove(ctx)
if err != nil {
return nil, fmt.Errorf("failed to remove existing destination file: %w", err)
}
} else if err != fs.ErrorObjectNotFound {
return nil, err
}
// Create the directory for the object if it doesn't exist
leaf, directoryID, err := f.dirCache.FindPath(ctx, remote, true)
if err != nil {
return nil, err
}
// Copy the object
opts := rest.Opts{
Method: "POST",
Path: "/files/" + srcObj.id + "/copy",
Parameters: url.Values{
"fields": []string{"*"},
},
}
copyReq := api.CopyFileRequest{
FileName: f.opt.Enc.FromStandardName(leaf),
}
// Set parent folder for the copy destination
if directoryID != "" {
copyReq.ParentFolder = []string{directoryID}
}
// Copy user metadata: merge source metadata with MetadataSet overrides
mergedMeta, err := fs.GetMetadataOptions(ctx, f, src, fs.MetadataAsOpenOptions(ctx))
if err != nil {
fs.Debugf(f, "Copy: failed to get metadata options: %v", err)
}
if len(mergedMeta) > 0 {
copyReq.Properties = make(map[string]interface{})
for key, value := range mergedMeta {
switch key {
case "content-type", "sha256", "btime", "mtime", "utime",
"recycled", "has-thumbnail":
// Skip read-only system metadata
case "description":
// Description is a top-level field, not a property
case "favorite":
// Favorite is not part of CopyFileRequest
default:
copyReq.Properties[key] = value
}
}
if len(copyReq.Properties) == 0 {
copyReq.Properties = nil
}
}
// If directoryID is empty or is the root parent ID, don't set parentFolder
// The API will copy the file to the drive root by default
var info *api.File
err = f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, &copyReq, &info)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, err
}
// Create the new object
dstObj, newErr := f.newObjectWithInfo(ctx, remote, info)
if newErr != nil {
return nil, newErr
}
// If metadata was requested but not properly preserved, set it manually
if len(mergedMeta) > 0 {
if huaweiObj, ok := dstObj.(*Object); ok {
if dstMetadata, err := huaweiObj.Metadata(ctx); err == nil {
// Check if all merged metadata was preserved
allMatched := true
for key, expectedValue := range mergedMeta {
if actualValue, exists := dstMetadata[key]; !exists || actualValue != expectedValue {
allMatched = false
break
}
}
// If metadata doesn't match exactly, set it manually
if !allMatched {
fs.Debugf(f, "Copy: metadata not preserved by API, setting manually")
if err := huaweiObj.SetMetadata(ctx, mergedMeta); err != nil {
fs.Errorf(f, "Copy: failed to set metadata on copied file: %v", err)
}
}
}
}
}
return dstObj, nil
}
// 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
//
// NOTE: Huawei Drive API has limitations with cross-directory moves:
// - Same-directory renames work perfectly
// - Cross-directory moves often fail silently (API returns success but file doesn't move)
// - We detect this by verifying the parentFolder after the API call
// - When detected, we return fs.ErrorCantMove to trigger rclone's copy+delete fallback
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
}
// Find the destination directory and leaf name
dstLeaf, dstDirectoryID, err := f.dirCache.FindPath(ctx, remote, true)
if err != nil {
return nil, err
}
// Get current parent folders
currentParents := srcObj.getParentIDs(ctx)
// Check what type of operation we need
needsDirMove := len(currentParents) == 0 || currentParents[0] != dstDirectoryID
srcLeaf := path.Base(srcObj.remote)
needsRename := srcLeaf != dstLeaf
// If no changes needed, return the existing object
if !needsDirMove && !needsRename {
return src, nil
}
// Get merged metadata BEFORE the move, because after renaming/moving
// the source path will no longer be valid for metadata reads
mergedMeta, _ := fs.GetMetadataOptions(ctx, f, src, fs.MetadataAsOpenOptions(ctx))
moveReq := api.UpdateFileRequest{}
// Set filename if we need to rename
if needsRename {
moveReq.FileName = f.opt.Enc.FromStandardName(dstLeaf)
}
var info *api.File
if needsDirMove {
if len(currentParents) == 0 || dstDirectoryID == "" {
return nil, fs.ErrorCantMove
}
info, err = f.updateFile(ctx, srcObj.id, url.Values{
"addParentFolder": []string{dstDirectoryID},
"removeParentFolder": []string{currentParents[0]},
}, &moveReq)
} else {
info, err = f.updateFile(ctx, srcObj.id, nil, &moveReq)
}
if err != nil {
return nil, fmt.Errorf("move operation failed: %w", err)
}
// Verify cross-directory moves actually worked.
if needsDirMove {
if info == nil || len(info.ParentFolder) == 0 {
fs.Debugf(f, "Cross-directory move returned no parentFolder. Falling back to copy+delete.")
return nil, fs.ErrorCantMove
}
parentFound := false
for _, actualParent := range info.ParentFolder {
if actualParent == dstDirectoryID {
parentFound = true
break
}
}
if !parentFound {
fs.Debugf(f, "Cross-directory move failed: expected parent=%q, got=%v. Falling back to copy+delete.",
dstDirectoryID, info.ParentFolder)
return nil, fs.ErrorCantMove
}
}
dstObj, err := f.newObjectWithInfo(ctx, remote, info)
if err != nil {
return nil, err
}
// Apply metadata overrides if any (using pre-move merged metadata)
if len(mergedMeta) > 0 {
if huaweiObj, ok := dstObj.(*Object); ok {
if err := huaweiObj.SetMetadata(ctx, mergedMeta); err != nil {
fs.Debugf(f, "Move: failed to set metadata: %v", err)
}
}
}
return dstObj, nil
}
func (f *Fs) updateFile(ctx context.Context, fileID string, parameters url.Values, req *api.UpdateFileRequest) (info *api.File, err error) {
if parameters == nil {
parameters = url.Values{}
}
parameters.Set("fields", "*")
opts := rest.Opts{
Method: "PATCH",
Path: "/files/" + fileID,
Parameters: parameters,
}
err = f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, req, &info)
return shouldRetry(ctx, resp, err)
})
return info, err
}
// 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, srcDirectoryID, srcLeaf, dstDirectoryID, dstLeaf, err := f.dirCache.DirMove(ctx, srcFs.dirCache, srcFs.root, srcRemote, f.root, dstRemote)
if err != nil {
return err
}
_ = srcLeaf // not used in this implementation
fs.Debugf(f, "DirMove: srcID=%q srcDirectoryID=%q srcLeaf=%q dstDirectoryID=%q dstLeaf=%q", srcID, srcDirectoryID, srcLeaf, dstDirectoryID, dstLeaf)
moveReq := api.UpdateFileRequest{
FileName: f.opt.Enc.FromStandardName(dstLeaf),
}
var info *api.File
if dstDirectoryID != srcDirectoryID {
if srcDirectoryID == "" || dstDirectoryID == "" {
return fmt.Errorf("can't move directory: source or destination parent ID is empty")
}
info, err = f.updateFile(ctx, srcID, url.Values{
"addParentFolder": []string{dstDirectoryID},
"removeParentFolder": []string{srcDirectoryID},
}, &moveReq)
} else {
info, err = f.updateFile(ctx, srcID, nil, &moveReq)
}
if err != nil {
return err
}
// Verify the move worked
if info != nil {
fs.Debugf(f, "DirMove result: id=%q fileName=%q parentFolder=%v", info.ID, info.FileName, info.ParentFolder)
}
// Clear the directory cache for both source and destination
srcFs.dirCache.FlushDir(srcRemote)
f.dirCache.FlushDir(dstRemote)
return nil
}
// CleanUp empties the trash
func (f *Fs) CleanUp(ctx context.Context) error {
opts := rest.Opts{
Method: "DELETE",
Path: "/files/recycle",
Parameters: url.Values{
"containers": []string{"drive"},
},
}
err := f.pacer.Call(func() (bool, error) {
resp, err := f.srv.Call(ctx, &opts)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("failed to empty recycle bin: %w", err)
}
fs.Debugf(f, "Successfully emptied recycle bin")
return nil
}
// ListR lists the objects and directories of the Fs starting
// from dir 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.
//
// Don't implement this unless you have a more efficient way
// of listing recursively that doing a directory traversal.
func (f *Fs) ListR(ctx context.Context, dir string, callback fs.ListRCallback) (err error) {
directoryID, err := f.dirCache.FindDir(ctx, dir, false)
if err != nil {
return err
}
directoryID, err = f.ensureDirectoryID(ctx, dir, directoryID)
if err != nil {
return err
}
// Use a simple recursive approach instead of goroutines to avoid complexity
return f.listRRecursive(ctx, dir, directoryID, callback)
}
// listRRecursive is a helper function for ListR that recursively lists directories
func (f *Fs) listRRecursive(ctx context.Context, dir string, directoryID string, callback fs.ListRCallback) error {
var entries fs.DirEntries
var dirs []struct {
path string
id string
}
// List current directory
_, err := f.listAll(ctx, directoryID, func(info *api.File) bool {
remote := path.Join(dir, f.opt.Enc.ToStandardName(info.FileName))
if info.IsDir() {
// It's a directory
directory := fs.NewDir(remote, info.EditedTime).SetID(info.ID)
entries = append(entries, directory)
// Store directory for later recursive processing
dirs = append(dirs, struct {
path string
id string
}{remote, info.ID})
} else {
// It's a file
o, err := f.newObjectWithInfo(ctx, remote, info)
if err != nil {
fs.Debugf(remote, "Skipping file in ListR: %v", err)
} else {
entries = append(entries, o)
}
}
// Cache metadata for ChangeNotify old path resolution
f.itemMetaCacheMu.Lock()
f.itemMetaCache[info.ID] = itemMeta{
parentID: directoryID,
name: info.FileName,
isDir: info.IsDir(),
}
f.itemMetaCacheMu.Unlock()
return false
})
if err != nil {
return err
}
// Send current directory entries to callback
if len(entries) > 0 {
if err := callback(entries); err != nil {
return err
}
}
// Recursively process subdirectories
for _, subdir := range dirs {
if err := f.listRRecursive(ctx, subdir.path, subdir.id, callback); err != nil {
return err
}
}
return nil
}
// ------------------------------------------------------------
// 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
}
// Hash returns the SHA256 of the object
func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) {
if t != hash.SHA256 {
return "", hash.ErrUnsupported
}
if o.sha256 == "" && o.size == 0 {
// Huawei Drive API does not return SHA256 for 0-byte files,
// so return the well-known SHA256 of empty content.
return "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", nil
}
return o.sha256, nil
}
// Size returns the size of an object in bytes
func (o *Object) Size() int64 {
if o.hasMetaData {
return o.size
}
err := o.readMetaData(context.TODO())
if err != nil {
fs.Logf(o, "Failed to read metadata: %v", err)
return 0
}
return o.size
}
// MimeType returns the content type of the object if known, or "" if not
func (o *Object) MimeType(ctx context.Context) string {
err := o.readMetaData(ctx)
if err != nil {
fs.Logf(o, "Failed to read metadata for mime type: %v", err)
return ""
}
return o.mimeType
}
// setMetaData sets the metadata from info
func (o *Object) setMetaData(info *api.File) (err error) {
if info.IsDir() {
return fs.ErrorIsDir
}
o.hasMetaData = true
o.size = info.Size
o.sha256 = info.SHA256
o.modTime = info.EditedTime
o.id = info.ID
o.mimeType = info.MimeType
return nil
}
// getParentIDs returns the parent folder IDs for this object
func (o *Object) getParentIDs(ctx context.Context) []string {
// We need to get the parent IDs from the API
info, err := o.fs.readMetaDataForPath(ctx, o.remote)
if err != nil {
fs.Debugf(o, "Failed to read metadata for parent IDs: %v", err)
return nil
}
return info.ParentFolder
}
// 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 {
return err
}
return o.setMetaData(info)
}
// readMetaDataForPath reads the metadata from the path
func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.File, err error) {
// defer log.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, func(item *api.File) bool {
// Convert the API filename to standard name for comparison
standardName := f.opt.Enc.ToStandardName(item.FileName)
if standardName == leaf && !item.IsDir() {
info = item
return true
}
return false
})
if err != nil {
return nil, err
}
if !found {
return nil, fs.ErrorObjectNotFound
}
return info, nil
}
// 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
//
// Huawei Drive API does not support setting modification times,
// so this always returns ErrorCantSetModTime.
func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
return fs.ErrorCantSetModTime
}
// Storable returns a boolean showing whether this object storable
func (o *Object) Storable() bool {
return true
}
// Open an object for read
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) {
if o.id == "" {
return nil, errors.New("can't download - no id")
}
if o.size == 0 {
return io.NopCloser(strings.NewReader("")), nil
}
fs.FixRangeOption(options, o.size)
opts := rest.Opts{
Method: "GET",
Path: "/files/" + o.id,
Parameters: url.Values{
"form": []string{"content"},
},
Options: options,
}
var resp *http.Response
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, fmt.Errorf("failed to open file %q for reading: %w", o.remote, err)
}
return resp.Body, nil
}
// 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) {
size := src.Size()
remote := o.Remote()
// 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
}
leaf = o.fs.opt.Enc.FromStandardName(leaf)
// Handle unknown size by spooling to a temp file to determine size
if size < 0 {
tmpFile, err := os.CreateTemp("", "rclone-huaweidrive-upload-")
if err != nil {
return fmt.Errorf("failed to create temp file for unknown size upload: %w", err)
}
defer func() {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
}()
size, err = io.Copy(tmpFile, in)
if err != nil {
return fmt.Errorf("failed to spool content for unknown size upload: %w", err)
}
if _, err = tmpFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek temp file: %w", err)
}
in = tmpFile
}
if size == 0 {
err = o.uploadEmpty(ctx, leaf, directoryID, src)
} else {
// Determine upload method based on size
// Cap multipart upload at 20 MiB (Huawei Drive API limit)
cutoff := int64(o.fs.opt.UploadCutoff)
const maxMultipartSize = 20 * 1024 * 1024
if cutoff > maxMultipartSize {
cutoff = maxMultipartSize
}
if size < cutoff {
err = o.uploadSimple(ctx, in, leaf, directoryID, size, src.ModTime(ctx), src)
} else {
err = o.uploadResume(ctx, in, leaf, directoryID, size, src.ModTime(ctx), src)
}
}
// Handle metadata if upload was successful
if err == nil {
// Get metadata from the source ObjectInfo and merge with options metadata
metadata, metaErr := fs.GetMetadataOptions(ctx, o.fs, src, options)
if metaErr != nil {
fs.Debugf(o, "Failed to get metadata options: %v", metaErr)
} else if len(metadata) > 0 {
if setMetaErr := o.SetMetadata(ctx, metadata); setMetaErr != nil {
fs.Debugf(o, "Failed to set metadata after upload: %v", setMetaErr)
}
}
}
return err
}
// uploadEmpty creates a zero-byte file using metadata-only file creation.
func (o *Object) uploadEmpty(ctx context.Context, leaf, directoryID string, src fs.ObjectInfo) error {
if leaf == "" {
return fmt.Errorf("invalid filename: cannot be empty")
}
if o.id != "" {
if err := o.Remove(ctx); err != nil && !errors.Is(err, fs.ErrorObjectNotFound) {
return fmt.Errorf("failed to replace existing file %q with empty file: %w", leaf, err)
}
o.id = ""
}
mimeType := "application/octet-stream"
if src != nil {
if mimeTyper, ok := src.(fs.MimeTyper); ok {
if srcMimeType := mimeTyper.MimeType(ctx); srcMimeType != "" {
mimeType = srcMimeType
}
}
}
if detectedMimeType := mime.TypeByExtension(path.Ext(leaf)); detectedMimeType != "" && mimeType == "application/octet-stream" {
mimeType = detectedMimeType
}
req := api.CreateFolderRequest{
FileName: leaf,
MimeType: mimeType,
}
if directoryID != "" {
req.ParentFolder = []string{directoryID}
}
// Reject server-side auto-rename on duplicate filenames; see uploadMultipart.
if o.id == "" {
req.AutoRename = 3
}
opts := rest.Opts{
Method: "POST",
Path: "/files",
Parameters: url.Values{
"fields": []string{"*"},
},
}
var info *api.File
err := o.fs.pacer.Call(func() (bool, error) {
resp, err := o.fs.srv.CallJSON(ctx, &opts, &req, &info)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("failed to create empty file %q: %w", leaf, err)
}
if info == nil {
return fmt.Errorf("no file info returned when creating empty file %q", leaf)
}
return o.setMetaData(info)
}
// uploadSimple uploads a file using simple upload for files < upload_cutoff
func (o *Object) uploadSimple(ctx context.Context, in io.Reader, leaf, directoryID string, size int64, modTime time.Time, src fs.ObjectInfo) (err error) {
// For files < 20MB, we should use multipart upload with metadata
// According to Huawei Drive API documentation
return o.uploadMultipart(ctx, in, leaf, directoryID, size, modTime, src)
}
// uploadMultipart uploads a file using multipart upload with metadata
func (o *Object) uploadMultipart(ctx context.Context, in io.Reader, leaf, directoryID string, size int64, modTime time.Time, src fs.ObjectInfo) (err error) {
// Use the main fs client for authentication, but with upload URL
srv := o.fs.srv
// Try to get MIME type from source object first, then fall back to extension detection
var mimeType string
if src != nil {
// Try to get MIME type from source object if it implements MimeTyper
if mimeTyper, ok := src.(fs.MimeTyper); ok {
mimeType = mimeTyper.MimeType(ctx)
}
}
// If we couldn't get MIME type from source, detect from extension
if mimeType == "" {
mimeType = mime.TypeByExtension(path.Ext(leaf))
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
// Basic filename validation
if leaf == "" {
return fmt.Errorf("invalid filename: cannot be empty")
}
// Create metadata
metadata := map[string]interface{}{
"fileName": leaf,
"mimeType": mimeType,
}
// Note: We don't set editedTime/createdTime here because
// Huawei Drive API ignores these parameters and always uses server time
// Set parent folder
if directoryID != "" {
metadata["parentFolder"] = []string{directoryID}
}
// Reject server-side auto-rename on duplicate filenames so the caller can
// detect conflicts and decide what to do (instead of getting a silently
// renamed "file (1)").
if o.id == "" {
metadata["autoRename"] = 3
}
// Create multipart form
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Add metadata part - use exact format from Huawei docs
metadataWriter, err := writer.CreatePart(textproto.MIMEHeader{
"Content-Type": []string{"application/json; charset=UTF-8"},
})
if err != nil {
return fmt.Errorf("failed to create metadata part: %w", err)
}
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
_, err = metadataWriter.Write(metadataJSON)
if err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
// Add file content part - use application/octet-stream as per Huawei docs
fileWriter, err := writer.CreatePart(textproto.MIMEHeader{
"Content-Type": []string{"application/octet-stream"},
})
if err != nil {
return fmt.Errorf("failed to create file part: %w", err)
}
// Read and write file content
_, err = io.Copy(fileWriter, in)
if err != nil {
return fmt.Errorf("failed to copy file content: %w", err)
}
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close multipart writer: %w", err)
}
bodyBytes := buf.Bytes()
opts := rest.Opts{
Method: "POST",
Parameters: url.Values{
"uploadType": []string{"multipart"},
"fields": []string{"*"},
},
ContentType: fmt.Sprintf("multipart/related; boundary=%s", writer.Boundary()),
RootURL: o.fs.uploadURL,
}
if o.id != "" {
// Update existing file
opts.Path = "/files/" + o.id
opts.Method = "PUT"
} else {
// Create new file
opts.Path = "/files"
}
var info *api.File
err = o.fs.pacer.Call(func() (bool, error) {
opts.Body = bytes.NewReader(bodyBytes)
resp, err := srv.CallJSON(ctx, &opts, nil, &info)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("failed to upload file %q: %w", leaf, err)
}
if info == nil {
return fmt.Errorf("no file info returned when uploading %q", leaf)
}
return o.setMetaData(info)
}
// uploadResume uploads a file using resumable upload for files >= upload_cutoff
func (o *Object) uploadResume(ctx context.Context, in io.Reader, leaf, directoryID string, size int64, modTime time.Time, src fs.ObjectInfo) (err error) {
// Use the main fs client for authentication
srv := o.fs.srv
// First, initialize the resumable upload
opts := rest.Opts{
Method: "POST",
Parameters: url.Values{
"uploadType": []string{"resume"},
"fields": []string{"*"},
},
ExtraHeaders: map[string]string{
"X-Upload-Content-Length": strconv.FormatInt(size, 10),
},
RootURL: o.fs.uploadURL,
}
// Try to get MIME type from source object first, then fall back to extension detection
var mimeType string
if src != nil {
// Try to get MIME type from source object if it implements MimeTyper
if mimeTyper, ok := src.(fs.MimeTyper); ok {
mimeType = mimeTyper.MimeType(ctx)
}
}
// If we couldn't get MIME type from source, detect from extension
if mimeType == "" {
mimeType = mime.TypeByExtension(path.Ext(leaf))
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
opts.ExtraHeaders["X-Upload-Content-Type"] = mimeType
if o.id != "" {
// Update existing file
opts.Path = "/files/" + o.id
opts.Method = "PUT"
} else {
// Create new file
opts.Path = "/files"
}
// Prepare metadata for resumable upload initialization
metadata := map[string]interface{}{
"fileName": leaf,
}
// Note: We don't set editedTime/createdTime here because
// Huawei Drive API ignores these parameters and always uses server time
if directoryID != "" {
metadata["parentFolder"] = []string{directoryID}
}
// Reject server-side auto-rename on duplicate filenames; see uploadMultipart.
if o.id == "" {
metadata["autoRename"] = 3
}
var resp *http.Response
var initResp api.ResumeUploadInitResponse
err = o.fs.pacer.Call(func() (bool, error) {
resp, err = srv.CallJSON(ctx, &opts, metadata, &initResp)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("failed to initialize resumable upload for %q: %w", leaf, err)
}
// Get the upload URL from Location header
location := resp.Header.Get("Location")
if location == "" {
return fmt.Errorf("no upload URL returned for %q", leaf)
}
// Now upload the content in chunks
chunkSize := int64(o.fs.opt.ChunkSize)
if chunkSize < 256*1024 {
chunkSize = 256 * 1024 // Minimum chunk size according to Huawei Drive API
}
if chunkSize > 64*1024*1024 {
chunkSize = 64 * 1024 * 1024 // Maximum single upload size
}
buf := make([]byte, chunkSize)
var offset int64
for offset < size {
n, err := io.ReadFull(in, buf)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return fmt.Errorf("failed to read chunk at offset %d: %w", offset, err)
}
chunk := buf[:n]
end := offset + int64(n) - 1
// Upload this chunk using the original client with OAuth authentication
chunkOpts := rest.Opts{
Method: "PUT",
RootURL: location,
Path: "", // Empty path since RootURL contains the full URL
ExtraHeaders: map[string]string{
"Content-Range": fmt.Sprintf("bytes %d-%d/%d", offset, end, size),
"Content-Length": strconv.Itoa(n),
"Content-Type": mimeType,
},
}
var info *api.File
var chunkResp *http.Response
err = o.fs.pacer.Call(func() (bool, error) {
chunkOpts.Body = bytes.NewReader(chunk)
var err error
chunkResp, err = srv.CallJSON(ctx, &chunkOpts, nil, &info)
// 308 means continue uploading (incomplete)
if chunkResp != nil && chunkResp.StatusCode == 308 {
// Clear info for incomplete upload
info = nil
return false, nil
}
// 200/201 means upload complete - let the response be processed
if chunkResp != nil && (chunkResp.StatusCode == 200 || chunkResp.StatusCode == 201) {
return false, err // Return err (should be nil) to allow response processing
}
return shouldRetry(ctx, chunkResp, err)
})
if err != nil {
return fmt.Errorf("failed to upload chunk at offset %d: %w", offset, err)
}
offset += int64(n)
// If we got file info back, we're done
if info != nil && info.ID != "" {
return o.setMetaData(info)
}
// Check if this was the last chunk and upload is complete (308 with all bytes uploaded)
if chunkResp != nil && chunkResp.StatusCode == 308 && offset >= size {
// Upload is complete, but we need to get file info
// Send a final request to get the created file info
finalOpts := rest.Opts{
Method: "PUT",
RootURL: location,
Path: "",
ExtraHeaders: map[string]string{
"Content-Range": fmt.Sprintf("bytes */%d", size),
"Content-Length": "0",
},
}
var finalInfo *api.File
err = o.fs.pacer.Call(func() (bool, error) {
finalOpts.Body = bytes.NewReader([]byte{})
finalResp, err := srv.CallJSON(ctx, &finalOpts, nil, &finalInfo)
if finalResp != nil && (finalResp.StatusCode == 200 || finalResp.StatusCode == 201) {
return false, err
}
return shouldRetry(ctx, finalResp, err)
})
if err != nil {
return fmt.Errorf("failed to finalize upload for %q: %w", leaf, err)
}
if finalInfo != nil && finalInfo.ID != "" {
return o.setMetaData(finalInfo)
}
}
}
return fmt.Errorf("upload completed but no file info received for %q", leaf)
}
// Remove an object
func (o *Object) Remove(ctx context.Context) error {
opts := rest.Opts{
Method: "DELETE",
Path: "/files/" + o.id,
}
return o.fs.pacer.Call(func() (bool, error) {
resp, err := o.fs.srv.Call(ctx, &opts)
return shouldRetry(ctx, resp, err)
})
}
// Metadata returns metadata for an object
//
// It should return nil if there is no Metadata
func (o *Object) Metadata(ctx context.Context) (metadata fs.Metadata, err error) {
// Get the full file information from the API
info, err := o.fs.readMetaDataForPath(ctx, o.remote)
if err != nil {
fs.Logf(o, "Failed to read metadata: %v", err)
return nil, err
}
// Create metadata map and populate it with available fields
metadata = make(fs.Metadata)
// Standard file information
if info.Description != "" {
metadata["description"] = info.Description
}
if info.MimeType != "" {
metadata["content-type"] = info.MimeType
}
if info.SHA256 != "" {
metadata["sha256"] = info.SHA256
}
if !info.CreatedTime.IsZero() {
metadata["btime"] = info.CreatedTime.Format(time.RFC3339Nano)
}
if !info.EditedTime.IsZero() {
metadata["mtime"] = info.EditedTime.Format(time.RFC3339Nano)
}
if !info.EditedByMeTime.IsZero() {
metadata["utime"] = info.EditedByMeTime.Format(time.RFC3339Nano)
}
// File attributes as string representations
if info.Favorite {
metadata["favorite"] = "true"
}
if info.Recycled {
metadata["recycled"] = "true"
}
if info.ExistThumbnail {
metadata["has-thumbnail"] = "true"
}
// Custom properties from Properties field
// Return these directly without prefix for user metadata compatibility
for key, value := range info.Properties {
if valueStr, ok := value.(string); ok {
metadata[key] = valueStr
} else if value != nil {
metadata[key] = fmt.Sprintf("%v", value)
}
}
// App settings from AppSettings field
// Also return these directly without prefix for user metadata compatibility
for key, value := range info.AppSettings {
if valueStr, ok := value.(string); ok {
metadata[key] = valueStr
} else if value != nil {
metadata[key] = fmt.Sprintf("%v", value)
}
}
return metadata, nil
}
// SetMetadata sets metadata for an Object
//
// It should return fs.ErrorNotImplemented if it can't set metadata
func (o *Object) SetMetadata(ctx context.Context, metadata fs.Metadata) error {
fs.Debugf(o, "SetMetadata called with %d keys: %v", len(metadata), metadata)
// Prepare the update request payload
updateReq := api.UpdateFileRequest{
Properties: make(map[string]interface{}),
AppSettings: make(map[string]interface{}),
}
// Process metadata and separate into properties and app settings
for key, value := range metadata {
switch key {
case "description":
// Standard description field
updateReq.Description = value
case "favorite":
if favorite, err := strconv.ParseBool(value); err == nil {
updateReq.Favorite = api.BoolPtr(favorite)
}
case "content-type":
// Allow setting/overriding MIME type
updateReq.MimeType = value
// Note: Other metadata like btime, mtime, sha256 are read-only
// and cannot be directly set via the update API
default:
// All other user metadata goes into Properties by default
// This makes the backend compatible with rclone's user metadata expectations
updateReq.Properties[key] = value
}
}
// Only proceed if we have something to update
if len(updateReq.Properties) == 0 && len(updateReq.AppSettings) == 0 &&
updateReq.Description == "" && updateReq.Favorite == nil &&
updateReq.MimeType == "" {
return nil
}
// Make the API call to update the file metadata
opts := rest.Opts{
Method: "PATCH",
Path: "/files/" + o.id,
Parameters: url.Values{
"fields": []string{"*"},
},
}
var info *api.File
err := o.fs.pacer.Call(func() (bool, error) {
resp, err := o.fs.srv.CallJSON(ctx, &opts, &updateReq, &info)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return fmt.Errorf("failed to set metadata: %w", err)
}
// The API response sometimes returns incomplete file info after PATCH
// Refresh from the API if needed
if info.Size == 0 && info.Version == 0 {
freshInfo, err := o.fs.readMetaDataForPath(ctx, o.Remote())
if err != nil {
return o.setMetaData(info)
}
return o.setMetaData(freshInfo)
}
return o.setMetaData(info)
}
// rootSlash returns root with a trailing slash
func (f *Fs) rootSlash() string {
if f.root == "" {
return f.root
}
return f.root + "/"
}
// shouldRetry returns a boolean as to whether this err deserves to be retried
func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
if fserrors.ContextError(ctx, &err) {
return false, err
}
// Handle specific Huawei Drive error responses
if resp != nil && err != nil {
errMsg := err.Error()
switch resp.StatusCode {
case 400:
if strings.Contains(errMsg, "21004001") || strings.Contains(errMsg, "LACK_OF_PARAM") {
return false, fserrors.NoRetryError(fmt.Errorf("missing required parameters: %w", err))
}
if strings.Contains(errMsg, "21004002") || strings.Contains(errMsg, "PARAM_INVALID") {
return false, fserrors.NoRetryError(fmt.Errorf("invalid parameters: %w", err))
}
if strings.Contains(errMsg, "21004009") || strings.Contains(errMsg, "PARENTFOLDER_NOT_FOUND") {
return false, fs.ErrorDirNotFound
}
return false, fserrors.NoRetryError(fmt.Errorf("bad request: %w", err))
case 401:
if strings.Contains(errMsg, "22044011") {
return true, err
}
return false, fserrors.NoRetryError(fmt.Errorf("unauthorized: %w", err))
case 403:
if strings.Contains(errMsg, "21004032") || strings.Contains(errMsg, "SERVICE_NOT_SUPPORT") {
return false, fserrors.NoRetryError(fmt.Errorf("service not supported: %w", err))
}
if strings.Contains(errMsg, "21004035") || strings.Contains(errMsg, "INSUFFICIENT_SCOPE") {
return false, fserrors.NoRetryError(fmt.Errorf("insufficient OAuth scope: %w", err))
}
if strings.Contains(errMsg, "21004036") || strings.Contains(errMsg, "INSUFFICIENT_PERMISSION") {
return false, fserrors.NoRetryError(fmt.Errorf("insufficient permissions: %w", err))
}
if strings.Contains(errMsg, "21074033") || strings.Contains(errMsg, "AGREEMENT_NOT_SIGNED") {
return false, fserrors.NoRetryError(fmt.Errorf("user agreement not signed: %w", err))
}
if strings.Contains(errMsg, "21084031") || strings.Contains(errMsg, "DATA_MIGRATING") {
return true, err
}
if strings.Contains(errMsg, "21084038") || strings.Contains(errMsg, "OUTER_SERVICE_ERROR") {
return true, err
}
return false, fserrors.NoRetryError(fmt.Errorf("forbidden: %w", err))
case 404:
return false, fserrors.NoRetryError(fmt.Errorf("not found: %w", err))
case 409:
return false, fs.ErrorDirExists
case 410:
if strings.Contains(errMsg, "21084100") || strings.Contains(errMsg, "CURSOR_EXPIRED") {
return false, fserrors.NoRetryError(fmt.Errorf("cursor expired: %w", err))
}
if strings.Contains(errMsg, "21084101") || strings.Contains(errMsg, "TEMP_DATA_CLEARED") {
return false, fserrors.NoRetryError(fmt.Errorf("temporary data cleared: %w", err))
}
return false, fserrors.NoRetryError(fmt.Errorf("gone: %w", err))
case 429:
if strings.Contains(errMsg, "22074292") || strings.Contains(errMsg, "APP_REQUEST_TOO_MANY") {
fs.Debugf(nil, "Downloaded too frequently, sleeping 60s before retry")
return true, pacer.RetryAfterError(err, 60*time.Second)
}
case 500:
if strings.Contains(errMsg, "21005006") || strings.Contains(errMsg, "SERVER_TEMP_ERROR") ||
strings.Contains(errMsg, "21085002") || strings.Contains(errMsg, "OUTER_SERVICE_UNAVAILABLE") ||
strings.Contains(errMsg, "21085006") {
return true, err
}
}
}
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
}
// 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
}
// DirCacheFlush resets the directory cache - used in testing as an optional interface
func (f *Fs) DirCacheFlush() {
f.dirCache.ResetRoot()
}
// changeNotifyStartCursor gets the start cursor for change notifications
func (f *Fs) changeNotifyStartCursor(ctx context.Context) (string, error) {
opts := rest.Opts{
Method: "GET",
Path: "/changes/getStartCursor",
Parameters: url.Values{
"fields": []string{"*"},
},
}
var result api.StartCursor
err := f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return "", fmt.Errorf("failed to get start cursor: %w", err)
}
return result.StartCursor, nil
}
// changeNotifyRunner polls for changes and notifies the caller
func (f *Fs) changeNotifyRunner(ctx context.Context, notifyFunc func(string, fs.EntryType), cursor string) (string, error) {
for {
opts := rest.Opts{
Method: "GET",
Path: "/changes",
Parameters: url.Values{
"fields": []string{"*"},
"cursor": []string{cursor},
},
}
var result api.ChangeList
err := f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, nil, &result)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return "", err
}
type entryInfo struct {
path string
entryType fs.EntryType
}
var pathsToClear []entryInfo
fs.Debugf(f, "changeNotifyRunner: got %d changes", len(result.Changes))
for _, change := range result.Changes {
// Check if we know the old path from dirCache (directories)
if oldPath, ok := f.dirCache.GetInv(change.FileID); ok {
entryType := fs.EntryObject
if change.File != nil && change.File.IsDir() {
entryType = fs.EntryDirectory
}
pathsToClear = append(pathsToClear, entryInfo{path: oldPath, entryType: entryType})
}
// Check if we know the old path from itemMetaCache (files and directories)
f.itemMetaCacheMu.Lock()
if cached, ok := f.itemMetaCache[change.FileID]; ok {
entryType := fs.EntryObject
if cached.isDir {
entryType = fs.EntryDirectory
}
if parentPath, ok := f.dirCache.GetInv(cached.parentID); ok {
oldPath := path.Join(parentPath, f.opt.Enc.ToStandardName(cached.name))
pathsToClear = append(pathsToClear, entryInfo{path: oldPath, entryType: entryType})
}
// Remove stale entry; future List calls will repopulate
delete(f.itemMetaCache, change.FileID)
}
f.itemMetaCacheMu.Unlock()
// Find the new path if the file still exists
if change.File != nil && !change.Deleted {
entryType := fs.EntryObject
if change.File.IsDir() {
entryType = fs.EntryDirectory
}
fileName := f.opt.Enc.ToStandardName(change.File.FileName)
if len(change.File.ParentFolder) > 0 {
for _, parent := range change.File.ParentFolder {
if parentPath, ok := f.dirCache.GetInv(parent); ok {
newPath := path.Join(parentPath, fileName)
pathsToClear = append(pathsToClear, entryInfo{path: newPath, entryType: entryType})
} else {
fs.Debugf(f, "Parent %s not in dirCache for file %s", parent, fileName)
}
}
} else {
pathsToClear = append(pathsToClear, entryInfo{path: fileName, entryType: entryType})
}
}
}
// Deduplicate and notify
visited := make(map[string]struct{})
for _, entry := range pathsToClear {
if _, ok := visited[entry.path]; ok {
continue
}
visited[entry.path] = struct{}{}
notifyFunc(entry.path, entry.entryType)
}
// Check for next page or completion
if result.NextCursor != "" {
cursor = result.NextCursor
continue
}
if result.NewStartCursor != "" {
// Always advance to the new start cursor provided by the API.
// Keeping a stale cursor risks CURSOR_EXPIRED on the next poll,
// which would cause a gap in change notifications.
return result.NewStartCursor, nil
}
return cursor, nil
}
}
// ChangeNotify calls the passed function with a path that has had changes.
// It polls the Huawei Drive Changes API at the given interval.
func (f *Fs) ChangeNotify(ctx context.Context, notifyFunc func(string, fs.EntryType), pollIntervalChan <-chan time.Duration) {
go func() {
cursor, err := f.changeNotifyStartCursor(ctx)
if err != nil {
fs.Infof(f, "Failed to get start cursor: %s", err)
}
var ticker *time.Ticker
var tickerC <-chan time.Time
for {
select {
case pollInterval, ok := <-pollIntervalChan:
if !ok {
if ticker != nil {
ticker.Stop()
}
return
}
if ticker != nil {
ticker.Stop()
ticker, tickerC = nil, nil
}
if pollInterval != 0 {
ticker = time.NewTicker(pollInterval)
tickerC = ticker.C
}
case <-tickerC:
if cursor == "" {
cursor, err = f.changeNotifyStartCursor(ctx)
if err != nil {
fs.Infof(f, "Failed to get start cursor: %s", err)
continue
}
}
fs.Debugf(f, "Checking for changes on remote")
cursor, err = f.changeNotifyRunner(ctx, notifyFunc, cursor)
if err != nil {
fs.Infof(f, "Change notify listener failure: %s", err)
}
}
}
}()
}
// UserInfo returns info about the connected user
func (f *Fs) UserInfo(ctx context.Context) (map[string]string, error) {
opts := rest.Opts{
Method: "GET",
Path: "/about",
Parameters: url.Values{
"fields": []string{"user"},
},
}
var about api.About
err := f.pacer.Call(func() (bool, error) {
resp, err := f.srv.CallJSON(ctx, &opts, nil, &about)
return shouldRetry(ctx, resp, err)
})
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
info := map[string]string{
"Name": about.User.DisplayName,
}
if about.User.PermissionID != "" {
info["Id"] = about.User.PermissionID
}
return info, nil
}
// Disconnect the current user by removing stored token
func (f *Fs) Disconnect(ctx context.Context) error {
config.FileDeleteKey(f.name, config.ConfigToken)
return nil
}
// Check the interfaces are satisfied
var (
_ fs.Fs = (*Fs)(nil)
_ fs.Purger = (*Fs)(nil)
_ fs.Copier = (*Fs)(nil)
_ fs.Mover = (*Fs)(nil)
_ fs.DirMover = (*Fs)(nil)
_ fs.ListRer = (*Fs)(nil)
_ fs.CleanUpper = (*Fs)(nil)
_ fs.Abouter = (*Fs)(nil)
_ fs.ChangeNotifier = (*Fs)(nil)
_ fs.UserInfoer = (*Fs)(nil)
_ fs.Disconnecter = (*Fs)(nil)
_ fs.DirCacheFlusher = (*Fs)(nil)
_ fs.Object = (*Object)(nil)
_ fs.MimeTyper = (*Object)(nil)
_ fs.Metadataer = (*Object)(nil)
_ dircache.DirCacher = (*Fs)(nil)
)