forked from TrueCloudLab/frostfs-node
[#1423] session: Upgrade SDK package
Signed-off-by: Leonard Lyubich <leonard@nspcc.ru>
This commit is contained in:
parent
dda56f1319
commit
4c8ec20e32
41 changed files with 740 additions and 663 deletions
|
@ -84,6 +84,16 @@ func (h headerSource) HeadersOfType(typ eaclSDK.FilterHeaderType) ([]eaclSDK.Hea
|
|||
}
|
||||
}
|
||||
|
||||
type xHeader session.XHeader
|
||||
|
||||
func (x xHeader) Key() string {
|
||||
return (*session.XHeader)(&x).GetKey()
|
||||
}
|
||||
|
||||
func (x xHeader) Value() string {
|
||||
return (*session.XHeader)(&x).GetValue()
|
||||
}
|
||||
|
||||
func requestHeaders(msg xHeaderSource) []eaclSDK.Header {
|
||||
return msg.GetXHeaders()
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package v2
|
||||
|
||||
import (
|
||||
"github.com/nspcc-dev/neofs-api-go/v2/session"
|
||||
eaclSDK "github.com/nspcc-dev/neofs-sdk-go/eacl"
|
||||
sessionSDK "github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
)
|
||||
|
||||
type xHeaderSource interface {
|
||||
|
@ -30,7 +30,7 @@ func (s requestXHeaderSource) GetXHeaders() []eaclSDK.Header {
|
|||
for meta := s.req.GetMetaHeader(); meta != nil; meta = meta.GetOrigin() {
|
||||
x := meta.GetXHeaders()
|
||||
for i := range x {
|
||||
res = append(res, sessionSDK.NewXHeaderFromV2(&x[i]))
|
||||
res = append(res, (xHeader)(x[i]))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,16 +39,21 @@ func (s requestXHeaderSource) GetXHeaders() []eaclSDK.Header {
|
|||
|
||||
func (s responseXHeaderSource) GetXHeaders() []eaclSDK.Header {
|
||||
ln := 0
|
||||
xHdrs := make([][]session.XHeader, 0)
|
||||
|
||||
for meta := s.req.GetMetaHeader(); meta != nil; meta = meta.GetOrigin() {
|
||||
ln += len(meta.GetXHeaders())
|
||||
x := meta.GetXHeaders()
|
||||
|
||||
ln += len(x)
|
||||
|
||||
xHdrs = append(xHdrs, x)
|
||||
}
|
||||
|
||||
res := make([]eaclSDK.Header, 0, ln)
|
||||
for meta := s.req.GetMetaHeader(); meta != nil; meta = meta.GetOrigin() {
|
||||
x := meta.GetXHeaders()
|
||||
for i := range x {
|
||||
res = append(res, sessionSDK.NewXHeaderFromV2(&x[i]))
|
||||
|
||||
for i := range xHdrs {
|
||||
for j := range xHdrs[i] {
|
||||
res = append(res, xHeader(xHdrs[i][j]))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -107,7 +107,7 @@ func (r RequestInfo) RequestRole() eaclSDK.Role {
|
|||
// verification header and raw API request.
|
||||
type MetaWithToken struct {
|
||||
vheader *sessionV2.RequestVerificationHeader
|
||||
token *sessionSDK.Token
|
||||
token *sessionSDK.Object
|
||||
bearer *bearer.Token
|
||||
src interface{}
|
||||
}
|
||||
|
|
|
@ -113,7 +113,10 @@ func (b Service) Get(request *objectV2.GetRequest, stream object.GetObjectStream
|
|||
return err
|
||||
}
|
||||
|
||||
sTok := originalSessionToken(request.GetMetaHeader())
|
||||
sTok, err := originalSessionToken(request.GetMetaHeader())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := MetaWithToken{
|
||||
vheader: request.GetVerificationHeader(),
|
||||
|
@ -164,7 +167,10 @@ func (b Service) Head(
|
|||
return nil, err
|
||||
}
|
||||
|
||||
sTok := originalSessionToken(request.GetMetaHeader())
|
||||
sTok, err := originalSessionToken(request.GetMetaHeader())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := MetaWithToken{
|
||||
vheader: request.GetVerificationHeader(),
|
||||
|
@ -207,9 +213,14 @@ func (b Service) Search(request *objectV2.SearchRequest, stream object.SearchStr
|
|||
return err
|
||||
}
|
||||
|
||||
sTok, err := originalSessionToken(request.GetMetaHeader())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := MetaWithToken{
|
||||
vheader: request.GetVerificationHeader(),
|
||||
token: originalSessionToken(request.GetMetaHeader()),
|
||||
token: sTok,
|
||||
bearer: originalBearerToken(request.GetMetaHeader()),
|
||||
src: request,
|
||||
}
|
||||
|
@ -245,7 +256,10 @@ func (b Service) Delete(
|
|||
return nil, err
|
||||
}
|
||||
|
||||
sTok := originalSessionToken(request.GetMetaHeader())
|
||||
sTok, err := originalSessionToken(request.GetMetaHeader())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := MetaWithToken{
|
||||
vheader: request.GetVerificationHeader(),
|
||||
|
@ -281,7 +295,10 @@ func (b Service) GetRange(request *objectV2.GetRangeRequest, stream object.GetOb
|
|||
return err
|
||||
}
|
||||
|
||||
sTok := originalSessionToken(request.GetMetaHeader())
|
||||
sTok, err := originalSessionToken(request.GetMetaHeader())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := MetaWithToken{
|
||||
vheader: request.GetVerificationHeader(),
|
||||
|
@ -322,7 +339,10 @@ func (b Service) GetRangeHash(
|
|||
return nil, err
|
||||
}
|
||||
|
||||
sTok := originalSessionToken(request.GetMetaHeader())
|
||||
sTok, err := originalSessionToken(request.GetMetaHeader())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := MetaWithToken{
|
||||
vheader: request.GetVerificationHeader(),
|
||||
|
@ -377,7 +397,16 @@ func (p putStreamBasicChecker) Send(request *objectV2.PutRequest) error {
|
|||
return fmt.Errorf("invalid object owner: %w", err)
|
||||
}
|
||||
|
||||
sTok := sessionSDK.NewTokenFromV2(request.GetMetaHeader().GetSessionToken())
|
||||
var sTok *sessionSDK.Object
|
||||
|
||||
if tokV2 := request.GetMetaHeader().GetSessionToken(); tokV2 != nil {
|
||||
sTok = new(sessionSDK.Object)
|
||||
|
||||
err = sTok.ReadFromV2(*tokV2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid session token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := MetaWithToken{
|
||||
vheader: request.GetVerificationHeader(),
|
||||
|
@ -449,14 +478,18 @@ func (b Service) findRequestInfo(
|
|||
return info, errors.New("missing owner in container descriptor")
|
||||
}
|
||||
|
||||
if req.token != nil && req.token.Exp() != 0 {
|
||||
if req.token != nil {
|
||||
currentEpoch, err := b.nm.Epoch()
|
||||
if err != nil {
|
||||
return info, errors.New("can't fetch current epoch")
|
||||
}
|
||||
if req.token.Exp() < currentEpoch {
|
||||
return info, fmt.Errorf("%w: token has expired (current epoch: %d, expired at %d)",
|
||||
ErrMalformedRequest, currentEpoch, req.token.Exp())
|
||||
if req.token.ExpiredAt(currentEpoch) {
|
||||
return info, fmt.Errorf("%w: token has expired (current epoch: %d)",
|
||||
ErrMalformedRequest, currentEpoch)
|
||||
}
|
||||
|
||||
if !assertVerb(*req.token, op) {
|
||||
return info, ErrInvalidVerb
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -470,16 +503,10 @@ func (b Service) findRequestInfo(
|
|||
return info, ErrUnknownRole
|
||||
}
|
||||
|
||||
// find verb from token if it is present
|
||||
verb, isUnknown := sourceVerbOfRequest(req.token, op)
|
||||
if !isUnknown && verb != op && !isVerbCompatible(verb, op) {
|
||||
return info, ErrInvalidVerb
|
||||
}
|
||||
|
||||
info.basicACL = cnr.BasicACL()
|
||||
info.requestRole = res.role
|
||||
info.isInnerRing = res.isIR
|
||||
info.operation = verb
|
||||
info.operation = op
|
||||
info.cnrOwner = cnr.OwnerID()
|
||||
info.idCnr = cid
|
||||
|
||||
|
|
|
@ -75,12 +75,24 @@ func originalBearerToken(header *sessionV2.RequestMetaHeader) *bearer.Token {
|
|||
|
||||
// originalSessionToken goes down to original request meta header and fetches
|
||||
// session token from there.
|
||||
func originalSessionToken(header *sessionV2.RequestMetaHeader) *sessionSDK.Token {
|
||||
func originalSessionToken(header *sessionV2.RequestMetaHeader) (*sessionSDK.Object, error) {
|
||||
for header.GetOrigin() != nil {
|
||||
header = header.GetOrigin()
|
||||
}
|
||||
|
||||
return sessionSDK.NewTokenFromV2(header.GetSessionToken())
|
||||
tokV2 := header.GetSessionToken()
|
||||
if tokV2 == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var tok sessionSDK.Object
|
||||
|
||||
err := tok.ReadFromV2(*tokV2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid session token: %w", err)
|
||||
}
|
||||
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
func getObjectIDFromRequestBody(body interface{}) (*oidSDK.ID, error) {
|
||||
|
@ -113,58 +125,35 @@ func getObjectIDFromRequestBody(body interface{}) (*oidSDK.ID, error) {
|
|||
return &id, nil
|
||||
}
|
||||
|
||||
// sourceVerbOfRequest looks for verb in session token and if it is not found,
|
||||
// returns reqVerb. Second return value is true if operation is unknown.
|
||||
func sourceVerbOfRequest(tok *sessionSDK.Token, reqVerb eaclSDK.Operation) (eaclSDK.Operation, bool) {
|
||||
ctx, ok := tok.Context().(*sessionSDK.ObjectContext)
|
||||
if ok {
|
||||
op := tokenVerbToOperation(ctx)
|
||||
if op != eaclSDK.OperationUnknown {
|
||||
return op, false
|
||||
}
|
||||
}
|
||||
|
||||
return reqVerb, true
|
||||
}
|
||||
|
||||
func useObjectIDFromSession(req *RequestInfo, token *sessionSDK.Token) {
|
||||
func useObjectIDFromSession(req *RequestInfo, token *sessionSDK.Object) {
|
||||
if token == nil {
|
||||
return
|
||||
}
|
||||
|
||||
objCtx, ok := token.Context().(*sessionSDK.ObjectContext)
|
||||
// TODO(@cthulhu-rider): It'd be nice to not pull object identifiers from
|
||||
// the token, but assert them. Track #1420
|
||||
var tokV2 sessionV2.Token
|
||||
token.WriteToV2(&tokV2)
|
||||
|
||||
ctx, ok := tokV2.GetBody().GetContext().(*sessionV2.ObjectSessionContext)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("wrong object session context %T, is it verified?", tokV2.GetBody().GetContext()))
|
||||
}
|
||||
|
||||
idV2 := ctx.GetAddress().GetObjectID()
|
||||
if idV2 == nil {
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := objCtx.Address().ObjectID()
|
||||
if ok {
|
||||
req.oid = &id
|
||||
req.oid = new(oidSDK.ID)
|
||||
|
||||
err := req.oid.ReadFromV2(*idV2)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("unexpected protocol violation error after correct session token decoding: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func tokenVerbToOperation(ctx *sessionSDK.ObjectContext) eaclSDK.Operation {
|
||||
switch {
|
||||
case ctx.IsForGet():
|
||||
return eaclSDK.OperationGet
|
||||
case ctx.IsForPut():
|
||||
return eaclSDK.OperationPut
|
||||
case ctx.IsForHead():
|
||||
return eaclSDK.OperationHead
|
||||
case ctx.IsForSearch():
|
||||
return eaclSDK.OperationSearch
|
||||
case ctx.IsForDelete():
|
||||
return eaclSDK.OperationDelete
|
||||
case ctx.IsForRange():
|
||||
return eaclSDK.OperationRange
|
||||
case ctx.IsForRangeHash():
|
||||
return eaclSDK.OperationRangeHash
|
||||
default:
|
||||
return eaclSDK.OperationUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func ownerFromToken(token *sessionSDK.Token) (*user.ID, *keys.PublicKey, error) {
|
||||
func ownerFromToken(token *sessionSDK.Object) (*user.ID, *keys.PublicKey, error) {
|
||||
// 1. First check signature of session token.
|
||||
if !token.VerifySignature() {
|
||||
return nil, nil, fmt.Errorf("%w: invalid session token signature", ErrMalformedRequest)
|
||||
|
@ -172,21 +161,32 @@ func ownerFromToken(token *sessionSDK.Token) (*user.ID, *keys.PublicKey, error)
|
|||
|
||||
// 2. Then check if session token owner issued the session token
|
||||
// TODO(@cthulhu-rider): #1387 implement and use another approach to avoid conversion
|
||||
tokV2 := token.ToV2()
|
||||
var tokV2 sessionV2.Token
|
||||
token.WriteToV2(&tokV2)
|
||||
|
||||
ownerSessionV2 := tokV2.GetBody().GetOwnerID()
|
||||
if ownerSessionV2 == nil {
|
||||
return nil, nil, errors.New("missing session owner")
|
||||
}
|
||||
|
||||
var ownerSession user.ID
|
||||
|
||||
err := ownerSession.ReadFromV2(*ownerSessionV2)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid session token: %w", err)
|
||||
}
|
||||
|
||||
tokenIssuerKey, err := unmarshalPublicKey(tokV2.GetSignature().GetKey())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid key in session token signature: %w", err)
|
||||
}
|
||||
|
||||
tokenOwner := token.OwnerID()
|
||||
|
||||
if !isOwnerFromKey(tokenOwner, tokenIssuerKey) {
|
||||
if !isOwnerFromKey(&ownerSession, tokenIssuerKey) {
|
||||
// TODO: #767 in this case we can issue all owner keys from neofs.id and check once again
|
||||
return nil, nil, fmt.Errorf("%w: invalid session token owner", ErrMalformedRequest)
|
||||
}
|
||||
|
||||
return tokenOwner, tokenIssuerKey, nil
|
||||
return &ownerSession, tokenIssuerKey, nil
|
||||
}
|
||||
|
||||
func originalBodySignature(v *sessionV2.RequestVerificationHeader) *refsV2.Signature {
|
||||
|
@ -216,17 +216,30 @@ func isOwnerFromKey(id *user.ID, key *keys.PublicKey) bool {
|
|||
return id2.Equals(*id)
|
||||
}
|
||||
|
||||
// isVerbCompatible checks that tokenVerb operation can create auxiliary op operation.
|
||||
func isVerbCompatible(tokenVerb, op eaclSDK.Operation) bool {
|
||||
switch tokenVerb {
|
||||
case eaclSDK.OperationGet:
|
||||
return op == eaclSDK.OperationGet || op == eaclSDK.OperationHead
|
||||
// assertVerb checks that token verb corresponds to op.
|
||||
func assertVerb(tok sessionSDK.Object, op eaclSDK.Operation) bool {
|
||||
//nolint:exhaustive
|
||||
switch op {
|
||||
case eaclSDK.OperationPut:
|
||||
return tok.AssertVerb(sessionSDK.VerbObjectPut, sessionSDK.VerbObjectDelete)
|
||||
case eaclSDK.OperationDelete:
|
||||
return op == eaclSDK.OperationPut || op == eaclSDK.OperationHead ||
|
||||
op == eaclSDK.OperationSearch
|
||||
case eaclSDK.OperationRange, eaclSDK.OperationRangeHash:
|
||||
return op == eaclSDK.OperationRange || op == eaclSDK.OperationHead
|
||||
default:
|
||||
return tokenVerb == op
|
||||
return tok.AssertVerb(sessionSDK.VerbObjectDelete)
|
||||
case eaclSDK.OperationGet:
|
||||
return tok.AssertVerb(sessionSDK.VerbObjectGet)
|
||||
case eaclSDK.OperationHead:
|
||||
return tok.AssertVerb(
|
||||
sessionSDK.VerbObjectHead,
|
||||
sessionSDK.VerbObjectGet,
|
||||
sessionSDK.VerbObjectDelete,
|
||||
sessionSDK.VerbObjectRange,
|
||||
sessionSDK.VerbObjectRangeHash)
|
||||
case eaclSDK.OperationSearch:
|
||||
return tok.AssertVerb(sessionSDK.VerbObjectSearch, sessionSDK.VerbObjectDelete)
|
||||
case eaclSDK.OperationRange:
|
||||
return tok.AssertVerb(sessionSDK.VerbObjectRange, sessionSDK.VerbObjectRangeHash)
|
||||
case eaclSDK.OperationRangeHash:
|
||||
return tok.AssertVerb(sessionSDK.VerbObjectRangeHash)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -6,23 +6,28 @@ import (
|
|||
"github.com/nspcc-dev/neofs-api-go/v2/acl"
|
||||
acltest "github.com/nspcc-dev/neofs-api-go/v2/acl/test"
|
||||
"github.com/nspcc-dev/neofs-api-go/v2/session"
|
||||
sessiontest "github.com/nspcc-dev/neofs-api-go/v2/session/test"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/bearer"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/eacl"
|
||||
sessionSDK "github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
sessiontest "github.com/nspcc-dev/neofs-sdk-go/session/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOriginalTokens(t *testing.T) {
|
||||
sToken := sessiontest.GenerateSessionToken(false)
|
||||
sToken := sessiontest.ObjectSigned()
|
||||
bTokenV2 := acltest.GenerateBearerToken(false)
|
||||
|
||||
var bToken bearer.Token
|
||||
bToken.ReadFromV2(*bTokenV2)
|
||||
|
||||
var sTokenV2 session.Token
|
||||
sToken.WriteToV2(&sTokenV2)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
metaHeaders := testGenerateMetaHeader(uint32(i), bTokenV2, sToken)
|
||||
require.Equal(t, sessionSDK.NewTokenFromV2(sToken), originalSessionToken(metaHeaders), i)
|
||||
metaHeaders := testGenerateMetaHeader(uint32(i), bTokenV2, &sTokenV2)
|
||||
res, err := originalSessionToken(metaHeaders)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sToken, res, i)
|
||||
require.Equal(t, &bToken, originalBearerToken(metaHeaders), i)
|
||||
}
|
||||
}
|
||||
|
@ -43,38 +48,48 @@ func testGenerateMetaHeader(depth uint32, b *acl.BearerToken, s *session.Token)
|
|||
|
||||
func TestIsVerbCompatible(t *testing.T) {
|
||||
// Source: https://nspcc.ru/upload/neofs-spec-latest.pdf#page=28
|
||||
table := map[eacl.Operation][]eacl.Operation{
|
||||
eacl.OperationPut: {eacl.OperationPut},
|
||||
eacl.OperationDelete: {eacl.OperationPut, eacl.OperationHead, eacl.OperationSearch},
|
||||
eacl.OperationHead: {eacl.OperationHead},
|
||||
eacl.OperationRange: {eacl.OperationRange, eacl.OperationHead},
|
||||
eacl.OperationRangeHash: {eacl.OperationRange, eacl.OperationHead},
|
||||
eacl.OperationGet: {eacl.OperationGet, eacl.OperationHead},
|
||||
eacl.OperationSearch: {eacl.OperationSearch},
|
||||
table := map[eacl.Operation][]sessionSDK.ObjectVerb{
|
||||
eacl.OperationPut: {sessionSDK.VerbObjectPut, sessionSDK.VerbObjectDelete},
|
||||
eacl.OperationDelete: {sessionSDK.VerbObjectDelete},
|
||||
eacl.OperationGet: {sessionSDK.VerbObjectGet},
|
||||
eacl.OperationHead: {
|
||||
sessionSDK.VerbObjectHead,
|
||||
sessionSDK.VerbObjectGet,
|
||||
sessionSDK.VerbObjectDelete,
|
||||
sessionSDK.VerbObjectRange,
|
||||
sessionSDK.VerbObjectRangeHash,
|
||||
},
|
||||
eacl.OperationRange: {sessionSDK.VerbObjectRange, sessionSDK.VerbObjectRangeHash},
|
||||
eacl.OperationRangeHash: {sessionSDK.VerbObjectRangeHash},
|
||||
eacl.OperationSearch: {sessionSDK.VerbObjectSearch, sessionSDK.VerbObjectDelete},
|
||||
}
|
||||
|
||||
ops := []eacl.Operation{
|
||||
eacl.OperationPut,
|
||||
eacl.OperationDelete,
|
||||
eacl.OperationHead,
|
||||
eacl.OperationRange,
|
||||
eacl.OperationRangeHash,
|
||||
eacl.OperationGet,
|
||||
eacl.OperationSearch,
|
||||
verbs := []sessionSDK.ObjectVerb{
|
||||
sessionSDK.VerbObjectPut,
|
||||
sessionSDK.VerbObjectDelete,
|
||||
sessionSDK.VerbObjectHead,
|
||||
sessionSDK.VerbObjectRange,
|
||||
sessionSDK.VerbObjectRangeHash,
|
||||
sessionSDK.VerbObjectGet,
|
||||
sessionSDK.VerbObjectSearch,
|
||||
}
|
||||
|
||||
for _, opToken := range ops {
|
||||
for _, op := range ops {
|
||||
var tok sessionSDK.Object
|
||||
|
||||
for op, list := range table {
|
||||
for _, verb := range verbs {
|
||||
var contains bool
|
||||
for _, o := range table[opToken] {
|
||||
if o == op {
|
||||
for _, v := range list {
|
||||
if v == verb {
|
||||
contains = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, contains, isVerbCompatible(opToken, op),
|
||||
"%s in token, %s executing", opToken, op)
|
||||
tok.ForVerb(verb)
|
||||
|
||||
require.Equal(t, contains, assertVerb(tok, op),
|
||||
"%v in token, %s executing", verb, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -243,15 +243,15 @@ func (exec *execCtx) initTombstoneObject() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
tombOwnerID := exec.commonParameters().SessionToken().OwnerID()
|
||||
if tombOwnerID == nil {
|
||||
tombOwnerID, ok := exec.commonParameters().SessionOwner()
|
||||
if !ok {
|
||||
// make local node a tombstone object owner
|
||||
tombOwnerID = exec.svc.netInfo.LocalNodeID()
|
||||
tombOwnerID = *exec.svc.netInfo.LocalNodeID()
|
||||
}
|
||||
|
||||
exec.tombstoneObj = object.New()
|
||||
exec.tombstoneObj.SetContainerID(*exec.containerID())
|
||||
exec.tombstoneObj.SetOwnerID(tombOwnerID)
|
||||
exec.tombstoneObj.SetOwnerID(&tombOwnerID)
|
||||
exec.tombstoneObj.SetType(object.TypeTombstone)
|
||||
exec.tombstoneObj.SetPayload(payload)
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
clientcore "github.com/nspcc-dev/neofs-node/pkg/core/client"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/core/object"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/services/object/util"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/services/object_manager/placement"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/util/logger"
|
||||
cid "github.com/nspcc-dev/neofs-sdk-go/container/id"
|
||||
|
@ -105,7 +106,18 @@ func (exec execCtx) isChild(obj *objectSDK.Object) bool {
|
|||
}
|
||||
|
||||
func (exec execCtx) key() (*ecdsa.PrivateKey, error) {
|
||||
return exec.svc.keyStore.GetKey(exec.prm.common.SessionToken())
|
||||
var sessionInfo *util.SessionInfo
|
||||
|
||||
if tok := exec.prm.common.SessionToken(); tok != nil {
|
||||
ownerSession, _ := exec.prm.common.SessionOwner()
|
||||
|
||||
sessionInfo = &util.SessionInfo{
|
||||
ID: tok.ID(),
|
||||
Owner: ownerSession,
|
||||
}
|
||||
}
|
||||
|
||||
return exec.svc.keyStore.GetKey(sessionInfo)
|
||||
}
|
||||
|
||||
func (exec *execCtx) canAssemble() bool {
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
package getsvc
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
|
||||
"github.com/nspcc-dev/neofs-node/pkg/core/client"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/core/netmap"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/local_object_storage/engine"
|
||||
|
@ -11,7 +9,6 @@ import (
|
|||
"github.com/nspcc-dev/neofs-node/pkg/util/logger"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/object"
|
||||
addressSDK "github.com/nspcc-dev/neofs-sdk-go/object/address"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
@ -48,9 +45,7 @@ type cfg struct {
|
|||
currentEpoch() (uint64, error)
|
||||
}
|
||||
|
||||
keyStore interface {
|
||||
GetKey(token *session.Token) (*ecdsa.PrivateKey, error)
|
||||
}
|
||||
keyStore *util.KeyStorage
|
||||
}
|
||||
|
||||
func defaultCfg() *cfg {
|
||||
|
|
|
@ -25,13 +25,13 @@ type commonPrm struct {
|
|||
|
||||
key *ecdsa.PrivateKey
|
||||
|
||||
tokenSession *session.Token
|
||||
tokenSession *session.Object
|
||||
|
||||
tokenBearer *bearer.Token
|
||||
|
||||
local bool
|
||||
|
||||
xHeaders []*session.XHeader
|
||||
xHeaders []string
|
||||
}
|
||||
|
||||
// SetClient sets base client for NeoFS API communication.
|
||||
|
@ -58,7 +58,7 @@ func (x *commonPrm) SetPrivateKey(key *ecdsa.PrivateKey) {
|
|||
// SetSessionToken sets token of the session within which request should be sent.
|
||||
//
|
||||
// By default the request will be sent outside the session.
|
||||
func (x *commonPrm) SetSessionToken(tok *session.Token) {
|
||||
func (x *commonPrm) SetSessionToken(tok *session.Object) {
|
||||
x.tokenSession = tok
|
||||
}
|
||||
|
||||
|
@ -77,23 +77,10 @@ func (x *commonPrm) SetTTL(ttl uint32) {
|
|||
// SetXHeaders sets request X-Headers.
|
||||
//
|
||||
// By default X-Headers will not be attached to the request.
|
||||
func (x *commonPrm) SetXHeaders(hs []*session.XHeader) {
|
||||
func (x *commonPrm) SetXHeaders(hs []string) {
|
||||
x.xHeaders = hs
|
||||
}
|
||||
|
||||
func (x commonPrm) xHeadersPrm() (res []string) {
|
||||
if len(x.xHeaders) > 0 {
|
||||
res = make([]string, len(x.xHeaders)*2)
|
||||
|
||||
for i := range x.xHeaders {
|
||||
res[2*i] = x.xHeaders[i].Key()
|
||||
res[2*i+1] = x.xHeaders[i].Value()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
type readPrmCommon struct {
|
||||
commonPrm
|
||||
}
|
||||
|
@ -163,7 +150,7 @@ func GetObject(prm GetObjectPrm) (*GetObjectRes, error) {
|
|||
prm.cliPrm.MarkLocal()
|
||||
}
|
||||
|
||||
prm.cliPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
prm.cliPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
rdr, err := prm.cli.ObjectGetInit(prm.ctx, prm.cliPrm)
|
||||
if err != nil {
|
||||
|
@ -258,7 +245,7 @@ func HeadObject(prm HeadObjectPrm) (*HeadObjectRes, error) {
|
|||
prm.cliPrm.WithBearerToken(*prm.tokenBearer)
|
||||
}
|
||||
|
||||
prm.cliPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
prm.cliPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
cliRes, err := prm.cli.ObjectHead(prm.ctx, prm.cliPrm)
|
||||
if err == nil {
|
||||
|
@ -350,7 +337,7 @@ func PayloadRange(prm PayloadRangePrm) (*PayloadRangeRes, error) {
|
|||
}
|
||||
|
||||
prm.cliPrm.SetLength(prm.ln)
|
||||
prm.cliPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
prm.cliPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
rdr, err := prm.cli.ObjectRangeInit(prm.ctx, prm.cliPrm)
|
||||
if err != nil {
|
||||
|
@ -420,7 +407,7 @@ func PutObject(prm PutObjectPrm) (*PutObjectRes, error) {
|
|||
w.WithBearerToken(*prm.tokenBearer)
|
||||
}
|
||||
|
||||
w.WithXHeaders(prm.xHeadersPrm()...)
|
||||
w.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
if w.WriteHeader(*prm.obj) {
|
||||
w.WritePayloadChunk(prm.obj.Payload())
|
||||
|
@ -492,7 +479,7 @@ func SearchObjects(prm SearchObjectsPrm) (*SearchObjectsRes, error) {
|
|||
prm.cliPrm.WithBearerToken(*prm.tokenBearer)
|
||||
}
|
||||
|
||||
prm.cliPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
prm.cliPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
rdr, err := prm.cli.ObjectSearchInit(prm.ctx, prm.cliPrm)
|
||||
if err != nil {
|
||||
|
|
|
@ -48,7 +48,18 @@ func (t *remoteTarget) WriteHeader(obj *object.Object) error {
|
|||
}
|
||||
|
||||
func (t *remoteTarget) Close() (*transformer.AccessIdentifiers, error) {
|
||||
key, err := t.keyStorage.GetKey(t.commonPrm.SessionToken())
|
||||
var sessionInfo *util.SessionInfo
|
||||
|
||||
if tok := t.commonPrm.SessionToken(); tok != nil {
|
||||
ownerSession, _ := t.commonPrm.SessionOwner()
|
||||
|
||||
sessionInfo = &util.SessionInfo{
|
||||
ID: tok.ID(),
|
||||
Owner: ownerSession,
|
||||
}
|
||||
}
|
||||
|
||||
key, err := t.keyStorage.GetKey(sessionInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("(%T) could not receive private key: %w", t, err)
|
||||
}
|
||||
|
|
|
@ -84,7 +84,18 @@ func (p *Streamer) initTarget(prm *PutInitPrm) error {
|
|||
// prepare trusted-Put object target
|
||||
|
||||
// get private token from local storage
|
||||
sessionKey, err := p.keyStorage.GetKey(sToken)
|
||||
var sessionInfo *util.SessionInfo
|
||||
|
||||
if sToken != nil {
|
||||
ownerSession, _ := prm.common.SessionOwner()
|
||||
|
||||
sessionInfo = &util.SessionInfo{
|
||||
ID: sToken.ID(),
|
||||
Owner: ownerSession,
|
||||
}
|
||||
}
|
||||
|
||||
sessionKey, err := p.keyStorage.GetKey(sessionInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("(%T) could not receive session key: %w", p, err)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
package searchsvc
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
|
||||
"github.com/nspcc-dev/neofs-node/pkg/core/client"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/core/netmap"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/local_object_storage/engine"
|
||||
|
@ -11,7 +9,6 @@ import (
|
|||
"github.com/nspcc-dev/neofs-node/pkg/util/logger"
|
||||
cid "github.com/nspcc-dev/neofs-sdk-go/container/id"
|
||||
oidSDK "github.com/nspcc-dev/neofs-sdk-go/object/id"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
@ -51,9 +48,7 @@ type cfg struct {
|
|||
currentEpoch() (uint64, error)
|
||||
}
|
||||
|
||||
keyStore interface {
|
||||
GetKey(token *session.Token) (*ecdsa.PrivateKey, error)
|
||||
}
|
||||
keyStore *util.KeyStorage
|
||||
}
|
||||
|
||||
func defaultCfg() *cfg {
|
||||
|
|
|
@ -85,7 +85,18 @@ func (c *clientWrapper) searchObjects(exec *execCtx, info client.NodeInfo) ([]oi
|
|||
return exec.prm.forwarder(info, c.client)
|
||||
}
|
||||
|
||||
key, err := exec.svc.keyStore.GetKey(exec.prm.common.SessionToken())
|
||||
var sessionInfo *util.SessionInfo
|
||||
|
||||
if tok := exec.prm.common.SessionToken(); tok != nil {
|
||||
ownerSession, _ := exec.prm.common.SessionOwner()
|
||||
|
||||
sessionInfo = &util.SessionInfo{
|
||||
ID: tok.ID(),
|
||||
Owner: ownerSession,
|
||||
}
|
||||
}
|
||||
|
||||
key, err := exec.svc.keyStore.GetKey(sessionInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -2,11 +2,12 @@ package util
|
|||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/core/netmap"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/services/session/storage"
|
||||
apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/user"
|
||||
)
|
||||
|
||||
|
@ -40,13 +41,28 @@ func NewKeyStorage(localKey *ecdsa.PrivateKey, tokenStore SessionSource, net net
|
|||
}
|
||||
}
|
||||
|
||||
// SessionInfo groups information about NeoFS Object session
|
||||
// which is reflected in KeyStorage.
|
||||
type SessionInfo struct {
|
||||
// Session unique identifier.
|
||||
ID uuid.UUID
|
||||
|
||||
// Session issuer.
|
||||
Owner user.ID
|
||||
}
|
||||
|
||||
// GetKey returns private key of the node.
|
||||
//
|
||||
// If token is not nil, session private key is returned.
|
||||
// Otherwise, node private key is returned.
|
||||
func (s *KeyStorage) GetKey(token *session.Token) (*ecdsa.PrivateKey, error) {
|
||||
if token != nil {
|
||||
pToken := s.tokenStore.Get(token.OwnerID(), token.ID())
|
||||
func (s *KeyStorage) GetKey(info *SessionInfo) (*ecdsa.PrivateKey, error) {
|
||||
if info != nil {
|
||||
binID, err := info.ID.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal ID: %w", err)
|
||||
}
|
||||
|
||||
pToken := s.tokenStore.Get(&info.Owner, binID)
|
||||
if pToken != nil {
|
||||
if pToken.ExpiredAt() <= s.networkState.CurrentEpoch() {
|
||||
var errExpired apistatus.SessionTokenExpired
|
||||
|
|
|
@ -11,7 +11,9 @@ import (
|
|||
sessionV2 "github.com/nspcc-dev/neofs-api-go/v2/session"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/services/object/util"
|
||||
tokenStorage "github.com/nspcc-dev/neofs-node/pkg/services/session/storage/temporary"
|
||||
neofsecdsa "github.com/nspcc-dev/neofs-sdk-go/crypto/ecdsa"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/user"
|
||||
usertest "github.com/nspcc-dev/neofs-sdk-go/user/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
@ -23,6 +25,8 @@ func TestNewKeyStorage(t *testing.T) {
|
|||
tokenStor := tokenStorage.NewTokenStore()
|
||||
stor := util.NewKeyStorage(&nodeKey.PrivateKey, tokenStor, mockedNetworkState{42})
|
||||
|
||||
owner := *usertest.ID()
|
||||
|
||||
t.Run("node key", func(t *testing.T) {
|
||||
key, err := stor.GetKey(nil)
|
||||
require.NoError(t, err)
|
||||
|
@ -30,48 +34,35 @@ func TestNewKeyStorage(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("unknown token", func(t *testing.T) {
|
||||
tok := generateToken(t)
|
||||
_, err = stor.GetKey(tok)
|
||||
_, err = stor.GetKey(&util.SessionInfo{
|
||||
ID: uuid.New(),
|
||||
Owner: *usertest.ID(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("known token", func(t *testing.T) {
|
||||
tok := createToken(t, tokenStor, 100)
|
||||
pubKey, err := keys.NewPublicKeyFromBytes(tok.SessionKey(), elliptic.P256())
|
||||
require.NoError(t, err)
|
||||
tok := createToken(t, tokenStor, owner, 100)
|
||||
|
||||
key, err := stor.GetKey(tok)
|
||||
key, err := stor.GetKey(&util.SessionInfo{
|
||||
ID: tok.ID(),
|
||||
Owner: owner,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pubKey.X, key.PublicKey.X)
|
||||
require.Equal(t, pubKey.Y, key.PublicKey.Y)
|
||||
require.True(t, tok.AssertAuthKey((*neofsecdsa.PublicKey)(&key.PublicKey)))
|
||||
})
|
||||
|
||||
t.Run("expired token", func(t *testing.T) {
|
||||
tok := createToken(t, tokenStor, 30)
|
||||
_, err := stor.GetKey(tok)
|
||||
tok := createToken(t, tokenStor, owner, 30)
|
||||
_, err := stor.GetKey(&util.SessionInfo{
|
||||
ID: tok.ID(),
|
||||
Owner: owner,
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func generateToken(t *testing.T) *session.Token {
|
||||
key, err := keys.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKey := key.PublicKey().Bytes()
|
||||
id, err := uuid.New().MarshalBinary()
|
||||
require.NoError(t, err)
|
||||
|
||||
tok := session.NewToken()
|
||||
tok.SetSessionKey(pubKey)
|
||||
tok.SetID(id)
|
||||
tok.SetOwnerID(usertest.ID())
|
||||
|
||||
return tok
|
||||
}
|
||||
|
||||
func createToken(t *testing.T, store *tokenStorage.TokenStore, exp uint64) *session.Token {
|
||||
owner := usertest.ID()
|
||||
|
||||
func createToken(t *testing.T, store *tokenStorage.TokenStore, owner user.ID, exp uint64) session.Object {
|
||||
var ownerV2 refs.OwnerID
|
||||
owner.WriteToV2(&ownerV2)
|
||||
|
||||
|
@ -82,10 +73,15 @@ func createToken(t *testing.T, store *tokenStorage.TokenStore, exp uint64) *sess
|
|||
resp, err := store.Create(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok := session.NewToken()
|
||||
tok.SetSessionKey(resp.GetSessionKey())
|
||||
tok.SetID(resp.GetID())
|
||||
tok.SetOwnerID(owner)
|
||||
pub, err := keys.NewPublicKeyFromBytes(resp.GetSessionKey(), elliptic.P256())
|
||||
require.NoError(t, err)
|
||||
|
||||
var id uuid.UUID
|
||||
require.NoError(t, id.UnmarshalBinary(resp.GetID()))
|
||||
|
||||
var tok session.Object
|
||||
tok.SetAuthKey((*neofsecdsa.PublicKey)(pub))
|
||||
tok.SetID(id)
|
||||
|
||||
return tok
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/nspcc-dev/neofs-api-go/v2/session"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/bearer"
|
||||
sessionsdk "github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/user"
|
||||
)
|
||||
|
||||
// maxLocalTTL is maximum TTL for an operation to be considered local.
|
||||
|
@ -16,13 +19,15 @@ type CommonPrm struct {
|
|||
|
||||
netmapEpoch, netmapLookupDepth uint64
|
||||
|
||||
token *sessionsdk.Token
|
||||
token *sessionsdk.Object
|
||||
|
||||
bearer *bearer.Token
|
||||
|
||||
ttl uint32
|
||||
|
||||
xhdrs []*sessionsdk.XHeader
|
||||
xhdrs []string
|
||||
|
||||
ownerSession user.ID
|
||||
}
|
||||
|
||||
// TTL returns TTL for new requests.
|
||||
|
@ -35,7 +40,7 @@ func (p *CommonPrm) TTL() uint32 {
|
|||
}
|
||||
|
||||
// XHeaders returns X-Headers for new requests.
|
||||
func (p *CommonPrm) XHeaders() []*sessionsdk.XHeader {
|
||||
func (p *CommonPrm) XHeaders() []string {
|
||||
if p != nil {
|
||||
return p.xhdrs
|
||||
}
|
||||
|
@ -59,7 +64,7 @@ func (p *CommonPrm) LocalOnly() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (p *CommonPrm) SessionToken() *sessionsdk.Token {
|
||||
func (p *CommonPrm) SessionToken() *sessionsdk.Object {
|
||||
if p != nil {
|
||||
return p.token
|
||||
}
|
||||
|
@ -67,6 +72,14 @@ func (p *CommonPrm) SessionToken() *sessionsdk.Token {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *CommonPrm) SessionOwner() (user.ID, bool) {
|
||||
if p != nil && p.token != nil {
|
||||
return p.ownerSession, true
|
||||
}
|
||||
|
||||
return user.ID{}, false
|
||||
}
|
||||
|
||||
func (p *CommonPrm) BearerToken() *bearer.Token {
|
||||
if p != nil {
|
||||
return p.bearer
|
||||
|
@ -102,17 +115,38 @@ func CommonPrmFromV2(req interface {
|
|||
}) (*CommonPrm, error) {
|
||||
meta := req.GetMetaHeader()
|
||||
|
||||
var tokenSession *sessionsdk.Object
|
||||
var err error
|
||||
var ownerSession user.ID
|
||||
|
||||
if tokenSessionV2 := meta.GetSessionToken(); tokenSessionV2 != nil {
|
||||
ownerSessionV2 := tokenSessionV2.GetBody().GetOwnerID()
|
||||
if ownerSessionV2 == nil {
|
||||
return nil, errors.New("missing session owner")
|
||||
}
|
||||
|
||||
err = ownerSession.ReadFromV2(*ownerSessionV2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid session token: %w", err)
|
||||
}
|
||||
|
||||
tokenSession = new(sessionsdk.Object)
|
||||
|
||||
err = tokenSession.ReadFromV2(*tokenSessionV2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid session token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
xHdrs := meta.GetXHeaders()
|
||||
ttl := meta.GetTTL()
|
||||
|
||||
prm := &CommonPrm{
|
||||
local: ttl <= maxLocalTTL,
|
||||
xhdrs: make([]*sessionsdk.XHeader, 0, len(xHdrs)),
|
||||
ttl: ttl - 1, // decrease TTL for new requests
|
||||
}
|
||||
|
||||
if tok := meta.GetSessionToken(); tok != nil {
|
||||
prm.token = sessionsdk.NewTokenFromV2(tok)
|
||||
local: ttl <= maxLocalTTL,
|
||||
token: tokenSession,
|
||||
ttl: ttl - 1, // decrease TTL for new requests
|
||||
xhdrs: make([]string, 0, 2*len(xHdrs)),
|
||||
ownerSession: ownerSession,
|
||||
}
|
||||
|
||||
if tok := meta.GetBearerToken(); tok != nil {
|
||||
|
@ -121,7 +155,7 @@ func CommonPrmFromV2(req interface {
|
|||
}
|
||||
|
||||
for i := range xHdrs {
|
||||
switch xHdrs[i].GetKey() {
|
||||
switch key := xHdrs[i].GetKey(); key {
|
||||
case session.XHeaderNetmapEpoch:
|
||||
var err error
|
||||
|
||||
|
@ -137,9 +171,7 @@ func CommonPrmFromV2(req interface {
|
|||
return nil, err
|
||||
}
|
||||
default:
|
||||
xhdr := sessionsdk.NewXHeaderFromV2(&xHdrs[i])
|
||||
|
||||
prm.xhdrs = append(prm.xhdrs, xhdr)
|
||||
prm.xhdrs = append(prm.xhdrs, key, xHdrs[i].GetValue())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue