diff --git a/auth/aws/credentials_provider.go b/auth/aws/credentials_provider.go new file mode 100644 index 000000000..1d3019835 --- /dev/null +++ b/auth/aws/credentials_provider.go @@ -0,0 +1,59 @@ +/* +Copyright 2025 The Flux authors + +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. +*/ + +package aws + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + + "github.com/fluxcd/pkg/auth" +) + +type credentialsProvider struct { + ctx context.Context + opts []auth.Option +} + +// NewCredentialsProvider creates a new credentials provider for the given options. +func NewCredentialsProvider(ctx context.Context, opts ...auth.Option) aws.CredentialsProvider { + return &credentialsProvider{ctx, opts} +} + +// Retrieve implements aws.CredentialsProvider. +// The context is ignored, use the constructor to set the context. +// This is because some callers of the library pass context.Background() +// when calling this method, so to ensure we have a real context we pass +// it in the constructor. +func (c *credentialsProvider) Retrieve(context.Context) (aws.Credentials, error) { + token, err := auth.GetToken(c.ctx, Provider{}, c.opts...) + if err != nil { + return aws.Credentials{}, err + } + awsToken, ok := token.(*Token) + if !ok { + return aws.Credentials{}, fmt.Errorf("failed to cast token to AWS token: %T", token) + } + return aws.Credentials{ + AccessKeyID: *awsToken.AccessKeyId, + SecretAccessKey: *awsToken.SecretAccessKey, + SessionToken: *awsToken.SessionToken, + Expires: *awsToken.Expiration, + CanExpire: true, + }, nil +} diff --git a/auth/aws/options.go b/auth/aws/options.go new file mode 100644 index 000000000..1be1143cc --- /dev/null +++ b/auth/aws/options.go @@ -0,0 +1,66 @@ +/* +Copyright 2025 The Flux authors + +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. +*/ + +package aws + +import ( + "fmt" + "os" + "regexp" + + corev1 "k8s.io/api/core/v1" +) + +func getRegion() string { + // The AWS_REGION is usually automatically set in EKS clusters. + // If not set users can set it manually (e.g. Fargate). + return os.Getenv("AWS_REGION") +} + +const roleARNPattern = `^arn:aws:iam::[0-9]{1,30}:role/.{1,200}$` + +var roleARNRegex = regexp.MustCompile(roleARNPattern) + +func getRoleARN(serviceAccount corev1.ServiceAccount) (string, error) { + arn := serviceAccount.Annotations["eks.amazonaws.com/role-arn"] + if !roleARNRegex.MatchString(arn) { + return "", fmt.Errorf("invalid AWS role ARN: '%s'. must match %s", + arn, roleARNPattern) + } + return arn, nil +} + +func getRoleSessionName(serviceAccount corev1.ServiceAccount) string { + name := serviceAccount.Name + namespace := serviceAccount.Namespace + region := getRegion() + return fmt.Sprintf("%s.%s.%s.fluxcd.io", name, namespace, region) +} + +// This regex is sourced from the AWS ECR Credential Helper (https://github.com/awslabs/amazon-ecr-credential-helper). +// It covers both public AWS partitions like amazonaws.com, China partitions like amazonaws.com.cn, and non-public partitions. +var registryPartRe = regexp.MustCompile(`([0-9+]*).dkr.ecr(?:-fips)?\.([^/.]*)\.(amazonaws\.com[.cn]*|sc2s\.sgov\.gov|c2s\.ic\.gov|cloud\.adc-e\.uk|csp\.hci\.ic\.gov)`) + +// ParseRegistry returns the AWS account ID and region and `true` if +// the image registry/repository is hosted in AWS's Elastic Container Registry, +// otherwise empty strings and `false`. +func ParseRegistry(registry string) (accountId, awsEcrRegion string, ok bool) { + registryParts := registryPartRe.FindAllStringSubmatch(registry, -1) + if len(registryParts) < 1 || len(registryParts[0]) < 3 { + return "", "", false + } + return registryParts[0][1], registryParts[0][2], true +} diff --git a/auth/aws/options_test.go b/auth/aws/options_test.go new file mode 100644 index 000000000..a1bfa56a2 --- /dev/null +++ b/auth/aws/options_test.go @@ -0,0 +1,115 @@ +/* +Copyright 2025 The Flux authors + +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. +*/ + +package aws_test + +import ( + "testing" + + . "github.com/onsi/gomega" + + "github.com/fluxcd/pkg/auth/aws" +) + +func TestParseRegistry(t *testing.T) { + tests := []struct { + registry string + wantAccountID string + wantRegion string + wantOK bool + }{ + { + registry: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1", + wantAccountID: "012345678901", + wantRegion: "us-east-1", + wantOK: true, + }, + { + registry: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo", + wantAccountID: "012345678901", + wantRegion: "us-east-1", + wantOK: true, + }, + { + registry: "012345678901.dkr.ecr.us-east-1.amazonaws.com", + wantAccountID: "012345678901", + wantRegion: "us-east-1", + wantOK: true, + }, + { + registry: "https://012345678901.dkr.ecr.us-east-1.amazonaws.com/v2/part/part", + wantAccountID: "012345678901", + wantRegion: "us-east-1", + wantOK: true, + }, + { + registry: "012345678901.dkr.ecr.cn-north-1.amazonaws.com.cn/foo", + wantAccountID: "012345678901", + wantRegion: "cn-north-1", + wantOK: true, + }, + { + registry: "012345678901.dkr.ecr-fips.us-gov-west-1.amazonaws.com", + wantAccountID: "012345678901", + wantRegion: "us-gov-west-1", + wantOK: true, + }, + { + registry: "012345678901.dkr.ecr.us-secret-region.sc2s.sgov.gov", + wantAccountID: "012345678901", + wantRegion: "us-secret-region", + wantOK: true, + }, + { + registry: "012345678901.dkr.ecr-fips.us-ts-region.c2s.ic.gov", + wantAccountID: "012345678901", + wantRegion: "us-ts-region", + wantOK: true, + }, + { + registry: "012345678901.dkr.ecr.uk-region.cloud.adc-e.uk", + wantAccountID: "012345678901", + wantRegion: "uk-region", + wantOK: true, + }, + { + registry: "012345678901.dkr.ecr.us-ts-region.csp.hci.ic.gov", + wantAccountID: "012345678901", + wantRegion: "us-ts-region", + wantOK: true, + }, + // TODO: Fix: this invalid registry is allowed by the regex. + // { + // registry: ".dkr.ecr.error.amazonaws.com", + // wantOK: false, + // }, + { + registry: "gcr.io/foo/bar:baz", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.registry, func(t *testing.T) { + g := NewWithT(t) + + accId, region, ok := aws.ParseRegistry(tt.registry) + g.Expect(ok).To(Equal(tt.wantOK), "unexpected OK") + g.Expect(accId).To(Equal(tt.wantAccountID), "unexpected account IDs") + g.Expect(region).To(Equal(tt.wantRegion), "unexpected regions") + }) + } +} diff --git a/auth/aws/provider.go b/auth/aws/provider.go new file mode 100644 index 000000000..feda04550 --- /dev/null +++ b/auth/aws/provider.go @@ -0,0 +1,204 @@ +/* +Copyright 2025 The Flux authors + +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. +*/ + +package aws + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/sts" + corev1 "k8s.io/api/core/v1" + + "github.com/fluxcd/pkg/auth" +) + +// ProviderName is the name of the AWS authentication provider. +const ProviderName = "aws" + +// Provider implements the auth.Provider interface for AWS authentication. +type Provider struct{} + +// GetName implements auth.Provider. +func (Provider) GetName() string { + return ProviderName +} + +// NewDefaultToken implements auth.Provider. +func (Provider) NewDefaultToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) { + var o auth.Options + o.Apply(opts...) + + var awsOpts []func(*config.LoadOptions) error + + region := getRegion() + awsOpts = append(awsOpts, config.WithRegion(region)) + + if e := o.STSEndpoint; e != "" { + awsOpts = append(awsOpts, config.WithBaseEndpoint(e)) + } + + if hc := o.GetHTTPClient(); hc != nil { + awsOpts = append(awsOpts, config.WithHTTPClient(hc)) + } + + conf, err := config.LoadDefaultConfig(ctx, awsOpts...) + if err != nil { + return nil, err + } + creds, err := conf.Credentials.Retrieve(ctx) + if err != nil { + return nil, err + } + + return newTokenFromAWSCredentials(&creds), nil +} + +// GetAudience implements auth.Provider. +func (Provider) GetAudience(ctx context.Context) (string, error) { + return "sts.amazonaws.com", nil +} + +// GetIdentity implements auth.Provider. +func (Provider) GetIdentity(serviceAccount corev1.ServiceAccount) (string, error) { + roleARN, err := getRoleARN(serviceAccount) + if err != nil { + return "", err + } + return roleARN, nil +} + +// NewTokenForServiceAccount implements auth.Provider. +func (Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string, + serviceAccount corev1.ServiceAccount, opts ...auth.Option) (auth.Token, error) { + + var o auth.Options + o.Apply(opts...) + + roleARN, err := getRoleARN(serviceAccount) + if err != nil { + return nil, err + } + + roleSessionName := getRoleSessionName(serviceAccount) + + var awsOpts sts.Options + + region := getRegion() + awsOpts.Region = region + + if e := o.STSEndpoint; e != "" { + awsOpts.BaseEndpoint = &e + } + + if u := o.ProxyURL; u != nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = http.ProxyURL(u) + httpClient := &http.Client{Transport: transport} + awsOpts.HTTPClient = httpClient + } + + req := &sts.AssumeRoleWithWebIdentityInput{ + RoleArn: &roleARN, + RoleSessionName: &roleSessionName, + WebIdentityToken: &oidcToken, + } + resp, err := sts.New(awsOpts).AssumeRoleWithWebIdentity(ctx, req) + if err != nil { + return nil, err + } + if resp.Credentials == nil { + return nil, fmt.Errorf("credentials are nil") + } + + token := &Token{*resp.Credentials} + if token.Expiration == nil { + token.Expiration = &time.Time{} + } + + return token, nil +} + +// GetArtifactCacheKey implements auth.Provider. +func (Provider) GetArtifactCacheKey(artifactRepository string) string { + if _, region, ok := ParseRegistry(artifactRepository); ok { + return region + } + return "" +} + +// NewArtifactRegistryToken implements auth.Provider. +func (Provider) NewArtifactRegistryToken(ctx context.Context, artifactRepository string, + accessToken auth.Token, opts ...auth.Option) (auth.Token, error) { + + var o auth.Options + o.Apply(opts...) + + _, region, ok := ParseRegistry(artifactRepository) + if !ok { + return nil, fmt.Errorf("invalid ecr repository: '%s'", artifactRepository) + } + + credsProvider := accessToken.(*Token).CredentialsProvider() + + conf := aws.Config{ + Region: region, + Credentials: credsProvider, + } + + if hc := o.GetHTTPClient(); hc != nil { + conf.HTTPClient = hc + } + + resp, err := ecr.NewFromConfig(conf).GetAuthorizationToken(ctx, nil) + if err != nil { + return nil, err + } + + // Parse the authorization token. + if len(resp.AuthorizationData) == 0 { + return nil, fmt.Errorf("no authorization data returned") + } + tokenResp := resp.AuthorizationData[0] + if tokenResp.AuthorizationToken == nil { + return nil, fmt.Errorf("authorization token is nil") + } + token := *tokenResp.AuthorizationToken + b, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return nil, fmt.Errorf("failed to parse authorization token: %w", err) + } + s := strings.Split(string(b), ":") + if len(s) != 2 { + return nil, fmt.Errorf("invalid authorization token format") + } + var expiresAt time.Time + if exp := tokenResp.ExpiresAt; exp != nil { + expiresAt = *exp + } + return &auth.ArtifactRegistryCredentials{ + Username: s[0], + Password: s[1], + ExpiresAt: expiresAt, + }, nil +} diff --git a/auth/aws/token.go b/auth/aws/token.go new file mode 100644 index 000000000..b8d383bc5 --- /dev/null +++ b/auth/aws/token.go @@ -0,0 +1,47 @@ +/* +Copyright 2025 The Flux authors + +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. +*/ + +package aws + +import ( + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/sts/types" +) + +// Token is the AWS token. +type Token struct{ types.Credentials } + +func newTokenFromAWSCredentials(creds *aws.Credentials) *Token { + return &Token{types.Credentials{ + AccessKeyId: &creds.AccessKeyID, + SecretAccessKey: &creds.SecretAccessKey, + SessionToken: &creds.SessionToken, + Expiration: &creds.Expires, + }} +} + +// GetDuration implements auth.Token. +func (t *Token) GetDuration() time.Duration { + return time.Until(*t.Expiration) +} + +// CredentialsProvider gets a credentials provider for the token to use with AWS libraries. +func (t *Token) CredentialsProvider() aws.CredentialsProvider { + return credentials.NewStaticCredentialsProvider(*t.AccessKeyId, *t.SecretAccessKey, *t.SessionToken) +} diff --git a/auth/go.mod b/auth/go.mod index a07ddfc35..cf1c5cc7e 100644 --- a/auth/go.mod +++ b/auth/go.mod @@ -10,6 +10,11 @@ replace ( require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 + github.com/aws/aws-sdk-go-v2 v1.36.3 + github.com/aws/aws-sdk-go-v2/config v1.29.14 + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 + github.com/aws/aws-sdk-go-v2/service/ecr v1.43.3 + github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 github.com/coreos/go-oidc/v3 v3.14.1 github.com/fluxcd/pkg/cache v0.8.0 github.com/golang-jwt/jwt/v5 v5.2.2 @@ -22,6 +27,15 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect + github.com/aws/smithy-go v1.22.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/auth/go.sum b/auth/go.sum index 0827b463a..d26d5146a 100644 --- a/auth/go.sum +++ b/auth/go.sum @@ -10,6 +10,34 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/ecr v1.43.3 h1:YyH8Hk73bYzdbvf6S8NF5z/fb/1stpiMnFSfL6jSfRA= +github.com/aws/aws-sdk-go-v2/service/ecr v1.43.3/go.mod h1:iQ1skgw1XRK+6Lgkb0I9ODatAP72WoTILh0zXQ5DtbU= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=