diff --git a/changelog/unreleased/s3ng-storage-driver.md b/changelog/unreleased/s3ng-storage-driver.md new file mode 100644 index 0000000000..d0945e9fb6 --- /dev/null +++ b/changelog/unreleased/s3ng-storage-driver.md @@ -0,0 +1,7 @@ +Enhancement: Add s3ng storage driver, storing blobs in a s3-compatible blobstore + +We added a new storage driver (s3ng) which stores the file metadata on a local +filesystem (reusing the decomposed filesystem of the ocis driver) and the +actual content as blobs in any s3-compatible blobstore. + +https://github.com/cs3org/reva/pull/1429 \ No newline at end of file diff --git a/go.mod b/go.mod index 1ff7ef2090..f11e1844e0 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,8 @@ require ( github.com/mattn/go-sqlite3 v2.0.3+incompatible github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/mapstructure v1.3.3 + github.com/onsi/ginkgo v1.7.0 + github.com/onsi/gomega v1.4.3 github.com/ory/fosite v0.35.1 github.com/pkg/errors v0.9.1 github.com/pkg/xattr v0.4.2 diff --git a/go.sum b/go.sum index de431fa4f1..f2351b1d42 100644 --- a/go.sum +++ b/go.sum @@ -215,6 +215,7 @@ github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.3.0 h1:gvV6jG9dTgFEncxo+AF7PH6MZXi/vZl25owA/8Dg8Wo= github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -298,7 +299,9 @@ github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn github.com/oleiade/reflections v1.0.0 h1:0ir4pc6v8/PJ0yw5AEtMddfXpWBXg9cnG7SgSoJuCgY= github.com/oleiade/reflections v1.0.0/go.mod h1:RbATFBbKYkVdqmSFtx13Bb/tVhR0lgOBXunWTZKeL4w= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/ory/fosite v0.35.1 h1:mGPcwVGwHA7Yy9wr/7LDps6BEXyavL32NxizL9eH53Q= @@ -389,6 +392,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -661,6 +665,7 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/h2non/gock.v1 v1.0.14/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -669,6 +674,7 @@ gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/grpc/services/storageprovider/storageprovider.go b/internal/grpc/services/storageprovider/storageprovider.go index 6c94cfc417..ae950d49c4 100644 --- a/internal/grpc/services/storageprovider/storageprovider.go +++ b/internal/grpc/services/storageprovider/storageprovider.go @@ -823,7 +823,7 @@ func (s *service) PurgeRecycle(ctx context.Context, req *provider.PurgeRecycleRe case errtypes.PermissionDenied: st = status.NewPermissionDenied(ctx, err, "permission denied") default: - st = status.NewInternal(ctx, err, "error restoring recycle item") + st = status.NewInternal(ctx, err, "error purging recycle item") } return &provider.PurgeRecycleResponse{ Status: st, @@ -838,7 +838,7 @@ func (s *service) PurgeRecycle(ctx context.Context, req *provider.PurgeRecycleRe case errtypes.PermissionDenied: st = status.NewPermissionDenied(ctx, err, "permission denied") default: - st = status.NewInternal(ctx, err, "error restoring recycle bin") + st = status.NewInternal(ctx, err, "error purging recycle bin") } return &provider.PurgeRecycleResponse{ Status: st, diff --git a/pkg/storage/fs/loader/loader.go b/pkg/storage/fs/loader/loader.go index 92b10ee31c..dd08dc6354 100644 --- a/pkg/storage/fs/loader/loader.go +++ b/pkg/storage/fs/loader/loader.go @@ -29,5 +29,6 @@ import ( _ "github.com/cs3org/reva/pkg/storage/fs/ocis" _ "github.com/cs3org/reva/pkg/storage/fs/owncloud" _ "github.com/cs3org/reva/pkg/storage/fs/s3" + _ "github.com/cs3org/reva/pkg/storage/fs/s3ng" // Add your own here ) diff --git a/pkg/storage/fs/ocis/tree.go b/pkg/storage/fs/ocis/tree.go index 44d27f9d76..742304dcba 100644 --- a/pkg/storage/fs/ocis/tree.go +++ b/pkg/storage/fs/ocis/tree.go @@ -262,7 +262,7 @@ func (t *Tree) Delete(ctx context.Context, n *Node) (err error) { // first make node appear in the owners (or root) trash // parent id and name are stored as extended attributes in the node itself trashLink := filepath.Join(t.lu.Options.Root, "trash", o.OpaqueId, n.ID) - err = os.Symlink("../nodes/"+n.ID+".T."+deletionTime, trashLink) + err = os.Symlink("../../nodes/"+n.ID+".T."+deletionTime, trashLink) if err != nil { // To roll back changes // TODO unset trashOriginAttr diff --git a/pkg/storage/fs/s3ng/blobstore/blobstore.go b/pkg/storage/fs/s3ng/blobstore/blobstore.go new file mode 100644 index 0000000000..4afb6a30bf --- /dev/null +++ b/pkg/storage/fs/s3ng/blobstore/blobstore.go @@ -0,0 +1,97 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package blobstore + +import ( + "io" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/s3/s3manager" + "github.com/pkg/errors" +) + +// Blobstore provides an interface to an s3 compatible blobstore +type Blobstore struct { + s3 *s3.S3 + uploader *s3manager.Uploader + + bucket string +} + +// New returns a new Blobstore +func New(endpoint, region, bucket, accessKey, secretKey string) (*Blobstore, error) { + sess, err := session.NewSession(&aws.Config{ + Endpoint: aws.String(endpoint), + Region: aws.String(region), + Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""), + S3ForcePathStyle: aws.Bool(true), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to setup s3 session") + } + uploader := s3manager.NewUploader(sess) + + return &Blobstore{ + uploader: uploader, + s3: s3.New(sess), + bucket: bucket, + }, nil +} + +// Upload stores some data in the blobstore under the given key +func (bs *Blobstore) Upload(key string, reader io.Reader) error { + _, err := bs.uploader.Upload(&s3manager.UploadInput{ + Bucket: aws.String(bs.bucket), + Key: aws.String(key), + Body: reader, + }) + if err != nil { + return errors.Wrapf(err, "could not store object '%s' into bucket '%s'", key, bs.bucket) + } + return nil +} + +// Download retrieves a blob from the blobstore for reading +func (bs *Blobstore) Download(key string) (io.ReadCloser, error) { + input := &s3.GetObjectInput{ + Bucket: aws.String(bs.bucket), + Key: aws.String(key), + } + result, err := bs.s3.GetObject(input) + if err != nil { + return nil, errors.Wrapf(err, "could not download object '%s' from bucket '%s'", key, bs.bucket) + } + return result.Body, nil +} + +// Delete deletes a blob from the blobstore +func (bs *Blobstore) Delete(key string) error { + input := &s3.DeleteObjectInput{ + Bucket: aws.String(bs.bucket), + Key: aws.String(key), + } + _, err := bs.s3.DeleteObject(input) + if err != nil { + return errors.Wrapf(err, "could not delete object '%s' from bucket '%s'", key, bs.bucket) + } + return nil +} diff --git a/pkg/storage/fs/s3ng/grants.go b/pkg/storage/fs/s3ng/grants.go new file mode 100644 index 0000000000..fce9d680e0 --- /dev/null +++ b/pkg/storage/fs/s3ng/grants.go @@ -0,0 +1,169 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng + +import ( + "context" + "path/filepath" + "strings" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/xattrs" + "github.com/cs3org/reva/pkg/storage/utils/ace" + "github.com/pkg/xattr" +) + +func (fs *s3ngfs) AddGrant(ctx context.Context, ref *provider.Reference, g *provider.Grant) (err error) { + log := appctx.GetLogger(ctx) + log.Debug().Interface("ref", ref).Interface("grant", g).Msg("AddGrant()") + var node *node.Node + if node, err = fs.lu.NodeFromResource(ctx, ref); err != nil { + return + } + if !node.Exists { + err = errtypes.NotFound(filepath.Join(node.ParentID, node.Name)) + return + } + + ok, err := fs.p.HasPermission(ctx, node, func(rp *provider.ResourcePermissions) bool { + // TODO remove AddGrant or UpdateGrant grant from CS3 api, redundant? tracked in https://github.com/cs3org/cs3apis/issues/92 + return rp.AddGrant || rp.UpdateGrant + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(filepath.Join(node.ParentID, node.Name)) + } + + np := fs.lu.InternalPath(node.ID) + e := ace.FromGrant(g) + principal, value := e.Marshal() + if err := xattr.Set(np, xattrs.GrantPrefix+principal, value); err != nil { + return err + } + return fs.tp.Propagate(ctx, node) +} + +func (fs *s3ngfs) ListGrants(ctx context.Context, ref *provider.Reference) (grants []*provider.Grant, err error) { + var node *node.Node + if node, err = fs.lu.NodeFromResource(ctx, ref); err != nil { + return + } + if !node.Exists { + err = errtypes.NotFound(filepath.Join(node.ParentID, node.Name)) + return + } + + ok, err := fs.p.HasPermission(ctx, node, func(rp *provider.ResourcePermissions) bool { + return rp.ListGrants + }) + switch { + case err != nil: + return nil, errtypes.InternalError(err.Error()) + case !ok: + return nil, errtypes.PermissionDenied(filepath.Join(node.ParentID, node.Name)) + } + + log := appctx.GetLogger(ctx) + np := fs.lu.InternalPath(node.ID) + var attrs []string + if attrs, err = xattr.List(np); err != nil { + log.Error().Err(err).Msg("error listing attributes") + return nil, err + } + + log.Debug().Interface("attrs", attrs).Msg("read attributes") + + aces := extractACEsFromAttrs(ctx, np, attrs) + + grants = make([]*provider.Grant, 0, len(aces)) + for i := range aces { + grants = append(grants, aces[i].Grant()) + } + + return grants, nil +} + +func (fs *s3ngfs) RemoveGrant(ctx context.Context, ref *provider.Reference, g *provider.Grant) (err error) { + var node *node.Node + if node, err = fs.lu.NodeFromResource(ctx, ref); err != nil { + return + } + if !node.Exists { + err = errtypes.NotFound(filepath.Join(node.ParentID, node.Name)) + return + } + + ok, err := fs.p.HasPermission(ctx, node, func(rp *provider.ResourcePermissions) bool { + return rp.RemoveGrant + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(filepath.Join(node.ParentID, node.Name)) + } + + var attr string + if g.Grantee.Type == provider.GranteeType_GRANTEE_TYPE_GROUP { + attr = xattrs.GrantPrefix + xattrs.GroupAcePrefix + g.Grantee.Id.OpaqueId + } else { + attr = xattrs.GrantPrefix + xattrs.UserAcePrefix + g.Grantee.Id.OpaqueId + } + + np := fs.lu.InternalPath(node.ID) + if err = xattr.Remove(np, attr); err != nil { + return + } + + return fs.tp.Propagate(ctx, node) +} + +func (fs *s3ngfs) UpdateGrant(ctx context.Context, ref *provider.Reference, g *provider.Grant) error { + // TODO remove AddGrant or UpdateGrant grant from CS3 api, redundant? tracked in https://github.com/cs3org/cs3apis/issues/92 + return fs.AddGrant(ctx, ref, g) +} + +// extractACEsFromAttrs reads ACEs in the list of attrs from the node +func extractACEsFromAttrs(ctx context.Context, fsfn string, attrs []string) (entries []*ace.ACE) { + log := appctx.GetLogger(ctx) + entries = []*ace.ACE{} + for i := range attrs { + if strings.HasPrefix(attrs[i], xattrs.GrantPrefix) { + var value []byte + var err error + if value, err = xattr.Get(fsfn, attrs[i]); err != nil { + log.Error().Err(err).Str("attr", attrs[i]).Msg("could not read attribute") + continue + } + var e *ace.ACE + principal := attrs[i][len(xattrs.GrantPrefix):] + if e, err = ace.Unmarshal(principal, value); err != nil { + log.Error().Err(err).Str("principal", principal).Str("attr", attrs[i]).Msg("could not unmarshal ace") + continue + } + entries = append(entries, e) + } + } + return +} diff --git a/pkg/storage/fs/s3ng/lookup.go b/pkg/storage/fs/s3ng/lookup.go new file mode 100644 index 0000000000..5cdadaf74a --- /dev/null +++ b/pkg/storage/fs/s3ng/lookup.go @@ -0,0 +1,168 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/utils/templates" + "github.com/cs3org/reva/pkg/user" +) + +// Lookup implements transformations from filepath to node and back +type Lookup struct { + Options *Options +} + +// NodeFromResource takes in a request path or request id and converts it to a Node +func (lu *Lookup) NodeFromResource(ctx context.Context, ref *provider.Reference) (*node.Node, error) { + if ref.GetPath() != "" { + return lu.NodeFromPath(ctx, ref.GetPath()) + } + + if ref.GetId() != nil { + return lu.NodeFromID(ctx, ref.GetId()) + } + + // reference is invalid + return nil, fmt.Errorf("invalid reference %+v", ref) +} + +// NodeFromPath converts a filename into a Node +func (lu *Lookup) NodeFromPath(ctx context.Context, fn string) (*node.Node, error) { + log := appctx.GetLogger(ctx) + log.Debug().Interface("fn", fn).Msg("NodeFromPath()") + + n, err := lu.HomeOrRootNode(ctx) + if err != nil { + return nil, err + } + + // TODO collect permissions of the current user on every segment + if fn != "/" { + n, err = lu.WalkPath(ctx, n, fn, func(ctx context.Context, n *node.Node) error { + log.Debug().Interface("node", n).Msg("NodeFromPath() walk") + return nil + }) + if err != nil { + return nil, err + } + } + + return n, nil +} + +// NodeFromID returns the internal path for the id +func (lu *Lookup) NodeFromID(ctx context.Context, id *provider.ResourceId) (n *node.Node, err error) { + if id == nil || id.OpaqueId == "" { + return nil, fmt.Errorf("invalid resource id %+v", id) + } + return node.ReadNode(ctx, lu, id.OpaqueId) +} + +// Path returns the path for node +func (lu *Lookup) Path(ctx context.Context, n *node.Node) (p string, err error) { + var root *node.Node + if root, err = lu.HomeOrRootNode(ctx); err != nil { + return + } + for n.ID != root.ID { + p = filepath.Join(n.Name, p) + if n, err = n.Parent(); err != nil { + appctx.GetLogger(ctx). + Error().Err(err). + Str("path", p). + Interface("node", n). + Msg("Path()") + return + } + } + return +} + +// RootNode returns the root node of the storage +func (lu *Lookup) RootNode(ctx context.Context) (*node.Node, error) { + return node.New("root", "", "", 0, nil, lu), nil +} + +// HomeNode returns the home node of a user +func (lu *Lookup) HomeNode(ctx context.Context) (node *node.Node, err error) { + if !lu.Options.EnableHome { + return nil, errtypes.NotSupported("s3ngfs: home supported disabled") + } + + if node, err = lu.RootNode(ctx); err != nil { + return + } + node, err = lu.WalkPath(ctx, node, lu.mustGetUserLayout(ctx), nil) + return +} + +// WalkPath calls n.Child(segment) on every path segment in p starting at the node r +// If a function f is given it will be executed for every segment node, but not the root node r +func (lu *Lookup) WalkPath(ctx context.Context, r *node.Node, p string, f func(ctx context.Context, n *node.Node) error) (*node.Node, error) { + segments := strings.Split(strings.Trim(p, "/"), "/") + var err error + for i := range segments { + if r, err = r.Child(segments[i]); err != nil { + return r, err + } + // if an intermediate node is missing return not found + if !r.Exists && i < len(segments)-1 { + return r, errtypes.NotFound(segments[i]) + } + if f != nil { + if err = f(ctx, r); err != nil { + return r, err + } + } + } + return r, nil +} + +// HomeOrRootNode returns the users home node when home support is enabled. +// it returns the storages root node otherwise +func (lu *Lookup) HomeOrRootNode(ctx context.Context) (node *node.Node, err error) { + if lu.Options.EnableHome { + return lu.HomeNode(ctx) + } + return lu.RootNode(ctx) +} + +// InternalRoot returns the internal storage root directory +func (lu *Lookup) InternalRoot() string { + return lu.Options.Root +} + +// InternalPath returns the internal path for a given ID +func (lu *Lookup) InternalPath(id string) string { + return filepath.Join(lu.Options.Root, "nodes", id) +} + +func (lu *Lookup) mustGetUserLayout(ctx context.Context) string { + u := user.ContextMustGetUser(ctx) + return templates.WithUser(u, lu.Options.UserLayout) +} diff --git a/pkg/storage/fs/s3ng/metadata.go b/pkg/storage/fs/s3ng/metadata.go new file mode 100644 index 0000000000..ca5aefe27d --- /dev/null +++ b/pkg/storage/fs/s3ng/metadata.go @@ -0,0 +1,200 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng + +import ( + "context" + "fmt" + "path/filepath" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/xattrs" + "github.com/cs3org/reva/pkg/user" + "github.com/pkg/errors" + "github.com/pkg/xattr" +) + +func (fs *s3ngfs) SetArbitraryMetadata(ctx context.Context, ref *provider.Reference, md *provider.ArbitraryMetadata) (err error) { + n, err := fs.lu.NodeFromResource(ctx, ref) + if err != nil { + return errors.Wrap(err, "s3ngfs: error resolving ref") + } + sublog := appctx.GetLogger(ctx).With().Interface("node", n).Logger() + + if !n.Exists { + err = errtypes.NotFound(filepath.Join(n.ParentID, n.Name)) + return err + } + + ok, err := fs.p.HasPermission(ctx, n, func(rp *provider.ResourcePermissions) bool { + // TODO add explicit SetArbitraryMetadata grant to CS3 api, tracked in https://github.com/cs3org/cs3apis/issues/91 + return rp.InitiateFileUpload + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(filepath.Join(n.ParentID, n.Name)) + } + + nodePath := n.InternalPath() + + errs := []error{} + // TODO should we really continue updating when an error occurs? + if md.Metadata != nil { + if val, ok := md.Metadata["mtime"]; ok { + delete(md.Metadata, "mtime") + err := n.SetMtime(ctx, val) + if err != nil { + errs = append(errs, errors.Wrap(err, "could not set mtime")) + } + } + // TODO(jfd) special handling for atime? + // TODO(jfd) allow setting birth time (btime)? + // TODO(jfd) any other metadata that is interesting? fileid? + // TODO unset when file is updated + // TODO unset when folder is updated or add timestamp to etag? + if val, ok := md.Metadata["etag"]; ok { + delete(md.Metadata, "etag") + err := n.SetEtag(ctx, val) + if err != nil { + errs = append(errs, errors.Wrap(err, "could not set etag")) + } + } + if val, ok := md.Metadata[node.FavoriteKey]; ok { + delete(md.Metadata, node.FavoriteKey) + if u, ok := user.ContextGetUser(ctx); ok { + if uid := u.GetId(); uid != nil { + if err := n.SetFavorite(uid, val); err != nil { + sublog.Error().Err(err). + Interface("user", u). + Msg("could not set favorite flag") + errs = append(errs, errors.Wrap(err, "could not set favorite flag")) + } + } else { + sublog.Error().Interface("user", u).Msg("user has no id") + errs = append(errs, errors.Wrap(errtypes.UserRequired("userrequired"), "user has no id")) + } + } else { + sublog.Error().Interface("user", u).Msg("error getting user from ctx") + errs = append(errs, errors.Wrap(errtypes.UserRequired("userrequired"), "error getting user from ctx")) + } + } + } + for k, v := range md.Metadata { + attrName := xattrs.MetadataPrefix + k + if err = xattr.Set(nodePath, attrName, []byte(v)); err != nil { + errs = append(errs, errors.Wrap(err, "s3ngfs: could not set metadata attribute "+attrName+" to "+k)) + } + } + + switch len(errs) { + case 0: + return fs.tp.Propagate(ctx, n) + case 1: + // TODO Propagate if anything changed + return errs[0] + default: + // TODO Propagate if anything changed + // TODO how to return multiple errors? + return errors.New("multiple errors occurred, see log for details") + } +} + +func (fs *s3ngfs) UnsetArbitraryMetadata(ctx context.Context, ref *provider.Reference, keys []string) (err error) { + n, err := fs.lu.NodeFromResource(ctx, ref) + if err != nil { + return errors.Wrap(err, "s3ngfs: error resolving ref") + } + sublog := appctx.GetLogger(ctx).With().Interface("node", n).Logger() + + if !n.Exists { + err = errtypes.NotFound(filepath.Join(n.ParentID, n.Name)) + return err + } + + ok, err := fs.p.HasPermission(ctx, n, func(rp *provider.ResourcePermissions) bool { + // TODO use SetArbitraryMetadata grant to CS3 api, tracked in https://github.com/cs3org/cs3apis/issues/91 + return rp.InitiateFileUpload + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(filepath.Join(n.ParentID, n.Name)) + } + + nodePath := n.InternalPath() + errs := []error{} + for _, k := range keys { + switch k { + case node.FavoriteKey: + if u, ok := user.ContextGetUser(ctx); ok { + // the favorite flag is specific to the user, so we need to incorporate the userid + if uid := u.GetId(); uid != nil { + fa := fmt.Sprintf("%s%s@%s", xattrs.FavPrefix, uid.GetOpaqueId(), uid.GetIdp()) + if err := xattr.Remove(nodePath, fa); err != nil { + sublog.Error().Err(err). + Interface("user", u). + Str("key", fa). + Msg("could not unset favorite flag") + errs = append(errs, errors.Wrap(err, "could not unset favorite flag")) + } + } else { + sublog.Error(). + Interface("user", u). + Msg("user has no id") + errs = append(errs, errors.Wrap(errtypes.UserRequired("userrequired"), "user has no id")) + } + } else { + sublog.Error(). + Interface("user", u). + Msg("error getting user from ctx") + errs = append(errs, errors.Wrap(errtypes.UserRequired("userrequired"), "error getting user from ctx")) + } + default: + if err = xattr.Remove(nodePath, xattrs.MetadataPrefix+k); err != nil { + // a non-existing attribute will return an error, which we can ignore + // (using string compare because the error type is syscall.Errno and not wrapped/recognizable) + if e, ok := err.(*xattr.Error); !ok || !(e.Err.Error() == "no data available" || + // darwin + e.Err.Error() == "attribute not found") { + sublog.Error().Err(err). + Str("key", k). + Msg("could not unset metadata") + errs = append(errs, errors.Wrap(err, "could not unset metadata")) + } + } + } + } + switch len(errs) { + case 0: + return fs.tp.Propagate(ctx, n) + case 1: + // TODO Propagate if anything changed + return errs[0] + default: + // TODO Propagate if anything changed + // TODO how to return multiple errors? + return errors.New("multiple errors occurred, see log for details") + } +} diff --git a/pkg/storage/fs/s3ng/mocks/PermissionsChecker.go b/pkg/storage/fs/s3ng/mocks/PermissionsChecker.go new file mode 100644 index 0000000000..83d797df9e --- /dev/null +++ b/pkg/storage/fs/s3ng/mocks/PermissionsChecker.go @@ -0,0 +1,79 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + node "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + mock "github.com/stretchr/testify/mock" + + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +// PermissionsChecker is an autogenerated mock type for the PermissionsChecker type +type PermissionsChecker struct { + mock.Mock +} + +// AssemblePermissions provides a mock function with given fields: ctx, n +func (_m *PermissionsChecker) AssemblePermissions(ctx context.Context, n *node.Node) (*providerv1beta1.ResourcePermissions, error) { + ret := _m.Called(ctx, n) + + var r0 *providerv1beta1.ResourcePermissions + if rf, ok := ret.Get(0).(func(context.Context, *node.Node) *providerv1beta1.ResourcePermissions); ok { + r0 = rf(ctx, n) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*providerv1beta1.ResourcePermissions) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *node.Node) error); ok { + r1 = rf(ctx, n) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// HasPermission provides a mock function with given fields: ctx, n, check +func (_m *PermissionsChecker) HasPermission(ctx context.Context, n *node.Node, check func(*providerv1beta1.ResourcePermissions) bool) (bool, error) { + ret := _m.Called(ctx, n, check) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, *node.Node, func(*providerv1beta1.ResourcePermissions) bool) bool); ok { + r0 = rf(ctx, n, check) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *node.Node, func(*providerv1beta1.ResourcePermissions) bool) error); ok { + r1 = rf(ctx, n, check) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/storage/fs/s3ng/mocks/Tree.go b/pkg/storage/fs/s3ng/mocks/Tree.go new file mode 100644 index 0000000000..3213927656 --- /dev/null +++ b/pkg/storage/fs/s3ng/mocks/Tree.go @@ -0,0 +1,291 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + io "io" + + mock "github.com/stretchr/testify/mock" + + node "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + + os "os" + + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +// Tree is an autogenerated mock type for the Tree type +type Tree struct { + mock.Mock +} + +// CreateDir provides a mock function with given fields: ctx, _a1 +func (_m *Tree) CreateDir(ctx context.Context, _a1 *node.Node) error { + ret := _m.Called(ctx, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *node.Node) error); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Delete provides a mock function with given fields: ctx, _a1 +func (_m *Tree) Delete(ctx context.Context, _a1 *node.Node) error { + ret := _m.Called(ctx, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *node.Node) error); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteBlob provides a mock function with given fields: key +func (_m *Tree) DeleteBlob(key string) error { + ret := _m.Called(key) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(key) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetMD provides a mock function with given fields: ctx, _a1 +func (_m *Tree) GetMD(ctx context.Context, _a1 *node.Node) (os.FileInfo, error) { + ret := _m.Called(ctx, _a1) + + var r0 os.FileInfo + if rf, ok := ret.Get(0).(func(context.Context, *node.Node) os.FileInfo); ok { + r0 = rf(ctx, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(os.FileInfo) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *node.Node) error); ok { + r1 = rf(ctx, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetPathByID provides a mock function with given fields: ctx, id +func (_m *Tree) GetPathByID(ctx context.Context, id *providerv1beta1.ResourceId) (string, error) { + ret := _m.Called(ctx, id) + + var r0 string + if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId) string); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListFolder provides a mock function with given fields: ctx, _a1 +func (_m *Tree) ListFolder(ctx context.Context, _a1 *node.Node) ([]*node.Node, error) { + ret := _m.Called(ctx, _a1) + + var r0 []*node.Node + if rf, ok := ret.Get(0).(func(context.Context, *node.Node) []*node.Node); ok { + r0 = rf(ctx, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*node.Node) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *node.Node) error); ok { + r1 = rf(ctx, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Move provides a mock function with given fields: ctx, oldNode, newNode +func (_m *Tree) Move(ctx context.Context, oldNode *node.Node, newNode *node.Node) error { + ret := _m.Called(ctx, oldNode, newNode) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *node.Node, *node.Node) error); ok { + r0 = rf(ctx, oldNode, newNode) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Propagate provides a mock function with given fields: ctx, _a1 +func (_m *Tree) Propagate(ctx context.Context, _a1 *node.Node) error { + ret := _m.Called(ctx, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *node.Node) error); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// PurgeRecycleItemFunc provides a mock function with given fields: ctx, key +func (_m *Tree) PurgeRecycleItemFunc(ctx context.Context, key string) (*node.Node, func() error, error) { + ret := _m.Called(ctx, key) + + var r0 *node.Node + if rf, ok := ret.Get(0).(func(context.Context, string) *node.Node); ok { + r0 = rf(ctx, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*node.Node) + } + } + + var r1 func() error + if rf, ok := ret.Get(1).(func(context.Context, string) func() error); ok { + r1 = rf(ctx, key) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(func() error) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, string) error); ok { + r2 = rf(ctx, key) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ReadBlob provides a mock function with given fields: key +func (_m *Tree) ReadBlob(key string) (io.ReadCloser, error) { + ret := _m.Called(key) + + var r0 io.ReadCloser + if rf, ok := ret.Get(0).(func(string) io.ReadCloser); ok { + r0 = rf(key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.ReadCloser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RestoreRecycleItemFunc provides a mock function with given fields: ctx, key +func (_m *Tree) RestoreRecycleItemFunc(ctx context.Context, key string) (*node.Node, func() error, error) { + ret := _m.Called(ctx, key) + + var r0 *node.Node + if rf, ok := ret.Get(0).(func(context.Context, string) *node.Node); ok { + r0 = rf(ctx, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*node.Node) + } + } + + var r1 func() error + if rf, ok := ret.Get(1).(func(context.Context, string) func() error); ok { + r1 = rf(ctx, key) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(func() error) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, string) error); ok { + r2 = rf(ctx, key) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Setup provides a mock function with given fields: owner +func (_m *Tree) Setup(owner string) error { + ret := _m.Called(owner) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(owner) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// WriteBlob provides a mock function with given fields: key, reader +func (_m *Tree) WriteBlob(key string, reader io.Reader) error { + ret := _m.Called(key, reader) + + var r0 error + if rf, ok := ret.Get(0).(func(string, io.Reader) error); ok { + r0 = rf(key, reader) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/storage/fs/s3ng/node/node.go b/pkg/storage/fs/s3ng/node/node.go new file mode 100644 index 0000000000..b68dd9af8e --- /dev/null +++ b/pkg/storage/fs/s3ng/node/node.go @@ -0,0 +1,791 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package node + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/pkg/xattr" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/cs3org/reva/internal/grpc/services/storageprovider" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/mime" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/xattrs" + "github.com/cs3org/reva/pkg/storage/utils/ace" + "github.com/cs3org/reva/pkg/user" +) + +// Define keys and values used in the node metadata +const ( + FavoriteKey = "http://owncloud.org/ns/favorite" + ShareTypesKey = "http://owncloud.org/ns/share-types" + ChecksumsKey = "http://owncloud.org/ns/checksums" + UserShareType = "0" +) + +// Node represents a node in the tree and provides methods to get a Parent or Child instance +type Node struct { + ParentID string + ID string + Name string + Blobsize int64 + owner *userpb.UserId + Exists bool + + lu PathLookup +} + +// PathLookup defines the interface for the lookup component +type PathLookup interface { + RootNode(ctx context.Context) (node *Node, err error) + HomeOrRootNode(ctx context.Context) (node *Node, err error) + + InternalRoot() string + InternalPath(ID string) string + Path(ctx context.Context, n *Node) (path string, err error) +} + +// New returns a new instance of Node +func New(id, parentID, name string, blobsize int64, owner *userpb.UserId, lu PathLookup) *Node { + return &Node{ + ID: id, + ParentID: parentID, + Name: name, + Blobsize: blobsize, + owner: owner, + lu: lu, + } +} + +// WriteMetadata writes the Node metadata to disk +func (n *Node) WriteMetadata(owner *userpb.UserId) (err error) { + nodePath := n.InternalPath() + if err = xattr.Set(nodePath, xattrs.ParentidAttr, []byte(n.ParentID)); err != nil { + return errors.Wrap(err, "s3ngfs: could not set parentid attribute") + } + if err = xattr.Set(nodePath, xattrs.NameAttr, []byte(n.Name)); err != nil { + return errors.Wrap(err, "s3ngfs: could not set name attribute") + } + if err = xattr.Set(nodePath, xattrs.BlobsizeAttr, []byte(fmt.Sprintf("%d", n.Blobsize))); err != nil { + return errors.Wrap(err, "s3ngfs: could not set blobsize attribute") + } + if owner == nil { + if err = xattr.Set(nodePath, xattrs.OwnerIDAttr, []byte("")); err != nil { + return errors.Wrap(err, "s3ngfs: could not set empty owner id attribute") + } + if err = xattr.Set(nodePath, xattrs.OwnerIDPAttr, []byte("")); err != nil { + return errors.Wrap(err, "s3ngfs: could not set empty owner idp attribute") + } + } else { + if err = xattr.Set(nodePath, xattrs.OwnerIDAttr, []byte(owner.OpaqueId)); err != nil { + return errors.Wrap(err, "s3ngfs: could not set owner id attribute") + } + if err = xattr.Set(nodePath, xattrs.OwnerIDPAttr, []byte(owner.Idp)); err != nil { + return errors.Wrap(err, "s3ngfs: could not set owner idp attribute") + } + } + return +} + +// ReadNode creates a new instance from an id and checks if it exists +func ReadNode(ctx context.Context, lu PathLookup, id string) (n *Node, err error) { + n = &Node{ + lu: lu, + ID: id, + } + + nodePath := n.InternalPath() + + // lookup parent id in extended attributes + var attrBytes []byte + attrBytes, err = xattr.Get(nodePath, xattrs.ParentidAttr) + switch { + case err == nil: + n.ParentID = string(attrBytes) + case isNoData(err): + return nil, errtypes.InternalError(err.Error()) + case isNotFound(err): + return n, nil // swallow not found, the node defaults to exists = false + default: + return nil, errtypes.InternalError(err.Error()) + } + // lookup name in extended attributes + if attrBytes, err = xattr.Get(nodePath, xattrs.NameAttr); err == nil { + n.Name = string(attrBytes) + } else { + return + } + // Lookup blobsize + if attrBytes, err = xattr.Get(nodePath, xattrs.BlobsizeAttr); err == nil { + var blobSize int64 + if blobSize, err = strconv.ParseInt(string(attrBytes), 10, 64); err == nil { + n.Blobsize = blobSize + } else { + return + } + } else { + return + } + + var root *Node + if root, err = lu.HomeOrRootNode(ctx); err != nil { + return + } + parentID := n.ParentID + + log := appctx.GetLogger(ctx) + for parentID != root.ID { + log.Debug().Interface("node", n).Str("root.ID", root.ID).Msg("ReadNode()") + // walk to root to check node is not part of a deleted subtree + + if attrBytes, err = xattr.Get(lu.InternalPath(parentID), xattrs.ParentidAttr); err == nil { + parentID = string(attrBytes) + log.Debug().Interface("node", n).Str("root.ID", root.ID).Str("parentID", parentID).Msg("ReadNode() found parent") + } else { + log.Error().Err(err).Interface("node", n).Str("root.ID", root.ID).Msg("ReadNode()") + if isNotFound(err) { + return nil, errtypes.NotFound(err.Error()) + } + return + } + } + + n.Exists = true + log.Debug().Interface("node", n).Msg("ReadNode() found node") + + return +} + +// Child returns the child node with the given name +func (n *Node) Child(name string) (*Node, error) { + c := &Node{ + lu: n.lu, + ParentID: n.ID, + Name: name, + } + + link, err := os.Readlink(filepath.Join(n.InternalPath(), name)) + if err != nil { + if os.IsNotExist(err) { + return c, nil // if the file does not exist we return a node that has Exists = false + } + + return nil, errors.Wrap(err, "s3ngfs: Wrap: readlink error") + } + + if strings.HasPrefix(link, "../") { + c.Exists = true + c.ID = filepath.Base(link) + } else { + return nil, fmt.Errorf("s3ngfs: expected '../ prefix, got' %+v", link) + } + + // Lookup blobsize + if attrBytes, err := xattr.Get(c.InternalPath(), xattrs.BlobsizeAttr); err == nil { + blobSize, err := strconv.ParseInt(string(attrBytes), 10, 64) + if err != nil { + return nil, errors.Wrap(err, "node: could not parse blob size") + + } + c.Blobsize = blobSize + } else { + return nil, errors.Wrap(err, "node: could not read blob size") + } + + return c, nil +} + +// Parent returns the parent node +func (n *Node) Parent() (p *Node, err error) { + if n.ParentID == "" { + return nil, fmt.Errorf("s3ngfs: root has no parent") + } + p = &Node{ + lu: n.lu, + ID: n.ParentID, + } + + parentPath := n.lu.InternalPath(n.ParentID) + + // lookup parent id in extended attributes + var attrBytes []byte + if attrBytes, err = xattr.Get(parentPath, xattrs.ParentidAttr); err == nil { + p.ParentID = string(attrBytes) + } else { + return + } + // lookup name in extended attributes + if attrBytes, err = xattr.Get(parentPath, xattrs.NameAttr); err == nil { + p.Name = string(attrBytes) + } else { + return + } + + // check node exists + if _, err := os.Stat(parentPath); err == nil { + p.Exists = true + } + return +} + +// Owner returns the cached owner id or reads it from the extended attributes +// TODO can be private as only the AsResourceInfo uses it +func (n *Node) Owner() (o *userpb.UserId, err error) { + if n.owner != nil { + return n.owner, nil + } + + // FIXME ... do we return the owner of the reference or the owner of the target? + // we don't really know the owner of the target ... and as the reference may point anywhere we cannot really find out + // but what are the permissions? all? none? the gateway has to fill in? + // TODO what if this is a reference? + nodePath := n.InternalPath() + // lookup parent id in extended attributes + var attrBytes []byte + // lookup name in extended attributes + if attrBytes, err = xattr.Get(nodePath, xattrs.OwnerIDAttr); err == nil { + if n.owner == nil { + n.owner = &userpb.UserId{} + } + n.owner.OpaqueId = string(attrBytes) + } else { + return + } + // lookup name in extended attributes + if attrBytes, err = xattr.Get(nodePath, xattrs.OwnerIDPAttr); err == nil { + if n.owner == nil { + n.owner = &userpb.UserId{} + } + n.owner.Idp = string(attrBytes) + } else { + return + } + return n.owner, err +} + +// PermissionSet returns the permission set for the current user +// the parent nodes are not taken into account +func (n *Node) PermissionSet(ctx context.Context) *provider.ResourcePermissions { + u, ok := user.ContextGetUser(ctx) + if !ok { + appctx.GetLogger(ctx).Debug().Interface("node", n).Msg("no user in context, returning default permissions") + return NoPermissions + } + if o, _ := n.Owner(); isSameUserID(u.Id, o) { + return OwnerPermissions + } + // read the permissions for the current user from the acls of the current node + if np, err := n.ReadUserPermissions(ctx, u); err == nil { + return np + } + return NoPermissions +} + +// InternalPath returns the internal path of the Node +func (n *Node) InternalPath() string { + return n.lu.InternalPath(n.ID) +} + +// calculateEtag returns a hash of fileid + tmtime (or mtime) +func calculateEtag(nodeID string, tmTime time.Time) (string, error) { + h := md5.New() + if _, err := io.WriteString(h, nodeID); err != nil { + return "", err + } + if tb, err := tmTime.UTC().MarshalBinary(); err == nil { + if _, err := h.Write(tb); err != nil { + return "", err + } + } else { + return "", err + } + return fmt.Sprintf(`"%x"`, h.Sum(nil)), nil +} + +// SetMtime sets the mtime and atime of a node +func (n *Node) SetMtime(ctx context.Context, mtime string) error { + sublog := appctx.GetLogger(ctx).With().Interface("node", n).Logger() + if mt, err := parseMTime(mtime); err == nil { + nodePath := n.lu.InternalPath(n.ID) + // updating mtime also updates atime + if err := os.Chtimes(nodePath, mt, mt); err != nil { + sublog.Error().Err(err). + Time("mtime", mt). + Msg("could not set mtime") + return errors.Wrap(err, "could not set mtime") + } + } else { + sublog.Error().Err(err). + Str("mtime", mtime). + Msg("could not parse mtime") + return errors.Wrap(err, "could not parse mtime") + } + return nil +} + +// SetEtag sets the temporary etag of a node if it differs from the current etag +func (n *Node) SetEtag(ctx context.Context, val string) (err error) { + sublog := appctx.GetLogger(ctx).With().Interface("node", n).Logger() + nodePath := n.lu.InternalPath(n.ID) + var tmTime time.Time + if tmTime, err = n.GetTMTime(); err != nil { + // no tmtime, use mtime + var fi os.FileInfo + if fi, err = os.Lstat(nodePath); err != nil { + return + } + tmTime = fi.ModTime() + } + var etag string + if etag, err = calculateEtag(n.ID, tmTime); err != nil { + return + } + + // sanitize etag + val = fmt.Sprintf("\"%s\"", strings.Trim(val, "\"")) + if etag == val { + sublog.Debug(). + Str("etag", val). + Msg("ignoring request to update identical etag") + return nil + } + // etag is only valid until the calculated etag changes, is part of propagation + return xattr.Set(nodePath, xattrs.TmpEtagAttr, []byte(val)) +} + +// SetFavorite sets the favorite for the current user +// TODO we should not mess with the user here ... the favorites is now a user specific property for a file +// that cannot be mapped to extended attributes without leaking who has marked a file as a favorite +// it is a specific case of a tag, which is user individual as well +// TODO there are different types of tags +// 1. public that are managed by everyone +// 2. private tags that are only visible to the user +// 3. system tags that are only visible to the system +// 4. group tags that are only visible to a group ... +// urgh ... well this can be solved using different namespaces +// 1. public = p: +// 2. private = u:: for user specific +// 3. system = s: for system +// 4. group = g:: +// 5. app? = a:: for apps? +// obviously this only is secure when the u/s/g/a namespaces are not accessible by users in the filesystem +// public tags can be mapped to extended attributes +func (n *Node) SetFavorite(uid *userpb.UserId, val string) error { + nodePath := n.lu.InternalPath(n.ID) + // the favorite flag is specific to the user, so we need to incorporate the userid + fa := fmt.Sprintf("%s%s@%s", xattrs.FavPrefix, uid.GetOpaqueId(), uid.GetIdp()) + return xattr.Set(nodePath, fa, []byte(val)) +} + +// AsResourceInfo return the node as CS3 ResourceInfo +func (n *Node) AsResourceInfo(ctx context.Context, rp *provider.ResourcePermissions, mdKeys []string) (ri *provider.ResourceInfo, err error) { + sublog := appctx.GetLogger(ctx).With().Interface("node", n).Logger() + + var fn string + nodePath := n.lu.InternalPath(n.ID) + + var fi os.FileInfo + + nodeType := provider.ResourceType_RESOURCE_TYPE_INVALID + if fi, err = os.Lstat(nodePath); err != nil { + return + } + + var target []byte + switch { + case fi.IsDir(): + if target, err = xattr.Get(nodePath, xattrs.ReferenceAttr); err == nil { + nodeType = provider.ResourceType_RESOURCE_TYPE_REFERENCE + } else { + nodeType = provider.ResourceType_RESOURCE_TYPE_CONTAINER + } + case fi.Mode().IsRegular(): + nodeType = provider.ResourceType_RESOURCE_TYPE_FILE + case fi.Mode()&os.ModeSymlink != 0: + nodeType = provider.ResourceType_RESOURCE_TYPE_SYMLINK + // TODO reference using ext attr on a symlink + // nodeType = provider.ResourceType_RESOURCE_TYPE_REFERENCE + } + + id := &provider.ResourceId{OpaqueId: n.ID} + + fn, err = n.lu.Path(ctx, n) + if err != nil { + return nil, err + } + + ri = &provider.ResourceInfo{ + Id: id, + Path: fn, + Type: nodeType, + MimeType: mime.Detect(nodeType == provider.ResourceType_RESOURCE_TYPE_CONTAINER, fn), + Size: uint64(n.Blobsize), + Target: string(target), + PermissionSet: rp, + } + + if ri.Owner, err = n.Owner(); err != nil { + sublog.Debug().Err(err).Msg("could not determine owner") + } + + // TODO make etag of files use fileid and checksum + + var tmTime time.Time + if tmTime, err = n.GetTMTime(); err != nil { + // no tmtime, use mtime + tmTime = fi.ModTime() + } + + // use temporary etag if it is set + if b, err := xattr.Get(nodePath, xattrs.TmpEtagAttr); err == nil { + ri.Etag = fmt.Sprintf(`"%x"`, string(b)) // TODO why do we convert string(b)? is the temporary etag stored as string? -> should we use bytes? use hex.EncodeToString? + } else if ri.Etag, err = calculateEtag(n.ID, tmTime); err != nil { + sublog.Debug().Err(err).Msg("could not calculate etag") + } + + // mtime uses tmtime if present + // TODO expose mtime and tmtime separately? + un := tmTime.UnixNano() + ri.Mtime = &types.Timestamp{ + Seconds: uint64(un / 1000000000), + Nanos: uint32(un % 1000000000), + } + + mdKeysMap := make(map[string]struct{}) + for _, k := range mdKeys { + mdKeysMap[k] = struct{}{} + } + + var returnAllKeys bool + if _, ok := mdKeysMap["*"]; len(mdKeys) == 0 || ok { + returnAllKeys = true + } + + metadata := map[string]string{} + + // read favorite flag for the current user + if _, ok := mdKeysMap[FavoriteKey]; returnAllKeys || ok { + favorite := "" + if u, ok := user.ContextGetUser(ctx); ok { + // the favorite flag is specific to the user, so we need to incorporate the userid + if uid := u.GetId(); uid != nil { + fa := fmt.Sprintf("%s%s@%s", xattrs.FavPrefix, uid.GetOpaqueId(), uid.GetIdp()) + if val, err := xattr.Get(nodePath, fa); err == nil { + sublog.Debug(). + Str("favorite", fa). + Msg("found favorite flag") + favorite = string(val) + } + } else { + sublog.Error().Err(errtypes.UserRequired("userrequired")).Msg("user has no id") + } + } else { + sublog.Error().Err(errtypes.UserRequired("userrequired")).Msg("error getting user from ctx") + } + metadata[FavoriteKey] = favorite + } + + // share indicator + if _, ok := mdKeysMap[ShareTypesKey]; returnAllKeys || ok { + if n.hasUserShares(ctx) { + metadata[ShareTypesKey] = UserShareType + } + } + + // checksums + if _, ok := mdKeysMap[ChecksumsKey]; (nodeType == provider.ResourceType_RESOURCE_TYPE_FILE) && returnAllKeys || ok { + // TODO which checksum was requested? sha1 adler32 or md5? for now hardcode sha1? + readChecksumIntoResourceChecksum(ctx, nodePath, storageprovider.XSSHA1, ri) + readChecksumIntoOpaque(ctx, nodePath, storageprovider.XSMD5, ri) + readChecksumIntoOpaque(ctx, nodePath, storageprovider.XSAdler32, ri) + } + + // only read the requested metadata attributes + attrs, err := xattr.List(nodePath) + if err != nil { + sublog.Error().Err(err).Msg("error getting list of extended attributes") + } else { + for i := range attrs { + // filter out non-custom properties + if !strings.HasPrefix(attrs[i], xattrs.MetadataPrefix) { + continue + } + // only read when key was requested + k := attrs[i][len(xattrs.MetadataPrefix):] + if _, ok := mdKeysMap[k]; returnAllKeys || ok { + if val, err := xattr.Get(nodePath, attrs[i]); err == nil { + metadata[k] = string(val) + } else { + sublog.Error().Err(err). + Str("entry", attrs[i]). + Msg("error retrieving xattr metadata") + } + } + + } + } + ri.ArbitraryMetadata = &provider.ArbitraryMetadata{ + Metadata: metadata, + } + + sublog.Debug(). + Interface("ri", ri). + Msg("AsResourceInfo") + + return ri, nil +} + +func readChecksumIntoResourceChecksum(ctx context.Context, nodePath, algo string, ri *provider.ResourceInfo) { + v, err := xattr.Get(nodePath, xattrs.ChecksumPrefix+algo) + switch { + case err == nil: + ri.Checksum = &provider.ResourceChecksum{ + Type: storageprovider.PKG2GRPCXS(algo), + Sum: hex.EncodeToString(v), + } + case isNoData(err): + appctx.GetLogger(ctx).Debug().Err(err).Str("nodepath", nodePath).Str("algorithm", algo).Msg("checksum not set") + case isNotFound(err): + appctx.GetLogger(ctx).Error().Err(err).Str("nodepath", nodePath).Str("algorithm", algo).Msg("file not fount") + default: + appctx.GetLogger(ctx).Error().Err(err).Str("nodepath", nodePath).Str("algorithm", algo).Msg("could not read checksum") + } +} + +func readChecksumIntoOpaque(ctx context.Context, nodePath, algo string, ri *provider.ResourceInfo) { + v, err := xattr.Get(nodePath, xattrs.ChecksumPrefix+algo) + switch { + case err == nil: + if ri.Opaque == nil { + ri.Opaque = &types.Opaque{ + Map: map[string]*types.OpaqueEntry{}, + } + } + ri.Opaque.Map[algo] = &types.OpaqueEntry{ + Decoder: "plain", + Value: []byte(hex.EncodeToString(v)), + } + case isNoData(err): + appctx.GetLogger(ctx).Debug().Err(err).Str("nodepath", nodePath).Str("algorithm", algo).Msg("checksum not set") + case isNotFound(err): + appctx.GetLogger(ctx).Error().Err(err).Str("nodepath", nodePath).Str("algorithm", algo).Msg("file not fount") + default: + appctx.GetLogger(ctx).Error().Err(err).Str("nodepath", nodePath).Str("algorithm", algo).Msg("could not read checksum") + } +} + +// HasPropagation checks if the propagation attribute exists and is set to "1" +func (n *Node) HasPropagation() (propagation bool) { + if b, err := xattr.Get(n.lu.InternalPath(n.ID), xattrs.PropagationAttr); err == nil { + return string(b) == "1" + } + return false +} + +// GetTMTime reads the tmtime from the extended attributes +func (n *Node) GetTMTime() (tmTime time.Time, err error) { + var b []byte + if b, err = xattr.Get(n.lu.InternalPath(n.ID), xattrs.TreeMTimeAttr); err != nil { + return + } + return time.Parse(time.RFC3339Nano, string(b)) +} + +// SetTMTime writes the tmtime to the extended attributes +func (n *Node) SetTMTime(t time.Time) (err error) { + return xattr.Set(n.lu.InternalPath(n.ID), xattrs.TreeMTimeAttr, []byte(t.UTC().Format(time.RFC3339Nano))) +} + +// SetChecksum writes the checksum with the given checksum type to the extended attributes +func (n *Node) SetChecksum(csType string, h hash.Hash) (err error) { + return xattr.Set(n.lu.InternalPath(n.ID), xattrs.ChecksumPrefix+csType, h.Sum(nil)) +} + +// UnsetTempEtag removes the temporary etag attribute +func (n *Node) UnsetTempEtag() (err error) { + if err = xattr.Remove(n.lu.InternalPath(n.ID), xattrs.TmpEtagAttr); err != nil { + if e, ok := err.(*xattr.Error); ok && (e.Err.Error() == "no data available" || + // darwin + e.Err.Error() == "attribute not found") { + return nil + } + } + return err +} + +// ReadUserPermissions will assemble the permissions for the current user on the given node without parent nodes +func (n *Node) ReadUserPermissions(ctx context.Context, u *userpb.User) (ap *provider.ResourcePermissions, err error) { + // check if the current user is the owner + o, err := n.Owner() + if err != nil { + // TODO check if a parent folder has the owner set? + appctx.GetLogger(ctx).Error().Err(err).Interface("node", n).Msg("could not determine owner, returning default permissions") + return NoPermissions, err + } + if o.OpaqueId == "" { + // this happens for root nodes in the storage. the extended attributes are set to emptystring to indicate: no owner + // TODO what if no owner is set but grants are present? + return NoOwnerPermissions, nil + } + if isSameUserID(u.Id, o) { + appctx.GetLogger(ctx).Debug().Interface("node", n).Msg("user is owner, returning owner permissions") + return OwnerPermissions, nil + } + + ap = &provider.ResourcePermissions{} + + // for an efficient group lookup convert the list of groups to a map + // groups are just strings ... groupnames ... or group ids ??? AAARGH !!! + groupsMap := make(map[string]bool, len(u.Groups)) + for i := range u.Groups { + groupsMap[u.Groups[i]] = true + } + + var g *provider.Grant + + // we read all grantees from the node + var grantees []string + if grantees, err = n.ListGrantees(ctx); err != nil { + appctx.GetLogger(ctx).Error().Err(err).Interface("node", n).Msg("error listing grantees") + return nil, err + } + + // instead of making n getxattr syscalls we are going to list the acls and filter them here + // we have two options here: + // 1. we can start iterating over the acls / grants on the node or + // 2. we can iterate over the number of groups + // The current implementation tries to be defensive for cases where users have hundreds or thousands of groups, so we iterate over the existing acls. + userace := xattrs.GrantPrefix + xattrs.UserAcePrefix + u.Id.OpaqueId + userFound := false + for i := range grantees { + switch { + // we only need to find the user once + case !userFound && grantees[i] == userace: + g, err = n.ReadGrant(ctx, grantees[i]) + case strings.HasPrefix(grantees[i], xattrs.GrantPrefix+xattrs.GroupAcePrefix): // only check group grantees + gr := strings.TrimPrefix(grantees[i], xattrs.GrantPrefix+xattrs.GroupAcePrefix) + if groupsMap[gr] { + g, err = n.ReadGrant(ctx, grantees[i]) + } else { + // no need to check attribute + continue + } + default: + // no need to check attribute + continue + } + + switch { + case err == nil: + AddPermissions(ap, g.GetPermissions()) + case isNoData(err): + err = nil + appctx.GetLogger(ctx).Error().Interface("node", n).Str("grant", grantees[i]).Interface("grantees", grantees).Msg("grant vanished from node after listing") + // continue with next segment + default: + appctx.GetLogger(ctx).Error().Err(err).Interface("node", n).Str("grant", grantees[i]).Msg("error reading permissions") + // continue with next segment + } + } + + appctx.GetLogger(ctx).Debug().Interface("permissions", ap).Interface("node", n).Interface("user", u).Msg("returning aggregated permissions") + return ap, nil +} + +// ListGrantees lists the grantees of the current node +// We don't want to wast time and memory by creating grantee objects. +// The function will return a list of opaque strings that can be used to make a ReadGrant call +func (n *Node) ListGrantees(ctx context.Context) (grantees []string, err error) { + var attrs []string + if attrs, err = xattr.List(n.InternalPath()); err != nil { + appctx.GetLogger(ctx).Error().Err(err).Interface("node", n).Msg("error listing attributes") + return nil, err + } + for i := range attrs { + if strings.HasPrefix(attrs[i], xattrs.GrantPrefix) { + grantees = append(grantees, attrs[i]) + } + } + return +} + +// ReadGrant reads a CS3 grant +func (n *Node) ReadGrant(ctx context.Context, grantee string) (g *provider.Grant, err error) { + var b []byte + if b, err = xattr.Get(n.InternalPath(), grantee); err != nil { + return nil, err + } + var e *ace.ACE + if e, err = ace.Unmarshal(strings.TrimPrefix(grantee, xattrs.GrantPrefix), b); err != nil { + return nil, err + } + return e.Grant(), nil +} + +func (n *Node) hasUserShares(ctx context.Context) bool { + g, err := n.ListGrantees(ctx) + if err != nil { + appctx.GetLogger(ctx).Error().Err(err).Msg("hasUserShares: listGrantees") + return false + } + + for i := range g { + if strings.Contains(g[i], xattrs.GrantPrefix+xattrs.UserAcePrefix) { + return true + } + } + return false +} + +func isSameUserID(i *userpb.UserId, j *userpb.UserId) bool { + switch { + case i == nil, j == nil: + return false + case i.OpaqueId == j.OpaqueId && i.Idp == j.Idp: + return true + default: + return false + } +} + +func parseMTime(v string) (t time.Time, err error) { + p := strings.SplitN(v, ".", 2) + var sec, nsec int64 + if sec, err = strconv.ParseInt(p[0], 10, 64); err == nil { + if len(p) > 1 { + nsec, err = strconv.ParseInt(p[1], 10, 64) + } + } + return time.Unix(sec, nsec), err +} diff --git a/pkg/storage/fs/s3ng/node/node_test.go b/pkg/storage/fs/s3ng/node/node_test.go new file mode 100644 index 0000000000..db63f9dfe2 --- /dev/null +++ b/pkg/storage/fs/s3ng/node/node_test.go @@ -0,0 +1,27 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package node_test + +import ( + . "github.com/onsi/ginkgo" +) + +var _ = Describe("Node", func() { + +}) diff --git a/pkg/storage/fs/s3ng/node/permissions.go b/pkg/storage/fs/s3ng/node/permissions.go new file mode 100644 index 0000000000..4e68ea6cc1 --- /dev/null +++ b/pkg/storage/fs/s3ng/node/permissions.go @@ -0,0 +1,280 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package node + +import ( + "context" + "strings" + "syscall" + + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/xattrs" + "github.com/cs3org/reva/pkg/user" + "github.com/pkg/errors" + "github.com/pkg/xattr" +) + +// NoPermissions represents an empty set of permssions +var NoPermissions *provider.ResourcePermissions = &provider.ResourcePermissions{} + +// NoOwnerPermissions defines permissions for nodes that don't have an owner set, eg the root node +var NoOwnerPermissions *provider.ResourcePermissions = &provider.ResourcePermissions{ + Stat: true, +} + +// OwnerPermissions defines permissions for nodes owned by the user +var OwnerPermissions *provider.ResourcePermissions = &provider.ResourcePermissions{ + // all permissions + AddGrant: true, + CreateContainer: true, + Delete: true, + GetPath: true, + GetQuota: true, + InitiateFileDownload: true, + InitiateFileUpload: true, + ListContainer: true, + ListFileVersions: true, + ListGrants: true, + ListRecycle: true, + Move: true, + PurgeRecycle: true, + RemoveGrant: true, + RestoreFileVersion: true, + RestoreRecycleItem: true, + Stat: true, + UpdateGrant: true, +} + +// Permissions implements permission checks +type Permissions struct { + lu PathLookup +} + +// NewPermissions returns a new Permissions instance +func NewPermissions(lu PathLookup) *Permissions { + return &Permissions{ + lu: lu, + } +} + +// AssemblePermissions will assemble the permissions for the current user on the given node, taking into account all parent nodes +func (p *Permissions) AssemblePermissions(ctx context.Context, n *Node) (ap *provider.ResourcePermissions, err error) { + u, ok := user.ContextGetUser(ctx) + if !ok { + appctx.GetLogger(ctx).Debug().Interface("node", n).Msg("no user in context, returning default permissions") + return NoPermissions, nil + } + // check if the current user is the owner + o, err := n.Owner() + if err != nil { + // TODO check if a parent folder has the owner set? + appctx.GetLogger(ctx).Error().Err(err).Interface("node", n).Msg("could not determine owner, returning default permissions") + return NoPermissions, err + } + if o.OpaqueId == "" { + // this happens for root nodes in the storage. the extended attributes are set to emptystring to indicate: no owner + // TODO what if no owner is set but grants are present? + return NoOwnerPermissions, nil + } + if isSameUserID(u.Id, o) { + appctx.GetLogger(ctx).Debug().Interface("node", n).Msg("user is owner, returning owner permissions") + return OwnerPermissions, nil + } + + // determine root + var rn *Node + if rn, err = p.lu.RootNode(ctx); err != nil { + return nil, err + } + + cn := n + + ap = &provider.ResourcePermissions{} + + // for an efficient group lookup convert the list of groups to a map + // groups are just strings ... groupnames ... or group ids ??? AAARGH !!! + groupsMap := make(map[string]bool, len(u.Groups)) + for i := range u.Groups { + groupsMap[u.Groups[i]] = true + } + + // for all segments, starting at the leaf + for cn.ID != rn.ID { + + if np, err := cn.ReadUserPermissions(ctx, u); err == nil { + AddPermissions(ap, np) + } else { + appctx.GetLogger(ctx).Error().Err(err).Interface("node", cn).Msg("error reading permissions") + // continue with next segment + } + + if cn, err = cn.Parent(); err != nil { + return ap, errors.Wrap(err, "s3ngfs: error getting parent "+cn.ParentID) + } + } + + appctx.GetLogger(ctx).Debug().Interface("permissions", ap).Interface("node", n).Interface("user", u).Msg("returning agregated permissions") + return ap, nil +} + +// AddPermissions merges a set of permissions into another +// TODO we should use a bitfield for this ... +func AddPermissions(l *provider.ResourcePermissions, r *provider.ResourcePermissions) { + l.AddGrant = l.AddGrant || r.AddGrant + l.CreateContainer = l.CreateContainer || r.CreateContainer + l.Delete = l.Delete || r.Delete + l.GetPath = l.GetPath || r.GetPath + l.GetQuota = l.GetQuota || r.GetQuota + l.InitiateFileDownload = l.InitiateFileDownload || r.InitiateFileDownload + l.InitiateFileUpload = l.InitiateFileUpload || r.InitiateFileUpload + l.ListContainer = l.ListContainer || r.ListContainer + l.ListFileVersions = l.ListFileVersions || r.ListFileVersions + l.ListGrants = l.ListGrants || r.ListGrants + l.ListRecycle = l.ListRecycle || r.ListRecycle + l.Move = l.Move || r.Move + l.PurgeRecycle = l.PurgeRecycle || r.PurgeRecycle + l.RemoveGrant = l.RemoveGrant || r.RemoveGrant + l.RestoreFileVersion = l.RestoreFileVersion || r.RestoreFileVersion + l.RestoreRecycleItem = l.RestoreRecycleItem || r.RestoreRecycleItem + l.Stat = l.Stat || r.Stat + l.UpdateGrant = l.UpdateGrant || r.UpdateGrant +} + +// HasPermission call check() for every node up to the root until check returns true +func (p *Permissions) HasPermission(ctx context.Context, n *Node, check func(*provider.ResourcePermissions) bool) (can bool, err error) { + + var u *userv1beta1.User + var perms *provider.ResourcePermissions + if u, perms = p.getUserAndPermissions(ctx, n); perms != nil { + return check(perms), nil + } + + // determine root + var rn *Node + if rn, err = p.lu.RootNode(ctx); err != nil { + return false, err + } + + cn := n + + // for an efficient group lookup convert the list of groups to a map + // groups are just strings ... groupnames ... or group ids ??? AAARGH !!! + groupsMap := make(map[string]bool, len(u.Groups)) + for i := range u.Groups { + groupsMap[u.Groups[i]] = true + } + + var g *provider.Grant + // for all segments, starting at the leaf + for cn.ID != rn.ID { + + var grantees []string + if grantees, err = cn.ListGrantees(ctx); err != nil { + appctx.GetLogger(ctx).Error().Err(err).Interface("node", cn).Msg("error listing grantees") + return false, err + } + + userace := xattrs.GrantPrefix + xattrs.UserAcePrefix + u.Id.OpaqueId + userFound := false + for i := range grantees { + // we only need the find the user once per node + switch { + case !userFound && grantees[i] == userace: + g, err = cn.ReadGrant(ctx, grantees[i]) + case strings.HasPrefix(grantees[i], xattrs.GrantPrefix+xattrs.GroupAcePrefix): + gr := strings.TrimPrefix(grantees[i], xattrs.GrantPrefix+xattrs.GroupAcePrefix) + if groupsMap[gr] { + g, err = cn.ReadGrant(ctx, grantees[i]) + } else { + // no need to check attribute + continue + } + default: + // no need to check attribute + continue + } + + switch { + case err == nil: + appctx.GetLogger(ctx).Debug().Interface("node", cn).Str("grant", grantees[i]).Interface("permissions", g.GetPermissions()).Msg("checking permissions") + if check(g.GetPermissions()) { + return true, nil + } + case isNoData(err): + err = nil + appctx.GetLogger(ctx).Error().Interface("node", cn).Str("grant", grantees[i]).Interface("grantees", grantees).Msg("grant vanished from node after listing") + default: + appctx.GetLogger(ctx).Error().Err(err).Interface("node", cn).Str("grant", grantees[i]).Msg("error reading permissions") + return false, err + } + } + + if cn, err = cn.Parent(); err != nil { + return false, errors.Wrap(err, "s3ngfs: error getting parent "+cn.ParentID) + } + } + + appctx.GetLogger(ctx).Debug().Interface("permissions", NoPermissions).Interface("node", n).Interface("user", u).Msg("no grant found, returning default permissions") + return false, nil +} + +func (p *Permissions) getUserAndPermissions(ctx context.Context, n *Node) (*userv1beta1.User, *provider.ResourcePermissions) { + u, ok := user.ContextGetUser(ctx) + if !ok { + appctx.GetLogger(ctx).Debug().Interface("node", n).Msg("no user in context, returning default permissions") + return nil, NoPermissions + } + // check if the current user is the owner + o, err := n.Owner() + if err != nil { + appctx.GetLogger(ctx).Error().Err(err).Interface("node", n).Msg("could not determine owner, returning default permissions") + return nil, NoPermissions + } + if o.OpaqueId == "" { + // this happens for root nodes in the storage. the extended attributes are set to emptystring to indicate: no owner + // TODO what if no owner is set but grants are present? + return nil, NoOwnerPermissions + } + if isSameUserID(u.Id, o) { + appctx.GetLogger(ctx).Debug().Interface("node", n).Msg("user is owner, returning owner permissions") + return u, OwnerPermissions + } + return u, nil +} +func isNoData(err error) bool { + if xerr, ok := err.(*xattr.Error); ok { + if serr, ok2 := xerr.Err.(syscall.Errno); ok2 { + return serr == syscall.ENODATA + } + } + return false +} + +// The os not exists error is buried inside the xattr error, +// so we cannot just use os.IsNotExists(). +func isNotFound(err error) bool { + if xerr, ok := err.(*xattr.Error); ok { + if serr, ok2 := xerr.Err.(syscall.Errno); ok2 { + return serr == syscall.ENOENT + } + } + return false +} diff --git a/pkg/storage/fs/s3ng/option.go b/pkg/storage/fs/s3ng/option.go new file mode 100644 index 0000000000..6ee9f69b83 --- /dev/null +++ b/pkg/storage/fs/s3ng/option.go @@ -0,0 +1,104 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng + +import ( + "path/filepath" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + // ocis fs works on top of a dir of uuid nodes + Root string `mapstructure:"root"` + + // UserLayout describes the relative path from the storage's root node to the users home node. + UserLayout string `mapstructure:"user_layout"` + + // TODO NodeLayout option to save nodes as eg. nodes/1d/d8/1dd84abf-9466-4e14-bb86-02fc4ea3abcf + ShareFolder string `mapstructure:"share_folder"` + + // EnableHome enables the creation of home directories. + EnableHome bool `mapstructure:"enable_home"` + + // propagate mtime changes as tmtime (tree modification time) to the parent directory when user.ocis.propagation=1 is set on a node + TreeTimeAccounting bool `mapstructure:"treetime_accounting"` + + // propagate size changes as treesize + TreeSizeAccounting bool `mapstructure:"treesize_accounting"` + + // set an owner for the root node + Owner string `mapstructure:"owner"` + + // Endpoint of the s3 blobstore + S3Endpoint string `mapstructure:"s3.endpoint"` + + // Region of the s3 blobstore + S3Region string `mapstructure:"s3.region"` + + // Bucket of the s3 blobstore + S3Bucket string `mapstructure:"s3.bucket"` + + // Access key for the s3 blobstore + S3AccessKey string `mapstructure:"s3.access_key"` + + // Secret key for the s3 blobstore + S3SecretKey string `mapstructure:"s3.secret_key"` +} + +// S3ConfigComplete return true if all required s3 fields are set +func (o *Options) S3ConfigComplete() bool { + return o.S3Endpoint != "" && + o.S3Region != "" && + o.S3Bucket != "" && + o.S3AccessKey != "" && + o.S3SecretKey != "" +} + +func parseConfig(m map[string]interface{}) (*Options, error) { + o := &Options{} + if err := mapstructure.Decode(m, o); err != nil { + err = errors.Wrap(err, "error decoding conf") + return nil, err + } + return o, nil +} + +func (o *Options) init(m map[string]interface{}) { + if o.UserLayout == "" { + o.UserLayout = "{{.Id.OpaqueId}}" + } + // ensure user layout has no starting or trailing / + o.UserLayout = strings.Trim(o.UserLayout, "/") + + if o.ShareFolder == "" { + o.ShareFolder = "/Shares" + } + // ensure share folder always starts with slash + o.ShareFolder = filepath.Join("/", o.ShareFolder) + + // c.DataDirectory should never end in / unless it is the root + o.Root = filepath.Clean(o.Root) +} diff --git a/pkg/storage/fs/s3ng/option_test.go b/pkg/storage/fs/s3ng/option_test.go new file mode 100644 index 0000000000..05e0cac43b --- /dev/null +++ b/pkg/storage/fs/s3ng/option_test.go @@ -0,0 +1,73 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng_test + +import ( + "github.com/mitchellh/mapstructure" + + "github.com/cs3org/reva/pkg/storage/fs/s3ng" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Options", func() { + var ( + o *s3ng.Options + raw map[string]interface{} + ) + + BeforeEach(func() { + raw := map[string]interface{}{ + "s3.endpoint": "http://1.2.3.4:5000", + "s3.region": "default", + "s3.bucket": "the-bucket", + "s3.access_key": "foo", + "s3.secret_key": "bar", + } + o = &s3ng.Options{} + err := mapstructure.Decode(raw, o) + Expect(err).ToNot(HaveOccurred()) + }) + + It("parses s3 configuration", func() { + Expect(o.S3Endpoint).To(Equal("http://1.2.3.4:5000")) + Expect(o.S3Region).To(Equal("default")) + Expect(o.S3AccessKey).To(Equal("foo")) + Expect(o.S3SecretKey).To(Equal("bar")) + }) + + Describe("S3ConfigComplete", func() { + It("returns true", func() { + Expect(o.S3ConfigComplete()).To(BeTrue()) + }) + + It("returns false", func() { + fields := []string{"s3.endpoint", "s3.region", "s3.bucket", "s3.access_key", "s3.secret_key"} + for _, f := range fields { + delete(raw, f) + o = &s3ng.Options{} + err := mapstructure.Decode(raw, o) + Expect(err).ToNot(HaveOccurred()) + + Expect(o.S3ConfigComplete()).To(BeFalse(), "failed to return false on missing '%s' field", f) + } + }) + }) +}) diff --git a/pkg/storage/fs/s3ng/recycle.go b/pkg/storage/fs/s3ng/recycle.go new file mode 100644 index 0000000000..e0a5ff5fe4 --- /dev/null +++ b/pkg/storage/fs/s3ng/recycle.go @@ -0,0 +1,215 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/xattrs" + "github.com/cs3org/reva/pkg/user" + "github.com/pkg/errors" + "github.com/pkg/xattr" +) + +// Recycle items are stored inside the node folder and start with the uuid of the deleted node. +// The `.T.` indicates it is a trash item and what follows is the timestamp of the deletion. +// The deleted file is kept in the same location/dir as the original node. This prevents deletes +// from triggering cross storage moves when the trash is accidentally stored on another partition, +// because the admin mounted a different partition there. +// TODO For an efficient listing of deleted nodes the ocis storages trash folder should have +// contain a directory with symlinks to trash files for every userid/"root" + +func (fs *s3ngfs) ListRecycle(ctx context.Context) (items []*provider.RecycleItem, err error) { + log := appctx.GetLogger(ctx) + + trashRoot := fs.getRecycleRoot(ctx) + + items = make([]*provider.RecycleItem, 0) + + // TODO how do we check if the storage allows listing the recycle for the current user? check owner of the root of the storage? + // use permissions ReadUserPermissions? + if fs.o.EnableHome { + if !node.OwnerPermissions.ListContainer { + log.Debug().Msg("owner not allowed to list trash") + return items, errtypes.PermissionDenied("owner not allowed to list trash") + } + } else { + if !node.NoPermissions.ListContainer { + log.Debug().Msg("default permissions prevent listing trash") + return items, errtypes.PermissionDenied("default permissions prevent listing trash") + } + } + + f, err := os.Open(trashRoot) + if err != nil { + if os.IsNotExist(err) { + return items, nil + } + return nil, errors.Wrap(err, "tree: error listing "+trashRoot) + } + defer f.Close() + + names, err := f.Readdirnames(0) + if err != nil { + return nil, err + } + for i := range names { + var trashnode string + trashnode, err = os.Readlink(filepath.Join(trashRoot, names[i])) + if err != nil { + log.Error().Err(err).Str("trashRoot", trashRoot).Str("name", names[i]).Msg("error reading trash link, skipping") + err = nil + continue + } + parts := strings.SplitN(filepath.Base(trashnode), ".T.", 2) + if len(parts) != 2 { + log.Error().Err(err).Str("trashRoot", trashRoot).Str("name", names[i]).Str("trashnode", trashnode).Interface("parts", parts).Msg("malformed trash link, skipping") + continue + } + + nodePath := fs.lu.InternalPath(filepath.Base(trashnode)) + md, err := os.Stat(nodePath) + if err != nil { + log.Error().Err(err).Str("trashRoot", trashRoot).Str("name", names[i]).Str("trashnode", trashnode).Interface("parts", parts).Msg("could not stat trash item, skipping") + continue + } + + item := &provider.RecycleItem{ + Type: getResourceType(md.IsDir()), + Size: uint64(md.Size()), + Key: filepath.Base(trashRoot) + ":" + parts[0], // glue using :, a / is interpreted as a path and only the node id will reach the other methods + } + if deletionTime, err := time.Parse(time.RFC3339Nano, parts[1]); err == nil { + item.DeletionTime = &types.Timestamp{ + Seconds: uint64(deletionTime.Unix()), + // TODO nanos + } + } else { + log.Error().Err(err).Str("trashRoot", trashRoot).Str("name", names[i]).Str("link", trashnode).Interface("parts", parts).Msg("could parse time format, ignoring") + } + + // lookup origin path in extended attributes + var attrBytes []byte + if attrBytes, err = xattr.Get(nodePath, xattrs.TrashOriginAttr); err == nil { + item.Path = string(attrBytes) + } else { + log.Error().Err(err).Str("trashRoot", trashRoot).Str("name", names[i]).Str("link", trashnode).Msg("could not read origin path, skipping") + continue + } + // TODO filter results by permission ... on the original parent? or the trashed node? + // if it were on the original parent it would be possible to see files that were trashed before the current user got access + // so -> check the trash node itself + // hmm listing trash currently lists the current users trash or the 'root' trash. from ocs only the home storage is queried for trash items. + // for now we can only really check if the current user is the owner + if attrBytes, err = xattr.Get(nodePath, xattrs.OwnerIDAttr); err == nil { + if fs.o.EnableHome { + u := user.ContextMustGetUser(ctx) + if u.Id.OpaqueId != string(attrBytes) { + log.Warn().Str("trashRoot", trashRoot).Str("name", names[i]).Str("link", trashnode).Msg("trash item not owned by current user, skipping") + continue + } + } + } else { + log.Error().Err(err).Str("trashRoot", trashRoot).Str("name", names[i]).Str("link", trashnode).Msg("could not read owner, skipping") + continue + } + + items = append(items, item) + } + return +} + +func (fs *s3ngfs) RestoreRecycleItem(ctx context.Context, key string) error { + rn, restoreFunc, err := fs.tp.RestoreRecycleItemFunc(ctx, key) + if err != nil { + return err + } + + // check permissions of deleted node + ok, err := fs.p.HasPermission(ctx, rn, func(rp *provider.ResourcePermissions) bool { + return rp.RestoreRecycleItem + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(key) + } + + // Run the restore func + return restoreFunc() +} + +func (fs *s3ngfs) PurgeRecycleItem(ctx context.Context, key string) error { + rn, purgeFunc, err := fs.tp.PurgeRecycleItemFunc(ctx, key) + if err != nil { + return err + } + + // check permissions of deleted node + ok, err := fs.p.HasPermission(ctx, rn, func(rp *provider.ResourcePermissions) bool { + return rp.PurgeRecycle + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(key) + } + + // Run the purge func + return purgeFunc() +} + +func (fs *s3ngfs) EmptyRecycle(ctx context.Context) error { + u, ok := user.ContextGetUser(ctx) + // TODO what permission should we check? we could check the root node of the user? or the owner permissions on his home root node? + // The current impl will wipe your own trash. or when no user provided the trash of 'root' + if !ok { + return os.RemoveAll(fs.getRecycleRoot(ctx)) + } + + // TODO use layout, see Tree.Delete() for problem + return os.RemoveAll(filepath.Join(fs.o.Root, "trash", u.Id.OpaqueId)) +} + +func getResourceType(isDir bool) provider.ResourceType { + if isDir { + return provider.ResourceType_RESOURCE_TYPE_CONTAINER + } + return provider.ResourceType_RESOURCE_TYPE_FILE +} + +func (fs *s3ngfs) getRecycleRoot(ctx context.Context) string { + if fs.o.EnableHome { + u := user.ContextMustGetUser(ctx) + // TODO use layout, see Tree.Delete() for problem + return filepath.Join(fs.o.Root, "trash", u.Id.OpaqueId) + } + return filepath.Join(fs.o.Root, "trash", "root") +} diff --git a/pkg/storage/fs/s3ng/revisions.go b/pkg/storage/fs/s3ng/revisions.go new file mode 100644 index 0000000000..e09a8c8113 --- /dev/null +++ b/pkg/storage/fs/s3ng/revisions.go @@ -0,0 +1,192 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "time" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/pkg/errors" +) + +// Revision entries are stored inside the node folder and start with the same uuid as the current version. +// The `.REV.` indicates it is a revision and what follows is a timestamp, so multiple versions +// can be kept in the same location as the current file content. This prevents new fileuploads +// to trigger cross storage moves when revisions accidentally are stored on another partition, +// because the admin mounted a different partition there. +// We can add a background process to move old revisions to a slower storage +// and replace the revision file with a symbolic link in the future, if necessary. + +func (fs *s3ngfs) ListRevisions(ctx context.Context, ref *provider.Reference) (revisions []*provider.FileVersion, err error) { + var n *node.Node + if n, err = fs.lu.NodeFromResource(ctx, ref); err != nil { + return + } + if !n.Exists { + err = errtypes.NotFound(filepath.Join(n.ParentID, n.Name)) + return + } + + ok, err := fs.p.HasPermission(ctx, n, func(rp *provider.ResourcePermissions) bool { + return rp.ListFileVersions + }) + switch { + case err != nil: + return nil, errtypes.InternalError(err.Error()) + case !ok: + return nil, errtypes.PermissionDenied(filepath.Join(n.ParentID, n.Name)) + } + + revisions = []*provider.FileVersion{} + np := n.InternalPath() + if items, err := filepath.Glob(np + ".REV.*"); err == nil { + for i := range items { + if fi, err := os.Stat(items[i]); err == nil { + rev := &provider.FileVersion{ + Key: filepath.Base(items[i]), + Size: uint64(fi.Size()), + Mtime: uint64(fi.ModTime().Unix()), + } + revisions = append(revisions, rev) + } + } + } + return +} + +func (fs *s3ngfs) DownloadRevision(ctx context.Context, ref *provider.Reference, revisionKey string) (io.ReadCloser, error) { + log := appctx.GetLogger(ctx) + + // verify revision key format + kp := strings.SplitN(revisionKey, ".REV.", 2) + if len(kp) != 2 { + log.Error().Str("revisionKey", revisionKey).Msg("malformed revisionKey") + return nil, errtypes.NotFound(revisionKey) + } + log.Debug().Str("revisionKey", revisionKey).Msg("DownloadRevision") + + // check if the node is available and has not been deleted + n, err := node.ReadNode(ctx, fs.lu, kp[0]) + if err != nil { + return nil, err + } + if !n.Exists { + err = errtypes.NotFound(filepath.Join(n.ParentID, n.Name)) + return nil, err + } + + ok, err := fs.p.HasPermission(ctx, n, func(rp *provider.ResourcePermissions) bool { + // TODO add explicit permission in the CS3 api? + return rp.ListFileVersions && rp.RestoreFileVersion && rp.InitiateFileDownload + }) + switch { + case err != nil: + return nil, errtypes.InternalError(err.Error()) + case !ok: + return nil, errtypes.PermissionDenied(filepath.Join(n.ParentID, n.Name)) + } + + contentPath := fs.lu.InternalPath(revisionKey) + + r, err := os.Open(contentPath) + if err != nil { + if os.IsNotExist(err) { + return nil, errtypes.NotFound(contentPath) + } + return nil, errors.Wrap(err, "s3ngfs: error opening revision "+revisionKey) + } + return r, nil +} + +func (fs *s3ngfs) RestoreRevision(ctx context.Context, ref *provider.Reference, revisionKey string) (err error) { + log := appctx.GetLogger(ctx) + + // verify revision key format + kp := strings.SplitN(revisionKey, ".REV.", 2) + if len(kp) != 2 { + log.Error().Str("revisionKey", revisionKey).Msg("malformed revisionKey") + return errtypes.NotFound(revisionKey) + } + + // check if the node is available and has not been deleted + n, err := node.ReadNode(ctx, fs.lu, kp[0]) + if err != nil { + return err + } + if !n.Exists { + err = errtypes.NotFound(filepath.Join(n.ParentID, n.Name)) + return err + } + + ok, err := fs.p.HasPermission(ctx, n, func(rp *provider.ResourcePermissions) bool { + return rp.RestoreFileVersion + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(filepath.Join(n.ParentID, n.Name)) + } + + // move current version to new revision + nodePath := fs.lu.InternalPath(kp[0]) + var fi os.FileInfo + if fi, err = os.Stat(nodePath); err == nil { + // versions are stored alongside the actual file, so a rename can be efficient and does not cross storage / partition boundaries + versionsPath := fs.lu.InternalPath(kp[0] + ".REV." + fi.ModTime().UTC().Format(time.RFC3339Nano)) + + err = os.Rename(nodePath, versionsPath) + if err != nil { + return + } + + // copy old revision to current location + + revisionPath := fs.lu.InternalPath(revisionKey) + var revision, destination *os.File + revision, err = os.Open(revisionPath) + if err != nil { + return + } + defer revision.Close() + + destination, err = os.OpenFile(nodePath, os.O_CREATE|os.O_WRONLY, defaultFilePerm) + if err != nil { + return + } + defer destination.Close() + _, err = io.Copy(destination, revision) + if err != nil { + return + } + + return fs.copyMD(revisionPath, nodePath) + } + + log.Error().Err(err).Interface("ref", ref).Str("originalnode", kp[0]).Str("revisionKey", revisionKey).Msg("original node does not exist") + return +} diff --git a/pkg/storage/fs/s3ng/s3ng.go b/pkg/storage/fs/s3ng/s3ng.go new file mode 100644 index 0000000000..e42bb197fa --- /dev/null +++ b/pkg/storage/fs/s3ng/s3ng.go @@ -0,0 +1,457 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng + +//go:generate mockery -name PermissionsChecker +//go:generate mockery -name Tree + +import ( + "context" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/logger" + "github.com/cs3org/reva/pkg/storage" + "github.com/cs3org/reva/pkg/storage/fs/registry" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/blobstore" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/tree" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/xattrs" + "github.com/cs3org/reva/pkg/storage/utils/chunking" + "github.com/cs3org/reva/pkg/storage/utils/templates" + "github.com/cs3org/reva/pkg/user" + "github.com/pkg/errors" + "github.com/pkg/xattr" +) + +func init() { + registry.Register("s3ng", NewDefault) +} + +// PermissionsChecker defines an interface for checking permissions on a Node +type PermissionsChecker interface { + AssemblePermissions(ctx context.Context, n *node.Node) (ap *provider.ResourcePermissions, err error) + HasPermission(ctx context.Context, n *node.Node, check func(*provider.ResourcePermissions) bool) (can bool, err error) +} + +// Tree is used to manage a tree hierarchy +type Tree interface { + Setup(owner string) error + + GetMD(ctx context.Context, node *node.Node) (os.FileInfo, error) + ListFolder(ctx context.Context, node *node.Node) ([]*node.Node, error) + //CreateHome(owner *userpb.UserId) (n *node.Node, err error) + CreateDir(ctx context.Context, node *node.Node) (err error) + //CreateReference(ctx context.Context, node *node.Node, targetURI *url.URL) error + Move(ctx context.Context, oldNode *node.Node, newNode *node.Node) (err error) + Delete(ctx context.Context, node *node.Node) (err error) + RestoreRecycleItemFunc(ctx context.Context, key string) (*node.Node, func() error, error) + PurgeRecycleItemFunc(ctx context.Context, key string) (*node.Node, func() error, error) + + WriteBlob(key string, reader io.Reader) error + ReadBlob(key string) (io.ReadCloser, error) + DeleteBlob(key string) error + + Propagate(ctx context.Context, node *node.Node) (err error) +} + +// NewDefault returns an s3ng filestore using the default configuration +func NewDefault(m map[string]interface{}) (storage.FS, error) { + o, err := parseConfig(m) + if err != nil { + return nil, err + } + + lu := &Lookup{} + p := node.NewPermissions(lu) + bs, err := blobstore.New(o.S3Endpoint, o.S3Region, o.S3Bucket, o.S3AccessKey, o.S3SecretKey) + if err != nil { + return nil, err + } + tp := tree.New(o.Root, o.TreeTimeAccounting, o.TreeSizeAccounting, lu, bs) + + return New(m, lu, p, tp) +} + +// New returns an implementation to of the storage.FS interface that talk to +// a local filesystem. +func New(m map[string]interface{}, lu *Lookup, permissionsChecker PermissionsChecker, tp Tree) (storage.FS, error) { + o, err := parseConfig(m) + if err != nil { + return nil, err + } + o.init(m) + + lu.Options = o + + err = tp.Setup(o.Owner) + if err != nil { + logger.New().Error().Err(err). + Msg("could not setup tree") + return nil, errors.Wrap(err, "could not setup tree") + } + + if !o.S3ConfigComplete() { + return nil, fmt.Errorf("S3 configuration incomplete") + } + + return &s3ngfs{ + tp: tp, + lu: lu, + o: o, + p: permissionsChecker, + chunkHandler: chunking.NewChunkHandler(filepath.Join(o.Root, "uploads")), + }, nil +} + +type s3ngfs struct { + lu *Lookup + tp Tree + o *Options + p PermissionsChecker + chunkHandler *chunking.ChunkHandler +} + +func (fs *s3ngfs) Shutdown(ctx context.Context) error { + return nil +} + +func (fs *s3ngfs) GetQuota(ctx context.Context) (int, int, error) { + return 0, 0, nil +} + +// CreateHome creates a new root node that has no parent id +func (fs *s3ngfs) CreateHome(ctx context.Context) (err error) { + if !fs.o.EnableHome || fs.o.UserLayout == "" { + return errtypes.NotSupported("s3ngfs: CreateHome() home supported disabled") + } + + var n, h *node.Node + if n, err = fs.lu.RootNode(ctx); err != nil { + return + } + h, err = fs.lu.WalkPath(ctx, n, fs.lu.mustGetUserLayout(ctx), func(ctx context.Context, n *node.Node) error { + if !n.Exists { + if err := fs.tp.CreateDir(ctx, n); err != nil { + return err + } + } + return nil + }) + if err != nil { + return + } + + // update the owner + u := user.ContextMustGetUser(ctx) + if err = h.WriteMetadata(u.Id); err != nil { + return + } + + if fs.o.TreeTimeAccounting { + homePath := h.InternalPath() + // mark the home node as the end of propagation + if err = xattr.Set(homePath, xattrs.PropagationAttr, []byte("1")); err != nil { + appctx.GetLogger(ctx).Error().Err(err).Interface("node", h).Msg("could not mark home as propagation root") + return + } + } + return +} + +// GetHome is called to look up the home path for a user +// It is NOT supposed to return the internal path but the external path +func (fs *s3ngfs) GetHome(ctx context.Context) (string, error) { + if !fs.o.EnableHome || fs.o.UserLayout == "" { + return "", errtypes.NotSupported("s3ngfs: GetHome() home supported disabled") + } + u := user.ContextMustGetUser(ctx) + layout := templates.WithUser(u, fs.o.UserLayout) + return filepath.Join(fs.o.Root, layout), nil // TODO use a namespace? +} + +// GetPathByID returns the fn pointed by the file id, without the internal namespace +func (fs *s3ngfs) GetPathByID(ctx context.Context, id *provider.ResourceId) (string, error) { + node, err := fs.lu.NodeFromID(ctx, id) + if err != nil { + return "", err + } + + return fs.lu.Path(ctx, node) +} + +func (fs *s3ngfs) CreateDir(ctx context.Context, fn string) (err error) { + var n *node.Node + if n, err = fs.lu.NodeFromPath(ctx, fn); err != nil { + return + } + + if n.Exists { + return errtypes.AlreadyExists(fn) + } + pn, err := n.Parent() + if err != nil { + return errors.Wrap(err, "s3ngfs: error getting parent "+n.ParentID) + } + ok, err := fs.p.HasPermission(ctx, pn, func(rp *provider.ResourcePermissions) bool { + return rp.CreateContainer + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(filepath.Join(n.ParentID, n.Name)) + } + + err = fs.tp.CreateDir(ctx, n) + + if fs.o.TreeTimeAccounting { + nodePath := n.InternalPath() + // mark the home node as the end of propagation + if err = xattr.Set(nodePath, xattrs.PropagationAttr, []byte("1")); err != nil { + appctx.GetLogger(ctx).Error().Err(err).Interface("node", n).Msg("could not mark node to propagate") + return + } + } + return +} + +// CreateReference creates a reference as a node folder with the target stored in extended attributes +// There is no difference between the /Shares folder and normal nodes because the storage is not supposed to be accessible without the storage provider. +// In effect everything is a shadow namespace. +// To mimic the eos end owncloud driver we only allow references as children of the "/Shares" folder +// TODO when home support is enabled should the "/Shares" folder still be listed? +func (fs *s3ngfs) CreateReference(ctx context.Context, p string, targetURI *url.URL) (err error) { + + p = strings.Trim(p, "/") + parts := strings.Split(p, "/") + + if len(parts) != 2 { + return errtypes.PermissionDenied("s3ngfs: references must be a child of the share folder: share_folder=" + fs.o.ShareFolder + " path=" + p) + } + + if parts[0] != strings.Trim(fs.o.ShareFolder, "/") { + return errtypes.PermissionDenied("s3ngfs: cannot create references outside the share folder: share_folder=" + fs.o.ShareFolder + " path=" + p) + } + + // create Shares folder if it does not exist + var n *node.Node + if n, err = fs.lu.NodeFromPath(ctx, fs.o.ShareFolder); err != nil { + return errtypes.InternalError(err.Error()) + } else if !n.Exists { + if err = fs.tp.CreateDir(ctx, n); err != nil { + return + } + } + + if n, err = n.Child(parts[1]); err != nil { + return errtypes.InternalError(err.Error()) + } + + if n.Exists { + // TODO append increasing number to mountpoint name + return errtypes.AlreadyExists(p) + } + + if err = fs.tp.CreateDir(ctx, n); err != nil { + return + } + + internal := n.InternalPath() + if err = xattr.Set(internal, xattrs.ReferenceAttr, []byte(targetURI.String())); err != nil { + return errors.Wrapf(err, "s3ngfs: error setting the target %s on the reference file %s", targetURI.String(), internal) + } + return nil +} + +func (fs *s3ngfs) Move(ctx context.Context, oldRef, newRef *provider.Reference) (err error) { + var oldNode, newNode *node.Node + if oldNode, err = fs.lu.NodeFromResource(ctx, oldRef); err != nil { + return + } + + if !oldNode.Exists { + err = errtypes.NotFound(filepath.Join(oldNode.ParentID, oldNode.Name)) + return + } + + ok, err := fs.p.HasPermission(ctx, oldNode, func(rp *provider.ResourcePermissions) bool { + return rp.Move + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(oldNode.ID) + } + + if newNode, err = fs.lu.NodeFromResource(ctx, newRef); err != nil { + return + } + if newNode.Exists { + err = errtypes.AlreadyExists(filepath.Join(newNode.ParentID, newNode.Name)) + return + } + + return fs.tp.Move(ctx, oldNode, newNode) +} + +func (fs *s3ngfs) GetMD(ctx context.Context, ref *provider.Reference, mdKeys []string) (ri *provider.ResourceInfo, err error) { + var node *node.Node + if node, err = fs.lu.NodeFromResource(ctx, ref); err != nil { + return + } + + if !node.Exists { + err = errtypes.NotFound(filepath.Join(node.ParentID, node.Name)) + return + } + + rp, err := fs.p.AssemblePermissions(ctx, node) + switch { + case err != nil: + return nil, errtypes.InternalError(err.Error()) + case !rp.Stat: + return nil, errtypes.PermissionDenied(node.ID) + } + + return node.AsResourceInfo(ctx, rp, mdKeys) +} + +func (fs *s3ngfs) ListFolder(ctx context.Context, ref *provider.Reference, mdKeys []string) (finfos []*provider.ResourceInfo, err error) { + var n *node.Node + if n, err = fs.lu.NodeFromResource(ctx, ref); err != nil { + return + } + + if !n.Exists { + err = errtypes.NotFound(filepath.Join(n.ParentID, n.Name)) + return + } + + rp, err := fs.p.AssemblePermissions(ctx, n) + switch { + case err != nil: + return nil, errtypes.InternalError(err.Error()) + case !rp.ListContainer: + return nil, errtypes.PermissionDenied(n.ID) + } + + var children []*node.Node + children, err = fs.tp.ListFolder(ctx, n) + if err != nil { + return + } + + for i := range children { + np := rp + // add this childs permissions + node.AddPermissions(np, n.PermissionSet(ctx)) + if ri, err := children[i].AsResourceInfo(ctx, np, mdKeys); err == nil { + finfos = append(finfos, ri) + } + } + return +} + +func (fs *s3ngfs) Delete(ctx context.Context, ref *provider.Reference) (err error) { + var node *node.Node + if node, err = fs.lu.NodeFromResource(ctx, ref); err != nil { + return + } + if !node.Exists { + err = errtypes.NotFound(filepath.Join(node.ParentID, node.Name)) + return + } + + ok, err := fs.p.HasPermission(ctx, node, func(rp *provider.ResourcePermissions) bool { + return rp.Delete + }) + switch { + case err != nil: + return errtypes.InternalError(err.Error()) + case !ok: + return errtypes.PermissionDenied(filepath.Join(node.ParentID, node.Name)) + } + + return fs.tp.Delete(ctx, node) +} + +// Data persistence +func (fs *s3ngfs) Download(ctx context.Context, ref *provider.Reference) (io.ReadCloser, error) { + node, err := fs.lu.NodeFromResource(ctx, ref) + if err != nil { + return nil, errors.Wrap(err, "s3ngfs: error resolving ref") + } + + if !node.Exists { + err = errtypes.NotFound(filepath.Join(node.ParentID, node.Name)) + return nil, err + } + + ok, err := fs.p.HasPermission(ctx, node, func(rp *provider.ResourcePermissions) bool { + return rp.InitiateFileDownload + }) + switch { + case err != nil: + return nil, errtypes.InternalError(err.Error()) + case !ok: + return nil, errtypes.PermissionDenied(filepath.Join(node.ParentID, node.Name)) + } + + reader, err := fs.tp.ReadBlob(node.ID) + if err != nil { + return nil, errors.Wrap(err, "s3ngfs: error download blob '"+node.ID+"'") + } + return reader, nil +} + +// arbitrary metadata persistence in metadata.go + +// Version persistence in revisions.go + +// Trash persistence in recycle.go + +// share persistence in grants.go + +func (fs *s3ngfs) copyMD(s string, t string) (err error) { + var attrs []string + if attrs, err = xattr.List(s); err != nil { + return err + } + for i := range attrs { + if strings.HasPrefix(attrs[i], xattrs.OcisPrefix) { + var d []byte + if d, err = xattr.Get(s, attrs[i]); err != nil { + return err + } + if err = xattr.Set(t, attrs[i], d); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/storage/fs/s3ng/s3ng_suite_test.go b/pkg/storage/fs/s3ng/s3ng_suite_test.go new file mode 100644 index 0000000000..c6df7d972c --- /dev/null +++ b/pkg/storage/fs/s3ng/s3ng_suite_test.go @@ -0,0 +1,31 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestS3ng(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "S3ng Suite") +} diff --git a/pkg/storage/fs/s3ng/s3ng_test.go b/pkg/storage/fs/s3ng/s3ng_test.go new file mode 100644 index 0000000000..dba4be27a0 --- /dev/null +++ b/pkg/storage/fs/s3ng/s3ng_test.go @@ -0,0 +1,140 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng_test + +import ( + "context" + "io/ioutil" + "os" + "strings" + + "github.com/stretchr/testify/mock" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/storage" + "github.com/cs3org/reva/pkg/storage/fs/s3ng" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/mocks" + helpers "github.com/cs3org/reva/pkg/storage/fs/s3ng/testhelpers" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/tree" + treemocks "github.com/cs3org/reva/pkg/storage/fs/s3ng/tree/mocks" + ruser "github.com/cs3org/reva/pkg/user" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("S3ng", func() { + var ( + ref *provider.Reference + user *userpb.User + ctx context.Context + + options map[string]interface{} + lookup *s3ng.Lookup + permissions *mocks.PermissionsChecker + bs *treemocks.Blobstore + fs storage.FS + ) + + BeforeEach(func() { + ref = &provider.Reference{ + Spec: &provider.Reference_Path{ + Path: "foo", + }, + } + user = &userpb.User{ + Id: &userpb.UserId{ + Idp: "idp", + OpaqueId: "userid", + }, + Username: "username", + } + ctx = ruser.ContextSetUser(context.Background(), user) + + tmpRoot, err := ioutil.TempDir("", "reva-unit-tests-*-root") + Expect(err).ToNot(HaveOccurred()) + + options = map[string]interface{}{ + "root": tmpRoot, + "enable_home": true, + "share_folder": "/Shares", + "s3.endpoint": "http://1.2.3.4:5000", + "s3.region": "default", + "s3.bucket": "the-bucket", + "s3.access_key": "foo", + "s3.secret_key": "bar", + } + lookup = &s3ng.Lookup{} + permissions = &mocks.PermissionsChecker{} + bs = &treemocks.Blobstore{} + }) + + JustBeforeEach(func() { + var err error + tree := tree.New(options["root"].(string), true, true, lookup, bs) + fs, err = s3ng.New(options, lookup, permissions, tree) + Expect(err).ToNot(HaveOccurred()) + Expect(fs.CreateHome(ctx)).To(Succeed()) + }) + + AfterEach(func() { + root := options["root"].(string) + if strings.HasPrefix(root, os.TempDir()) { + os.RemoveAll(root) + } + }) + + Describe("NewDefault", func() { + It("fails on missing s3 configuration", func() { + _, err := s3ng.NewDefault(map[string]interface{}{}) + Expect(err).To(MatchError("S3 configuration incomplete")) + }) + }) + + Describe("Delete", func() { + JustBeforeEach(func() { + _, err := helpers.CreateEmptyNode(ctx, "foo", "foo", user.Id, lookup) + Expect(err).ToNot(HaveOccurred()) + }) + + Context("with insufficient permissions", func() { + It("returns an error", func() { + permissions.On("HasPermission", mock.Anything, mock.Anything, mock.Anything).Return(false, nil) + + err := fs.Delete(ctx, ref) + + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + Context("with sufficient permissions", func() { + JustBeforeEach(func() { + permissions.On("HasPermission", mock.Anything, mock.Anything, mock.Anything).Return(true, nil) + }) + + It("does not (yet) delete the blob from the blobstore", func() { + err := fs.Delete(ctx, ref) + + Expect(err).ToNot(HaveOccurred()) + bs.AssertNotCalled(GinkgoT(), "Delete", mock.AnythingOfType("string")) + }) + }) + }) +}) diff --git a/pkg/storage/fs/s3ng/testhelpers/helpers.go b/pkg/storage/fs/s3ng/testhelpers/helpers.go new file mode 100644 index 0000000000..6cbb960d07 --- /dev/null +++ b/pkg/storage/fs/s3ng/testhelpers/helpers.go @@ -0,0 +1,81 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package helpers + +import ( + "context" + "os" + "path" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/cs3org/reva/pkg/storage" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/tree" + ruser "github.com/cs3org/reva/pkg/user" +) + +// CreateEmptyNodeForOtherUser creates a home and an empty node for a new user +func CreateEmptyNodeForOtherUser(id, name string, fs storage.FS, lookup tree.PathLookup) (*node.Node, error) { + user := &userpb.User{ + Id: &userpb.UserId{ + Idp: "idp", + OpaqueId: "userid2", + }, + Username: "otheruser", + } + ctx := ruser.ContextSetUser(context.Background(), user) + err := fs.CreateHome(ctx) + if err != nil { + return nil, err + } + return CreateEmptyNode(ctx, id, name, user.Id, lookup) +} + +// CreateEmptyNode creates a home and an empty node for the given context +func CreateEmptyNode(ctx context.Context, id, name string, userid *userpb.UserId, lookup tree.PathLookup) (*node.Node, error) { + root, err := lookup.HomeOrRootNode(ctx) + if err != nil { + return nil, err + } + + n := node.New(id, root.ID, name, 1234, userid, lookup) + p, err := n.Parent() + if err != nil { + return nil, err + } + + // Create an empty file node + _, err = os.OpenFile(n.InternalPath(), os.O_CREATE, 0644) + if err != nil { + return nil, err + } + + // ... and an according link in the parent + err = os.Symlink("../"+n.ID, path.Join(p.InternalPath(), n.Name)) + if err != nil { + return nil, err + } + + err = n.WriteMetadata(userid) + if err != nil { + return nil, err + } + + return n, nil +} diff --git a/pkg/storage/fs/s3ng/tree/mocks/Blobstore.go b/pkg/storage/fs/s3ng/tree/mocks/Blobstore.go new file mode 100644 index 0000000000..6a2e425298 --- /dev/null +++ b/pkg/storage/fs/s3ng/tree/mocks/Blobstore.go @@ -0,0 +1,83 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package mocks + +import ( + io "io" + + mock "github.com/stretchr/testify/mock" +) + +// Blobstore is an autogenerated mock type for the Blobstore type +type Blobstore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: key +func (_m *Blobstore) Delete(key string) error { + ret := _m.Called(key) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(key) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Download provides a mock function with given fields: key +func (_m *Blobstore) Download(key string) (io.ReadCloser, error) { + ret := _m.Called(key) + + var r0 io.ReadCloser + if rf, ok := ret.Get(0).(func(string) io.ReadCloser); ok { + r0 = rf(key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.ReadCloser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Upload provides a mock function with given fields: key, reader +func (_m *Blobstore) Upload(key string, reader io.Reader) error { + ret := _m.Called(key, reader) + + var r0 error + if rf, ok := ret.Get(0).(func(string, io.Reader) error); ok { + r0 = rf(key, reader) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/storage/fs/s3ng/tree/tree.go b/pkg/storage/fs/s3ng/tree/tree.go new file mode 100644 index 0000000000..eb160719bd --- /dev/null +++ b/pkg/storage/fs/s3ng/tree/tree.go @@ -0,0 +1,598 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package tree + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "time" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/xattrs" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/pkg/xattr" + "github.com/rs/zerolog/log" +) + +//go:generate mockery -name Blobstore + +// Blobstore defines an interface for storing blobs in a blobstore +type Blobstore interface { + Upload(key string, reader io.Reader) error + Download(key string) (io.ReadCloser, error) + Delete(key string) error +} + +// PathLookup defines the interface for the lookup component +type PathLookup interface { + NodeFromPath(ctx context.Context, fn string) (*node.Node, error) + NodeFromID(ctx context.Context, id *provider.ResourceId) (n *node.Node, err error) + RootNode(ctx context.Context) (node *node.Node, err error) + HomeOrRootNode(ctx context.Context) (node *node.Node, err error) + + InternalRoot() string + InternalPath(ID string) string + Path(ctx context.Context, n *node.Node) (path string, err error) +} + +// Tree manages a hierarchical tree +type Tree struct { + lookup PathLookup + blobstore Blobstore + + root string + treeSizeAccounting bool + treeTimeAccounting bool +} + +// PermissionCheckFunc defined a function used to check resource permissions +type PermissionCheckFunc func(rp *provider.ResourcePermissions) bool + +// New returns a new instance of Tree +func New(root string, tta bool, tsa bool, lu PathLookup, bs Blobstore) *Tree { + return &Tree{ + lookup: lu, + blobstore: bs, + root: root, + treeTimeAccounting: tta, + treeSizeAccounting: tsa, + } +} + +// Setup prepares the tree structure +func (t *Tree) Setup(owner string) error { + // create data paths for internal layout + dataPaths := []string{ + filepath.Join(t.root, "nodes"), + // notes contain symlinks from nodes//uploads/ to ../../uploads/ + // better to keep uploads on a fast / volatile storage before a workflow finally moves them to the nodes dir + filepath.Join(t.root, "uploads"), + filepath.Join(t.root, "trash"), + } + for _, v := range dataPaths { + err := os.MkdirAll(v, 0700) + if err != nil { + return err + } + } + + // the root node has an empty name + // the root node has no parent + n := node.New("root", "", "", 0, nil, t.lookup) + err := t.createNode( + n, + &userpb.UserId{ + OpaqueId: owner, + }, + ) + if err != nil { + return err + } + return nil +} + +// GetMD returns the metadata of a node in the tree +func (t *Tree) GetMD(ctx context.Context, n *node.Node) (os.FileInfo, error) { + md, err := os.Stat(n.InternalPath()) + if err != nil { + if os.IsNotExist(err) { + return nil, errtypes.NotFound(n.ID) + } + return nil, errors.Wrap(err, "tree: error stating "+n.ID) + } + + return md, nil +} + +// CreateDir creates a new directory entry in the tree +func (t *Tree) CreateDir(ctx context.Context, n *node.Node) (err error) { + + if n.Exists || n.ID != "" { + return errtypes.AlreadyExists(n.ID) // path? + } + + // create a directory node + n.ID = uuid.New().String() + + // who will become the owner? the owner of the parent node, not the current user + var p *node.Node + p, err = n.Parent() + if err != nil { + return + } + var owner *userpb.UserId + owner, err = p.Owner() + if err != nil { + return + } + + err = t.createNode(n, owner) + if err != nil { + return nil + } + + // make child appear in listings + err = os.Symlink("../"+n.ID, filepath.Join(t.lookup.InternalPath(n.ParentID), n.Name)) + if err != nil { + return + } + return t.Propagate(ctx, n) +} + +// Move replaces the target with the source +func (t *Tree) Move(ctx context.Context, oldNode *node.Node, newNode *node.Node) (err error) { + // if target exists delete it without trashing it + if newNode.Exists { + // TODO make sure all children are deleted + if err := os.RemoveAll(newNode.InternalPath()); err != nil { + return errors.Wrap(err, "s3ngfs: Move: error deleting target node "+newNode.ID) + } + } + + // Always target the old node ID for xattr updates. + // The new node id is empty if the target does not exist + // and we need to overwrite the new one when overwriting an existing path. + tgtPath := oldNode.InternalPath() + + // are we just renaming (parent stays the same)? + if oldNode.ParentID == newNode.ParentID { + + parentPath := t.lookup.InternalPath(oldNode.ParentID) + + // rename child + err = os.Rename( + filepath.Join(parentPath, oldNode.Name), + filepath.Join(parentPath, newNode.Name), + ) + if err != nil { + return errors.Wrap(err, "s3ngfs: could not rename child") + } + + // update name attribute + if err := xattr.Set(tgtPath, xattrs.NameAttr, []byte(newNode.Name)); err != nil { + return errors.Wrap(err, "s3ngfs: could not set name attribute") + } + + return t.Propagate(ctx, newNode) + } + + // we are moving the node to a new parent, any target has been removed + // bring old node to the new parent + + // rename child + err = os.Rename( + filepath.Join(t.lookup.InternalPath(oldNode.ParentID), oldNode.Name), + filepath.Join(t.lookup.InternalPath(newNode.ParentID), newNode.Name), + ) + if err != nil { + return errors.Wrap(err, "s3ngfs: could not move child") + } + + // update target parentid and name + if err := xattr.Set(tgtPath, xattrs.ParentidAttr, []byte(newNode.ParentID)); err != nil { + return errors.Wrap(err, "s3ngfs: could not set parentid attribute") + } + if err := xattr.Set(tgtPath, xattrs.NameAttr, []byte(newNode.Name)); err != nil { + return errors.Wrap(err, "s3ngfs: could not set name attribute") + } + + // TODO inefficient because we might update several nodes twice, only propagate unchanged nodes? + // collect in a list, then only stat each node once + // also do this in a go routine ... webdav should check the etag async + + err = t.Propagate(ctx, oldNode) + if err != nil { + return errors.Wrap(err, "s3ngfs: Move: could not propagate old node") + } + err = t.Propagate(ctx, newNode) + if err != nil { + return errors.Wrap(err, "s3ngfs: Move: could not propagate new node") + } + return nil +} + +// ListFolder lists the content of a folder node +func (t *Tree) ListFolder(ctx context.Context, n *node.Node) ([]*node.Node, error) { + dir := n.InternalPath() + f, err := os.Open(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, errtypes.NotFound(dir) + } + return nil, errors.Wrap(err, "tree: error listing "+dir) + } + defer f.Close() + + names, err := f.Readdirnames(0) + if err != nil { + return nil, err + } + nodes := []*node.Node{} + for i := range names { + link, err := os.Readlink(filepath.Join(dir, names[i])) + if err != nil { + // TODO log + continue + } + + n, err := node.ReadNode(ctx, t.lookup, filepath.Base(link)) + if err != nil { + // TODO log + continue + } + nodes = append(nodes, n) + } + return nodes, nil +} + +// Delete deletes a node in the tree by moving it to the trash +func (t *Tree) Delete(ctx context.Context, n *node.Node) (err error) { + + // Prepare the trash + // TODO use layout?, but it requires resolving the owners user if the username is used instead of the id. + // the node knows the owner id so we use that for now + o, err := n.Owner() + if err != nil { + return + } + if o.OpaqueId == "" { + // fall back to root trash + o.OpaqueId = "root" + } + err = os.MkdirAll(filepath.Join(t.root, "trash", o.OpaqueId), 0700) + if err != nil { + return + } + + // get the original path + origin, err := t.lookup.Path(ctx, n) + if err != nil { + return + } + + // set origin location in metadata + nodePath := n.InternalPath() + if err := xattr.Set(nodePath, xattrs.TrashOriginAttr, []byte(origin)); err != nil { + return err + } + + deletionTime := time.Now().UTC().Format(time.RFC3339Nano) + + // first make node appear in the owners (or root) trash + // parent id and name are stored as extended attributes in the node itself + trashLink := filepath.Join(t.root, "trash", o.OpaqueId, n.ID) + err = os.Symlink("../../nodes/"+n.ID+".T."+deletionTime, trashLink) + if err != nil { + // To roll back changes + // TODO unset trashOriginAttr + return + } + + // at this point we have a symlink pointing to a non existing destination, which is fine + + // rename the trashed node so it is not picked up when traversing up the tree and matches the symlink + trashPath := nodePath + ".T." + deletionTime + err = os.Rename(nodePath, trashPath) + if err != nil { + // To roll back changes + // TODO remove symlink + // TODO unset trashOriginAttr + return + } + + // finally remove the entry from the parent dir + src := filepath.Join(t.lookup.InternalPath(n.ParentID), n.Name) + err = os.Remove(src) + if err != nil { + // To roll back changes + // TODO revert the rename + // TODO remove symlink + // TODO unset trashOriginAttr + return + } + + p, err := n.Parent() + if err != nil { + return errors.Wrap(err, "s3ngfs: error getting parent "+n.ParentID) + } + return t.Propagate(ctx, p) +} + +// RestoreRecycleItemFunc returns a node and a function to restore it from the trash +func (t *Tree) RestoreRecycleItemFunc(ctx context.Context, key string) (*node.Node, func() error, error) { + rn, trashItem, deletedNodePath, origin, err := t.readRecycleItem(ctx, key) + if err != nil { + return nil, nil, err + } + + fn := func() error { + // link to origin + var n *node.Node + n, err = t.lookup.NodeFromPath(ctx, origin) + if err != nil { + return err + } + + if n.Exists { + return errtypes.AlreadyExists("origin already exists") + } + + // add the entry for the parent dir + err = os.Symlink("../"+rn.ID, filepath.Join(t.lookup.InternalPath(n.ParentID), n.Name)) + if err != nil { + return err + } + + // rename to node only name, so it is picked up by id + nodePath := rn.InternalPath() + err = os.Rename(deletedNodePath, nodePath) + if err != nil { + return err + } + + n.Exists = true + + // delete item link in trash + if err = os.Remove(trashItem); err != nil { + log.Error().Err(err).Str("trashItem", trashItem).Msg("error deleting trashitem") + } + return t.Propagate(ctx, n) + } + return rn, fn, nil +} + +// PurgeRecycleItemFunc returns a node and a function to purge it from the trash +func (t *Tree) PurgeRecycleItemFunc(ctx context.Context, key string) (*node.Node, func() error, error) { + rn, trashItem, deletedNodePath, _, err := t.readRecycleItem(ctx, key) + if err != nil { + return nil, nil, err + } + + fn := func() error { + if err := os.Remove(deletedNodePath); err != nil { + log.Error().Err(err).Str("deletedNodePath", deletedNodePath).Msg("error deleting trash node") + return err + } + + // delete blob from blobstore + if err = t.DeleteBlob(rn.ID); err != nil { + log.Error().Err(err).Str("trashItem", trashItem).Msg("error deleting trash item blob") + return err + } + + // delete item link in trash + if err = os.Remove(trashItem); err != nil { + log.Error().Err(err).Str("trashItem", trashItem).Msg("error deleting trash item") + return err + } + + return nil + } + + return rn, fn, nil +} + +// Propagate propagates changes to the root of the tree +func (t *Tree) Propagate(ctx context.Context, n *node.Node) (err error) { + if !t.treeTimeAccounting && !t.treeSizeAccounting { + // no propagation enabled + log.Debug().Msg("propagation disabled") + return + } + log := appctx.GetLogger(ctx) + + // is propagation enabled for the parent node? + + var root *node.Node + if root, err = t.lookup.HomeOrRootNode(ctx); err != nil { + return + } + + // use a sync time and don't rely on the mtime of the current node, as the stat might not change when a rename happened too quickly + sTime := time.Now().UTC() + + for err == nil && n.ID != root.ID { + log.Debug().Interface("node", n).Msg("propagating") + + if n, err = n.Parent(); err != nil { + break + } + + // TODO none, sync and async? + if !n.HasPropagation() { + log.Debug().Interface("node", n).Str("attr", xattrs.PropagationAttr).Msg("propagation attribute not set or unreadable, not propagating") + // if the attribute is not set treat it as false / none / no propagation + return nil + } + + if t.treeTimeAccounting { + // update the parent tree time if it is older than the nodes mtime + updateSyncTime := false + + var tmTime time.Time + tmTime, err = n.GetTMTime() + switch { + case err != nil: + // missing attribute, or invalid format, overwrite + log.Debug().Err(err). + Interface("node", n). + Msg("could not read tmtime attribute, overwriting") + updateSyncTime = true + case tmTime.Before(sTime): + log.Debug(). + Interface("node", n). + Time("tmtime", tmTime). + Time("stime", sTime). + Msg("parent tmtime is older than node mtime, updating") + updateSyncTime = true + default: + log.Debug(). + Interface("node", n). + Time("tmtime", tmTime). + Time("stime", sTime). + Dur("delta", sTime.Sub(tmTime)). + Msg("parent tmtime is younger than node mtime, not updating") + } + + if updateSyncTime { + // update the tree time of the parent node + if err = n.SetTMTime(sTime); err != nil { + log.Error().Err(err).Interface("node", n).Time("tmtime", sTime).Msg("could not update tmtime of parent node") + return + } + log.Debug().Interface("node", n).Time("tmtime", sTime).Msg("updated tmtime of parent node") + } + + if err := n.UnsetTempEtag(); err != nil { + log.Error().Err(err).Interface("node", n).Msg("could not remove temporary etag attribute") + } + + } + + // TODO size accounting + + } + if err != nil { + log.Error().Err(err).Interface("node", n).Msg("error propagating") + return + } + return +} + +// WriteBlob writes a blob to the blobstore +func (t *Tree) WriteBlob(key string, reader io.Reader) error { + return t.blobstore.Upload(key, reader) +} + +// ReadBlob reads a blob from the blobstore +func (t *Tree) ReadBlob(key string) (io.ReadCloser, error) { + return t.blobstore.Download(key) +} + +// DeleteBlob deletes a blob from the blobstore +func (t *Tree) DeleteBlob(key string) error { + return t.blobstore.Delete(key) +} + +// TODO check if node exists? +func (t *Tree) createNode(n *node.Node, owner *userpb.UserId) (err error) { + // create a directory node + nodePath := n.InternalPath() + if err = os.MkdirAll(nodePath, 0700); err != nil { + return errors.Wrap(err, "s3ngfs: error creating node") + } + + return n.WriteMetadata(owner) +} + +// TODO refactor the returned params into Node properties? would make all the path transformations go away... +func (t *Tree) readRecycleItem(ctx context.Context, key string) (n *node.Node, trashItem string, deletedNodePath string, origin string, err error) { + if key == "" { + return nil, "", "", "", errtypes.InternalError("key is empty") + } + + kp := strings.SplitN(key, ":", 2) + if len(kp) != 2 { + appctx.GetLogger(ctx).Error().Err(err).Str("key", key).Msg("malformed key") + return + } + trashItem = filepath.Join(t.lookup.InternalRoot(), "trash", kp[0], kp[1]) + + var link string + link, err = os.Readlink(trashItem) + if err != nil { + appctx.GetLogger(ctx).Error().Err(err).Str("trashItem", trashItem).Msg("error reading trash link") + return + } + parts := strings.SplitN(filepath.Base(link), ".T.", 2) + if len(parts) != 2 { + appctx.GetLogger(ctx).Error().Err(err).Str("trashItem", trashItem).Interface("parts", parts).Msg("malformed trash link") + return + } + + var attrBytes []byte + deletedNodePath = t.lookup.InternalPath(filepath.Base(link)) + + owner := &userpb.UserId{} + // lookup ownerId in extended attributes + if attrBytes, err = xattr.Get(deletedNodePath, xattrs.OwnerIDAttr); err == nil { + owner.OpaqueId = string(attrBytes) + } else { + return + } + // lookup ownerIdp in extended attributes + if attrBytes, err = xattr.Get(deletedNodePath, xattrs.OwnerIDPAttr); err == nil { + owner.Idp = string(attrBytes) + } else { + return + } + + n = node.New(parts[0], "", "", 0, owner, t.lookup) + // lookup parent id in extended attributes + if attrBytes, err = xattr.Get(deletedNodePath, xattrs.ParentidAttr); err == nil { + n.ParentID = string(attrBytes) + } else { + return + } + // lookup name in extended attributes + if attrBytes, err = xattr.Get(deletedNodePath, xattrs.NameAttr); err == nil { + n.Name = string(attrBytes) + } else { + return + } + + // get origin node + origin = "/" + + // lookup origin path in extended attributes + if attrBytes, err = xattr.Get(deletedNodePath, xattrs.TrashOriginAttr); err == nil { + origin = string(attrBytes) + } else { + log.Error().Err(err).Str("trashItem", trashItem).Str("link", link).Str("deletedNodePath", deletedNodePath).Msg("could not read origin path, restoring to /") + } + return +} diff --git a/pkg/storage/fs/s3ng/tree/tree_suite_test.go b/pkg/storage/fs/s3ng/tree/tree_suite_test.go new file mode 100644 index 0000000000..ef20ab4f6e --- /dev/null +++ b/pkg/storage/fs/s3ng/tree/tree_suite_test.go @@ -0,0 +1,31 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package tree_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestTree(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Tree Suite") +} diff --git a/pkg/storage/fs/s3ng/tree/tree_test.go b/pkg/storage/fs/s3ng/tree/tree_test.go new file mode 100644 index 0000000000..02a3e37d60 --- /dev/null +++ b/pkg/storage/fs/s3ng/tree/tree_test.go @@ -0,0 +1,213 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package tree_test + +import ( + "context" + "io/ioutil" + "os" + "path" + "strings" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/cs3org/reva/pkg/storage/fs/s3ng" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/tree" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/tree/mocks" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/xattrs" + ruser "github.com/cs3org/reva/pkg/user" + "github.com/mitchellh/mapstructure" + "github.com/pkg/xattr" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tree", func() { + var ( + user *userpb.User + ctx context.Context + + blobstore *mocks.Blobstore + lookup tree.PathLookup + options *s3ng.Options + + t *tree.Tree + treeTimeAccounting bool + treeSizeAccounting bool + ) + + BeforeEach(func() { + user = &userpb.User{ + Id: &userpb.UserId{ + Idp: "idp", + OpaqueId: "userid", + }, + Username: "username", + } + ctx = ruser.ContextSetUser(context.Background(), user) + tmpRoot, err := ioutil.TempDir("", "reva-unit-tests-*-root") + Expect(err).ToNot(HaveOccurred()) + options = &s3ng.Options{} + err = mapstructure.Decode(map[string]interface{}{ + "root": tmpRoot, + }, options) + Expect(err).ToNot(HaveOccurred()) + + blobstore = &mocks.Blobstore{} + lookup = &s3ng.Lookup{Options: options} + }) + + JustBeforeEach(func() { + t = tree.New(options.Root, treeTimeAccounting, treeSizeAccounting, lookup, blobstore) + Expect(t.Setup("root")).To(Succeed()) + }) + + AfterEach(func() { + root := options.Root + if strings.HasPrefix(root, os.TempDir()) { + os.RemoveAll(root) + } + }) + + Describe("New", func() { + It("returns a Tree instance", func() { + Expect(t).ToNot(BeNil()) + }) + }) + + Context("with an existingfile", func() { + var ( + n *node.Node + ) + + JustBeforeEach(func() { + n = createEmptyNode("fooId", "root", "fooName", user.Id, lookup) + Expect(n.WriteMetadata(user.Id)).To(Succeed()) + }) + + Describe("Delete", func() { + JustBeforeEach(func() { + _, err := os.Stat(n.InternalPath()) + Expect(err).ToNot(HaveOccurred()) + + Expect(t.Delete(ctx, n)).To(Succeed()) + + _, err = os.Stat(n.InternalPath()) + Expect(err).To(HaveOccurred()) + }) + + It("moves the file to the trash", func() { + trashPath := path.Join(options.Root, "trash", user.Id.OpaqueId, n.ID) + _, err := os.Stat(trashPath) + Expect(err).ToNot(HaveOccurred()) + }) + + It("removes the file from its original location", func() { + _, err := os.Stat(n.InternalPath()) + Expect(err).To(HaveOccurred()) + }) + + It("sets the trash origin xattr", func() { + trashPath := path.Join(options.Root, "trash", user.Id.OpaqueId, n.ID) + attr, err := xattr.Get(trashPath, xattrs.TrashOriginAttr) + Expect(err).ToNot(HaveOccurred()) + Expect(string(attr)).To(Equal(n.Name)) + }) + + It("does not delete the blob from the blobstore", func() { + blobstore.AssertNotCalled(GinkgoT(), "Delete", mock.AnythingOfType("string")) + }) + }) + + Context("that was deleted", func() { + var ( + trashPath string + ) + + BeforeEach(func() { + blobstore.On("Delete", n.ID).Return(nil) + trashPath = path.Join(options.Root, "trash", user.Id.OpaqueId, n.ID) + }) + + JustBeforeEach(func() { + Expect(t.Delete(ctx, n)).To(Succeed()) + }) + + Describe("PurgeRecycleItemFunc", func() { + JustBeforeEach(func() { + _, err := os.Stat(trashPath) + Expect(err).ToNot(HaveOccurred()) + + _, purgeFunc, err := t.PurgeRecycleItemFunc(ctx, user.Id.OpaqueId+":"+n.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(purgeFunc()).To(Succeed()) + }) + + It("removes the file from the trash", func() { + _, err := os.Stat(trashPath) + Expect(err).To(HaveOccurred()) + }) + + It("deletes the blob from the blobstore", func() { + blobstore.AssertCalled(GinkgoT(), "Delete", mock.AnythingOfType("string")) + }) + }) + + Describe("RestoreRecycleItemFunc", func() { + JustBeforeEach(func() { + _, err := os.Stat(trashPath) + Expect(err).ToNot(HaveOccurred()) + _, err = os.Stat(n.InternalPath()) + Expect(err).To(HaveOccurred()) + + _, restoreFunc, err := t.RestoreRecycleItemFunc(ctx, user.Id.OpaqueId+":"+n.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(restoreFunc()).To(Succeed()) + }) + + It("restores the file to its original location", func() { + _, err := os.Stat(n.InternalPath()) + Expect(err).ToNot(HaveOccurred()) + }) + It("removes the file from the trash", func() { + _, err := os.Stat(trashPath) + Expect(err).To(HaveOccurred()) + }) + }) + }) + }) +}) + +func createEmptyNode(id, parent, name string, userid *userpb.UserId, lookup tree.PathLookup) *node.Node { + n := node.New(id, parent, name, 0, userid, lookup) + p, err := n.Parent() + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + + // Create an empty file node + _, err = os.OpenFile(n.InternalPath(), os.O_CREATE, 0644) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + + // ... and an according link in the parent + err = os.Symlink("../"+n.ID, path.Join(p.InternalPath(), n.Name)) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + + return n +} diff --git a/pkg/storage/fs/s3ng/upload.go b/pkg/storage/fs/s3ng/upload.go new file mode 100644 index 0000000000..f34b90526e --- /dev/null +++ b/pkg/storage/fs/s3ng/upload.go @@ -0,0 +1,682 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng + +import ( + "context" + "crypto/md5" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "hash/adler32" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/logger" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/node" + "github.com/cs3org/reva/pkg/storage/utils/chunking" + "github.com/cs3org/reva/pkg/user" + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/rs/zerolog" + tusd "github.com/tus/tusd/pkg/handler" +) + +var defaultFilePerm = os.FileMode(0664) + +// TODO Upload (and InitiateUpload) needs a way to receive the expected checksum. +// Maybe in metadata as 'checksum' => 'sha1 aeosvp45w5xaeoe' = lowercase, space separated? +func (fs *s3ngfs) Upload(ctx context.Context, ref *provider.Reference, r io.ReadCloser) (err error) { + upload, err := fs.GetUpload(ctx, ref.GetPath()) + if err != nil { + // Upload corresponding to this ID was not found. + // Assume that this corresponds to the resource path to which the file has to be uploaded. + + // Set the length to 0 and set SizeIsDeferred to true + metadata := map[string]string{"sizedeferred": "true"} + uploadIDs, err := fs.InitiateUpload(ctx, ref, 0, metadata) + if err != nil { + return err + } + if upload, err = fs.GetUpload(ctx, uploadIDs["simple"]); err != nil { + return errors.Wrap(err, "s3ngfs: error retrieving upload") + } + } + + uploadInfo := upload.(*fileUpload) + + p := uploadInfo.info.Storage["NodeName"] + ok, err := chunking.IsChunked(p) // check chunking v1 + if err != nil { + return errors.Wrap(err, "s3ngfs: error checking path") + } + if ok { + var assembledFile string + p, assembledFile, err = fs.chunkHandler.WriteChunk(p, r) + if err != nil { + return err + } + if p == "" { + if err = uploadInfo.Terminate(ctx); err != nil { + return errors.Wrap(err, "ocfs: error removing auxiliary files") + } + return errtypes.PartialContent(ref.String()) + } + uploadInfo.info.Storage["NodeName"] = p + fd, err := os.Open(assembledFile) + if err != nil { + return errors.Wrap(err, "s3ngfs: error opening assembled file") + } + defer fd.Close() + defer os.RemoveAll(assembledFile) + r = fd + } + + if _, err := uploadInfo.WriteChunk(ctx, 0, r); err != nil { + return errors.Wrap(err, "s3ngfs: error writing to binary file") + } + + return uploadInfo.FinishUpload(ctx) +} + +// InitiateUpload returns upload ids corresponding to different protocols it supports +// TODO read optional content for small files in this request +// TODO InitiateUpload (and Upload) needs a way to receive the expected checksum. Maybe in metadata as 'checksum' => 'sha1 aeosvp45w5xaeoe' = lowercase, space separated? +func (fs *s3ngfs) InitiateUpload(ctx context.Context, ref *provider.Reference, uploadLength int64, metadata map[string]string) (map[string]string, error) { + + log := appctx.GetLogger(ctx) + + var relative string // the internal path of the file node + + n, err := fs.lu.NodeFromResource(ctx, ref) + if err != nil { + return nil, err + } + + // permissions are checked in NewUpload below + + relative, err = fs.lu.Path(ctx, n) + if err != nil { + return nil, err + } + + info := tusd.FileInfo{ + MetaData: tusd.MetaData{ + "filename": filepath.Base(relative), + "dir": filepath.Dir(relative), + }, + Size: uploadLength, + } + + if metadata != nil { + if metadata["mtime"] != "" { + info.MetaData["mtime"] = metadata["mtime"] + } + if _, ok := metadata["sizedeferred"]; ok { + info.SizeIsDeferred = true + } + if metadata["checksum"] != "" { + parts := strings.SplitN(metadata["checksum"], " ", 2) + if len(parts) != 2 { + return nil, errtypes.BadRequest("invalid checksum format. must be '[algorithm] [checksum]'") + } + switch parts[0] { + case "sha1", "md5", "adler32": + info.MetaData["checksum"] = metadata["checksum"] + default: + return nil, errtypes.BadRequest("unsupported checksum algorithm: " + parts[0]) + } + } + } + + log.Debug().Interface("info", info).Interface("node", n).Interface("metadata", metadata).Msg("s3ngfs: resolved filename") + + upload, err := fs.NewUpload(ctx, info) + if err != nil { + return nil, err + } + + info, _ = upload.GetInfo(ctx) + + return map[string]string{ + "simple": info.ID, + "tus": info.ID, + }, nil +} + +// UseIn tells the tus upload middleware which extensions it supports. +func (fs *s3ngfs) UseIn(composer *tusd.StoreComposer) { + composer.UseCore(fs) + composer.UseTerminater(fs) + composer.UseConcater(fs) + composer.UseLengthDeferrer(fs) +} + +// To implement the core tus.io protocol as specified in https://tus.io/protocols/resumable-upload.html#core-protocol +// - the storage needs to implement NewUpload and GetUpload +// - the upload needs to implement the tusd.Upload interface: WriteChunk, GetInfo, GetReader and FinishUpload + +func (fs *s3ngfs) NewUpload(ctx context.Context, info tusd.FileInfo) (upload tusd.Upload, err error) { + + log := appctx.GetLogger(ctx) + log.Debug().Interface("info", info).Msg("s3ngfs: NewUpload") + + fn := info.MetaData["filename"] + if fn == "" { + return nil, errors.New("s3ngfs: missing filename in metadata") + } + info.MetaData["filename"] = filepath.Clean(info.MetaData["filename"]) + + dir := info.MetaData["dir"] + if dir == "" { + return nil, errors.New("s3ngfs: missing dir in metadata") + } + info.MetaData["dir"] = filepath.Clean(info.MetaData["dir"]) + + n, err := fs.lu.NodeFromPath(ctx, filepath.Join(info.MetaData["dir"], info.MetaData["filename"])) + if err != nil { + return nil, errors.Wrap(err, "s3ngfs: error wrapping filename") + } + + log.Debug().Interface("info", info).Interface("node", n).Msg("s3ngfs: resolved filename") + + // the parent owner will become the new owner + p, perr := n.Parent() + if perr != nil { + return nil, errors.Wrap(perr, "s3ngfs: error getting parent "+n.ParentID) + } + + // check permissions + var ok bool + if n.Exists { + // check permissions of file to be overwritten + ok, err = fs.p.HasPermission(ctx, n, func(rp *provider.ResourcePermissions) bool { + return rp.InitiateFileUpload + }) + } else { + // check permissions of parent + ok, err = fs.p.HasPermission(ctx, p, func(rp *provider.ResourcePermissions) bool { + return rp.InitiateFileUpload + }) + } + switch { + case err != nil: + return nil, errtypes.InternalError(err.Error()) + case !ok: + return nil, errtypes.PermissionDenied(filepath.Join(n.ParentID, n.Name)) + } + + info.ID = uuid.New().String() + + binPath, err := fs.getUploadPath(ctx, info.ID) + if err != nil { + return nil, errors.Wrap(err, "s3ngfs: error resolving upload path") + } + usr := user.ContextMustGetUser(ctx) + + owner, err := p.Owner() + if err != nil { + return nil, errors.Wrap(err, "s3ngfs: error determining owner") + } + + info.Storage = map[string]string{ + "Type": "OCISStore", + "BinPath": binPath, + + "NodeId": n.ID, + "NodeParentId": n.ParentID, + "NodeName": n.Name, + + "Idp": usr.Id.Idp, + "UserId": usr.Id.OpaqueId, + "UserName": usr.Username, + + "OwnerIdp": owner.Idp, + "OwnerId": owner.OpaqueId, + + "LogLevel": log.GetLevel().String(), + } + // Create binary file in the upload folder with no content + log.Debug().Interface("info", info).Msg("s3ngfs: built storage info") + file, err := os.OpenFile(binPath, os.O_CREATE|os.O_WRONLY, defaultFilePerm) + if err != nil { + return nil, err + } + defer file.Close() + + u := &fileUpload{ + info: info, + binPath: binPath, + infoPath: filepath.Join(fs.o.Root, "uploads", info.ID+".info"), + fs: fs, + ctx: ctx, + } + + if !info.SizeIsDeferred && info.Size == 0 { + log.Debug().Interface("info", info).Msg("s3ngfs: finishing upload for empty file") + // no need to create info file and finish directly + err := u.FinishUpload(ctx) + if err != nil { + return nil, err + } + return u, nil + } + + // writeInfo creates the file by itself if necessary + err = u.writeInfo() + if err != nil { + return nil, err + } + + return u, nil +} + +func (fs *s3ngfs) getUploadPath(ctx context.Context, uploadID string) (string, error) { + return filepath.Join(fs.o.Root, "uploads", uploadID), nil +} + +// GetUpload returns the Upload for the given upload id +func (fs *s3ngfs) GetUpload(ctx context.Context, id string) (tusd.Upload, error) { + infoPath := filepath.Join(fs.o.Root, "uploads", id+".info") + + info := tusd.FileInfo{} + data, err := ioutil.ReadFile(infoPath) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &info); err != nil { + return nil, err + } + + stat, err := os.Stat(info.Storage["BinPath"]) + if err != nil { + return nil, err + } + + info.Offset = stat.Size() + + u := &userpb.User{ + Id: &userpb.UserId{ + Idp: info.Storage["Idp"], + OpaqueId: info.Storage["UserId"], + }, + Username: info.Storage["UserName"], + } + + ctx = user.ContextSetUser(ctx, u) + // TODO configure the logger the same way ... store and add traceid in file info + + var opts []logger.Option + opts = append(opts, logger.WithLevel(info.Storage["LogLevel"])) + opts = append(opts, logger.WithWriter(os.Stderr, logger.ConsoleMode)) + l := logger.New(opts...) + + sub := l.With().Int("pid", os.Getpid()).Logger() + + ctx = appctx.WithLogger(ctx, &sub) + + return &fileUpload{ + info: info, + binPath: info.Storage["BinPath"], + infoPath: infoPath, + fs: fs, + ctx: ctx, + }, nil +} + +type fileUpload struct { + // info stores the current information about the upload + info tusd.FileInfo + // infoPath is the path to the .info file + infoPath string + // binPath is the path to the binary file (which has no extension) + binPath string + // only fs knows how to handle metadata and versions + fs *s3ngfs + // a context with a user + // TODO add logger as well? + ctx context.Context +} + +// GetInfo returns the FileInfo +func (upload *fileUpload) GetInfo(ctx context.Context) (tusd.FileInfo, error) { + return upload.info, nil +} + +// WriteChunk writes the stream from the reader to the given offset of the upload +func (upload *fileUpload) WriteChunk(ctx context.Context, offset int64, src io.Reader) (int64, error) { + file, err := os.OpenFile(upload.binPath, os.O_WRONLY|os.O_APPEND, defaultFilePerm) + if err != nil { + return 0, err + } + defer file.Close() + + // calculate cheksum here? needed for the TUS checksum extension. https://tus.io/protocols/resumable-upload.html#checksum + // TODO but how do we get the `Upload-Checksum`? WriteChunk() only has a context, offset and the reader ... + // It is sent with the PATCH request, well or in the POST when the creation-with-upload extension is used + // but the tus handler uses a context.Background() so we cannot really check the header and put it in the context ... + n, err := io.Copy(file, src) + + // If the HTTP PATCH request gets interrupted in the middle (e.g. because + // the user wants to pause the upload), Go's net/http returns an io.ErrUnexpectedEOF. + // However, for the ocis driver it's not important whether the stream has ended + // on purpose or accidentally. + if err != nil { + if err != io.ErrUnexpectedEOF { + return n, err + } + } + + upload.info.Offset += n + err = upload.writeInfo() // TODO info is written here ... we need to truncate in DiscardChunk + + return n, err +} + +// GetReader returns an io.Reader for the upload +func (upload *fileUpload) GetReader(ctx context.Context) (io.Reader, error) { + return os.Open(upload.binPath) +} + +// writeInfo updates the entire information. Everything will be overwritten. +func (upload *fileUpload) writeInfo() error { + data, err := json.Marshal(upload.info) + if err != nil { + return err + } + return ioutil.WriteFile(upload.infoPath, data, defaultFilePerm) +} + +// FinishUpload finishes an upload and moves the file to the internal destination +func (upload *fileUpload) FinishUpload(ctx context.Context) (err error) { + fi, err := os.Stat(upload.binPath) + if err != nil { + appctx.GetLogger(upload.ctx).Err(err).Msg("s3ngfs: could not stat uploaded file") + return + } + n := node.New( + upload.info.Storage["NodeId"], + upload.info.Storage["NodeParentId"], + upload.info.Storage["NodeName"], + fi.Size(), + nil, + upload.fs.lu, + ) + + if n.ID == "" { + n.ID = uuid.New().String() + } + targetPath := n.InternalPath() + sublog := appctx.GetLogger(upload.ctx). + With(). + Interface("info", upload.info). + Str("binPath", upload.binPath). + Str("targetPath", targetPath). + Logger() + + // calculate the checksum of the written bytes + // they will all be written to the metadata later, so we cannot omit any of them + // TODO only calculate the checksum in sync that was requested to match, the rest could be async ... but the tests currently expect all to be present + // TODO the hashes all implement BinaryMarshaler so we could try to persist the state for resumable upload. we would neet do keep track of the copied bytes ... + sha1h := sha1.New() + md5h := md5.New() + adler32h := adler32.New() + { + f, err := os.Open(upload.binPath) + if err != nil { + sublog.Err(err).Msg("s3ngfs: could not open file for checksumming") + // we can continue if no oc checksum header is set + } + defer f.Close() + + r1 := io.TeeReader(f, sha1h) + r2 := io.TeeReader(r1, md5h) + + if _, err := io.Copy(adler32h, r2); err != nil { + sublog.Err(err).Msg("s3ngfs: could not copy bytes for checksumming") + } + } + // compare if they match the sent checksum + // TODO the tus checksum extension would do this on every chunk, but I currently don't see an easy way to pass in the requested checksum. for now we do it in FinishUpload which is also called for chunked uploads + if upload.info.MetaData["checksum"] != "" { + parts := strings.SplitN(upload.info.MetaData["checksum"], " ", 2) + if len(parts) != 2 { + return errtypes.BadRequest("invalid checksum format. must be '[algorithm] [checksum]'") + } + switch parts[0] { + case "sha1": + err = upload.checkHash(parts[1], sha1h) + case "md5": + err = upload.checkHash(parts[1], md5h) + case "adler32": + err = upload.checkHash(parts[1], adler32h) + default: + err = errtypes.BadRequest("unsupported checksum algorithm: " + parts[0]) + } + if err != nil { + return err + } + } + + // defer writing the checksums until the node is in place + + // if target exists create new version + if fi, err = os.Stat(targetPath); err == nil { + // versions are stored alongside the actual file, so a rename can be efficient and does not cross storage / partition boundaries + versionsPath := upload.fs.lu.InternalPath(n.ID + ".REV." + fi.ModTime().UTC().Format(time.RFC3339Nano)) + + if err = os.Rename(targetPath, versionsPath); err != nil { + sublog.Err(err). + Str("binPath", upload.binPath). + Str("versionsPath", versionsPath). + Msg("s3ngfs: could not create version") + return + } + } + + // upload the data to the blobstore + file, err := os.Open(upload.binPath) + if err != nil { + return err + } + defer file.Close() + err = upload.fs.tp.WriteBlob(n.ID, file) + if err != nil { + return errors.Wrap(err, "failed to upload file to blostore") + } + + // now truncate the upload (the payload stays in the blobstore) and move it to the target path + // TODO put uploads on the same underlying storage as the destination dir? + // TODO trigger a workflow as the final rename might eg involve antivirus scanning + if err = os.Truncate(upload.binPath, 0); err != nil { + sublog.Err(err). + Msg("s3ngfs: could not truncate") + return + } + if err = os.Rename(upload.binPath, targetPath); err != nil { + sublog.Err(err). + Msg("s3ngfs: could not rename") + return + } + + // now try write all checksums + tryWritingChecksum(&sublog, n, "sha1", sha1h) + tryWritingChecksum(&sublog, n, "md5", md5h) + tryWritingChecksum(&sublog, n, "adler32", adler32h) + + // who will become the owner? the owner of the parent actually ... not the currently logged in user + err = n.WriteMetadata(&userpb.UserId{ + Idp: upload.info.Storage["OwnerIdp"], + OpaqueId: upload.info.Storage["OwnerId"], + }) + if err != nil { + return errors.Wrap(err, "s3ngfs: could not write metadata") + } + + // link child name to parent if it is new + childNameLink := filepath.Join(upload.fs.lu.InternalPath(n.ParentID), n.Name) + var link string + link, err = os.Readlink(childNameLink) + if err == nil && link != "../"+n.ID { + sublog.Err(err). + Interface("node", n). + Str("childNameLink", childNameLink). + Str("link", link). + Msg("s3ngfs: child name link has wrong target id, repairing") + + if err = os.Remove(childNameLink); err != nil { + return errors.Wrap(err, "s3ngfs: could not remove symlink child entry") + } + } + if os.IsNotExist(err) || link != "../"+n.ID { + if err = os.Symlink("../"+n.ID, childNameLink); err != nil { + return errors.Wrap(err, "s3ngfs: could not symlink child entry") + } + } + + // only delete the upload if it was successfully written to the storage + if err = os.Remove(upload.infoPath); err != nil { + if !os.IsNotExist(err) { + sublog.Err(err).Msg("s3ngfs: could not delete upload info") + return + } + } + // use set arbitrary metadata? + /*if upload.info.MetaData["mtime"] != "" { + err := upload.fs.SetMtime(ctx, np, upload.info.MetaData["mtime"]) + if err != nil { + log.Err(err).Interface("info", upload.info).Msg("s3ngfs: could not set mtime metadata") + return err + } + }*/ + + n.Exists = true + + return upload.fs.tp.Propagate(upload.ctx, n) +} + +func (upload *fileUpload) checkHash(expected string, h hash.Hash) error { + if expected != hex.EncodeToString(h.Sum(nil)) { + upload.discardChunk() + return errtypes.ChecksumMismatch(fmt.Sprintf("invalid checksum: expected %s got %x", upload.info.MetaData["checksum"], h.Sum(nil))) + } + return nil +} +func tryWritingChecksum(log *zerolog.Logger, n *node.Node, algo string, h hash.Hash) { + if err := n.SetChecksum(algo, h); err != nil { + log.Err(err). + Str("csType", algo). + Bytes("hash", h.Sum(nil)). + Msg("s3ngfs: could not write checksum") + // this is not critical, the bytes are there so we will continue + } +} + +func (upload *fileUpload) discardChunk() { + if err := os.Remove(upload.binPath); err != nil { + if !os.IsNotExist(err) { + appctx.GetLogger(upload.ctx).Err(err).Interface("info", upload.info).Str("binPath", upload.binPath).Interface("info", upload.info).Msg("s3ngfs: could not discard chunk") + return + } + } +} + +// To implement the termination extension as specified in https://tus.io/protocols/resumable-upload.html#termination +// - the storage needs to implement AsTerminatableUpload +// - the upload needs to implement Terminate + +// AsTerminatableUpload returns a TerminatableUpload +func (fs *s3ngfs) AsTerminatableUpload(upload tusd.Upload) tusd.TerminatableUpload { + return upload.(*fileUpload) +} + +// Terminate terminates the upload +func (upload *fileUpload) Terminate(ctx context.Context) error { + if err := os.Remove(upload.infoPath); err != nil { + if !os.IsNotExist(err) { + return err + } + } + if err := os.Remove(upload.binPath); err != nil { + if !os.IsNotExist(err) { + return err + } + } + return nil +} + +// To implement the creation-defer-length extension as specified in https://tus.io/protocols/resumable-upload.html#creation +// - the storage needs to implement AsLengthDeclarableUpload +// - the upload needs to implement DeclareLength + +// AsLengthDeclarableUpload returns a LengthDeclarableUpload +func (fs *s3ngfs) AsLengthDeclarableUpload(upload tusd.Upload) tusd.LengthDeclarableUpload { + return upload.(*fileUpload) +} + +// DeclareLength updates the upload length information +func (upload *fileUpload) DeclareLength(ctx context.Context, length int64) error { + upload.info.Size = length + upload.info.SizeIsDeferred = false + return upload.writeInfo() +} + +// To implement the concatenation extension as specified in https://tus.io/protocols/resumable-upload.html#concatenation +// - the storage needs to implement AsConcatableUpload +// - the upload needs to implement ConcatUploads + +// AsConcatableUpload returns a ConcatableUpload +func (fs *s3ngfs) AsConcatableUpload(upload tusd.Upload) tusd.ConcatableUpload { + return upload.(*fileUpload) +} + +// ConcatUploads concatenates multiple uploads +func (upload *fileUpload) ConcatUploads(ctx context.Context, uploads []tusd.Upload) (err error) { + file, err := os.OpenFile(upload.binPath, os.O_WRONLY|os.O_APPEND, defaultFilePerm) + if err != nil { + return err + } + defer file.Close() + + for _, partialUpload := range uploads { + fileUpload := partialUpload.(*fileUpload) + + src, err := os.Open(fileUpload.binPath) + if err != nil { + return err + } + defer src.Close() + + if _, err := io.Copy(file, src); err != nil { + return err + } + } + + return +} diff --git a/pkg/storage/fs/s3ng/upload_test.go b/pkg/storage/fs/s3ng/upload_test.go new file mode 100644 index 0000000000..24109aa667 --- /dev/null +++ b/pkg/storage/fs/s3ng/upload_test.go @@ -0,0 +1,154 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package s3ng_test + +import ( + "bytes" + "context" + "io" + "io/ioutil" + "os" + "strings" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/stretchr/testify/mock" + + "github.com/cs3org/reva/pkg/storage" + "github.com/cs3org/reva/pkg/storage/fs/s3ng" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/mocks" + "github.com/cs3org/reva/pkg/storage/fs/s3ng/tree" + treemocks "github.com/cs3org/reva/pkg/storage/fs/s3ng/tree/mocks" + ruser "github.com/cs3org/reva/pkg/user" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("File uploads", func() { + var ( + ref *provider.Reference + fs storage.FS + user *userpb.User + ctx context.Context + + options map[string]interface{} + lookup *s3ng.Lookup + permissions *mocks.PermissionsChecker + bs *treemocks.Blobstore + ) + + BeforeEach(func() { + ref = &provider.Reference{ + Spec: &provider.Reference_Path{ + Path: "/foo", + }, + } + user = &userpb.User{ + Id: &userpb.UserId{ + Idp: "idp", + OpaqueId: "userid", + }, + Username: "username", + } + ctx = ruser.ContextSetUser(context.Background(), user) + + tmpRoot, err := ioutil.TempDir("", "reva-unit-tests-*-root") + Expect(err).ToNot(HaveOccurred()) + + options = map[string]interface{}{ + "root": tmpRoot, + "s3.endpoint": "http://1.2.3.4:5000", + "s3.region": "default", + "s3.bucket": "the-bucket", + "s3.access_key": "foo", + "s3.secret_key": "bar", + } + lookup = &s3ng.Lookup{} + permissions = &mocks.PermissionsChecker{} + bs = &treemocks.Blobstore{} + }) + + AfterEach(func() { + root := options["root"].(string) + if strings.HasPrefix(root, os.TempDir()) { + os.RemoveAll(root) + } + }) + + JustBeforeEach(func() { + var err error + tree := tree.New(options["root"].(string), true, true, lookup, bs) + fs, err = s3ng.New(options, lookup, permissions, tree) + Expect(err).ToNot(HaveOccurred()) + }) + + Context("with insufficient permissions", func() { + BeforeEach(func() { + permissions.On("HasPermission", mock.Anything, mock.Anything, mock.Anything).Return(false, nil) + }) + + Describe("InitiateUpload", func() { + It("fails", func() { + _, err := fs.InitiateUpload(ctx, ref, 10, map[string]string{}) + Expect(err).To(MatchError("error: permission denied: root/foo")) + }) + }) + }) + + Context("with sufficient permissions", func() { + BeforeEach(func() { + permissions.On("HasPermission", mock.Anything, mock.Anything, mock.Anything).Return(true, nil) + }) + + Describe("InitiateUpload", func() { + It("returns uploadIds for simple and tus uploads", func() { + uploadIds, err := fs.InitiateUpload(ctx, ref, 10, map[string]string{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(len(uploadIds)).To(Equal(2)) + Expect(uploadIds["simple"]).ToNot(BeEmpty()) + Expect(uploadIds["tus"]).ToNot(BeEmpty()) + }) + }) + + Describe("Upload", func() { + var ( + fileContent = []byte("0123456789") + ) + + It("stores the blob in the blobstore", func() { + bs.On("Upload", mock.AnythingOfType("string"), mock.AnythingOfType("*os.File")). + Return(nil). + Run(func(args mock.Arguments) { + reader := args.Get(1).(io.Reader) + data, err := ioutil.ReadAll(reader) + + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]byte("0123456789"))) + }) + + err := fs.Upload(ctx, ref, ioutil.NopCloser(bytes.NewReader(fileContent))) + Expect(err).ToNot(HaveOccurred()) + + bs.AssertCalled(GinkgoT(), "Upload", mock.Anything, mock.Anything) + }) + }) + }) +}) diff --git a/pkg/storage/fs/s3ng/xattrs/xattrs.go b/pkg/storage/fs/s3ng/xattrs/xattrs.go new file mode 100644 index 0000000000..b8e3f38639 --- /dev/null +++ b/pkg/storage/fs/s3ng/xattrs/xattrs.go @@ -0,0 +1,68 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package xattrs + +// Declare a list of xattr keys +// TODO the below comment is currently copied from the owncloud driver, revisit +// Currently,extended file attributes have four separated +// namespaces (user, trusted, security and system) followed by a dot. +// A non root user can only manipulate the user. namespace, which is what +// we will use to store ownCloud specific metadata. To prevent name +// collisions with other apps We are going to introduce a sub namespace +// "user.ocis." +const ( + OcisPrefix string = "user.ocis." + ParentidAttr string = OcisPrefix + "parentid" + OwnerIDAttr string = OcisPrefix + "owner.id" + OwnerIDPAttr string = OcisPrefix + "owner.idp" + // the base name of the node + // updated when the file is renamed or moved + NameAttr string = OcisPrefix + "name" + BlobsizeAttr string = OcisPrefix + "blobsize" + + // grantPrefix is the prefix for sharing related extended attributes + GrantPrefix string = OcisPrefix + "grant." + MetadataPrefix string = OcisPrefix + "md." + + // favorite flag, per user + FavPrefix string = OcisPrefix + "fav." + + // a temporary etag for a folder that is removed when the mtime propagation happens + TmpEtagAttr string = OcisPrefix + "tmp.etag" + ReferenceAttr string = OcisPrefix + "cs3.ref" // arbitrary metadata + ChecksumPrefix string = OcisPrefix + "cs." // followed by the algorithm, eg. ocis.cs.sha1 + TrashOriginAttr string = OcisPrefix + "trash.origin" // trash origin + + // we use a single attribute to enable or disable propagation of both: synctime and treesize + PropagationAttr string = OcisPrefix + "propagation" + + // the tree modification time of the tree below this node, + // propagated when synctime_accounting is true and + // user.ocis.propagation=1 is set + // stored as a readable time.RFC3339Nano + TreeMTimeAttr string = OcisPrefix + "tmtime" + + // the size of the tree below this node, + // propagated when treesize_accounting is true and + // user.ocis.propagation=1 is set + //treesizeAttr string = OcisPrefix + "treesize" + + UserAcePrefix string = "u:" + GroupAcePrefix string = "g:" +)