// Package mailru provides an interface to the Mail.ru Cloud storage system. package mailru import ( "bytes" "context" "errors" "fmt" gohash "hash" "io" "path" "path/filepath" "sort" "strconv" "strings" "sync" "time" "encoding/hex" "encoding/json" "net/http" "net/url" "github.com/rclone/rclone/backend/mailru/api" "github.com/rclone/rclone/backend/mailru/mrhash" "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/fshttp" "github.com/rclone/rclone/fs/hash" "github.com/rclone/rclone/fs/object" "github.com/rclone/rclone/fs/operations" "github.com/rclone/rclone/lib/encoder" "github.com/rclone/rclone/lib/oauthutil" "github.com/rclone/rclone/lib/pacer" "github.com/rclone/rclone/lib/readers" "github.com/rclone/rclone/lib/rest" "golang.org/x/oauth2" ) // Global constants const ( minSleepPacer = 10 * time.Millisecond maxSleepPacer = 2 * time.Second decayConstPacer = 2 // bigger for slower decay, exponential metaExpirySec = 20 * 60 // meta server expiration time serverExpirySec = 3 * 60 // download server expiration time shardExpirySec = 30 * 60 // upload server expiration time maxServerLocks = 4 // maximum number of locks per single download server maxInt32 = 2147483647 // used as limit in directory list request speedupMinSize = 512 // speedup is not optimal if data is smaller than average packet ) // Global errors var ( ErrorDirAlreadyExists = errors.New("directory already exists") ErrorDirSourceNotExists = errors.New("directory source does not exist") ErrorInvalidName = errors.New("invalid characters in object name") // MrHashType is the hash.Type for Mailru MrHashType hash.Type ) // Description of how to authorize var oauthConfig = &oauth2.Config{ ClientID: api.OAuthClientID, ClientSecret: "", Endpoint: oauth2.Endpoint{ AuthURL: api.OAuthURL, TokenURL: api.OAuthURL, AuthStyle: oauth2.AuthStyleInParams, }, } // Register with Fs func init() { MrHashType = hash.RegisterHash("mailru", "MailruHash", 40, mrhash.New) fs.Register(&fs.RegInfo{ Name: "mailru", Description: "Mail.ru Cloud", NewFs: NewFs, Options: []fs.Option{{ Name: "user", Help: "User name (usually email).", Required: true, }, { Name: "pass", Help: `Password. This must be an app password - rclone will not work with your normal password. See the Configuration section in the docs for how to make an app password. `, Required: true, IsPassword: true, }, { Name: "speedup_enable", Default: true, Advanced: false, Help: `Skip full upload if there is another file with same data hash. This feature is called "speedup" or "put by hash". It is especially efficient in case of generally available files like popular books, video or audio clips, because files are searched by hash in all accounts of all mailru users. It is meaningless and ineffective if source file is unique or encrypted. Please note that rclone may need local memory and disk space to calculate content hash in advance and decide whether full upload is required. Also, if rclone does not know file size in advance (e.g. in case of streaming or partial uploads), it will not even try this optimization.`, Examples: []fs.OptionExample{{ Value: "true", Help: "Enable", }, { Value: "false", Help: "Disable", }}, }, { Name: "speedup_file_patterns", Default: "*.mkv,*.avi,*.mp4,*.mp3,*.zip,*.gz,*.rar,*.pdf", Advanced: true, Help: `Comma separated list of file name patterns eligible for speedup (put by hash). Patterns are case insensitive and can contain '*' or '?' meta characters.`, Examples: []fs.OptionExample{{ Value: "", Help: "Empty list completely disables speedup (put by hash).", }, { Value: "*", Help: "All files will be attempted for speedup.", }, { Value: "*.mkv,*.avi,*.mp4,*.mp3", Help: "Only common audio/video files will be tried for put by hash.", }, { Value: "*.zip,*.gz,*.rar,*.pdf", Help: "Only common archives or PDF books will be tried for speedup.", }}, }, { Name: "speedup_max_disk", Default: fs.SizeSuffix(3 * 1024 * 1024 * 1024), Advanced: true, Help: `This option allows you to disable speedup (put by hash) for large files. Reason is that preliminary hashing can exhaust your RAM or disk space.`, Examples: []fs.OptionExample{{ Value: "0", Help: "Completely disable speedup (put by hash).", }, { Value: "1G", Help: "Files larger than 1Gb will be uploaded directly.", }, { Value: "3G", Help: "Choose this option if you have less than 3Gb free on local disk.", }}, }, { Name: "speedup_max_memory", Default: fs.SizeSuffix(32 * 1024 * 1024), Advanced: true, Help: `Files larger than the size given below will always be hashed on disk.`, Examples: []fs.OptionExample{{ Value: "0", Help: "Preliminary hashing will always be done in a temporary disk location.", }, { Value: "32M", Help: "Do not dedicate more than 32Mb RAM for preliminary hashing.", }, { Value: "256M", Help: "You have at most 256Mb RAM free for hash calculations.", }}, }, { Name: "check_hash", Default: true, Advanced: true, Help: "What should copy do if file checksum is mismatched or invalid.", Examples: []fs.OptionExample{{ Value: "true", Help: "Fail with error.", }, { Value: "false", Help: "Ignore and continue.", }}, }, { Name: "user_agent", Default: "", Advanced: true, Hide: fs.OptionHideBoth, Help: `HTTP user agent used internally by client. Defaults to "rclone/VERSION" or "--user-agent" provided on command line.`, }, { Name: "quirks", Default: "", Advanced: true, Hide: fs.OptionHideBoth, Help: `Comma separated list of internal maintenance flags. This option must not be used by an ordinary user. It is intended only to facilitate remote troubleshooting of backend issues. Strict meaning of flags is not documented and not guaranteed to persist between releases. Quirks will be removed when the backend grows stable. Supported quirks: atomicmkdir binlist unknowndirs`, }, { Name: config.ConfigEncoding, Help: config.ConfigEncodingHelp, Advanced: true, // Encode invalid UTF-8 bytes as json doesn't handle them properly. Default: (encoder.Display | encoder.EncodeWin | // :?"*<>| encoder.EncodeBackSlash | encoder.EncodeInvalidUtf8), }}, }) } // Options defines the configuration for this backend type Options struct { Username string `config:"user"` Password string `config:"pass"` UserAgent string `config:"user_agent"` CheckHash bool `config:"check_hash"` SpeedupEnable bool `config:"speedup_enable"` SpeedupPatterns string `config:"speedup_file_patterns"` SpeedupMaxDisk fs.SizeSuffix `config:"speedup_max_disk"` SpeedupMaxMem fs.SizeSuffix `config:"speedup_max_memory"` Quirks string `config:"quirks"` Enc encoder.MultiEncoder `config:"encoding"` } // retryErrorCodes is a slice of error codes that we will retry var retryErrorCodes = []int{ 429, // Too Many Requests. 500, // Internal Server Error 502, // Bad Gateway 503, // Service Unavailable 504, // Gateway Timeout 509, // Bandwidth Limit Exceeded } // shouldRetry returns a boolean as to whether this response and err // deserve to be retried. It returns the err as a convenience. // Retries password authorization (once) in a special case of access denied. func shouldRetry(ctx context.Context, res *http.Response, err error, f *Fs, opts *rest.Opts) (bool, error) { if fserrors.ContextError(ctx, &err) { return false, err } if res != nil && res.StatusCode == 403 && f.opt.Password != "" && !f.passFailed { reAuthErr := f.reAuthorize(opts, err) return reAuthErr == nil, err // return an original error } return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(res, retryErrorCodes), err } // errorHandler parses a non 2xx error response into an error func errorHandler(res *http.Response) (err error) { data, err := rest.ReadBody(res) if err != nil { return err } fileError := &api.FileErrorResponse{} err = json.NewDecoder(bytes.NewReader(data)).Decode(fileError) if err == nil { fileError.Message = fileError.Body.Home.Error return fileError } serverError := &api.ServerErrorResponse{} err = json.NewDecoder(bytes.NewReader(data)).Decode(serverError) if err == nil { return serverError } serverError.Message = string(data) if serverError.Message == "" || strings.HasPrefix(serverError.Message, "{") { // Replace empty or JSON response with a human-readable text. serverError.Message = res.Status } serverError.Status = res.StatusCode return serverError } // Fs represents a remote mail.ru type Fs struct { name string root string // root path opt Options // parsed options ci *fs.ConfigInfo // global config speedupGlobs []string // list of file name patterns eligible for speedup speedupAny bool // true if all file names are eligible for speedup features *fs.Features // optional features srv *rest.Client // REST API client cli *http.Client // underlying HTTP client (for authorize) m configmap.Mapper // config reader (for authorize) source oauth2.TokenSource // OAuth token refresher pacer *fs.Pacer // pacer for API calls metaMu sync.Mutex // lock for meta server switcher metaURL string // URL of meta server metaExpiry time.Time // time to refresh meta server shardMu sync.Mutex // lock for upload shard switcher shardURL string // URL of upload shard shardExpiry time.Time // time to refresh upload shard fileServers serverPool // file server dispatcher authMu sync.Mutex // mutex for authorize() passFailed bool // true if authorize() failed after 403 quirks quirks // internal maintenance flags } // NewFs constructs an Fs from the path, container:path func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) { // fs.Debugf(nil, ">>> NewFs %q %q", name, root) // Parse config into Options struct opt := new(Options) err := configstruct.Set(m, opt) if err != nil { return nil, err } if opt.Password != "" { opt.Password = obscure.MustReveal(opt.Password) } // Trailing slash signals us to optimize out one file check rootIsDir := strings.HasSuffix(root, "/") // However the f.root string should not have leading or trailing slashes root = strings.Trim(root, "/") ci := fs.GetConfig(ctx) f := &Fs{ name: name, root: root, opt: *opt, ci: ci, m: m, } if err := f.parseSpeedupPatterns(opt.SpeedupPatterns); err != nil { return nil, err } f.quirks.parseQuirks(opt.Quirks) f.pacer = fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleepPacer), pacer.MaxSleep(maxSleepPacer), pacer.DecayConstant(decayConstPacer))) f.features = (&fs.Features{ CaseInsensitive: true, CanHaveEmptyDirectories: true, // Can copy/move across mailru configs (almost, thus true here), but // only when they share common account (this is checked in Copy/Move). ServerSideAcrossConfigs: true, }).Fill(ctx, f) // Override few config settings and create a client newCtx, clientConfig := fs.AddConfig(ctx) if opt.UserAgent != "" { clientConfig.UserAgent = opt.UserAgent } clientConfig.NoGzip = true // Mimic official client, skip sending "Accept-Encoding: gzip" f.cli = fshttp.NewClient(newCtx) f.srv = rest.NewClient(f.cli) f.srv.SetRoot(api.APIServerURL) f.srv.SetHeader("Accept", "*/*") // Send "Accept: */*" with every request like official client f.srv.SetErrorHandler(errorHandler) if err = f.authorize(ctx, false); err != nil { return nil, err } f.fileServers = serverPool{ pool: make(pendingServerMap), fs: f, path: "/d", expirySec: serverExpirySec, } if !rootIsDir { _, dirSize, err := f.readItemMetaData(ctx, f.root) rootIsDir = (dirSize >= 0) // Ignore non-existing item and other errors if err == nil && !rootIsDir { root = path.Dir(f.root) if root == "." { root = "" } f.root = root // Return fs that points to the parent and signal rclone to do filtering return f, fs.ErrorIsFile } } return f, nil } // Internal maintenance flags (to be removed when the backend matures). // Primarily intended to facilitate remote support and troubleshooting. type quirks struct { binlist bool atomicmkdir bool unknowndirs bool } func (q *quirks) parseQuirks(option string) { for _, flag := range strings.Split(option, ",") { switch strings.ToLower(strings.TrimSpace(flag)) { case "binlist": // The official client sometimes uses a so called "bin" protocol, // implemented in the listBin file system method below. This method // is generally faster than non-recursive listM1 but results in // sporadic deserialization failures if total size of tree data // approaches 8Kb (?). The recursive method is normally disabled. // This quirk can be used to enable it for further investigation. // Remove this quirk when the "bin" protocol support is complete. q.binlist = true case "atomicmkdir": // At the moment rclone requires Mkdir to return success if the // directory already exists. However, such programs as borgbackup // use mkdir as a locking primitive and depend on its atomicity. // Remove this quirk when the above issue is investigated. q.atomicmkdir = true case "unknowndirs": // Accepts unknown resource types as folders. q.unknowndirs = true default: // Ignore unknown flags } } } // Note: authorize() is not safe for concurrent access as it updates token source func (f *Fs) authorize(ctx context.Context, force bool) (err error) { var t *oauth2.Token if !force { t, err = oauthutil.GetToken(f.name, f.m) } if err != nil || !tokenIsValid(t) { fs.Infof(f, "Valid token not found, authorizing.") ctx := oauthutil.Context(ctx, f.cli) t, err = oauthConfig.PasswordCredentialsToken(ctx, f.opt.Username, f.opt.Password) } if err == nil && !tokenIsValid(t) { err = errors.New("invalid token") } if err != nil { return fmt.Errorf("failed to authorize: %w", err) } if err = oauthutil.PutToken(f.name, f.m, t, false); err != nil { return err } // Mailru API server expects access token not in the request header but // in the URL query string, so we must use a bare token source rather than // client provided by oauthutil. // // WARNING: direct use of the returned token source triggers a bug in the // `(*token != *ts.token)` comparison in oauthutil.TokenSource.Token() // crashing with panic `comparing uncomparable type map[string]interface{}` // As a workaround, mimic oauth2.NewClient() wrapping token source in // oauth2.ReuseTokenSource _, ts, err := oauthutil.NewClientWithBaseClient(ctx, f.name, f.m, oauthConfig, f.cli) if err == nil { f.source = oauth2.ReuseTokenSource(nil, ts) } return err } func tokenIsValid(t *oauth2.Token) bool { return t.Valid() && t.RefreshToken != "" && t.Type() == "Bearer" } // reAuthorize is called after getting 403 (access denied) from the server. // It handles the case when user has changed password since a previous // rclone invocation and obtains a new access token, if needed. func (f *Fs) reAuthorize(opts *rest.Opts, origErr error) error { // lock and recheck the flag to ensure authorize() is attempted only once f.authMu.Lock() defer f.authMu.Unlock() if f.passFailed { return origErr } ctx := context.Background() // Note: reAuthorize is called by ShouldRetry, no context! fs.Debugf(f, "re-authorize with new password") if err := f.authorize(ctx, true); err != nil { f.passFailed = true return err } // obtain new token, if needed tokenParameter := "" if opts != nil && opts.Parameters.Get("token") != "" { tokenParameter = "token" } if opts != nil && opts.Parameters.Get("access_token") != "" { tokenParameter = "access_token" } if tokenParameter != "" { token, err := f.accessToken() if err != nil { f.passFailed = true return err } opts.Parameters.Set(tokenParameter, token) } return nil } // accessToken() returns OAuth token and possibly refreshes it func (f *Fs) accessToken() (string, error) { token, err := f.source.Token() if err != nil { return "", fmt.Errorf("cannot refresh access token: %w", err) } return token.AccessToken, nil } // absPath converts root-relative remote to absolute home path func (f *Fs) absPath(remote string) string { return path.Join("/", f.root, remote) } // relPath converts absolute home path to root-relative remote // Note that f.root can not have leading and trailing slashes func (f *Fs) relPath(absPath string) (string, error) { target := strings.Trim(absPath, "/") if f.root == "" { return target, nil } if target == f.root { return "", nil } if strings.HasPrefix(target+"/", f.root+"/") { return target[len(f.root)+1:], nil } return "", fmt.Errorf("path %q should be under %q", absPath, f.root) } // metaServer returns URL of current meta server func (f *Fs) metaServer(ctx context.Context) (string, error) { f.metaMu.Lock() defer f.metaMu.Unlock() if f.metaURL != "" && time.Now().Before(f.metaExpiry) { return f.metaURL, nil } opts := rest.Opts{ RootURL: api.DispatchServerURL, Method: "GET", Path: "/m", } var ( res *http.Response url string err error ) err = f.pacer.Call(func() (bool, error) { res, err = f.srv.Call(ctx, &opts) if err == nil { url, err = readBodyWord(res) } return fserrors.ShouldRetry(err), err }) if err != nil { closeBody(res) return "", err } f.metaURL = url f.metaExpiry = time.Now().Add(metaExpirySec * time.Second) fs.Debugf(f, "new meta server: %s", f.metaURL) return f.metaURL, nil } // readBodyWord reads the single line response to completion // and extracts the first word from the first line. func readBodyWord(res *http.Response) (word string, err error) { var body []byte body, err = rest.ReadBody(res) if err == nil { line := strings.Trim(string(body), " \r\n") word = strings.Split(line, " ")[0] } if word == "" { return "", errors.New("empty reply from dispatcher") } return word, nil } // readItemMetaData returns a file/directory info at given full path // If it can't be found it fails with fs.ErrorObjectNotFound // For the return value `dirSize` please see Fs.itemToEntry() func (f *Fs) readItemMetaData(ctx context.Context, path string) (entry fs.DirEntry, dirSize int, err error) { token, err := f.accessToken() if err != nil { return nil, -1, err } opts := rest.Opts{ Method: "GET", Path: "/api/m1/file", Parameters: url.Values{ "access_token": {token}, "home": {f.opt.Enc.FromStandardPath(path)}, "offset": {"0"}, "limit": {strconv.Itoa(maxInt32)}, }, } var info api.ItemInfoResponse err = f.pacer.Call(func() (bool, error) { res, err := f.srv.CallJSON(ctx, &opts, nil, &info) return shouldRetry(ctx, res, err, f, &opts) }) if err != nil { if apiErr, ok := err.(*api.FileErrorResponse); ok { switch apiErr.Status { case 404: err = fs.ErrorObjectNotFound case 400: fs.Debugf(f, "object %q status %d (%s)", path, apiErr.Status, apiErr.Message) err = fs.ErrorObjectNotFound } } return } entry, dirSize, err = f.itemToDirEntry(ctx, &info.Body) return } // itemToEntry converts API item to rclone directory entry // The dirSize return value is: // // <0 - for a file or in case of error // =0 - for an empty directory // >0 - for a non-empty directory func (f *Fs) itemToDirEntry(ctx context.Context, item *api.ListItem) (entry fs.DirEntry, dirSize int, err error) { remote, err := f.relPath(f.opt.Enc.ToStandardPath(item.Home)) if err != nil { return nil, -1, err } modTime := time.Unix(int64(item.Mtime), 0) isDir, err := f.isDir(item.Kind, remote) if err != nil { return nil, -1, err } if isDir { dir := fs.NewDir(remote, modTime).SetSize(item.Size) return dir, item.Count.Files + item.Count.Folders, nil } binHash, err := mrhash.DecodeString(item.Hash) if err != nil { return nil, -1, err } file := &Object{ fs: f, remote: remote, hasMetaData: true, size: item.Size, mrHash: binHash, modTime: modTime, } return file, -1, nil } // isDir returns true for directories, false for files func (f *Fs) isDir(kind, path string) (bool, error) { switch kind { case "": return false, errors.New("empty resource type") case "file": return false, nil case "folder": // fall thru case "camera-upload", "mounted", "shared": fs.Debugf(f, "[%s]: folder has type %q", path, kind) default: if !f.quirks.unknowndirs { return false, fmt.Errorf("unknown resource type %q", kind) } fs.Errorf(f, "[%s]: folder has unknown type %q", path, kind) } return true, nil } // List the objects and directories in dir into entries. // The entries can be returned in any order but should be for a complete directory. // dir should be "" to list the root, and should not have trailing slashes. // This should return ErrDirNotFound if the directory isn't found. func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) { // fs.Debugf(f, ">>> List: %q", dir) if f.quirks.binlist { entries, err = f.listBin(ctx, f.absPath(dir), 1) } else { entries, err = f.listM1(ctx, f.absPath(dir), 0, maxInt32) } if err == nil && f.ci.LogLevel >= fs.LogLevelDebug { names := []string{} for _, entry := range entries { names = append(names, entry.Remote()) } sort.Strings(names) // fs.Debugf(f, "List(%q): %v", dir, names) } return } // list using protocol "m1" func (f *Fs) listM1(ctx context.Context, dirPath string, offset int, limit int) (entries fs.DirEntries, err error) { token, err := f.accessToken() if err != nil { return nil, err } params := url.Values{} params.Set("access_token", token) params.Set("offset", strconv.Itoa(offset)) params.Set("limit", strconv.Itoa(limit)) data := url.Values{} data.Set("home", f.opt.Enc.FromStandardPath(dirPath)) opts := rest.Opts{ Method: "POST", Path: "/api/m1/folder", Parameters: params, Body: strings.NewReader(data.Encode()), ContentType: api.BinContentType, } var ( info api.FolderInfoResponse res *http.Response ) err = f.pacer.Call(func() (bool, error) { res, err = f.srv.CallJSON(ctx, &opts, nil, &info) return shouldRetry(ctx, res, err, f, &opts) }) if err != nil { apiErr, ok := err.(*api.FileErrorResponse) if ok && apiErr.Status == 404 { return nil, fs.ErrorDirNotFound } return nil, err } isDir, err := f.isDir(info.Body.Kind, dirPath) if err != nil { return nil, err } if !isDir { return nil, fs.ErrorIsFile } for _, item := range info.Body.List { entry, _, err := f.itemToDirEntry(ctx, &item) if err == nil { entries = append(entries, entry) } else { fs.Debugf(f, "Excluding path %q from list: %v", item.Home, err) } } return entries, nil } // list using protocol "bin" func (f *Fs) listBin(ctx context.Context, dirPath string, depth int) (entries fs.DirEntries, err error) { options := api.ListOptDefaults req := api.NewBinWriter() req.WritePu16(api.OperationFolderList) req.WriteString(f.opt.Enc.FromStandardPath(dirPath)) req.WritePu32(int64(depth)) req.WritePu32(int64(options)) req.WritePu32(0) token, err := f.accessToken() if err != nil { return nil, err } metaURL, err := f.metaServer(ctx) if err != nil { return nil, err } opts := rest.Opts{ Method: "POST", RootURL: metaURL, Parameters: url.Values{ "client_id": {api.OAuthClientID}, "token": {token}, }, ContentType: api.BinContentType, Body: req.Reader(), } var res *http.Response err = f.pacer.Call(func() (bool, error) { res, err = f.srv.Call(ctx, &opts) return shouldRetry(ctx, res, err, f, &opts) }) if err != nil { closeBody(res) return nil, err } r := api.NewBinReader(res.Body) defer closeBody(res) // read status switch status := r.ReadByteAsInt(); status { case api.ListResultOK: // go on... case api.ListResultNotExists: return nil, fs.ErrorDirNotFound default: return nil, fmt.Errorf("directory list error %d", status) } t := &treeState{ f: f, r: r, options: options, rootDir: parentDir(dirPath), lastDir: "", level: 0, } t.currDir = t.rootDir // read revision if err := t.revision.Read(r); err != nil { return nil, err } // read space if (options & api.ListOptTotalSpace) != 0 { t.totalSpace = int64(r.ReadULong()) } if (options & api.ListOptUsedSpace) != 0 { t.usedSpace = int64(r.ReadULong()) } t.fingerprint = r.ReadBytesByLength() // deserialize for { entry, err := t.NextRecord() if err != nil { break } if entry != nil { entries = append(entries, entry) } } if err != nil && err != fs.ErrorListAborted { fs.Debugf(f, "listBin failed at offset %d: %v", r.Count(), err) return nil, err } return entries, nil } func (t *treeState) NextRecord() (fs.DirEntry, error) { r := t.r parseOp := r.ReadByteAsShort() if r.Error() != nil { return nil, r.Error() } switch parseOp { case api.ListParseDone: return nil, fs.ErrorListAborted case api.ListParsePin: if t.lastDir == "" { return nil, errors.New("last folder is null") } t.currDir = t.lastDir t.level++ return nil, nil case api.ListParsePinUpper: if t.currDir == t.rootDir { return nil, nil } if t.level <= 0 { return nil, errors.New("no parent folder") } t.currDir = parentDir(t.currDir) t.level-- return nil, nil case api.ListParseUnknown15: skip := int(r.ReadPu32()) for i := 0; i < skip; i++ { r.ReadPu32() r.ReadPu32() } return nil, nil case api.ListParseReadItem: // get item (see below) default: return nil, fmt.Errorf("unknown parse operation %d", parseOp) } // get item head := r.ReadIntSpl() itemType := head & 3 if (head & 4096) != 0 { t.dunnoNodeID = r.ReadNBytes(api.DunnoNodeIDLength) } name := t.f.opt.Enc.FromStandardPath(string(r.ReadBytesByLength())) t.dunno1 = int(r.ReadULong()) t.dunno2 = 0 t.dunno3 = 0 if r.Error() != nil { return nil, r.Error() } var ( modTime time.Time size int64 binHash []byte dirSize int64 isDir = true ) switch itemType { case api.ListItemMountPoint: t.treeID = r.ReadNBytes(api.TreeIDLength) t.dunno2 = int(r.ReadULong()) t.dunno3 = int(r.ReadULong()) case api.ListItemFolder: t.dunno2 = int(r.ReadULong()) case api.ListItemSharedFolder: t.dunno2 = int(r.ReadULong()) t.treeID = r.ReadNBytes(api.TreeIDLength) case api.ListItemFile: isDir = false modTime = r.ReadDate() size = int64(r.ReadULong()) binHash = r.ReadNBytes(mrhash.Size) default: return nil, fmt.Errorf("unknown item type %d", itemType) } if isDir { t.lastDir = path.Join(t.currDir, name) if (t.options & api.ListOptDelete) != 0 { t.dunnoDel1 = int(r.ReadPu32()) t.dunnoDel2 = int(r.ReadPu32()) } if (t.options & api.ListOptFolderSize) != 0 { dirSize = int64(r.ReadULong()) } } if r.Error() != nil { return nil, r.Error() } if t.f.ci.LogLevel >= fs.LogLevelDebug { ctime, _ := modTime.MarshalJSON() fs.Debugf(t.f, "binDir %d.%d %q %q (%d) %s", t.level, itemType, t.currDir, name, size, ctime) } if t.level != 1 { // TODO: implement recursion and ListR // Note: recursion is broken because maximum buffer size is 8K return nil, nil } remote, err := t.f.relPath(path.Join(t.currDir, name)) if err != nil { return nil, err } if isDir { return fs.NewDir(remote, modTime).SetSize(dirSize), nil } obj := &Object{ fs: t.f, remote: remote, hasMetaData: true, size: size, mrHash: binHash, modTime: modTime, } return obj, nil } type treeState struct { f *Fs r *api.BinReader options int rootDir string currDir string lastDir string level int revision treeRevision totalSpace int64 usedSpace int64 fingerprint []byte dunno1 int dunno2 int dunno3 int dunnoDel1 int dunnoDel2 int dunnoNodeID []byte treeID []byte } type treeRevision struct { ver int16 treeID []byte treeIDNew []byte bgn uint64 bgnNew uint64 } func (rev *treeRevision) Read(data *api.BinReader) error { rev.ver = data.ReadByteAsShort() switch rev.ver { case 0: // Revision() case 1, 2: rev.treeID = data.ReadNBytes(api.TreeIDLength) rev.bgn = data.ReadULong() case 3, 4: rev.treeID = data.ReadNBytes(api.TreeIDLength) rev.bgn = data.ReadULong() rev.treeIDNew = data.ReadNBytes(api.TreeIDLength) rev.bgnNew = data.ReadULong() case 5: rev.treeID = data.ReadNBytes(api.TreeIDLength) rev.bgn = data.ReadULong() rev.treeIDNew = data.ReadNBytes(api.TreeIDLength) default: return fmt.Errorf("unknown directory revision %d", rev.ver) } return data.Error() } // CreateDir makes a directory (parent must exist) func (f *Fs) CreateDir(ctx context.Context, path string) error { // fs.Debugf(f, ">>> CreateDir %q", path) req := api.NewBinWriter() req.WritePu16(api.OperationCreateFolder) req.WritePu16(0) // revision req.WriteString(f.opt.Enc.FromStandardPath(path)) req.WritePu32(0) token, err := f.accessToken() if err != nil { return err } metaURL, err := f.metaServer(ctx) if err != nil { return err } opts := rest.Opts{ Method: "POST", RootURL: metaURL, Parameters: url.Values{ "client_id": {api.OAuthClientID}, "token": {token}, }, ContentType: api.BinContentType, Body: req.Reader(), } var res *http.Response err = f.pacer.Call(func() (bool, error) { res, err = f.srv.Call(ctx, &opts) return shouldRetry(ctx, res, err, f, &opts) }) if err != nil { closeBody(res) return err } reply := api.NewBinReader(res.Body) defer closeBody(res) switch status := reply.ReadByteAsInt(); status { case api.MkdirResultOK: return nil case api.MkdirResultAlreadyExists, api.MkdirResultExistsDifferentCase: return ErrorDirAlreadyExists case api.MkdirResultSourceNotExists: return ErrorDirSourceNotExists case api.MkdirResultInvalidName: return ErrorInvalidName default: return fmt.Errorf("mkdir error %d", status) } } // Mkdir creates the container (and its parents) if it doesn't exist. // Normally it ignores the ErrorDirAlreadyExist, as required by rclone tests. // Nevertheless, such programs as borgbackup or restic use mkdir as a locking // primitive and depend on its atomicity, i.e. mkdir should fail if directory // already exists. As a workaround, users can add string "atomicmkdir" in the // hidden `quirks` parameter or in the `--mailru-quirks` command-line option. func (f *Fs) Mkdir(ctx context.Context, dir string) error { // fs.Debugf(f, ">>> Mkdir %q", dir) err := f.mkDirs(ctx, f.absPath(dir)) if err == ErrorDirAlreadyExists && !f.quirks.atomicmkdir { return nil } return err } // mkDirs creates container and its parents by absolute path, // fails with ErrorDirAlreadyExists if it already exists. func (f *Fs) mkDirs(ctx context.Context, path string) error { if path == "/" || path == "" { return nil } switch err := f.CreateDir(ctx, path); err { case nil: return nil case ErrorDirSourceNotExists: fs.Debugf(f, "mkDirs by part %q", path) // fall thru... default: return err } parts := strings.Split(strings.Trim(path, "/"), "/") path = "" for _, part := range parts { if part == "" { continue } path += "/" + part switch err := f.CreateDir(ctx, path); err { case nil, ErrorDirAlreadyExists: continue default: return err } } return nil } func parentDir(absPath string) string { parent := path.Dir(strings.TrimRight(absPath, "/")) if parent == "." { parent = "" } return parent } // mkParentDirs creates parent containers by absolute path, // ignores the ErrorDirAlreadyExists func (f *Fs) mkParentDirs(ctx context.Context, path string) error { err := f.mkDirs(ctx, parentDir(path)) if err == ErrorDirAlreadyExists { return nil } return err } // Rmdir deletes a directory. // Returns an error if it isn't empty. func (f *Fs) Rmdir(ctx context.Context, dir string) error { // fs.Debugf(f, ">>> Rmdir %q", dir) return f.purgeWithCheck(ctx, dir, true, "rmdir") } // 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 { // fs.Debugf(f, ">>> Purge") return f.purgeWithCheck(ctx, dir, false, "purge") } // purgeWithCheck() removes the root directory. // Refuses if `check` is set and directory has anything in. func (f *Fs) purgeWithCheck(ctx context.Context, dir string, check bool, opName string) error { path := f.absPath(dir) if path == "/" || path == "" { // Mailru will not allow to purge root space returning status 400 return fs.ErrorNotDeletingDirs } _, dirSize, err := f.readItemMetaData(ctx, path) if err != nil { return fmt.Errorf("%s failed: %w", opName, err) } if check && dirSize > 0 { return fs.ErrorDirectoryNotEmpty } return f.delete(ctx, path, false) } func (f *Fs) delete(ctx context.Context, path string, hardDelete bool) error { token, err := f.accessToken() if err != nil { return err } data := url.Values{"home": {f.opt.Enc.FromStandardPath(path)}} opts := rest.Opts{ Method: "POST", Path: "/api/m1/file/remove", Parameters: url.Values{ "access_token": {token}, }, Body: strings.NewReader(data.Encode()), ContentType: api.BinContentType, } var response api.GenericResponse err = f.pacer.Call(func() (bool, error) { res, err := f.srv.CallJSON(ctx, &opts, nil, &response) return shouldRetry(ctx, res, err, f, &opts) }) switch { case err != nil: return err case response.Status == 200: return nil default: return fmt.Errorf("delete failed with code %d", response.Status) } } // 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) { // fs.Debugf(f, ">>> Copy %q %q", src.Remote(), remote) srcObj, ok := src.(*Object) if !ok { fs.Debugf(src, "Can't copy - not same remote type") return nil, fs.ErrorCantCopy } if srcObj.fs.opt.Username != f.opt.Username { // Can copy across mailru configs only if they share common account fs.Debugf(src, "Can't copy - not same account") return nil, fs.ErrorCantCopy } srcPath := srcObj.absPath() dstPath := f.absPath(remote) overwrite := false // fs.Debugf(f, "copy %q -> %q\n", srcPath, dstPath) err := f.mkParentDirs(ctx, dstPath) if err != nil { return nil, err } data := url.Values{} data.Set("home", f.opt.Enc.FromStandardPath(srcPath)) data.Set("folder", f.opt.Enc.FromStandardPath(parentDir(dstPath))) data.Set("email", f.opt.Username) data.Set("x-email", f.opt.Username) if overwrite { data.Set("conflict", "rewrite") } else { data.Set("conflict", "rename") } token, err := f.accessToken() if err != nil { return nil, err } opts := rest.Opts{ Method: "POST", Path: "/api/m1/file/copy", Parameters: url.Values{ "access_token": {token}, }, Body: strings.NewReader(data.Encode()), ContentType: api.BinContentType, } var response api.GenericBodyResponse err = f.pacer.Call(func() (bool, error) { res, err := f.srv.CallJSON(ctx, &opts, nil, &response) return shouldRetry(ctx, res, err, f, &opts) }) if err != nil { return nil, fmt.Errorf("couldn't copy file: %w", err) } if response.Status != 200 { return nil, fmt.Errorf("copy failed with code %d", response.Status) } tmpPath := f.opt.Enc.ToStandardPath(response.Body) if tmpPath != dstPath { // fs.Debugf(f, "rename temporary file %q -> %q\n", tmpPath, dstPath) err = f.moveItemBin(ctx, tmpPath, dstPath, "rename temporary file") if err != nil { _ = f.delete(ctx, tmpPath, false) // ignore error return nil, err } } // fix modification time at destination dstObj := &Object{ fs: f, remote: remote, } err = dstObj.readMetaData(ctx, true) if err == nil && dstObj.modTime != srcObj.modTime { dstObj.modTime = srcObj.modTime err = dstObj.addFileMetaData(ctx, true) } if err != nil { dstObj = nil } return dstObj, err } // Move src to this remote using server-side move operations. // This is stored with the remote path given. // It returns the destination Object and a possible error. // Will only be called if src.Fs().Name() == f.Name() // If it isn't possible then return fs.ErrorCantMove func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) { // fs.Debugf(f, ">>> Move %q %q", src.Remote(), remote) srcObj, ok := src.(*Object) if !ok { fs.Debugf(src, "Can't move - not same remote type") return nil, fs.ErrorCantMove } if srcObj.fs.opt.Username != f.opt.Username { // Can move across mailru configs only if they share common account fs.Debugf(src, "Can't move - not same account") return nil, fs.ErrorCantMove } srcPath := srcObj.absPath() dstPath := f.absPath(remote) err := f.mkParentDirs(ctx, dstPath) if err != nil { return nil, err } err = f.moveItemBin(ctx, srcPath, dstPath, "move file") if err != nil { return nil, err } return f.NewObject(ctx, remote) } // move/rename an object using BIN protocol func (f *Fs) moveItemBin(ctx context.Context, srcPath, dstPath, opName string) error { token, err := f.accessToken() if err != nil { return err } metaURL, err := f.metaServer(ctx) if err != nil { return err } req := api.NewBinWriter() req.WritePu16(api.OperationRename) req.WritePu32(0) // old revision req.WriteString(f.opt.Enc.FromStandardPath(srcPath)) req.WritePu32(0) // new revision req.WriteString(f.opt.Enc.FromStandardPath(dstPath)) req.WritePu32(0) // dunno opts := rest.Opts{ Method: "POST", RootURL: metaURL, Parameters: url.Values{ "client_id": {api.OAuthClientID}, "token": {token}, }, ContentType: api.BinContentType, Body: req.Reader(), } var res *http.Response err = f.pacer.Call(func() (bool, error) { res, err = f.srv.Call(ctx, &opts) return shouldRetry(ctx, res, err, f, &opts) }) if err != nil { closeBody(res) return err } reply := api.NewBinReader(res.Body) defer closeBody(res) switch status := reply.ReadByteAsInt(); status { case api.MoveResultOK: return nil default: return fmt.Errorf("%s failed with error %d", opName, status) } } // 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 { // fs.Debugf(f, ">>> DirMove %q %q", srcRemote, dstRemote) srcFs, ok := src.(*Fs) if !ok { fs.Debugf(srcFs, "Can't move directory - not same remote type") return fs.ErrorCantDirMove } if srcFs.opt.Username != f.opt.Username { // Can move across mailru configs only if they share common account fs.Debugf(src, "Can't move - not same account") return fs.ErrorCantDirMove } srcPath := srcFs.absPath(srcRemote) dstPath := f.absPath(dstRemote) // fs.Debugf(srcFs, "DirMove [%s]%q --> [%s]%q\n", srcRemote, srcPath, dstRemote, dstPath) // Refuse to move to or from the root if len(srcPath) <= len(srcFs.root) || len(dstPath) <= len(f.root) { fs.Debugf(src, "DirMove error: Can't move root") return errors.New("can't move root directory") } err := f.mkParentDirs(ctx, dstPath) if err != nil { return err } _, _, err = f.readItemMetaData(ctx, dstPath) switch err { case fs.ErrorObjectNotFound: // OK! case nil: return fs.ErrorDirExists default: return err } return f.moveItemBin(ctx, srcPath, dstPath, "directory move") } // PublicLink generates a public link to the remote path (usually readable by anyone) func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) { // fs.Debugf(f, ">>> PublicLink %q", remote) token, err := f.accessToken() if err != nil { return "", err } data := url.Values{} data.Set("home", f.opt.Enc.FromStandardPath(f.absPath(remote))) data.Set("email", f.opt.Username) data.Set("x-email", f.opt.Username) opts := rest.Opts{ Method: "POST", Path: "/api/m1/file/publish", Parameters: url.Values{ "access_token": {token}, }, Body: strings.NewReader(data.Encode()), ContentType: api.BinContentType, } var response api.GenericBodyResponse err = f.pacer.Call(func() (bool, error) { res, err := f.srv.CallJSON(ctx, &opts, nil, &response) return shouldRetry(ctx, res, err, f, &opts) }) if err == nil && response.Body != "" { return api.PublicLinkURL + response.Body, nil } if err == nil { return "", errors.New("server returned empty link") } if apiErr, ok := err.(*api.FileErrorResponse); ok && apiErr.Status == 404 { return "", fs.ErrorObjectNotFound } return "", err } // CleanUp permanently deletes all trashed files/folders func (f *Fs) CleanUp(ctx context.Context) error { // fs.Debugf(f, ">>> CleanUp") token, err := f.accessToken() if err != nil { return err } data := url.Values{ "email": {f.opt.Username}, "x-email": {f.opt.Username}, } opts := rest.Opts{ Method: "POST", Path: "/api/m1/trashbin/empty", Parameters: url.Values{ "access_token": {token}, }, Body: strings.NewReader(data.Encode()), ContentType: api.BinContentType, } var response api.CleanupResponse err = f.pacer.Call(func() (bool, error) { res, err := f.srv.CallJSON(ctx, &opts, nil, &response) return shouldRetry(ctx, res, err, f, &opts) }) if err != nil { return err } switch response.StatusStr { case "200": return nil default: return fmt.Errorf("cleanup failed (%s)", response.StatusStr) } } // About gets quota information func (f *Fs) About(ctx context.Context) (*fs.Usage, error) { // fs.Debugf(f, ">>> About") token, err := f.accessToken() if err != nil { return nil, err } opts := rest.Opts{ Method: "GET", Path: "/api/m1/user", Parameters: url.Values{ "access_token": {token}, }, } var info api.UserInfoResponse err = f.pacer.Call(func() (bool, error) { res, err := f.srv.CallJSON(ctx, &opts, nil, &info) return shouldRetry(ctx, res, err, f, &opts) }) if err != nil { return nil, err } total := info.Body.Cloud.Space.BytesTotal used := info.Body.Cloud.Space.BytesUsed usage := &fs.Usage{ Total: fs.NewUsageValue(total), Used: fs.NewUsageValue(used), Free: fs.NewUsageValue(total - used), } return usage, nil } // Put the object // 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) { o := &Object{ fs: f, remote: src.Remote(), size: src.Size(), modTime: src.ModTime(ctx), } // fs.Debugf(f, ">>> Put: %q %d '%v'", o.remote, o.size, o.modTime) return o, o.Update(ctx, in, src, options...) } // Update an existing object // Copy the reader into the object updating modTime and size // 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) error { wrapIn := in size := src.Size() if size < 0 { return errors.New("mailru does not support streaming uploads") } err := o.fs.mkParentDirs(ctx, o.absPath()) if err != nil { return err } var ( fileBuf []byte fileHash []byte newHash []byte slowHash bool localSrc bool ) if srcObj := fs.UnWrapObjectInfo(src); srcObj != nil { srcFeatures := srcObj.Fs().Features() slowHash = srcFeatures.SlowHash localSrc = srcFeatures.IsLocal } // Try speedup if it's globally enabled but skip extra post // request if file is small and fits in the metadata request trySpeedup := o.fs.opt.SpeedupEnable && size > mrhash.Size // Try to get the hash if it's instant if trySpeedup && !slowHash { if srcHash, err := src.Hash(ctx, MrHashType); err == nil && srcHash != "" { fileHash, _ = mrhash.DecodeString(srcHash) } if fileHash != nil { if o.putByHash(ctx, fileHash, src, "source") { return nil } trySpeedup = false // speedup failed, force upload } } // Need to calculate hash, check whether file is still eligible for speedup trySpeedup = trySpeedup && o.fs.eligibleForSpeedup(o.Remote(), size, options...) // Attempt to put by hash if file is local and eligible if trySpeedup && localSrc { if srcHash, err := src.Hash(ctx, MrHashType); err == nil && srcHash != "" { fileHash, _ = mrhash.DecodeString(srcHash) } if fileHash != nil && o.putByHash(ctx, fileHash, src, "localfs") { return nil } // If local file hashing has failed, it's pointless to try anymore trySpeedup = false } // Attempt to put by calculating hash in memory if trySpeedup && size <= int64(o.fs.opt.SpeedupMaxMem) { fileBuf, err = io.ReadAll(in) if err != nil { return err } fileHash = mrhash.Sum(fileBuf) if o.putByHash(ctx, fileHash, src, "memory") { return nil } wrapIn = bytes.NewReader(fileBuf) trySpeedup = false // speedup failed, force upload } // Attempt to put by hash using a spool file if trySpeedup { tmpFs, err := fs.TemporaryLocalFs(ctx) if err != nil { fs.Infof(tmpFs, "Failed to create spool FS: %v", err) } else { defer func() { if err := operations.Purge(ctx, tmpFs, ""); err != nil { fs.Infof(tmpFs, "Failed to cleanup spool FS: %v", err) } }() spoolFile, mrHash, err := makeTempFile(ctx, tmpFs, wrapIn, src) if err != nil { return fmt.Errorf("failed to create spool file: %w", err) } if o.putByHash(ctx, mrHash, src, "spool") { // If put by hash is successful, ignore transitive error return nil } if wrapIn, err = spoolFile.Open(ctx); err != nil { return err } fileHash = mrHash } } // Upload object data if size <= mrhash.Size { // Optimize upload: skip extra request if data fits in the hash buffer. if fileBuf == nil { fileBuf, err = io.ReadAll(wrapIn) } if fileHash == nil && err == nil { fileHash = mrhash.Sum(fileBuf) } newHash = fileHash } else { var hasher gohash.Hash if fileHash == nil { // Calculate hash in transit hasher = mrhash.New() wrapIn = io.TeeReader(wrapIn, hasher) } newHash, err = o.upload(ctx, wrapIn, size, options...) if fileHash == nil && err == nil { fileHash = hasher.Sum(nil) } } if err != nil { return err } if !bytes.Equal(fileHash, newHash) { if o.fs.opt.CheckHash { return mrhash.ErrorInvalidHash } fs.Infof(o, "hash mismatch on upload: expected %x received %x", fileHash, newHash) } o.mrHash = newHash o.size = size o.modTime = src.ModTime(ctx) return o.addFileMetaData(ctx, true) } // eligibleForSpeedup checks whether file is eligible for speedup method (put by hash) func (f *Fs) eligibleForSpeedup(remote string, size int64, options ...fs.OpenOption) bool { if !f.opt.SpeedupEnable { return false } if size <= mrhash.Size || size < speedupMinSize || size >= int64(f.opt.SpeedupMaxDisk) { return false } _, _, partial := getTransferRange(size, options...) if partial { return false } if f.speedupAny { return true } if f.speedupGlobs == nil { return false } nameLower := strings.ToLower(strings.TrimSpace(path.Base(remote))) for _, pattern := range f.speedupGlobs { if matches, _ := filepath.Match(pattern, nameLower); matches { return true } } return false } // parseSpeedupPatterns converts pattern string into list of unique glob patterns func (f *Fs) parseSpeedupPatterns(patternString string) (err error) { f.speedupGlobs = nil f.speedupAny = false uniqueValidPatterns := make(map[string]interface{}) for _, pattern := range strings.Split(patternString, ",") { pattern = strings.ToLower(strings.TrimSpace(pattern)) if pattern == "" { continue } if pattern == "*" { f.speedupAny = true } if _, err := filepath.Match(pattern, ""); err != nil { return fmt.Errorf("invalid file name pattern %q", pattern) } uniqueValidPatterns[pattern] = nil } for pattern := range uniqueValidPatterns { f.speedupGlobs = append(f.speedupGlobs, pattern) } return nil } // putByHash is a thin wrapper around addFileMetaData func (o *Object) putByHash(ctx context.Context, mrHash []byte, info fs.ObjectInfo, method string) bool { oNew := new(Object) *oNew = *o oNew.mrHash = mrHash oNew.size = info.Size() oNew.modTime = info.ModTime(ctx) if err := oNew.addFileMetaData(ctx, true); err != nil { fs.Debugf(o, "Cannot put by hash from %s, performing upload", method) return false } *o = *oNew fs.Debugf(o, "File has been put by hash from %s", method) return true } func makeTempFile(ctx context.Context, tmpFs fs.Fs, wrapIn io.Reader, src fs.ObjectInfo) (spoolFile fs.Object, mrHash []byte, err error) { // Local temporary file system must support SHA1 hashType := hash.SHA1 // Calculate Mailru and spool verification hashes in transit hashSet := hash.NewHashSet(MrHashType, hashType) hasher, err := hash.NewMultiHasherTypes(hashSet) if err != nil { return nil, nil, err } wrapIn = io.TeeReader(wrapIn, hasher) // Copy stream into spool file tmpInfo := object.NewStaticObjectInfo(src.Remote(), src.ModTime(ctx), src.Size(), false, nil, nil) hashOption := &fs.HashesOption{Hashes: hashSet} if spoolFile, err = tmpFs.Put(ctx, wrapIn, tmpInfo, hashOption); err != nil { return nil, nil, err } // Validate spool file sums := hasher.Sums() checkSum := sums[hashType] fileSum, err := spoolFile.Hash(ctx, hashType) if spoolFile.Size() != src.Size() || err != nil || checkSum == "" || fileSum != checkSum { return nil, nil, mrhash.ErrorInvalidHash } mrHash, err = mrhash.DecodeString(sums[MrHashType]) return } func (o *Object) upload(ctx context.Context, in io.Reader, size int64, options ...fs.OpenOption) ([]byte, error) { token, err := o.fs.accessToken() if err != nil { return nil, err } shardURL, err := o.fs.uploadShard(ctx) if err != nil { return nil, err } opts := rest.Opts{ Method: "PUT", RootURL: shardURL, Body: in, Options: options, ContentLength: &size, Parameters: url.Values{ "client_id": {api.OAuthClientID}, "token": {token}, }, ExtraHeaders: map[string]string{ "Accept": "*/*", }, } var ( res *http.Response strHash string ) err = o.fs.pacer.Call(func() (bool, error) { res, err = o.fs.srv.Call(ctx, &opts) if err == nil { strHash, err = readBodyWord(res) } return fserrors.ShouldRetry(err), err }) if err != nil { closeBody(res) return nil, err } switch res.StatusCode { case 200, 201: return mrhash.DecodeString(strHash) default: return nil, fmt.Errorf("upload failed with code %s (%d)", res.Status, res.StatusCode) } } func (f *Fs) uploadShard(ctx context.Context) (string, error) { f.shardMu.Lock() defer f.shardMu.Unlock() if f.shardURL != "" && time.Now().Before(f.shardExpiry) { return f.shardURL, nil } opts := rest.Opts{ RootURL: api.DispatchServerURL, Method: "GET", Path: "/u", } var ( res *http.Response url string err error ) err = f.pacer.Call(func() (bool, error) { res, err = f.srv.Call(ctx, &opts) if err == nil { url, err = readBodyWord(res) } return fserrors.ShouldRetry(err), err }) if err != nil { closeBody(res) return "", err } f.shardURL = url f.shardExpiry = time.Now().Add(shardExpirySec * time.Second) fs.Debugf(f, "new upload shard: %s", f.shardURL) return f.shardURL, nil } // Object describes a mailru 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 // Bytes in the object modTime time.Time // Modified time of the object mrHash []byte // Mail.ru flavored SHA1 hash of the object } // NewObject finds an Object at the remote. // If object can't be found it fails with fs.ErrorObjectNotFound func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) { // fs.Debugf(f, ">>> NewObject %q", remote) o := &Object{ fs: f, remote: remote, } err := o.readMetaData(ctx, true) if err != nil { return nil, err } return o, nil } // absPath converts root-relative remote to absolute home path func (o *Object) absPath() string { return o.fs.absPath(o.remote) } // Object.readMetaData reads and fills a file info // If object can't be found it fails with fs.ErrorObjectNotFound func (o *Object) readMetaData(ctx context.Context, force bool) error { if o.hasMetaData && !force { return nil } entry, dirSize, err := o.fs.readItemMetaData(ctx, o.absPath()) if err != nil { return err } newObj, ok := entry.(*Object) if !ok || dirSize >= 0 { return fs.ErrorIsDir } if newObj.remote != o.remote { return fmt.Errorf("file %q path has changed to %q", o.remote, newObj.remote) } o.hasMetaData = true o.size = newObj.size o.modTime = newObj.modTime o.mrHash = newObj.mrHash 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 "" } //return fmt.Sprintf("[%s]%q", o.fs.root, o.remote) return o.remote } // Remote returns the remote path func (o *Object) Remote() string { return o.remote } // 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, false) if err != nil { fs.Errorf(o, "%v", err) } return o.modTime } // Size returns the size of an object in bytes func (o *Object) Size() int64 { ctx := context.Background() // Note: Object.Size does not pass context! err := o.readMetaData(ctx, false) if err != nil { fs.Errorf(o, "%v", err) } return o.size } // Hash returns the MD5 or SHA1 sum of an object // returning a lowercase hex string func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) { if t == MrHashType { return hex.EncodeToString(o.mrHash), nil } return "", hash.ErrUnsupported } // Storable returns whether this object is storable func (o *Object) Storable() bool { return true } // SetModTime sets the modification time of the local fs object // // Commits the datastore func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error { // fs.Debugf(o, ">>> SetModTime [%v]", modTime) o.modTime = modTime return o.addFileMetaData(ctx, true) } func (o *Object) addFileMetaData(ctx context.Context, overwrite bool) error { if len(o.mrHash) != mrhash.Size { return mrhash.ErrorInvalidHash } token, err := o.fs.accessToken() if err != nil { return err } metaURL, err := o.fs.metaServer(ctx) if err != nil { return err } req := api.NewBinWriter() req.WritePu16(api.OperationAddFile) req.WritePu16(0) // revision req.WriteString(o.fs.opt.Enc.FromStandardPath(o.absPath())) req.WritePu64(o.size) req.WriteP64(o.modTime.Unix()) req.WritePu32(0) req.Write(o.mrHash) if overwrite { // overwrite req.WritePu32(1) } else { // don't add if not changed, add with rename if changed req.WritePu32(55) req.Write(o.mrHash) req.WritePu64(o.size) } opts := rest.Opts{ Method: "POST", RootURL: metaURL, Parameters: url.Values{ "client_id": {api.OAuthClientID}, "token": {token}, }, ContentType: api.BinContentType, Body: req.Reader(), } var res *http.Response err = o.fs.pacer.Call(func() (bool, error) { res, err = o.fs.srv.Call(ctx, &opts) return shouldRetry(ctx, res, err, o.fs, &opts) }) if err != nil { closeBody(res) return err } reply := api.NewBinReader(res.Body) defer closeBody(res) switch status := reply.ReadByteAsInt(); status { case api.AddResultOK, api.AddResultNotModified, api.AddResultDunno04, api.AddResultDunno09: return nil case api.AddResultInvalidName: return ErrorInvalidName default: return fmt.Errorf("add file error %d", status) } } // Remove an object func (o *Object) Remove(ctx context.Context) error { // fs.Debugf(o, ">>> Remove") return o.fs.delete(ctx, o.absPath(), false) } // getTransferRange detects partial transfers and calculates start/end offsets into file func getTransferRange(size int64, options ...fs.OpenOption) (start int64, end int64, partial bool) { var offset, limit int64 = 0, -1 for _, option := range options { switch opt := option.(type) { case *fs.SeekOption: offset = opt.Offset case *fs.RangeOption: offset, limit = opt.Decode(size) default: if option.Mandatory() { fs.Errorf(nil, "Unsupported mandatory option: %v", option) } } } if limit < 0 { limit = size - offset } end = offset + limit if end > size { end = size } partial = !(offset == 0 && end == size) return offset, end, partial } // Open an object for read and download its content func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) { // fs.Debugf(o, ">>> Open") token, err := o.fs.accessToken() if err != nil { return nil, err } start, end, partialRequest := getTransferRange(o.size, options...) headers := map[string]string{ "Accept": "*/*", "Content-Type": "application/octet-stream", } if partialRequest { rangeStr := fmt.Sprintf("bytes=%d-%d", start, end-1) headers["Range"] = rangeStr // headers["Content-Range"] = rangeStr headers["Accept-Ranges"] = "bytes" } // TODO: set custom timeouts opts := rest.Opts{ Method: "GET", Options: options, Path: url.PathEscape(strings.TrimLeft(o.fs.opt.Enc.FromStandardPath(o.absPath()), "/")), Parameters: url.Values{ "client_id": {api.OAuthClientID}, "token": {token}, }, ExtraHeaders: headers, } var res *http.Response server := "" err = o.fs.pacer.Call(func() (bool, error) { server, err = o.fs.fileServers.Dispatch(ctx, server) if err != nil { return false, err } opts.RootURL = server res, err = o.fs.srv.Call(ctx, &opts) return shouldRetry(ctx, res, err, o.fs, &opts) }) if err != nil { if res != nil && res.Body != nil { closeBody(res) } return nil, err } // Server should respond with Status 206 and Content-Range header to a range // request. Status 200 (and no Content-Range) means a full-content response. partialResponse := res.StatusCode == 206 var ( hasher gohash.Hash wrapStream io.ReadCloser ) if !partialResponse { // Cannot check hash of partial download hasher = mrhash.New() } wrapStream = &endHandler{ ctx: ctx, stream: res.Body, hasher: hasher, o: o, server: server, } if partialRequest && !partialResponse { fs.Debugf(o, "Server returned full content instead of range") if start > 0 { // Discard the beginning of the data _, err = io.CopyN(io.Discard, wrapStream, start) if err != nil { closeBody(res) return nil, err } } wrapStream = readers.NewLimitedReadCloser(wrapStream, end-start) } return wrapStream, nil } type endHandler struct { ctx context.Context stream io.ReadCloser hasher gohash.Hash o *Object server string done bool } func (e *endHandler) Read(p []byte) (n int, err error) { n, err = e.stream.Read(p) if e.hasher != nil { // hasher will not return an error, just panic _, _ = e.hasher.Write(p[:n]) } if err != nil { // io.Error or EOF err = e.handle(err) } return } func (e *endHandler) Close() error { _ = e.handle(nil) // ignore returned error return e.stream.Close() } func (e *endHandler) handle(err error) error { if e.done { return err } e.done = true o := e.o o.fs.fileServers.Free(e.server) if err != io.EOF || e.hasher == nil { return err } newHash := e.hasher.Sum(nil) if bytes.Equal(o.mrHash, newHash) { return io.EOF } if o.fs.opt.CheckHash { return mrhash.ErrorInvalidHash } fs.Infof(o, "hash mismatch on download: expected %x received %x", o.mrHash, newHash) return io.EOF } // serverPool backs server dispatcher type serverPool struct { pool pendingServerMap mu sync.Mutex path string expirySec int fs *Fs } type pendingServerMap map[string]*pendingServer type pendingServer struct { locks int expiry time.Time } // Dispatch dispatches next download server. // It prefers switching and tries to avoid current server // in use by caller because it may be overloaded or slow. func (p *serverPool) Dispatch(ctx context.Context, current string) (string, error) { now := time.Now() url := p.getServer(current, now) if url != "" { return url, nil } // Server not found - ask Mailru dispatcher. opts := rest.Opts{ Method: "GET", RootURL: api.DispatchServerURL, Path: p.path, } var ( res *http.Response err error ) err = p.fs.pacer.Call(func() (bool, error) { res, err = p.fs.srv.Call(ctx, &opts) if err != nil { return fserrors.ShouldRetry(err), err } url, err = readBodyWord(res) return fserrors.ShouldRetry(err), err }) if err != nil || url == "" { closeBody(res) return "", fmt.Errorf("failed to request file server: %w", err) } p.addServer(url, now) return url, nil } func (p *serverPool) Free(url string) { if url == "" { return } p.mu.Lock() defer p.mu.Unlock() srv := p.pool[url] if srv == nil { return } if srv.locks <= 0 { // Getting here indicates possible race fs.Infof(p.fs, "Purge file server: locks -, url %s", url) delete(p.pool, url) return } srv.locks-- if srv.locks == 0 && time.Now().After(srv.expiry) { delete(p.pool, url) fs.Debugf(p.fs, "Free file server: locks 0, url %s", url) return } fs.Debugf(p.fs, "Unlock file server: locks %d, url %s", srv.locks, url) } // Find an underlocked server func (p *serverPool) getServer(current string, now time.Time) string { p.mu.Lock() defer p.mu.Unlock() for url, srv := range p.pool { if url == "" || srv.locks < 0 { continue // Purged server slot } if url == current { continue // Current server - prefer another } if srv.locks >= maxServerLocks { continue // Overlocked server } if now.After(srv.expiry) { continue // Expired server } srv.locks++ fs.Debugf(p.fs, "Lock file server: locks %d, url %s", srv.locks, url) return url } return "" } func (p *serverPool) addServer(url string, now time.Time) { p.mu.Lock() defer p.mu.Unlock() expiry := now.Add(time.Duration(p.expirySec) * time.Second) expiryStr := []byte("-") if p.fs.ci.LogLevel >= fs.LogLevelInfo { expiryStr, _ = expiry.MarshalJSON() } // Attach to a server proposed by dispatcher srv := p.pool[url] if srv != nil { srv.locks++ srv.expiry = expiry fs.Debugf(p.fs, "Reuse file server: locks %d, url %s, expiry %s", srv.locks, url, expiryStr) return } // Add new server p.pool[url] = &pendingServer{locks: 1, expiry: expiry} fs.Debugf(p.fs, "Switch file server: locks 1, url %s, expiry %s", url, expiryStr) } // 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("[%s]", f.root) } // Precision return the precision of this Fs func (f *Fs) Precision() time.Duration { return time.Second } // Hashes returns the supported hash sets func (f *Fs) Hashes() hash.Set { return hash.Set(MrHashType) } // Features returns the optional features of this Fs func (f *Fs) Features() *fs.Features { return f.features } // close response body ignoring errors func closeBody(res *http.Response) { if res != nil { _ = res.Body.Close() } } // 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.PublicLinker = (*Fs)(nil) _ fs.CleanUpper = (*Fs)(nil) _ fs.Abouter = (*Fs)(nil) _ fs.Object = (*Object)(nil) )