Merge pull request #3674 from aduffeck/guest-id-canonical-ascii

Use the stored guest id when returning an invite permission
This commit is contained in:
Andre Duffeck authored and GitHub committed 2026-10-08 11:55:22 +02:00
commit ba98b1d942
20 files changed
+242 -40

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ require (
github.com/open-policy-agent/opa v1.21.1
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89
github.com/opencloud-eu/libre-graph-api-go v1.0.8
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261006112042-7d9dd4c07424
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261007142032-7148a82e0e45
github.com/opensearch-project/opensearch-go/v4 v4.7.3
github.com/orcaman/concurrent-map v1.0.0
github.com/pkg/errors v0.9.1
+2 -2
View File
@@ -761,8 +761,8 @@ github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89 h1:W1ms+l
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89/go.mod h1:vigJkNss1N2QEceCuNw/ullDehncuJNFB6mEnzfq9UI=
github.com/opencloud-eu/libre-graph-api-go v1.0.8 h1:VTkFLkLNna+T4RN6JUZnVQ1bqVAfPtMeORXbFzEYGKs=
github.com/opencloud-eu/libre-graph-api-go v1.0.8/go.mod h1:lTM8JeGblNpoMySTW7Lui2+c5TTLI95mwxtdUIHHrhU=
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261006112042-7d9dd4c07424 h1:rvopdGQTsI0PT7P3MRamrDsO1yYIKJBOxJAbbYmc4P8=
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261006112042-7d9dd4c07424/go.mod h1:ngLsDakKnt8zCFCeULhuSZFESGy5NIuSq5/JGeOhPdU=
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261007142032-7148a82e0e45 h1:rrZBLsznQDL6YFjtbZF+Z8WCHbGSWn0RsD0ddwaVY8c=
github.com/opencloud-eu/reva/v2 v2.51.1-0.20261007142032-7148a82e0e45/go.mod h1:rAwuI3PX8n3RxBXHfpMtAKqfwxTIKhZol1Bi3Qj39p8=
github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4 h1:l2oB/RctH+t8r7QBj5p8thfEHCM/jF35aAY3WQ3hADI=
github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -182,7 +182,8 @@ func (s DriveItemPermissionsService) Invite(ctx context.Context, resourceId *sto
cTime = createShareResponse.GetShare().GetCtime()
expiration = createShareResponse.GetShare().GetExpiration()
guestIdentity, err := cs3UserIdToIdentity(ctx, s.identityCache, createShareRequest.GetGrant().GetGrantee().GetUserId())
// the share provider stores the guest's mail address in its canonical form
guestIdentity, err := cs3UserIdToIdentity(ctx, s.identityCache, createShareResponse.GetShare().GetGrantee().GetUserId())
if err != nil {
s.logger.Debug().Err(err).Msg("failed to convert guest user id to identity")
return libregraph.Permission{}, err
@@ -176,7 +176,8 @@ var _ = Describe("DriveItemPermissionsService", func() {
{Email: libregraph.PtrString("guest@example.com")},
}
createShareResponse.Share = &collaboration.Share{
Id: &collaboration.ShareId{OpaqueId: "guest123"},
Id: &collaboration.ShareId{OpaqueId: "guest123"},
Grantee: guestGrantee("guest@example.com"),
}
permission, err := driveItemPermissionsService.Invite(ctx, driveItemId, driveItemInvite)
@@ -187,6 +188,23 @@ var _ = Describe("DriveItemPermissionsService", func() {
Expect(permission.GrantedToV2.User.GetLibreGraphUserType()).To(Equal("Guest"))
})
It("returns the guest id as stored by the share provider", func() {
cfg.Commons = &shared.Commons{EnableGuestLinks: true}
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(getUserResponse, nil)
gatewayClient.On("CreateShare", mock.Anything, mock.Anything).Return(createShareResponse, nil)
driveItemInvite.Recipients = []libregraph.DriveRecipient{
{Email: libregraph.PtrString("Guest@\u0130nfocorp.com")},
}
createShareResponse.Share = &collaboration.Share{
Id: &collaboration.ShareId{OpaqueId: "guest123"},
Grantee: guestGrantee("guest@xn--infocorp-o0e.com"),
}
permission, err := driveItemPermissionsService.Invite(ctx, driveItemId, driveItemInvite)
Expect(err).ToNot(HaveOccurred())
Expect(permission.GrantedToV2.User.GetId()).To(Equal("guest@xn--infocorp-o0e.com"))
})
It("rejects guest shares when guest invites are disabled by default", func() {
driveItemInvite.Recipients = []libregraph.DriveRecipient{
{Email: libregraph.PtrString("guest@example.com")},
@@ -1477,3 +1495,12 @@ var _ = Describe("DriveItemPermissionsApi", func() {
})
})
})
func guestGrantee(mail string) *provider.Grantee {
return &provider.Grantee{
Type: provider.GranteeType_GRANTEE_TYPE_USER,
Id: &provider.Grantee_UserId{
UserId: &userpb.UserId{Type: userpb.UserType_USER_TYPE_GUEST, OpaqueId: mail},
},
}
}
@@ -20,7 +20,6 @@ package usershareprovider
import (
"context"
"net/mail"
"path/filepath"
"regexp"
"slices"
@@ -184,13 +183,15 @@ func (s *service) CreateShare(ctx context.Context, req *collaboration.CreateShar
if req.GetGrant().GetGrantee().GetUserId().GetType() == userpb.UserType_USER_TYPE_GUEST {
// guests are identified by their mail address, lets be strict here and only accept bare mail addresses
// and reject the "Mailbox"-Format that also contains a Display name
addr, err := mail.ParseAddress(req.GetGrant().GetGrantee().GetUserId().GetOpaqueId())
if err != nil || addr.Name != "" {
// and store them in their canonical form
guestID := req.GetGrant().GetGrantee().GetUserId()
canonical, err := utils.CanonicalMail(guestID.GetOpaqueId())
if err != nil {
return &collaboration.CreateShareResponse{
Status: status.NewInvalidArg(ctx, "invalid mail address for guest grantee"),
Status: status.NewInvalidArg(ctx, "invalid mail address for guest grantee: "+err.Error()),
}, nil
}
guestID.OpaqueId = canonical
}
}
gatewayClient, err := s.gatewaySelector.Next()
@@ -271,6 +271,10 @@ func guestUserFromShare(s *collaboration.Share) (*userpb.User, error) {
return nil, errors.New("guestlinks: grantee is not a guest user")
}
if !utils.IsASCII(uid.GetOpaqueId()) {
return nil, errors.New("guestlinks: grantee opaque id is not ASCII")
}
addr, err := mail.ParseAddress(uid.GetOpaqueId())
if err != nil || addr.Name != "" || addr.Address != uid.GetOpaqueId() {
return nil, errors.New("guestlinks: grantee opaque id is not a bare email address")
+15 -6
View File
@@ -183,12 +183,21 @@ type UploadReady struct {
SpaceOwner *user.UserId
ExecutingUser *user.User
ImpersonatingUser *user.User
FileRef *provider.Reference
ParentID *provider.ResourceId
Timestamp *types.Timestamp
Failed bool
IsVersion bool
// add reference here? We could use it to inform client pp is finished
// FileRef is a legacy reference to the uploaded file and its shape depends on
// the producer: decomposedfs anchors it at the space root
// (ResourceId.OpaqueId == SpaceId, with a Path relative to that anchor), while
// the posix assimilation emits the file node ID with an empty Path. Neither
// OpaqueId nor Path can be relied on universally, so treat Path as optional
// and use ResourceID to identify the file.
FileRef *provider.Reference
ParentID *provider.ResourceId
// ResourceID identifies the uploaded file: OpaqueId is the node ID. It mirrors
// BytesReceived and is the only file reference every producer sets the same
// way, so id based consumers (Graph API, fileid lookups) should use it.
ResourceID *provider.ResourceId
Timestamp *types.Timestamp
Failed bool
IsVersion bool
}
// Unmarshal to fulfill umarshaller interface
+4
View File
@@ -46,6 +46,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/aspects"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/metadata"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/metadata/prefixes"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/node"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/permissions"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/upload"
@@ -105,6 +106,9 @@ func New(o *options.Options, stream events.Stream, cache, historyCache *idcache.
if o.FileMetadataCache.Store == "noop" {
return nil, fmt.Errorf("the posix driver requires a file metadata cache")
}
if err := prefixes.SetOcPrefix(o.MetadataPrefix); err != nil {
return nil, err
}
var err error
if log == nil {
@@ -67,7 +67,7 @@ type queueItem struct {
timer *time.Timer
}
const dirtyFlag = prefixes.OcPrefix + "dirty"
func dirtyFlag() string { return prefixes.OcPrefix + "dirty" }
type assimilationNode struct {
path string
@@ -601,10 +601,11 @@ func (t *Tree) assimilate(item scanItem) error {
})
} else {
t.PublishEvent(events.UploadReady{
FileRef: ref,
ParentID: parentResourceID,
Timestamp: utils.TSNow(),
IsVersion: true,
FileRef: ref,
ParentID: parentResourceID,
ResourceID: ref.ResourceId,
Timestamp: utils.TSNow(),
IsVersion: true,
})
}
}
@@ -671,10 +672,11 @@ func (t *Tree) assimilate(item scanItem) error {
})
} else {
t.PublishEvent(events.UploadReady{
FileRef: ref,
ParentID: parentId,
Timestamp: utils.TSNow(),
IsVersion: false,
FileRef: ref,
ParentID: parentId,
ResourceID: ref.ResourceId,
Timestamp: utils.TSNow(),
IsVersion: false,
})
}
}
@@ -1088,11 +1090,11 @@ func (t *Tree) propagateSizeDiff(n *node.Node, size int64) error {
}
func (t *Tree) setDirty(path string, dirty bool) error {
return xattr.Set(path, dirtyFlag, []byte(fmt.Sprintf("%t", dirty)))
return xattr.Set(path, dirtyFlag(), []byte(fmt.Sprintf("%t", dirty)))
}
func (t *Tree) isDirty(path string) (bool, error) {
dirtyAttr, err := xattr.Get(path, dirtyFlag)
dirtyAttr, err := xattr.Get(path, dirtyFlag())
if err != nil {
if metadata.IsAttrUnset(err) {
return true, nil
@@ -51,6 +51,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/aspects"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/lookup"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/metadata"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/metadata/prefixes"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/node"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/options"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/permissions"
@@ -147,6 +148,10 @@ func NewDefault(m map[string]interface{}, bs node.Blobstore, es events.Stream, l
return nil, err
}
if err := prefixes.SetOcPrefix(o.MetadataPrefix); err != nil {
return nil, err
}
var lu *lookup.Lookup
switch o.MetadataBackend {
case "xattrs":
@@ -415,6 +420,11 @@ func (fs *Decomposedfs) handlePostprocessingEvent(ctx context.Context, event eve
},
Path: utils.MakeRelativePath(filepath.Join(session.Dir(), session.Filename())),
},
ResourceID: &provider.ResourceId{
StorageId: session.ProviderID(),
SpaceId: session.SpaceID(),
OpaqueId: session.NodeID(),
},
ParentID: parentId,
Timestamp: utils.TimeToTS(now),
SpaceOwner: n.SpaceOwnerOrManager(ctx),
@@ -21,7 +21,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/metadata/prefixes"
)
var _metadataOffloadedAttr = prefixes.OcPrefix + "metadata_offloaded"
func metadataOffloadedAttr() string { return prefixes.OcPrefix + "metadata_offloaded" }
type MetadataPathFunc func(MetadataNode) string
@@ -72,7 +72,7 @@ func (b HybridBackend) Get(ctx context.Context, n MetadataNode, key string) ([]b
if isOffloadingAttribute(key) {
// check if key is offloaded
offloaded, err := xattr.Get(n.InternalPath(), _metadataOffloadedAttr)
offloaded, err := xattr.Get(n.InternalPath(), metadataOffloadedAttr())
if err == nil && string(offloaded) == "1" {
msgpackAttribs := map[string][]byte{}
msgBytes, err := os.ReadFile(b.MetadataPath(n))
@@ -174,7 +174,7 @@ func (b HybridBackend) getAll(ctx context.Context, n MetadataNode, skipOffloaded
}
// merge the attributes from the offload file
offloaded, err := xattr.Get(path, _metadataOffloadedAttr)
offloaded, err := xattr.Get(path, metadataOffloadedAttr())
if err != nil && !IsAttrUnset(err) {
return nil, err
}
@@ -217,7 +217,7 @@ func (b HybridBackend) SetMultiple(ctx context.Context, n MetadataNode, attribs
defer func() { _ = unlock() }()
}
offloadAttr, err := xattr.Get(path, _metadataOffloadedAttr)
offloadAttr, err := xattr.Get(path, metadataOffloadedAttr())
offloaded := err == nil && string(offloadAttr) == "1"
// offload if the offloading metadata size exceeds the limit
@@ -346,7 +346,7 @@ func (b HybridBackend) offloadMetadata(ctx context.Context, n MetadataNode) erro
}
// set the metadata offloaded attribute
err = xattr.Set(path, _metadataOffloadedAttr, []byte("1"))
err = xattr.Set(path, metadataOffloadedAttr(), []byte("1"))
if err != nil {
return err
}
@@ -376,7 +376,7 @@ func (b HybridBackend) Remove(ctx context.Context, n MetadataNode, key string) e
}
if isOffloadingAttribute(key) {
offloadAttr, err := xattr.Get(path, _metadataOffloadedAttr)
offloadAttr, err := xattr.Get(path, metadataOffloadedAttr())
offloaded := err == nil && string(offloadAttr) == "1"
if offloaded {
// remove from offloaded metadata
@@ -24,6 +24,6 @@ package prefixes
// manipulate the user. namespace, which is what is used to store decomposedfs
// specific metadata. To prevent name collisions with other apps, we are going
// to introduce a sub namespace "user.oc."
const (
var (
OcPrefix string = "user.oc."
)
@@ -23,6 +23,6 @@ package prefixes
// On FreeBSD the `user` namespace is implied through a separate syscall argument
// and will fail with invalid argument when you try to start an xattr name with user. or system.
// For that reason we drop the superfluous user. prefix for FreeBSD specifically.
const (
var (
OcPrefix string = "oc."
)
@@ -19,6 +19,9 @@
package prefixes
import (
"fmt"
"sync"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/ace"
)
@@ -31,7 +34,7 @@ import (
// we will use to store decomposedfs specific metadata. To prevent name
// collisions with other apps We are going to introduce a sub namespace
// "user.oc." in the xattrs_prefix*.go files.
const (
var (
TypeAttr string = OcPrefix + "type"
IDAttr string = OcPrefix + "id"
ParentidAttr string = OcPrefix + "parentid"
@@ -111,3 +114,76 @@ func FavoriteKey(uid *userpb.UserId) string {
// the favorite flag is specific to the user, so we need to incorporate the userid
return FavPrefix + uid.OpaqueId
}
var (
defaultOcPrefix = OcPrefix
mu sync.Mutex
fixed bool
)
// SetOcPrefix sets the key prefix for the whole process, empty means the
// default. The first call fixes it, a later call with another prefix fails.
// It has to run before any key is used.
func SetOcPrefix(prefix string) error {
if prefix == "" {
prefix = defaultOcPrefix
}
mu.Lock()
defer mu.Unlock()
if fixed {
if prefix != OcPrefix {
return fmt.Errorf("metadata prefix is already set to %q, cannot change it to %q", OcPrefix, prefix)
}
return nil
}
fixed = true
if prefix != OcPrefix {
setKeys(prefix)
}
return nil
}
func setKeys(p string) {
OcPrefix = p
TypeAttr = p + "type"
IDAttr = p + "id"
ParentidAttr = p + "parentid"
OwnerIDAttr = p + "owner.id"
OwnerIDPAttr = p + "owner.idp"
OwnerTypeAttr = p + "owner.type"
NameAttr = p + "name"
BlobIDAttr = p + "blobid"
BlobsizeAttr = p + "blobsize"
StatusPrefix = p + "nodestatus"
ScanStatusPrefix = p + "scanstatus"
ScanDatePrefix = p + "scandate"
GrantPrefix = p + "grant."
GrantUserAcePrefix = p + "grant." + ace.UserAcePrefix
GrantGroupAcePrefix = p + "grant." + ace.GroupAcePrefix
GrantMailAcePrefix = p + "grant." + ace.MailAcePrefix
MetadataPrefix = p + "md."
FavPrefix = p + "fav."
TmpEtagAttr = p + "tmp.etag"
ReferenceAttr = p + "cs3.ref"
ChecksumPrefix = p + "cs."
TrashOriginAttr = p + "trash.origin"
PropagationAttr = p + "propagation"
MTimeAttr = p + "mtime"
TreeMTimeAttr = p + "tmtime"
DTimeAttr = p + "dtime"
TreesizeAttr = p + "treesize"
QuotaAttr = p + "quota"
SpaceIDAttr = p + "space.id"
SpaceNameAttr = p + "space.name"
SpaceTypeAttr = p + "space.type"
SpaceDescriptionAttr = p + "space.description"
SpaceReadmeAttr = p + "space.readme"
SpaceImageAttr = p + "space.image"
SpaceAliasAttr = p + "space.alias"
SpaceTenantIDAttr = p + "space.tenantid"
SpaceContentTypeAttr = p + "space.contenttype"
}
@@ -42,6 +42,9 @@ type Options struct {
// the metadata backend to use, currently supports `xattr` or `ini`
MetadataBackend string `mapstructure:"metadata_backend"`
// the attribute key prefix of the metadata on disk, process wide, see prefixes.SetOcPrefix
MetadataPrefix string `mapstructure:"metadata_prefix"`
// the propagator to use for this fs. currently only `sync` is fully supported, `async` is available as an experimental feature
Propagator string `mapstructure:"propagator"`
// Options specific to the async propagator
@@ -404,6 +404,11 @@ func (fs *Decomposedfs) handlePostprocessingEvent(ctx context.Context, event eve
},
Path: utils.MakeRelativePath(filepath.Join(session.Dir(), session.Filename())),
},
ResourceID: &provider.ResourceId{
StorageId: session.ProviderID(),
SpaceId: session.SpaceID(),
OpaqueId: session.NodeID(),
},
Timestamp: utils.TimeToTS(now),
SpaceOwner: n.SpaceOwnerOrManager(ctx),
IsVersion: isVersion,
+1 -2
View File
@@ -2,7 +2,6 @@ package utils
import (
"encoding/base64"
"strings"
grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
@@ -28,7 +27,7 @@ func (id FSSafeUserID) SafeFilename() string {
// it lowercase: RFC 5321 does specify that email address local-parts
// are case sensitive but, in practice, it's a de-facto standard that
// email providers consider them to be case insensitive:
return base64.RawURLEncoding.EncodeToString([]byte(strings.ToLower(opaqueID)))
return base64.RawURLEncoding.EncodeToString([]byte(LowerASCII(opaqueID)))
}
return opaqueID
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0
package utils
import (
"errors"
"net/mail"
"strings"
"golang.org/x/net/idna"
)
// CanonicalMail returns the canonical form of an email address. The result
// is always plain ASCII, so it can't contain look-alike characters, and two
// addresses that mail delivery treats the same end up equal:
// - only a plain address is accepted (no display name, no quoting)
// - the local part must be ASCII
// - international domains are converted to punycode
// - the result is lowercased
func CanonicalMail(email string) (string, error) {
addr, err := mail.ParseAddress(email)
if err != nil || addr.Name != "" || addr.Address != email {
return "", errors.New("invalid email address")
}
at := strings.LastIndex(email, "@")
local, domain := email[:at], email[at+1:]
if !IsASCII(local) {
return "", errors.New("email addresses with non-ASCII characters before the @ are not supported")
}
if !IsASCII(domain) {
if domain, err = idna.Lookup.ToASCII(domain); err != nil {
return "", errors.New("invalid email domain")
}
}
return strings.ToLower(local + "@" + domain), nil
}
+25 -1
View File
@@ -32,6 +32,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
appprovider "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
@@ -240,11 +241,34 @@ func CanonicalUserID(id *userpb.UserId) string {
// comparisons: those are email addresses, and while RFC 5321 states
// that the local-part is case sensitive, in practice, it's a de facto
// standard that email providers consider them to be case insensitive.
return strings.ToLower(id.GetOpaqueId())
return LowerASCII(id.GetOpaqueId())
}
return id.GetOpaqueId()
}
// LowerASCII lowercases only the ASCII letters A-Z in s and leaves all other
// characters untouched. Unlike strings.ToLower it never maps a non-ASCII
// character to an ASCII one (e.g. 'İ' -> 'i' or the Kelvin sign 'K' -> 'k'),
// so two different email addresses can't end up with the same canonical form.
func LowerASCII(s string) string {
return strings.Map(func(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
return r
}, s)
}
// IsASCII returns true if s contains only ASCII characters.
func IsASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}
// GroupEqual returns whether two groups have the same field values.
func GroupEqual(u, v *grouppb.GroupId) bool {
return u != nil && v != nil && u.Idp == v.Idp && u.OpaqueId == v.OpaqueId
+1 -1
View File
@@ -1360,7 +1360,7 @@ github.com/opencloud-eu/icap-client
# github.com/opencloud-eu/libre-graph-api-go v1.0.8
## explicit; go 1.23
github.com/opencloud-eu/libre-graph-api-go
# github.com/opencloud-eu/reva/v2 v2.51.1-0.20261006112042-7d9dd4c07424
# github.com/opencloud-eu/reva/v2 v2.51.1-0.20261007142032-7148a82e0e45
## explicit; go 1.26.0
github.com/opencloud-eu/reva/v2/cmd/revad/internal/grace
github.com/opencloud-eu/reva/v2/cmd/revad/runtime