1
mirror of https://github.com/rclone/rclone synced 2024-11-11 09:30:44 +01:00
rclone/backend/netstorage/netstorage.go
Nick Craig-Wood d0d41fe847 rclone config redacted: implement support mechanism for showing redacted config
This introduces a new fs.Option flag, Sensitive and uses this along
with IsPassword to redact the info in the config file for support
purposes.

It adds this flag into backends where appropriate. It was necessary to
add oauthutil.SharedOptions to some backends as they were missing
them.

Fixes #5209
2023-07-07 16:25:14 +01:00

1279 lines
40 KiB
Go
Executable File

// Package netstorage provides an interface to Akamai NetStorage API
package netstorage
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
gohash "hash"
"io"
"math/rand"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/rclone/rclone/fs"
"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/walk"
"github.com/rclone/rclone/lib/pacer"
"github.com/rclone/rclone/lib/rest"
)
// Constants
const (
minSleep = 10 * time.Millisecond
maxSleep = 2 * time.Second
decayConstant = 2 // bigger for slower decay, exponential
)
func init() {
fsi := &fs.RegInfo{
Name: "netstorage",
Description: "Akamai NetStorage",
NewFs: NewFs,
CommandHelp: commandHelp,
Options: []fs.Option{{
Name: "protocol",
Help: `Select between HTTP or HTTPS protocol.
Most users should choose HTTPS, which is the default.
HTTP is provided primarily for debugging purposes.`,
Examples: []fs.OptionExample{{
Value: "http",
Help: "HTTP protocol",
}, {
Value: "https",
Help: "HTTPS protocol",
}},
Default: "https",
Advanced: true,
}, {
Name: "host",
Help: `Domain+path of NetStorage host to connect to.
Format should be ` + "`<domain>/<internal folders>`",
Required: true,
Sensitive: true,
}, {
Name: "account",
Help: "Set the NetStorage account name",
Required: true,
Sensitive: true,
}, {
Name: "secret",
Help: `Set the NetStorage account secret/G2O key for authentication.
Please choose the 'y' option to set your own password then enter your secret.`,
IsPassword: true,
Required: true,
}},
}
fs.Register(fsi)
}
var commandHelp = []fs.CommandHelp{{
Name: "du",
Short: "Return disk usage information for a specified directory",
Long: `The usage information returned, includes the targeted directory as well as all
files stored in any sub-directories that may exist.`,
}, {
Name: "symlink",
Short: "You can create a symbolic link in ObjectStore with the symlink action.",
Long: `The desired path location (including applicable sub-directories) ending in
the object that will be the target of the symlink (for example, /links/mylink).
Include the file extension for the object, if applicable.
` + "`rclone backend symlink <src> <path>`",
},
}
// Options defines the configuration for this backend
type Options struct {
Endpoint string `config:"host"`
Account string `config:"account"`
Secret string `config:"secret"`
Protocol string `config:"protocol"`
}
// Fs stores the interface to the remote HTTP files
type Fs struct {
name string
root string
features *fs.Features // optional features
opt Options // options for this backend
endpointURL string // endpoint as a string
srv *rest.Client // the connection to the Netstorage server
pacer *fs.Pacer // to pace the API calls
filetype string // dir, file or symlink
dirscreated map[string]bool // if implicit dir has been created already
dirscreatedMutex sync.Mutex // mutex to protect dirscreated
statcache map[string][]File // cache successful stat requests
statcacheMutex sync.RWMutex // RWMutex to protect statcache
}
// Object is a remote object that has been stat'd (so it exists, but is not necessarily open for reading)
type Object struct {
fs *Fs
filetype string // dir, file or symlink
remote string // remote path
size int64 // size of the object in bytes
modTime int64 // modification time of the object
md5sum string // md5sum of the object
fullURL string // full path URL
target string // symlink target when filetype is symlink
}
//------------------------------------------------------------------------------
// Stat is an object which holds the information of the stat element of the response xml
type Stat struct {
XMLName xml.Name `xml:"stat"`
Files []File `xml:"file"`
Directory string `xml:"directory,attr"`
}
// File is an object which holds the information of the file element of the response xml
type File struct {
XMLName xml.Name `xml:"file"`
Type string `xml:"type,attr"`
Name string `xml:"name,attr"`
NameBase64 string `xml:"name_base64,attr"`
Size int64 `xml:"size,attr"`
Md5 string `xml:"md5,attr"`
Mtime int64 `xml:"mtime,attr"`
Bytes int64 `xml:"bytes,attr"`
Files int64 `xml:"files,attr"`
Target string `xml:"target,attr"`
}
// List is an object which holds the information of the list element of the response xml
type List struct {
XMLName xml.Name `xml:"list"`
Files []File `xml:"file"`
Resume ListResume `xml:"resume"`
}
// ListResume represents the resume xml element of the list
type ListResume struct {
XMLName xml.Name `xml:"resume"`
Start string `xml:"start,attr"`
}
// Du represents the du xml element of the response
type Du struct {
XMLName xml.Name `xml:"du"`
Directory string `xml:"directory,attr"`
Duinfo DuInfo `xml:"du-info"`
}
// DuInfo represents the du-info xml element of the response
type DuInfo struct {
XMLName xml.Name `xml:"du-info"`
Files int64 `xml:"files,attr"`
Bytes int64 `xml:"bytes,attr"`
}
// GetName returns a normalized name of the Stat item
func (s Stat) GetName() xml.Name {
return s.XMLName
}
// GetName returns a normalized name of the List item
func (l List) GetName() xml.Name {
return l.XMLName
}
// GetName returns a normalized name of the Du item
func (d Du) GetName() xml.Name {
return d.XMLName
}
//------------------------------------------------------------------------------
// NewFs creates a new Fs object from the name and root. It connects to
// the host specified in the config file.
//
// If root refers to an existing object, then it should return an Fs which
// points to the parent of that object and ErrorIsFile.
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
}
// The base URL (endPoint is protocol + :// + domain/internal folder
opt.Endpoint = opt.Protocol + "://" + opt.Endpoint
fs.Debugf(nil, "NetStorage NewFS endpoint %q", opt.Endpoint)
if !strings.HasSuffix(opt.Endpoint, "/") {
opt.Endpoint += "/"
}
// Decrypt credentials, even though it is hard to eyedrop the hex string, it adds an extra piece of mind
opt.Secret = obscure.MustReveal(opt.Secret)
// Parse the endpoint and stick the root onto it
base, err := url.Parse(opt.Endpoint)
if err != nil {
return nil, fmt.Errorf("couldn't parse URL %q: %w", opt.Endpoint, err)
}
u, err := rest.URLJoin(base, rest.URLPathEscape(root))
if err != nil {
return nil, fmt.Errorf("couldn't join URL %q and %q: %w", base.String(), root, err)
}
client := fshttp.NewClient(ctx)
f := &Fs{
name: name,
root: root,
opt: *opt,
endpointURL: u.String(),
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
dirscreated: make(map[string]bool),
statcache: make(map[string][]File),
}
f.srv = rest.NewClient(client)
f.srv.SetSigner(f.getAuth)
f.features = (&fs.Features{
CanHaveEmptyDirectories: true,
}).Fill(ctx, f)
err = f.initFs(ctx, "")
switch err {
case nil:
// Object is the directory
return f, nil
case fs.ErrorObjectNotFound:
return f, nil
case fs.ErrorIsFile:
// Fs points to the parent directory
return f, err
default:
return nil, err
}
}
// Command the backend to run a named commands: du and symlink
func (f *Fs) Command(ctx context.Context, name string, arg []string, opt map[string]string) (out interface{}, err error) {
switch name {
case "du":
// No arg parsing needed, the path is passed in the fs
return f.netStorageDuRequest(ctx)
case "symlink":
dst := ""
if len(arg) > 0 {
dst = arg[0]
} else {
return nil, errors.New("NetStorage symlink command: need argument for target")
}
// Strip off the leading slash added by NewFs on object not found
URL := strings.TrimSuffix(f.url(""), "/")
return f.netStorageSymlinkRequest(ctx, URL, dst, nil)
default:
return nil, fs.ErrorCommandNotFound
}
}
// Name returns the configured name of the file system
func (f *Fs) Name() string {
return f.name
}
// Root returns the root for the filesystem
func (f *Fs) Root() string {
return f.root
}
// String returns the URL for the filesystem
func (f *Fs) String() string {
return f.endpointURL
}
// Features returns the optional features of this Fs
func (f *Fs) Features() *fs.Features {
return f.features
}
// Precision return the precision of this Fs
func (f *Fs) Precision() time.Duration {
return time.Second
}
// NewObject creates a new remote http file object
// NewObject finds the Object at remote
// If it can't be found returns fs.ErrorObjectNotFound
// If it isn't a file, then it returns fs.ErrorIsDir
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
URL := f.url(remote)
files, err := f.netStorageStatRequest(ctx, URL, false)
if err != nil {
return nil, err
}
if files == nil {
fs.Errorf(nil, "Stat for %q has empty files", URL)
return nil, fs.ErrorObjectNotFound
}
file := files[0]
switch file.Type {
case
"file",
"symlink":
return f.newObjectWithInfo(remote, &file)
case "dir":
return nil, fs.ErrorIsDir
default:
return nil, fmt.Errorf("object of an unsupported type %s for %q: %w", file.Type, URL, err)
}
}
// initFs initializes Fs based on the stat reply
func (f *Fs) initFs(ctx context.Context, dir string) error {
// Path must end with the slash, so the join later will work correctly
defer func() {
if !strings.HasSuffix(f.endpointURL, "/") {
f.endpointURL += "/"
}
}()
URL := f.url(dir)
files, err := f.netStorageStatRequest(ctx, URL, true)
if err == fs.ErrorObjectNotFound || files == nil {
return fs.ErrorObjectNotFound
}
if err != nil {
return err
}
f.filetype = files[0].Type
switch f.filetype {
case "dir":
// This directory is known to exist, adding to explicit directories
f.setDirscreated(URL)
return nil
case
"file",
"symlink":
// Fs should point to the parent of that object and return ErrorIsFile
lastindex := strings.LastIndex(f.endpointURL, "/")
if lastindex != -1 {
f.endpointURL = f.endpointURL[0 : lastindex+1]
} else {
fs.Errorf(nil, "Remote URL %q unexpectedly does not include the slash", f.endpointURL)
}
return fs.ErrorIsFile
default:
err = fmt.Errorf("unsupported object type %s for %q: %w", f.filetype, URL, err)
f.filetype = ""
return err
}
}
// url joins the remote onto the endpoint URL
func (f *Fs) url(remote string) string {
if remote == "" {
return f.endpointURL
}
pathescapeURL := rest.URLPathEscape(remote)
// Strip off initial "./" from the path, which can be added by path escape function following the RFC 3986 4.2
// (a segment must be preceded by a dot-segment (e.g., "./this:that") to make a relative-path reference).
pathescapeURL = strings.TrimPrefix(pathescapeURL, "./")
// Cannot use rest.URLJoin() here because NetStorage is an object storage and allows to have a "."
// directory name, which will be eliminated by the join function.
return f.endpointURL + pathescapeURL
}
// getFileName returns the file name if present, otherwise decoded name_base64
// if present, otherwise an empty string
func (f *Fs) getFileName(file *File) string {
if file.Name != "" {
return file.Name
}
if file.NameBase64 != "" {
decoded, err := base64.StdEncoding.DecodeString(file.NameBase64)
if err == nil {
return string(decoded)
}
fs.Errorf(nil, "Failed to base64 decode object %s: %v", file.NameBase64, err)
}
return ""
}
// 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) {
if f.filetype == "" {
// This happens in two scenarios.
// 1. NewFs is done on a nonexistent object, then later rclone attempts to List/ListR this NewFs.
// 2. List/ListR is called from the context of test_all and not the regular rclone binary.
err := f.initFs(ctx, dir)
if err != nil {
if err == fs.ErrorObjectNotFound {
return nil, fs.ErrorDirNotFound
}
return nil, err
}
}
URL := f.url(dir)
files, err := f.netStorageDirRequest(ctx, dir, URL)
if err != nil {
return nil, err
}
if dir != "" && !strings.HasSuffix(dir, "/") {
dir += "/"
}
for _, item := range files {
name := dir + f.getFileName(&item)
switch item.Type {
case "dir":
when := time.Unix(item.Mtime, 0)
entry := fs.NewDir(name, when).SetSize(item.Bytes).SetItems(item.Files)
entries = append(entries, entry)
case "file":
if entry, _ := f.newObjectWithInfo(name, &item); entry != nil {
entries = append(entries, entry)
}
case "symlink":
var entry fs.Object
// Add .rclonelink suffix to allow local backend code to convert to a symlink.
// In case both .rclonelink file AND symlink file exists, the first will be used.
if entry, _ = f.newObjectWithInfo(name+".rclonelink", &item); entry != nil {
fs.Infof(nil, "Converting a symlink to the rclonelink %s target %s", entry.Remote(), item.Target)
entries = append(entries, entry)
}
default:
fs.Logf(nil, "Ignoring unsupported object type %s for %q path", item.Type, name)
}
}
return entries, 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) {
if f.filetype == "" {
// This happens in two scenarios.
// 1. NewFs is done on a nonexistent object, then later rclone attempts to List/ListR this NewFs.
// 2. List/ListR is called from the context of test_all and not the regular rclone binary.
err := f.initFs(ctx, dir)
if err != nil {
if err == fs.ErrorObjectNotFound {
return fs.ErrorDirNotFound
}
return err
}
}
if !strings.HasSuffix(dir, "/") && dir != "" {
dir += "/"
}
URL := f.url(dir)
u, err := url.Parse(URL)
if err != nil {
fs.Errorf(nil, "Unable to parse URL %q: %v", URL, err)
return fs.ErrorDirNotFound
}
list := walk.NewListRHelper(callback)
for resumeStart := u.Path; resumeStart != ""; {
var files []File
files, resumeStart, err = f.netStorageListRequest(ctx, URL, u.Path)
if err != nil {
if err == fs.ErrorObjectNotFound {
return fs.ErrorDirNotFound
}
return err
}
for _, item := range files {
name := f.getFileName(&item)
// List output includes full paths starting from [CP Code]/
path := strings.TrimPrefix("/"+name, u.Path)
if path == "" {
// Skip the starting directory itself
continue
}
switch item.Type {
case "dir":
when := time.Unix(item.Mtime, 0)
entry := fs.NewDir(dir+strings.TrimSuffix(path, "/"), when)
if err := list.Add(entry); err != nil {
return err
}
case "file":
if entry, _ := f.newObjectWithInfo(dir+path, &item); entry != nil {
if err := list.Add(entry); err != nil {
return err
}
}
case "symlink":
// Add .rclonelink suffix to allow local backend code to convert to a symlink.
// In case both .rclonelink file AND symlink file exists, the first will be used.
if entry, _ := f.newObjectWithInfo(dir+path+".rclonelink", &item); entry != nil {
fs.Infof(nil, "Converting a symlink to the rclonelink %s for target %s", entry.Remote(), item.Target)
if err := list.Add(entry); err != nil {
return err
}
}
default:
fs.Logf(nil, "Ignoring unsupported object type %s for %s path", item.Type, name)
}
}
if resumeStart != "" {
// Perform subsequent list action call, construct the
// URL where the previous request finished
u, err := url.Parse(f.endpointURL)
if err != nil {
fs.Errorf(nil, "Unable to parse URL %q: %v", f.endpointURL, err)
return fs.ErrorDirNotFound
}
resumeURL, err := rest.URLJoin(u, rest.URLPathEscape(resumeStart))
if err != nil {
fs.Errorf(nil, "Unable to join URL %q for resumeStart %s: %v", f.endpointURL, resumeStart, err)
return fs.ErrorDirNotFound
}
URL = resumeURL.String()
}
}
return list.Flush()
}
// Put in to the remote path with the modTime given of the given size
//
// May create the object even if it returns an error - if so
// will return the object and the error, otherwise will return
// nil and the error
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
err := f.implicitCheck(ctx, src.Remote(), true)
if err != nil {
return nil, err
}
// Barebones object will get filled in Update
o := &Object{
fs: f,
remote: src.Remote(),
fullURL: f.url(src.Remote()),
}
// We pass through the Update's error
err = o.Update(ctx, in, src, options...)
if err != nil {
return nil, err
}
return o, nil
}
// implicitCheck prevents implicit dir creation by doing mkdir from base up to current dir,
// does NOT check if these dirs created conflict with existing dirs/files so can result in dupe
func (f *Fs) implicitCheck(ctx context.Context, remote string, isfile bool) error {
// Find base (URL including the CPCODE path) and root (what follows after that)
URL := f.url(remote)
u, err := url.Parse(URL)
if err != nil {
fs.Errorf(nil, "Unable to parse URL %q while implicit checking directory: %v", URL, err)
return err
}
startPos := 0
if strings.HasPrefix(u.Path, "/") {
startPos = 1
}
pos := strings.Index(u.Path[startPos:], "/")
if pos == -1 {
fs.Errorf(nil, "URL %q unexpectedly does not include the slash in the CPCODE path", URL)
return nil
}
root := rest.URLPathEscape(u.Path[startPos+pos+1:])
u.Path = u.Path[:startPos+pos]
base := u.String()
if !strings.HasSuffix(base, "/") {
base += "/"
}
if isfile {
// Get the base name of root
lastindex := strings.LastIndex(root, "/")
if lastindex == -1 {
// We are at the level of CPCODE path
return nil
}
root = root[0 : lastindex+1]
}
// We make sure root always has "/" at the end
if !strings.HasSuffix(root, "/") {
root += "/"
}
for root != "" {
frontindex := strings.Index(root, "/")
if frontindex == -1 {
return nil
}
frontdir := root[0 : frontindex+1]
root = root[frontindex+1:]
base += frontdir
if !f.testAndSetDirscreated(base) {
fs.Infof(nil, "Implicitly create directory %s", base)
err := f.netStorageMkdirRequest(ctx, base)
if err != nil {
fs.Errorf("Mkdir request in implicit check failed for base %s: %v", base, err)
return err
}
}
}
return nil
}
// Purge all files in the directory specified.
// NetStorage quick-delete is disabled by default AND not instantaneous.
// Returns fs.ErrorCantPurge when quick-delete fails.
func (f *Fs) Purge(ctx context.Context, dir string) error {
URL := f.url(dir)
const actionHeader = "version=1&action=quick-delete&quick-delete=imreallyreallysure"
if _, err := f.callBackend(ctx, URL, "POST", actionHeader, true, nil, nil); err != nil {
fs.Logf(nil, "Purge using quick-delete failed, fallback on recursive delete: %v", err)
return fs.ErrorCantPurge
}
fs.Logf(nil, "Purge using quick-delete has been queued, you may not see immediate changes")
return nil
}
// PutStream uploads to the remote path with the modTime given of indeterminate size
func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
// Pass through error from Put
return f.Put(ctx, in, src, options...)
}
// Fs is the filesystem this remote http file object is located within
func (o *Object) Fs() fs.Info {
return o.fs
}
// String returns the URL to the remote HTTP file
func (o *Object) String() string {
if o == nil {
return "<nil>"
}
return o.remote
}
// Remote the name of the remote HTTP file, relative to the fs root
func (o *Object) Remote() string {
return o.remote
}
// Hash returns the Md5sum of an object returning a lowercase hex string
func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) {
if t != hash.MD5 {
return "", hash.ErrUnsupported
}
return o.md5sum, nil
}
// Size returns the size in bytes of the remote http file
func (o *Object) Size() int64 {
return o.size
}
// Md5Sum returns the md5 of the object
func (o *Object) Md5Sum() string {
return o.md5sum
}
// 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 {
return time.Unix(o.modTime, 0)
}
// SetModTime sets the modification and access time to the specified time
func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error {
URL := o.fullURL
when := strconv.FormatInt(modTime.Unix(), 10)
actionHeader := "version=1&action=mtime&mtime=" + when
if _, err := o.fs.callBackend(ctx, URL, "POST", actionHeader, true, nil, nil); err != nil {
fs.Debugf(nil, "NetStorage action mtime failed for %q: %v", URL, err)
return err
}
o.fs.deleteStatCache(URL)
o.modTime = modTime.Unix()
return nil
}
// Storable returns whether this object is 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) {
return o.netStorageDownloadRequest(ctx, options)
}
// Hashes returns the supported hash sets.
func (f *Fs) Hashes() hash.Set {
return hash.Set(hash.MD5)
}
// Mkdir makes the root directory of the Fs object
// Shouldn't return an error if it already exists
func (f *Fs) Mkdir(ctx context.Context, dir string) error {
// ImplicitCheck will mkdir from base up to dir, if not already in dirscreated
return f.implicitCheck(ctx, dir, false)
}
// Remove an object
func (o *Object) Remove(ctx context.Context) error {
return o.netStorageDeleteRequest(ctx)
}
// Rmdir removes the root directory of the Fs object
// Return an error if it doesn't exist or isn't empty
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
return f.netStorageRmdirRequest(ctx, dir)
}
// Update netstorage with the object
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
o.size = src.Size()
o.modTime = src.ModTime(ctx).Unix()
// Don't do md5 check because that's done by server
o.md5sum = ""
err := o.netStorageUploadRequest(ctx, in, src)
// We return an object updated with source stats,
// we don't refetch the obj after upload
if err != nil {
return err
}
return nil
}
// newObjectWithInfo creates an fs.Object for any netstorage.File or symlink.
// If it can't be found it returns the error fs.ErrorObjectNotFound.
// It returns fs.ErrorIsDir error for directory objects, but still fills the
// fs.Object structure (for directory operations).
func (f *Fs) newObjectWithInfo(remote string, info *File) (fs.Object, error) {
if info == nil {
return nil, fs.ErrorObjectNotFound
}
URL := f.url(remote)
size := info.Size
if info.Type == "symlink" {
// File size for symlinks is absent but for .rclonelink to work
// the size should be the length of the target name
size = int64(len(info.Target))
}
o := &Object{
fs: f,
filetype: info.Type,
remote: remote,
size: size,
modTime: info.Mtime,
md5sum: info.Md5,
fullURL: URL,
target: info.Target,
}
if info.Type == "dir" {
return o, fs.ErrorIsDir
}
return o, nil
}
// getAuth is the signing hook to get the NetStorage auth
func (f *Fs) getAuth(req *http.Request) error {
// Set Authorization header
dataHeader := generateDataHeader(f)
path := req.URL.RequestURI()
//lint:ignore SA1008 false positive when running staticcheck, the header name is according to docs even if not canonical
//nolint:staticcheck // Don't include staticcheck when running golangci-lint to avoid SA1008
actionHeader := req.Header["X-Akamai-ACS-Action"][0]
fs.Debugf(nil, "NetStorage API %s call %s for path %q", req.Method, actionHeader, path)
req.Header.Set("X-Akamai-ACS-Auth-Data", dataHeader)
req.Header.Set("X-Akamai-ACS-Auth-Sign", generateSignHeader(f, dataHeader, path, actionHeader))
return nil
}
// retryErrorCodes is a slice of error codes that we will retry
var retryErrorCodes = []int{
423, // Locked
429, // Too Many Requests
500, // Internal Server Error
502, // Bad Gateway
503, // Service Unavailable
504, // Gateway Timeout
509, // Bandwidth Limit Exceeded
}
// shouldRetry returns a boolean as to whether this resp and err
// deserve to be retried. It returns the err as a convenience
func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
if fserrors.ContextError(ctx, &err) {
return false, err
}
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
}
// callBackend calls NetStorage API using either rest.Call or rest.CallXML function,
// depending on whether the response is required
func (f *Fs) callBackend(ctx context.Context, URL, method, actionHeader string, noResponse bool, response interface{}, options []fs.OpenOption) (io.ReadCloser, error) {
opts := rest.Opts{
Method: method,
RootURL: URL,
NoResponse: noResponse,
ExtraHeaders: map[string]string{
"*X-Akamai-ACS-Action": actionHeader,
},
}
if options != nil {
opts.Options = options
}
var resp *http.Response
err := f.pacer.Call(func() (bool, error) {
var err error
if response != nil {
resp, err = f.srv.CallXML(ctx, &opts, nil, response)
} else {
resp, err = f.srv.Call(ctx, &opts)
}
return shouldRetry(ctx, resp, err)
})
if err != nil {
if resp != nil && resp.StatusCode == http.StatusNotFound {
// 404 HTTP code translates into Object not found
return nil, fs.ErrorObjectNotFound
}
return nil, fmt.Errorf("failed to call NetStorage API: %w", err)
}
if noResponse {
return nil, nil
}
return resp.Body, nil
}
// netStorageStatRequest performs a NetStorage stat request
func (f *Fs) netStorageStatRequest(ctx context.Context, URL string, directory bool) ([]File, error) {
if strings.HasSuffix(URL, ".rclonelink") {
fs.Infof(nil, "Converting rclonelink to a symlink on the stat request %q", URL)
URL = strings.TrimSuffix(URL, ".rclonelink")
}
URL = strings.TrimSuffix(URL, "/")
files := f.getStatCache(URL)
if files == nil {
const actionHeader = "version=1&action=stat&implicit=yes&format=xml&encoding=utf-8&slash=both"
statResp := &Stat{}
if _, err := f.callBackend(ctx, URL, "GET", actionHeader, false, statResp, nil); err != nil {
fs.Debugf(nil, "NetStorage action stat failed for %q: %v", URL, err)
return nil, err
}
files = statResp.Files
f.setStatCache(URL, files)
}
// Multiple objects can be returned with the "slash=both" option,
// when file/symlink/directory has the same name
for i := range files {
if files[i].Type == "symlink" {
// Add .rclonelink suffix to allow local backend code to convert to a symlink.
files[i].Name += ".rclonelink"
fs.Infof(nil, "Converting a symlink to the rclonelink on the stat request %s", files[i].Name)
}
entrywanted := (directory && files[i].Type == "dir") ||
(!directory && files[i].Type != "dir")
if entrywanted {
filestamp := files[0]
files[0] = files[i]
files[i] = filestamp
}
}
return files, nil
}
// netStorageDirRequest performs a NetStorage dir request
func (f *Fs) netStorageDirRequest(ctx context.Context, dir string, URL string) ([]File, error) {
const actionHeader = "version=1&action=dir&format=xml&encoding=utf-8"
statResp := &Stat{}
if _, err := f.callBackend(ctx, URL, "GET", actionHeader, false, statResp, nil); err != nil {
if err == fs.ErrorObjectNotFound {
return nil, fs.ErrorDirNotFound
}
fs.Debugf(nil, "NetStorage action dir failed for %q: %v", URL, err)
return nil, err
}
return statResp.Files, nil
}
// netStorageListRequest performs a NetStorage list request
// Second returning parameter is resumeStart string, if not empty the function should be restarted with the adjusted URL to continue the listing.
func (f *Fs) netStorageListRequest(ctx context.Context, URL, endPath string) ([]File, string, error) {
actionHeader := "version=1&action=list&mtime_all=yes&format=xml&encoding=utf-8"
if !pathIsOneLevelDeep(endPath) {
// Add end= to limit the depth to endPath
escapeEndPath := url.QueryEscape(strings.TrimSuffix(endPath, "/"))
// The "0" character exists in place of the trailing slash to
// accommodate ObjectStore directory logic
end := "&end=" + strings.TrimSuffix(escapeEndPath, "/") + "0"
actionHeader += end
}
listResp := &List{}
if _, err := f.callBackend(ctx, URL, "GET", actionHeader, false, listResp, nil); err != nil {
if err == fs.ErrorObjectNotFound {
// List action is known to return 404 for a valid [CP Code] path with no objects inside.
// Call stat to find out whether it is an empty directory or path does not exist.
fs.Debugf(nil, "NetStorage action list returned 404, call stat for %q", URL)
files, err := f.netStorageStatRequest(ctx, URL, true)
if err == nil && len(files) > 0 && files[0].Type == "dir" {
return []File{}, "", nil
}
}
fs.Debugf(nil, "NetStorage action list failed for %q: %v", URL, err)
return nil, "", err
}
return listResp.Files, listResp.Resume.Start, nil
}
// netStorageUploadRequest performs a NetStorage upload request
func (o *Object) netStorageUploadRequest(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
URL := o.fullURL
if URL == "" {
URL = o.fs.url(src.Remote())
}
if strings.HasSuffix(URL, ".rclonelink") {
bits, err := io.ReadAll(in)
if err != nil {
return err
}
targ := string(bits)
symlinkloc := strings.TrimSuffix(URL, ".rclonelink")
fs.Infof(nil, "Converting rclonelink to a symlink on upload %s target %s", symlinkloc, targ)
_, err = o.fs.netStorageSymlinkRequest(ctx, symlinkloc, targ, &o.modTime)
return err
}
u, err := url.Parse(URL)
if err != nil {
return fmt.Errorf("unable to parse URL %q while uploading: %w", URL, err)
}
path := u.RequestURI()
const actionHeader = "version=1&action=upload&sha256=atend&mtime=atend"
trailers := &http.Header{}
hr := newHashReader(in, sha256.New())
reader := customReader(
func(p []byte) (n int, err error) {
if n, err = hr.Read(p); err != nil && err == io.EOF {
// Send the "chunked trailer" after upload of the object
digest := hex.EncodeToString(hr.Sum(nil))
actionHeader := "version=1&action=upload&sha256=" + digest +
"&mtime=" + strconv.FormatInt(src.ModTime(ctx).Unix(), 10)
trailers.Add("X-Akamai-ACS-Action", actionHeader)
dataHeader := generateDataHeader(o.fs)
trailers.Add("X-Akamai-ACS-Auth-Data", dataHeader)
signHeader := generateSignHeader(o.fs, dataHeader, path, actionHeader)
trailers.Add("X-Akamai-ACS-Auth-Sign", signHeader)
}
return
},
)
var resp *http.Response
opts := rest.Opts{
Method: "PUT",
RootURL: URL,
NoResponse: true,
Options: options,
Body: reader,
Trailer: trailers,
ExtraHeaders: map[string]string{
"*X-Akamai-ACS-Action": actionHeader,
},
}
err = o.fs.pacer.CallNoRetry(func() (bool, error) {
resp, err = o.fs.srv.Call(ctx, &opts)
return shouldRetry(ctx, resp, err)
})
if err != nil {
if resp != nil && resp.StatusCode == http.StatusNotFound {
// 404 HTTP code translates into Object not found
return fs.ErrorObjectNotFound
}
fs.Debugf(nil, "NetStorage action upload failed for %q: %v", URL, err)
// Remove failed upload
_ = o.Remove(ctx)
return fmt.Errorf("failed to call NetStorage API upload: %w", err)
}
// Invalidate stat cache
o.fs.deleteStatCache(URL)
if o.size == -1 {
files, err := o.fs.netStorageStatRequest(ctx, URL, false)
if err != nil {
return nil
}
if files != nil {
o.size = files[0].Size
}
}
return nil
}
// netStorageDownloadRequest performs a NetStorage download request
func (o *Object) netStorageDownloadRequest(ctx context.Context, options []fs.OpenOption) (in io.ReadCloser, err error) {
URL := o.fullURL
// If requested file ends with .rclonelink and target has value
// then serve the content of target (the symlink target)
if strings.HasSuffix(URL, ".rclonelink") && o.target != "" {
fs.Infof(nil, "Converting a symlink to the rclonelink file on download %q", URL)
reader := strings.NewReader(o.target)
readcloser := io.NopCloser(reader)
return readcloser, nil
}
const actionHeader = "version=1&action=download"
fs.FixRangeOption(options, o.size)
body, err := o.fs.callBackend(ctx, URL, "GET", actionHeader, false, nil, options)
if err != nil {
fs.Debugf(nil, "NetStorage action download failed for %q: %v", URL, err)
return nil, err
}
return body, nil
}
// netStorageDuRequest performs a NetStorage du request
func (f *Fs) netStorageDuRequest(ctx context.Context) (interface{}, error) {
URL := f.url("")
const actionHeader = "version=1&action=du&format=xml&encoding=utf-8"
duResp := &Du{}
if _, err := f.callBackend(ctx, URL, "GET", actionHeader, false, duResp, nil); err != nil {
if err == fs.ErrorObjectNotFound {
return nil, errors.New("NetStorage du command: target is not a directory or does not exist")
}
fs.Debugf(nil, "NetStorage action du failed for %q: %v", URL, err)
return nil, err
}
//passing the output format expected from return of Command to be displayed by rclone code
out := map[string]int64{
"Number of files": duResp.Duinfo.Files,
"Total bytes": duResp.Duinfo.Bytes,
}
return out, nil
}
// netStorageDuRequest performs a NetStorage symlink request
func (f *Fs) netStorageSymlinkRequest(ctx context.Context, URL string, dst string, modTime *int64) (interface{}, error) {
target := url.QueryEscape(strings.TrimSuffix(dst, "/"))
actionHeader := "version=1&action=symlink&target=" + target
if modTime != nil {
when := strconv.FormatInt(*modTime, 10)
actionHeader += "&mtime=" + when
}
if _, err := f.callBackend(ctx, URL, "POST", actionHeader, true, nil, nil); err != nil {
fs.Debugf(nil, "NetStorage action symlink failed for %q: %v", URL, err)
return nil, fmt.Errorf("symlink creation failed: %w", err)
}
f.deleteStatCache(URL)
out := map[string]string{
"Symlink successfully created": dst,
}
return out, nil
}
// netStorageMkdirRequest performs a NetStorage mkdir request
func (f *Fs) netStorageMkdirRequest(ctx context.Context, URL string) error {
const actionHeader = "version=1&action=mkdir"
if _, err := f.callBackend(ctx, URL, "POST", actionHeader, true, nil, nil); err != nil {
fs.Debugf(nil, "NetStorage action mkdir failed for %q: %v", URL, err)
return err
}
f.deleteStatCache(URL)
return nil
}
// netStorageDeleteRequest performs a NetStorage delete request
func (o *Object) netStorageDeleteRequest(ctx context.Context) error {
URL := o.fullURL
// We shouldn't be creating .rclonelink files on remote
// but delete corresponding symlink if it exists
if strings.HasSuffix(URL, ".rclonelink") {
fs.Infof(nil, "Converting rclonelink to a symlink on delete %q", URL)
URL = strings.TrimSuffix(URL, ".rclonelink")
}
const actionHeader = "version=1&action=delete"
if _, err := o.fs.callBackend(ctx, URL, "POST", actionHeader, true, nil, nil); err != nil {
fs.Debugf(nil, "NetStorage action delete failed for %q: %v", URL, err)
return err
}
o.fs.deleteStatCache(URL)
return nil
}
// netStorageRmdirRequest performs a NetStorage rmdir request
func (f *Fs) netStorageRmdirRequest(ctx context.Context, dir string) error {
URL := f.url(dir)
const actionHeader = "version=1&action=rmdir"
if _, err := f.callBackend(ctx, URL, "POST", actionHeader, true, nil, nil); err != nil {
if err == fs.ErrorObjectNotFound {
return fs.ErrorDirNotFound
}
fs.Debugf(nil, "NetStorage action rmdir failed for %q: %v", URL, err)
return err
}
f.deleteStatCache(URL)
f.deleteDirscreated(URL)
return nil
}
// deleteDirscreated deletes URL from dirscreated map thread-safely
func (f *Fs) deleteDirscreated(URL string) {
URL = strings.TrimSuffix(URL, "/")
f.dirscreatedMutex.Lock()
delete(f.dirscreated, URL)
f.dirscreatedMutex.Unlock()
}
// setDirscreated sets to true URL in dirscreated map thread-safely
func (f *Fs) setDirscreated(URL string) {
URL = strings.TrimSuffix(URL, "/")
f.dirscreatedMutex.Lock()
f.dirscreated[URL] = true
f.dirscreatedMutex.Unlock()
}
// testAndSetDirscreated atomic test-and-set to true URL in dirscreated map,
// returns the previous value
func (f *Fs) testAndSetDirscreated(URL string) bool {
URL = strings.TrimSuffix(URL, "/")
f.dirscreatedMutex.Lock()
oldValue := f.dirscreated[URL]
f.dirscreated[URL] = true
f.dirscreatedMutex.Unlock()
return oldValue
}
// deleteStatCache deletes URL from stat cache thread-safely
func (f *Fs) deleteStatCache(URL string) {
URL = strings.TrimSuffix(URL, "/")
f.statcacheMutex.Lock()
delete(f.statcache, URL)
f.statcacheMutex.Unlock()
}
// getStatCache gets value from statcache map thread-safely
func (f *Fs) getStatCache(URL string) (files []File) {
URL = strings.TrimSuffix(URL, "/")
f.statcacheMutex.RLock()
files = f.statcache[URL]
f.statcacheMutex.RUnlock()
if files != nil {
fs.Debugf(nil, "NetStorage stat cache hit for %q", URL)
}
return
}
// setStatCache sets value to statcache map thread-safely
func (f *Fs) setStatCache(URL string, files []File) {
URL = strings.TrimSuffix(URL, "/")
f.statcacheMutex.Lock()
f.statcache[URL] = files
f.statcacheMutex.Unlock()
}
type hashReader struct {
io.Reader
gohash.Hash
}
func newHashReader(r io.Reader, h gohash.Hash) hashReader {
return hashReader{io.TeeReader(r, h), h}
}
type customReader func([]byte) (int, error)
func (c customReader) Read(p []byte) (n int, err error) {
return c(p)
}
// generateRequestID generates the unique requestID
func generateRequestID() int64 {
s1 := rand.NewSource(time.Now().UnixNano())
r1 := rand.New(s1)
return r1.Int63()
}
// computeHmac256 calculates the hash for the sign header
func computeHmac256(message string, secret string) string {
key := []byte(secret)
h := hmac.New(sha256.New, key)
h.Write([]byte(message))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
// getEpochTimeInSeconds returns current epoch time in seconds
func getEpochTimeInSeconds() int64 {
now := time.Now()
secs := now.Unix()
return secs
}
// generateDataHeader generates data header needed for making the request
func generateDataHeader(f *Fs) string {
return "5, 0.0.0.0, 0.0.0.0, " + strconv.FormatInt(getEpochTimeInSeconds(), 10) + ", " + strconv.FormatInt(generateRequestID(), 10) + "," + f.opt.Account
}
// generateSignHeader generates sign header needed for making the request
func generateSignHeader(f *Fs, dataHeader string, path string, actionHeader string) string {
var message = dataHeader + path + "\nx-akamai-acs-action:" + actionHeader + "\n"
return computeHmac256(message, f.opt.Secret)
}
// pathIsOneLevelDeep returns true if a given path does not go deeper than one level
func pathIsOneLevelDeep(path string) bool {
return !strings.Contains(strings.TrimSuffix(strings.TrimPrefix(path, "/"), "/"), "/")
}
// Check the interfaces are satisfied
var (
_ fs.Fs = &Fs{}
_ fs.Purger = &Fs{}
_ fs.PutStreamer = &Fs{}
_ fs.ListRer = &Fs{}
_ fs.Object = &Object{}
)