From 6262c39ef4cd2df4456024e28fb8959f34770bac Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Wed, 2 Sep 2026 16:28:47 +0200 Subject: [PATCH 1/4] feat: capabilities ^ Conflicts: ^ pkg/upload/coordinator.go --- .../feat-per-provider-capabilities.md | 11 +++ .../storageprovider/storageprovider.go | 2 + .../cloud/capabilities/capabilities.go | 7 ++ .../handlers/cloud/capabilities/providers.go | 53 +++++++++++++ pkg/owncloud/ocs/capabilities.go | 55 +++++++++++-- pkg/owncloud/ocs/capabilities_test.go | 78 +++++++++++++++++++ pkg/storage/fs/kiteworks/kiteworks.go | 7 ++ pkg/storage/fs/kiteworks/kiteworks_test.go | 8 ++ pkg/storage/storage.go | 40 ++++++++++ .../utils/decomposedfs/decomposedfs.go | 6 ++ pkg/upload/coordinator.go | 10 +++ pkg/upload/coordinator_test.go | 20 +++++ 12 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 changelog/unreleased/feat-per-provider-capabilities.md create mode 100644 internal/http/services/owncloud/ocs/handlers/cloud/capabilities/providers.go create mode 100644 pkg/owncloud/ocs/capabilities_test.go diff --git a/changelog/unreleased/feat-per-provider-capabilities.md b/changelog/unreleased/feat-per-provider-capabilities.md new file mode 100644 index 00000000000..58a8315cad0 --- /dev/null +++ b/changelog/unreleased/feat-per-provider-capabilities.md @@ -0,0 +1,11 @@ +Enhancement: Declare storage capabilities per provider + +Added a `providers` section to the `/ocs/v1.php/cloud/capabilities` response, +keyed by provider id, reporting the write actions each storage provider +supports. Drivers declare their capabilities through the optional +`storage.CapabilityProvider` interface and default to the full set when they do +not implement it. The global storage-capability keys (`files.undelete`, +`files.versioning`, `files.favorites`, `files.tags` and `dav.trashbin`) are +deprecated in favor of the per-provider section. + +https://github.com/owncloud/reva/pull/722 diff --git a/internal/grpc/services/storageprovider/storageprovider.go b/internal/grpc/services/storageprovider/storageprovider.go index b23604a275f..f95bcdde287 100644 --- a/internal/grpc/services/storageprovider/storageprovider.go +++ b/internal/grpc/services/storageprovider/storageprovider.go @@ -597,6 +597,7 @@ func (s *Service) ListStorageSpaces(ctx context.Context, req *provider.ListStora }, nil } + caps := s.Coordinator.Capabilities(ctx) for _, sp := range spaces { if sp.Id == nil || sp.Id.OpaqueId == "" { log.Error().Str("service", "storageprovider").Str("driver", s.conf.Driver).Interface("space", sp).Msg("space is missing space id and root id") @@ -604,6 +605,7 @@ func (s *Service) ListStorageSpaces(ctx context.Context, req *provider.ListStora } s.addMissingStorageProviderID(sp.GetRoot(), sp.GetId()) + sp.Opaque = utils.AppendJSONToOpaque(sp.Opaque, storage.CapabilitiesOpaqueKey, caps) } return &provider.ListStorageSpacesResponse{ diff --git a/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go index f9449aafd26..d2b4f786e66 100644 --- a/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go +++ b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go @@ -32,6 +32,7 @@ type Handler struct { c ocs.CapabilitiesData defaultUploadProtocol string userAgentChunkingMap map[string]string + gatewayAddr string } // Init initializes this and any contained handlers @@ -39,6 +40,7 @@ func (h *Handler) Init(c *config.Config) { h.c = c.Capabilities h.defaultUploadProtocol = c.DefaultUploadProtocol h.userAgentChunkingMap = c.UserAgentChunkingMap + h.gatewayAddr = c.GatewaySvc // capabilities if h.c.Capabilities == nil { @@ -237,6 +239,11 @@ func (h *Handler) GetCapabilities(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("vault") == "true" && c.Capabilities != nil && c.Capabilities.Vault != nil && bool(c.Capabilities.Vault.Enabled) { c = h.vaultCapabilities(c) } + if providers := h.resolveProviders(r.Context()); len(providers) > 0 && c.Capabilities != nil { + caps := *c.Capabilities + caps.Providers = providers + c.Capabilities = &caps + } response.WriteOCSSuccess(w, r, c) } diff --git a/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/providers.go b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/providers.go new file mode 100644 index 00000000000..0b656608e22 --- /dev/null +++ b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/providers.go @@ -0,0 +1,53 @@ +package capabilities + +import ( + "context" + + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + + "github.com/owncloud/reva/v2/pkg/appctx" + "github.com/owncloud/reva/v2/pkg/owncloud/ocs" + "github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool" + "github.com/owncloud/reva/v2/pkg/storage" + "github.com/owncloud/reva/v2/pkg/utils" +) + +// resolveProviders builds the per-provider capability map from the spaces visible +// to the current user, keyed by provider ID. Returns nil on no user context or an +// unreachable gateway, so the response omits the section rather than failing. +func (h *Handler) resolveProviders(ctx context.Context) map[string]*ocs.ProviderCapabilities { + log := appctx.GetLogger(ctx) + + gc, err := pool.GetGatewayServiceClient(h.gatewayAddr) + if err != nil { + log.Error().Err(err).Msg("capabilities: error getting gateway client") + return nil + } + + res, err := gc.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{}) + if err != nil { + log.Error().Err(err).Msg("capabilities: error listing storage spaces") + return nil + } + if res.GetStatus().GetCode() != rpc.Code_CODE_OK { + return nil + } + + providers := map[string]*ocs.ProviderCapabilities{} + for _, sp := range res.GetStorageSpaces() { + id := sp.GetRoot().GetStorageId() + if id == "" { + continue + } + if _, done := providers[id]; done { + continue + } + var caps storage.Capabilities + if err := utils.ReadJSONFromOpaque(sp.GetOpaque(), storage.CapabilitiesOpaqueKey, &caps); err != nil { + continue + } + providers[id] = ocs.NewProviderCapabilities(caps) + } + return providers +} diff --git a/pkg/owncloud/ocs/capabilities.go b/pkg/owncloud/ocs/capabilities.go index bac1d732c22..79403cecafc 100644 --- a/pkg/owncloud/ocs/capabilities.go +++ b/pkg/owncloud/ocs/capabilities.go @@ -20,6 +20,8 @@ package ocs import ( "encoding/xml" + + "github.com/owncloud/reva/v2/pkg/storage" ) // ocsBool implements the xml/json Marshaler interface. The OCS API inconsistency require us to parse boolean values @@ -63,6 +65,38 @@ type Capabilities struct { Notifications *CapabilitiesNotifications `json:"notifications,omitempty" xml:"notifications,omitempty"` Auth *CapabilitiesAuth `json:"auth,omitempty" xml:"auth,omitempty"` Vault *CapabilitiesVault `json:"vault,omitempty" xml:"vault,omitempty" mapstructure:"vault"` + // Providers is the per-provider capability section, keyed by provider ID. It + // supersedes the deprecated global storage keys under Files/Dav; there is no + // fallback between them. + Providers map[string]*ProviderCapabilities `json:"providers,omitempty" xml:"providers,omitempty" mapstructure:"providers"` +} + +// ProviderCapabilities is the wire form of a provider's declared capabilities. +type ProviderCapabilities struct { + Upload ocsBool `json:"upload" xml:"upload"` + CreateContainer ocsBool `json:"create_container" xml:"create_container"` + Delete ocsBool `json:"delete" xml:"delete"` + Move ocsBool `json:"move" xml:"move"` + Versioning ocsBool `json:"versioning" xml:"versioning"` + Trash ocsBool `json:"trash" xml:"trash"` + Locking ocsBool `json:"locking" xml:"locking"` + Sharing ocsBool `json:"sharing" xml:"sharing"` + ArbitraryMetadata ocsBool `json:"arbitrary_metadata" xml:"arbitrary_metadata"` +} + +// NewProviderCapabilities maps a driver's declaration onto the OCS response type. +func NewProviderCapabilities(c storage.Capabilities) *ProviderCapabilities { + return &ProviderCapabilities{ + Upload: ocsBool(c.Upload), + CreateContainer: ocsBool(c.CreateContainer), + Delete: ocsBool(c.Delete), + Move: ocsBool(c.Move), + Versioning: ocsBool(c.Versioning), + Trash: ocsBool(c.Trash), + Locking: ocsBool(c.Locking), + Sharing: ocsBool(c.Sharing), + ArbitraryMetadata: ocsBool(c.ArbitraryMetadata), + } } // CapabilitiesSearch holds the search capabilities @@ -207,13 +241,17 @@ type CapabilitiesAppProvider struct { // CapabilitiesFiles TODO this is storage specific, not global. What effect do these options have on the clients? type CapabilitiesFiles struct { - PrivateLinks ocsBool `json:"privateLinks" xml:"privateLinks" mapstructure:"private_links"` - BigFileChunking ocsBool `json:"bigfilechunking" xml:"bigfilechunking"` - Undelete ocsBool `json:"undelete" xml:"undelete"` - Versioning ocsBool `json:"versioning" xml:"versioning"` - Favorites ocsBool `json:"favorites" xml:"favorites"` - FullTextSearch ocsBool `json:"full_text_search" xml:"full_text_search" mapstructure:"full_text_search"` - Tags ocsBool `json:"tags" xml:"tags"` + PrivateLinks ocsBool `json:"privateLinks" xml:"privateLinks" mapstructure:"private_links"` + BigFileChunking ocsBool `json:"bigfilechunking" xml:"bigfilechunking"` + // Deprecated: use capabilities.providers[].trash. + Undelete ocsBool `json:"undelete" xml:"undelete"` + // Deprecated: use capabilities.providers[].versioning. + Versioning ocsBool `json:"versioning" xml:"versioning"` + // Deprecated: use capabilities.providers[].arbitrary_metadata. + Favorites ocsBool `json:"favorites" xml:"favorites"` + FullTextSearch ocsBool `json:"full_text_search" xml:"full_text_search" mapstructure:"full_text_search"` + // Deprecated: use capabilities.providers[].arbitrary_metadata. + Tags ocsBool `json:"tags" xml:"tags"` BlacklistedFiles []string `json:"blacklisted_files" xml:"blacklisted_files>element" mapstructure:"blacklisted_files"` TusSupport *CapabilitiesFilesTusSupport `json:"tus_support" xml:"tus_support" mapstructure:"tus_support"` Archivers []*CapabilitiesArchiver `json:"archivers" xml:"archivers" mapstructure:"archivers"` @@ -222,7 +260,8 @@ type CapabilitiesFiles struct { // CapabilitiesDav holds dav endpoint config type CapabilitiesDav struct { - Chunking string `json:"chunking" xml:"chunking"` + Chunking string `json:"chunking" xml:"chunking"` + // Deprecated: use capabilities.providers[].trash. Trashbin string `json:"trashbin" xml:"trashbin"` Reports []string `json:"reports" xml:"reports>element" mapstructure:"reports"` ChunkingParallelUploadDisabled bool `json:"chunkingParallelUploadDisabled" xml:"chunkingParallelUploadDisabled"` diff --git a/pkg/owncloud/ocs/capabilities_test.go b/pkg/owncloud/ocs/capabilities_test.go new file mode 100644 index 00000000000..ca7bd3d4a9e --- /dev/null +++ b/pkg/owncloud/ocs/capabilities_test.go @@ -0,0 +1,78 @@ +package ocs + +import ( + "encoding/json" + "encoding/xml" + "testing" + + "github.com/owncloud/reva/v2/pkg/storage" +) + +func TestNewProviderCapabilitiesReadOnly(t *testing.T) { + pc := NewProviderCapabilities(storage.Capabilities{}) + + b, err := json.Marshal(pc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]bool + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for k, v := range got { + if v { + t.Errorf("absent capability %q marshalled to true, want false", k) + } + } + if _, ok := got["trash"]; !ok { + t.Error("expected a trash key in the per-provider section") + } +} + +func TestNewProviderCapabilitiesFullSet(t *testing.T) { + pc := NewProviderCapabilities(storage.FullCapabilities()) + + b, err := json.Marshal(pc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]bool + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for k, v := range got { + if !v { + t.Errorf("capability %q marshalled to false, want true", k) + } + } +} + +// TestProviderCapabilitiesXMLParity guards the ocsBool 1/0 XML rendering the OCS +// API requires, so the per-provider section stays consistent across formats. +func TestProviderCapabilitiesXMLParity(t *testing.T) { + pc := NewProviderCapabilities(storage.Capabilities{Upload: true}) + + b, err := xml.Marshal(pc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + out := string(b) + if want := "1"; !contains(out, want) { + t.Errorf("xml %q missing %q", out, want) + } + if want := "0"; !contains(out, want) { + t.Errorf("xml %q missing %q", out, want) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/pkg/storage/fs/kiteworks/kiteworks.go b/pkg/storage/fs/kiteworks/kiteworks.go index 6f7f00edc93..1c844e26a16 100644 --- a/pkg/storage/fs/kiteworks/kiteworks.go +++ b/pkg/storage/fs/kiteworks/kiteworks.go @@ -94,6 +94,13 @@ func (d *Driver) toResourceInfo(fi *kwlib.FileInfo, spaceID string) *provider.Re return ri } +// Capabilities declares kiteworks read-only: every write method rejects with +// NotSupported. The zero value is the declaration, so any capability added later +// stays false here without an edit. +func (d *Driver) Capabilities(_ context.Context) storage.Capabilities { + return storage.Capabilities{} +} + // --- Read methods --- func (d *Driver) Shutdown(_ context.Context) error { return nil } diff --git a/pkg/storage/fs/kiteworks/kiteworks_test.go b/pkg/storage/fs/kiteworks/kiteworks_test.go index a451ac31ce0..61902b6e70c 100644 --- a/pkg/storage/fs/kiteworks/kiteworks_test.go +++ b/pkg/storage/fs/kiteworks/kiteworks_test.go @@ -239,4 +239,12 @@ var _ = Describe("kiteworks driver", func() { Expect(err).To(Satisfy(notSupported)) }) }) + + Context("capabilities", func() { + It("declares an all-false (read-only) set", func() { + cp, ok := d.(storage.CapabilityProvider) + Expect(ok).To(BeTrue()) + Expect(cp.Capabilities(fix.ctx)).To(Equal(storage.Capabilities{})) + }) + }) }) diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 0716672f79f..ca405033df0 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -277,6 +277,46 @@ type ComposableFS interface { UseIn(composer *tusd.StoreComposer) } +// CapabilitiesOpaqueKey is the StorageSpace/ProviderInfo Opaque key under which +// a provider's Capabilities are carried as JSON up to the OCS capabilities handler. +const CapabilitiesOpaqueKey = "storage-capabilities" + +// Capabilities is a storage driver's self-declaration of the write-shaped +// actions it supports. A false key means unsupported: no merge, no inheritance. +type Capabilities struct { + Upload bool + CreateContainer bool + Delete bool + Move bool + Versioning bool + Trash bool + Locking bool + Sharing bool + ArbitraryMetadata bool +} + +// FullCapabilities is the default for drivers that do not implement +// CapabilityProvider, so existing drivers keep their current behavior. +func FullCapabilities() Capabilities { + return Capabilities{ + Upload: true, + CreateContainer: true, + Delete: true, + Move: true, + Versioning: true, + Trash: true, + Locking: true, + Sharing: true, + ArbitraryMetadata: true, + } +} + +// CapabilityProvider is an optional interface a storage.FS may implement to +// declare its capabilities. +type CapabilityProvider interface { + Capabilities(ctx context.Context) Capabilities +} + // Registry is the interface that storage registries implement // for discovering storage providers type Registry interface { diff --git a/pkg/storage/utils/decomposedfs/decomposedfs.go b/pkg/storage/utils/decomposedfs/decomposedfs.go index eab994b780c..301067d245a 100644 --- a/pkg/storage/utils/decomposedfs/decomposedfs.go +++ b/pkg/storage/utils/decomposedfs/decomposedfs.go @@ -625,6 +625,12 @@ func (fs *Decomposedfs) Shutdown(ctx context.Context) error { return nil } +// Capabilities declares decomposedfs fully writeable, stated explicitly so it is +// a decision here and not an inherited default. +func (fs *Decomposedfs) Capabilities(_ context.Context) storage.Capabilities { + return storage.FullCapabilities() +} + // GetQuota returns the quota available // TODO Document in the cs3 should we return quota or free space? func (fs *Decomposedfs) GetQuota(ctx context.Context, ref *provider.Reference) (total uint64, inUse uint64, remaining uint64, err error) { diff --git a/pkg/upload/coordinator.go b/pkg/upload/coordinator.go index 9af3bab176c..bc0f144868b 100644 --- a/pkg/upload/coordinator.go +++ b/pkg/upload/coordinator.go @@ -38,6 +38,9 @@ type Coordinator interface { // StartPostprocessing subscribes to postprocessing results and enables async // uploads. Call once, before serving requests. StartPostprocessing(stream events.Consumer, group, mountID string, numConsumers int) error + // Capabilities returns the served provider's declaration, defaulting to the + // full set when the driver does not implement storage.CapabilityProvider. + Capabilities(ctx context.Context) storage.Capabilities } // coordinator is the concrete implementation of Coordinator. @@ -62,6 +65,13 @@ func NewCoordinator(fs storage.FS, store SessionStore, chunkFolder string, pub e return c } +func (c *coordinator) Capabilities(ctx context.Context) storage.Capabilities { + if cp, ok := c.fs.(storage.CapabilityProvider); ok { + return cp.Capabilities(ctx) + } + return storage.FullCapabilities() +} + // InitiateUpload resolves the target, then creates and persists the session that // bytes are appended to. func (c *coordinator) InitiateUpload(ctx context.Context, ref *provider.Reference, uploadLength int64, metadata map[string]string) (map[string]string, error) { diff --git a/pkg/upload/coordinator_test.go b/pkg/upload/coordinator_test.go index a8161f6f497..caec87883f8 100644 --- a/pkg/upload/coordinator_test.go +++ b/pkg/upload/coordinator_test.go @@ -350,4 +350,24 @@ var _ = Describe("coordinator", func() { Expect(fs.calls).To(BeEmpty()) }) }) + + Describe("Capabilities", func() { + It("defaults to the full set when the driver is not a CapabilityProvider", func() { + Expect(c.Capabilities(ctx)).To(Equal(storage.FullCapabilities())) + }) + + It("delegates to a driver that declares its own capabilities", func() { + want := storage.Capabilities{Upload: true, Trash: true} + c = NewCoordinator(&capableFS{fakeFS: fs, caps: want}, store, "", nil) + Expect(c.Capabilities(ctx)).To(Equal(want)) + }) + }) }) + +// capableFS is a fakeFS that also declares capabilities. +type capableFS struct { + *fakeFS + caps storage.Capabilities +} + +func (f *capableFS) Capabilities(context.Context) storage.Capabilities { return f.caps } From 31e9430279314015cd67faf3ac56ad9e1825d96e Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Mon, 7 Sep 2026 17:14:28 +0200 Subject: [PATCH 2/4] feat: capabilities ^ Conflicts: ^ pkg/upload/coordinator.go --- .../storageprovider/storageprovider.go | 5 ++- pkg/owncloud/ocs/capabilities.go | 33 +++++++++++++- pkg/owncloud/ocs/capabilities_test.go | 43 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/internal/grpc/services/storageprovider/storageprovider.go b/internal/grpc/services/storageprovider/storageprovider.go index f95bcdde287..ce0196fe0d5 100644 --- a/internal/grpc/services/storageprovider/storageprovider.go +++ b/internal/grpc/services/storageprovider/storageprovider.go @@ -597,7 +597,10 @@ func (s *Service) ListStorageSpaces(ctx context.Context, req *provider.ListStora }, nil } - caps := s.Coordinator.Capabilities(ctx) + caps := storage.FullCapabilities() + if cp, ok := s.Storage.(storage.CapabilityProvider); ok { + caps = cp.Capabilities(ctx) + } for _, sp := range spaces { if sp.Id == nil || sp.Id.OpaqueId == "" { log.Error().Str("service", "storageprovider").Str("driver", s.conf.Driver).Interface("space", sp).Msg("space is missing space id and root id") diff --git a/pkg/owncloud/ocs/capabilities.go b/pkg/owncloud/ocs/capabilities.go index 79403cecafc..559410615bd 100644 --- a/pkg/owncloud/ocs/capabilities.go +++ b/pkg/owncloud/ocs/capabilities.go @@ -20,6 +20,7 @@ package ocs import ( "encoding/xml" + "sort" "github.com/owncloud/reva/v2/pkg/storage" ) @@ -68,7 +69,37 @@ type Capabilities struct { // Providers is the per-provider capability section, keyed by provider ID. It // supersedes the deprecated global storage keys under Files/Dav; there is no // fallback between them. - Providers map[string]*ProviderCapabilities `json:"providers,omitempty" xml:"providers,omitempty" mapstructure:"providers"` + Providers ProviderCapabilitiesMap `json:"providers,omitempty" xml:"providers,omitempty" mapstructure:"providers"` +} + +// ProviderCapabilitiesMap is the per-provider section keyed by provider ID. +type ProviderCapabilitiesMap map[string]*ProviderCapabilities + +// MarshalXML renders the map as ......, +// ordered by id. encoding/xml cannot marshal a Go map, so XML output is produced +// explicitly; JSON uses the default map encoding. +func (m ProviderCapabilitiesMap) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if len(m) == 0 { + return nil + } + if err := e.EncodeToken(start); err != nil { + return err + } + ids := make([]string, 0, len(m)) + for id := range m { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + el := xml.StartElement{ + Name: xml.Name{Local: "provider"}, + Attr: []xml.Attr{{Name: xml.Name{Local: "id"}, Value: id}}, + } + if err := e.EncodeElement(m[id], el); err != nil { + return err + } + } + return e.EncodeToken(start.End()) } // ProviderCapabilities is the wire form of a provider's declared capabilities. diff --git a/pkg/owncloud/ocs/capabilities_test.go b/pkg/owncloud/ocs/capabilities_test.go index ca7bd3d4a9e..7d93b1e37d7 100644 --- a/pkg/owncloud/ocs/capabilities_test.go +++ b/pkg/owncloud/ocs/capabilities_test.go @@ -68,6 +68,49 @@ func TestProviderCapabilitiesXMLParity(t *testing.T) { } } +// TestCapabilitiesProvidersXML guards the whole Capabilities envelope: XML is the +// default OCS format and encoding/xml cannot marshal a Go map, so a populated +// providers section must still marshal via the custom ProviderCapabilitiesMap. +func TestCapabilitiesProvidersXML(t *testing.T) { + c := Capabilities{ + Providers: ProviderCapabilitiesMap{ + "kiteworks": NewProviderCapabilities(storage.Capabilities{}), + "decomposedfs": NewProviderCapabilities(storage.FullCapabilities()), + }, + } + + b, err := xml.Marshal(c) + if err != nil { + t.Fatalf("marshal populated capabilities: %v", err) + } + + out := string(b) + // ordered by id, so decomposedfs comes before kiteworks + for _, want := range []string{ + ``, + ``, + ``, + `1`, + `0`, + } { + if !contains(out, want) { + t.Errorf("xml %q missing %q", out, want) + } + } +} + +// TestCapabilitiesProvidersXMLEmpty verifies omitempty still drops the section +// when no provider resolved, leaving the rest of the envelope untouched. +func TestCapabilitiesProvidersXMLEmpty(t *testing.T) { + b, err := xml.Marshal(Capabilities{}) + if err != nil { + t.Fatalf("marshal empty capabilities: %v", err) + } + if contains(string(b), "") { + t.Errorf("empty providers should be omitted, got %q", string(b)) + } +} + func contains(s, sub string) bool { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { From 494c354af14fb8cfad0ee783a3bd1a2c191d4ea7 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Wed, 9 Sep 2026 13:06:59 +0200 Subject: [PATCH 3/4] feat: capabilities ^ Conflicts: ^ pkg/upload/coordinator.go --- pkg/owncloud/ocs/capabilities.go | 10 +++------- pkg/owncloud/ocs/capabilities_test.go | 18 +++++------------- pkg/upload/coordinator.go | 10 ---------- pkg/upload/coordinator_test.go | 20 -------------------- 4 files changed, 8 insertions(+), 50 deletions(-) diff --git a/pkg/owncloud/ocs/capabilities.go b/pkg/owncloud/ocs/capabilities.go index 559410615bd..f0e92cb93b7 100644 --- a/pkg/owncloud/ocs/capabilities.go +++ b/pkg/owncloud/ocs/capabilities.go @@ -20,7 +20,8 @@ package ocs import ( "encoding/xml" - "sort" + "maps" + "slices" "github.com/owncloud/reva/v2/pkg/storage" ) @@ -85,12 +86,7 @@ func (m ProviderCapabilitiesMap) MarshalXML(e *xml.Encoder, start xml.StartEleme if err := e.EncodeToken(start); err != nil { return err } - ids := make([]string, 0, len(m)) - for id := range m { - ids = append(ids, id) - } - sort.Strings(ids) - for _, id := range ids { + for _, id := range slices.Sorted(maps.Keys(m)) { el := xml.StartElement{ Name: xml.Name{Local: "provider"}, Attr: []xml.Attr{{Name: xml.Name{Local: "id"}, Value: id}}, diff --git a/pkg/owncloud/ocs/capabilities_test.go b/pkg/owncloud/ocs/capabilities_test.go index 7d93b1e37d7..2ec8a56b1a1 100644 --- a/pkg/owncloud/ocs/capabilities_test.go +++ b/pkg/owncloud/ocs/capabilities_test.go @@ -3,6 +3,7 @@ package ocs import ( "encoding/json" "encoding/xml" + "strings" "testing" "github.com/owncloud/reva/v2/pkg/storage" @@ -60,10 +61,10 @@ func TestProviderCapabilitiesXMLParity(t *testing.T) { } out := string(b) - if want := "1"; !contains(out, want) { + if want := "1"; !strings.Contains(out, want) { t.Errorf("xml %q missing %q", out, want) } - if want := "0"; !contains(out, want) { + if want := "0"; !strings.Contains(out, want) { t.Errorf("xml %q missing %q", out, want) } } @@ -93,7 +94,7 @@ func TestCapabilitiesProvidersXML(t *testing.T) { `1`, `0`, } { - if !contains(out, want) { + if !strings.Contains(out, want) { t.Errorf("xml %q missing %q", out, want) } } @@ -106,16 +107,7 @@ func TestCapabilitiesProvidersXMLEmpty(t *testing.T) { if err != nil { t.Fatalf("marshal empty capabilities: %v", err) } - if contains(string(b), "") { + if strings.Contains(string(b), "") { t.Errorf("empty providers should be omitted, got %q", string(b)) } } - -func contains(s, sub string) bool { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} diff --git a/pkg/upload/coordinator.go b/pkg/upload/coordinator.go index bc0f144868b..9af3bab176c 100644 --- a/pkg/upload/coordinator.go +++ b/pkg/upload/coordinator.go @@ -38,9 +38,6 @@ type Coordinator interface { // StartPostprocessing subscribes to postprocessing results and enables async // uploads. Call once, before serving requests. StartPostprocessing(stream events.Consumer, group, mountID string, numConsumers int) error - // Capabilities returns the served provider's declaration, defaulting to the - // full set when the driver does not implement storage.CapabilityProvider. - Capabilities(ctx context.Context) storage.Capabilities } // coordinator is the concrete implementation of Coordinator. @@ -65,13 +62,6 @@ func NewCoordinator(fs storage.FS, store SessionStore, chunkFolder string, pub e return c } -func (c *coordinator) Capabilities(ctx context.Context) storage.Capabilities { - if cp, ok := c.fs.(storage.CapabilityProvider); ok { - return cp.Capabilities(ctx) - } - return storage.FullCapabilities() -} - // InitiateUpload resolves the target, then creates and persists the session that // bytes are appended to. func (c *coordinator) InitiateUpload(ctx context.Context, ref *provider.Reference, uploadLength int64, metadata map[string]string) (map[string]string, error) { diff --git a/pkg/upload/coordinator_test.go b/pkg/upload/coordinator_test.go index caec87883f8..a8161f6f497 100644 --- a/pkg/upload/coordinator_test.go +++ b/pkg/upload/coordinator_test.go @@ -350,24 +350,4 @@ var _ = Describe("coordinator", func() { Expect(fs.calls).To(BeEmpty()) }) }) - - Describe("Capabilities", func() { - It("defaults to the full set when the driver is not a CapabilityProvider", func() { - Expect(c.Capabilities(ctx)).To(Equal(storage.FullCapabilities())) - }) - - It("delegates to a driver that declares its own capabilities", func() { - want := storage.Capabilities{Upload: true, Trash: true} - c = NewCoordinator(&capableFS{fakeFS: fs, caps: want}, store, "", nil) - Expect(c.Capabilities(ctx)).To(Equal(want)) - }) - }) }) - -// capableFS is a fakeFS that also declares capabilities. -type capableFS struct { - *fakeFS - caps storage.Capabilities -} - -func (f *capableFS) Capabilities(context.Context) storage.Capabilities { return f.caps } From 0a6d30b76b4686f704e40adb27d5a402b6005502 Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Wed, 9 Sep 2026 15:09:25 +0200 Subject: [PATCH 4/4] feat: capabilities ^ Conflicts: ^ pkg/upload/coordinator.go --- changelog/unreleased/feat-per-provider-capabilities.md | 4 ++-- pkg/owncloud/ocs/capabilities.go | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/changelog/unreleased/feat-per-provider-capabilities.md b/changelog/unreleased/feat-per-provider-capabilities.md index 58a8315cad0..b44e500a666 100644 --- a/changelog/unreleased/feat-per-provider-capabilities.md +++ b/changelog/unreleased/feat-per-provider-capabilities.md @@ -5,7 +5,7 @@ keyed by provider id, reporting the write actions each storage provider supports. Drivers declare their capabilities through the optional `storage.CapabilityProvider` interface and default to the full set when they do not implement it. The global storage-capability keys (`files.undelete`, -`files.versioning`, `files.favorites`, `files.tags` and `dav.trashbin`) are -deprecated in favor of the per-provider section. +`files.versioning`, `files.favorites` and `dav.trashbin`) are deprecated in +favor of the per-provider section. https://github.com/owncloud/reva/pull/722 diff --git a/pkg/owncloud/ocs/capabilities.go b/pkg/owncloud/ocs/capabilities.go index f0e92cb93b7..46a10700756 100644 --- a/pkg/owncloud/ocs/capabilities.go +++ b/pkg/owncloud/ocs/capabilities.go @@ -277,8 +277,7 @@ type CapabilitiesFiles struct { // Deprecated: use capabilities.providers[].arbitrary_metadata. Favorites ocsBool `json:"favorites" xml:"favorites"` FullTextSearch ocsBool `json:"full_text_search" xml:"full_text_search" mapstructure:"full_text_search"` - // Deprecated: use capabilities.providers[].arbitrary_metadata. - Tags ocsBool `json:"tags" xml:"tags"` + Tags ocsBool `json:"tags" xml:"tags"` BlacklistedFiles []string `json:"blacklisted_files" xml:"blacklisted_files>element" mapstructure:"blacklisted_files"` TusSupport *CapabilitiesFilesTusSupport `json:"tus_support" xml:"tus_support" mapstructure:"tus_support"` Archivers []*CapabilitiesArchiver `json:"archivers" xml:"archivers" mapstructure:"archivers"`