Compare commits
4
Commits
353c2471ad
...
df3f4d8df5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df3f4d8df5 | ||
|
|
8d5e6f3399 | ||
|
|
abad8161bc | ||
|
|
05a2c86d65 |
+2
-1
@@ -302,7 +302,8 @@ storage_url: "rclone://myremote/path/to/backups"
|
||||
|
||||
# Average chunk size for content-defined chunking
|
||||
# Smaller chunks = better deduplication but more metadata
|
||||
# Supports: 10MB, 5M, 1GB, 500KB, 64MiB, etc.
|
||||
# Minimum 1MB
|
||||
# Supports: 10MB, 5M, 1GB, 64MiB, etc.
|
||||
# Default: 10MB
|
||||
#chunk_size: 10MB
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ storage_url: ""
|
||||
|
||||
# Average chunk size for content-defined chunking (FastCDC).
|
||||
# Smaller = better deduplication but more metadata overhead.
|
||||
# Accepts: 1MB, 10M, 64KB, etc.
|
||||
# Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
|
||||
# Default: 10MB
|
||||
# chunk_size: 10MB
|
||||
|
||||
|
||||
@@ -30,13 +30,15 @@ const appName = "vaultik"
|
||||
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
||||
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||
|
||||
// Defaults and validation bounds for tunable settings.
|
||||
// Defaults and validation bounds for tunable settings. MB and GB are
|
||||
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
|
||||
// limits, which are 1024-based (MiB, GiB).
|
||||
const (
|
||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
||||
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
|
||||
defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
|
||||
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
||||
defaultCompressionLevel = 3
|
||||
minChunkSize = 1024 * 1024 // 1MB
|
||||
minChunkSize = 1000 * 1000 // 1MB
|
||||
minCompressionLevel = 1
|
||||
maxCompressionLevel = 19
|
||||
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
||||
|
||||
@@ -337,6 +337,81 @@ func TestLoadS3PartSize(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestDocumentedSizeDefaults checks that the defaults are the documented
|
||||
// chunk_size 10MB and blob_size_limit 10GB as the parser reads those values
|
||||
// (1000-based), so writing a default out in the config file changes nothing.
|
||||
func TestDocumentedSizeDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := Parse([]byte("chunk_size: 10MB\nblob_size_limit: 10GB\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.ChunkSize != defaultChunkSize {
|
||||
t.Errorf("chunk_size: 10MB is %d bytes, the default is %d",
|
||||
cfg.ChunkSize, defaultChunkSize)
|
||||
}
|
||||
|
||||
if cfg.BlobSizeLimit != defaultBlobSizeLimit {
|
||||
t.Errorf("blob_size_limit: 10GB is %d bytes, the default is %d",
|
||||
cfg.BlobSizeLimit, defaultBlobSizeLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
||||
// every size the README, config.example.yml and the config init template
|
||||
// list as an example is accepted.
|
||||
func TestDocumentedSizeExamples(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const withoutSizes = "snapshots:\n" +
|
||||
" test:\n" +
|
||||
" paths: [/tmp/vaultik-test-source]\n" +
|
||||
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||
|
||||
tests := []struct {
|
||||
setting string
|
||||
wantErr error
|
||||
}{
|
||||
{"chunk_size: 1MB", nil},
|
||||
{"chunk_size: 999999", errChunkSizeTooSmall},
|
||||
{"chunk_size: 5M", nil},
|
||||
{"chunk_size: 10M", nil},
|
||||
{"chunk_size: 10MB", nil},
|
||||
{"chunk_size: 64MiB", nil},
|
||||
{"chunk_size: 1GB", nil},
|
||||
// Four times the default chunk_size, the documented minimum.
|
||||
{"blob_size_limit: 40MB", nil},
|
||||
{"blob_size_limit: 500MB", nil},
|
||||
{"blob_size_limit: 1GB", nil},
|
||||
{"blob_size_limit: 1GiB", nil},
|
||||
{"blob_size_limit: 10G", nil},
|
||||
{"blob_size_limit: 10GB", nil},
|
||||
{"s3: {part_size: 5MiB}", nil},
|
||||
{"s3: {part_size: 10MB}", nil},
|
||||
{"s3: {part_size: 16MiB}", nil},
|
||||
{"s3: {part_size: 100MiB}", nil},
|
||||
{"s3: {part_size: 5GiB}", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
err = cfg.Validate()
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||
// relative path fails at load. Accepted, it would resolve against the
|
||||
// working directory, so a backup run from another directory would write
|
||||
|
||||
@@ -578,3 +578,54 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
||||
require.Equal(t, 1, result.FilesScanned,
|
||||
"**/ file pattern should match at the top level and nested")
|
||||
}
|
||||
|
||||
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As in gitignore, /**/ also matches directly under the source dir
|
||||
fs := setupAnchoredTestFS(t)
|
||||
|
||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||
|
||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||
require.NoError(t, err)
|
||||
|
||||
// /**/projectname should exclude BOTH:
|
||||
// - /backup/projectname/file.txt
|
||||
// - /backup/otherproject/projectname/file.txt
|
||||
// Total files: 4, excluded: 2
|
||||
require.Equal(t, 2, result.FilesScanned,
|
||||
"/**/ pattern should match at the top level and nested")
|
||||
}
|
||||
|
||||
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
|
||||
// is a glob: with a plain name the glob library already matched a/b.
|
||||
fs := &afero.MemMapFs{}
|
||||
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
|
||||
|
||||
for _, path := range files {
|
||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
|
||||
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
|
||||
}
|
||||
|
||||
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||
|
||||
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||
require.NoError(t, err)
|
||||
|
||||
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
|
||||
// Total files: 3, excluded: 2
|
||||
require.Equal(t, 1, result.FilesScanned,
|
||||
"a/**/*.log should match a/x.log as well as a/c/y.log")
|
||||
}
|
||||
|
||||
@@ -2035,33 +2035,60 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
||||
pattern = strings.TrimSuffix(pattern, "/")
|
||||
|
||||
// A leading "**/" demands a "/" before the name and so misses an
|
||||
// entry directly under the source path. An unanchored pattern
|
||||
// already matches at every depth, so drop the prefix: as in
|
||||
// gitignore, "**/x" then also matches a top-level "x".
|
||||
if !anchored {
|
||||
pattern = strings.TrimPrefix(pattern, "**/")
|
||||
// entry directly under the source path. It means "at every depth",
|
||||
// which is what an unanchored pattern already does, so drop the
|
||||
// prefix and match unanchored: as in gitignore, "**/x" and
|
||||
// "/**/x" then also match a top-level "x".
|
||||
if rest, found := strings.CutPrefix(pattern, "**/"); found {
|
||||
pattern = rest
|
||||
anchored = false
|
||||
}
|
||||
|
||||
// Compile the glob pattern
|
||||
// For patterns without path separators, we need to match them as components
|
||||
// e.g., ".git" should match ".git" anywhere in the path
|
||||
g, err := glob.Compile(pattern, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||
// Likewise "/**/" inside a pattern demands two slashes, so
|
||||
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
||||
// zero or more directories, so also compile the pattern with it
|
||||
// made a single "/". The glob library's {a,b} is no help: with a
|
||||
// "**" inside it in mid-pattern, it misses paths it should match.
|
||||
for _, variant := range doubleStarVariants(pattern) {
|
||||
// For patterns without path separators, we need to match them as components
|
||||
// e.g., ".git" should match ".git" anywhere in the path
|
||||
g, err := glob.Compile(variant, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||
|
||||
continue
|
||||
break
|
||||
}
|
||||
|
||||
compiled = append(compiled, compiledPattern{
|
||||
pattern: g,
|
||||
anchored: anchored,
|
||||
original: p,
|
||||
})
|
||||
}
|
||||
|
||||
compiled = append(compiled, compiledPattern{
|
||||
pattern: g,
|
||||
anchored: anchored,
|
||||
original: p,
|
||||
})
|
||||
}
|
||||
|
||||
return compiled
|
||||
}
|
||||
|
||||
// doubleStarVariants returns pattern once for every way of keeping each
|
||||
// "/**/" in it or making it a single "/".
|
||||
func doubleStarVariants(pattern string) []string {
|
||||
const doubleStar = "/**/"
|
||||
|
||||
before, after, found := strings.Cut(pattern, doubleStar)
|
||||
if !found {
|
||||
return []string{pattern}
|
||||
}
|
||||
|
||||
var variants []string
|
||||
|
||||
for _, rest := range doubleStarVariants(after) {
|
||||
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||
// filePath is the full path to the file
|
||||
// rootPath is the root of the backup source directory
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
"github.com/rclone/rclone/fs/config/configfile"
|
||||
"github.com/rclone/rclone/fs/operations"
|
||||
|
||||
@@ -33,6 +35,15 @@ type RcloneStorer struct {
|
||||
// The remote parameter is the rclone remote name (as configured via `rclone config`).
|
||||
// The path parameter is the path within the remote.
|
||||
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
|
||||
// rclone reads RCLONE_CONFIG only as the value of its --config flag,
|
||||
// which vaultik does not have, so it is applied here as that flag would.
|
||||
if configPath, ok := os.LookupEnv("RCLONE_CONFIG"); ok {
|
||||
err := config.SetConfigPath(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("using RCLONE_CONFIG %q: %w", configPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Install the default config file handler
|
||||
configfile.Install()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
@@ -390,3 +391,41 @@ func TestNewRcloneStorerUnknownRemote(t *testing.T) {
|
||||
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRcloneStorerReadsRcloneConfigEnv checks that a remote defined only
|
||||
// in the file RCLONE_CONFIG names is found and written to, as rclone itself
|
||||
// would.
|
||||
func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
|
||||
// rclone keeps the config file path in a process-global variable, which
|
||||
// NewRcloneStorer leaves pointing at this test's file.
|
||||
previous := config.GetConfigPath()
|
||||
|
||||
t.Cleanup(func() { _ = config.SetConfigPath(previous) })
|
||||
|
||||
confFile := filepath.Join(t.TempDir(), "rclone.conf")
|
||||
|
||||
err := os.WriteFile(confFile, []byte("[loc]\ntype = local\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing rclone config: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("RCLONE_CONFIG", confFile)
|
||||
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := storage.NewRcloneStorer(ctx, "loc", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRcloneStorer: %v", err)
|
||||
}
|
||||
|
||||
err = s.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, testBlobKey))
|
||||
if err != nil {
|
||||
t.Errorf("object is not in the remote's directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+47
-6
@@ -30,6 +30,12 @@ var (
|
||||
"storage URL must not carry credentials; " +
|
||||
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
||||
ErrURLBadQuery = errors.New(
|
||||
"storage URL query cannot be decoded; " +
|
||||
"check it for a ; or a % not followed by two hex digits")
|
||||
ErrRcloneRemoteParams = errors.New(
|
||||
"rclone URL must not carry parameters in the remote name; " +
|
||||
"set them on the remote with rclone config instead")
|
||||
)
|
||||
|
||||
// URL represents a parsed storage URL.
|
||||
@@ -90,8 +96,9 @@ func parseFileURL(rawPath string) (*URL, error) {
|
||||
}
|
||||
|
||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
||||
// the userinfo and any query parameter other than endpoint, region and
|
||||
// ssl, so a credential-bearing URL is never stored or echoed.
|
||||
// the userinfo or in the endpoint parameter's userinfo, a query that
|
||||
// cannot be decoded, and any query parameter other than endpoint, region
|
||||
// and ssl, so a credential-bearing URL is never stored or echoed.
|
||||
func parseS3URL(rawURL string) (*URL, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
@@ -107,13 +114,26 @@ func parseS3URL(rawURL string) (*URL, error) {
|
||||
return nil, ErrMissingBucket
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
// u.Query() would silently drop a part it cannot decode, endpoint
|
||||
// included. The decode error is not wrapped: it can quote the value.
|
||||
query, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
return nil, ErrURLBadQuery
|
||||
}
|
||||
|
||||
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A host name never contains "@", so one in an endpoint, with or
|
||||
// without a scheme, can only introduce userinfo.
|
||||
for _, endpoint := range query["endpoint"] {
|
||||
if strings.Contains(endpoint, "@") {
|
||||
return nil, ErrURLCredentials
|
||||
}
|
||||
}
|
||||
|
||||
return &URL{
|
||||
Scheme: schemeS3,
|
||||
Bucket: bucket,
|
||||
@@ -125,9 +145,23 @@ func parseS3URL(rawURL string) (*URL, error) {
|
||||
}
|
||||
|
||||
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
||||
// query parameters, so credentials in the userinfo and any parameter at
|
||||
// query parameters, so credentials in the userinfo, parameters in the
|
||||
// remote name, a query that cannot be decoded and any query parameter at
|
||||
// all are rejected rather than silently ignored.
|
||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
||||
// The remote name runs up to the first "/", "?" or "#". It never
|
||||
// contains a comma; one starts rclone's remote,param=value form,
|
||||
// whose values can be credentials. This is checked before url.Parse,
|
||||
// whose error for such a name can print part of it.
|
||||
remote := strings.TrimPrefix(rawURL, "rclone://")
|
||||
if end := strings.IndexAny(remote, "/?#"); end >= 0 {
|
||||
remote = remote[:end]
|
||||
}
|
||||
|
||||
if strings.Contains(remote, ",") {
|
||||
return nil, ErrRcloneRemoteParams
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, wrapParseError(err)
|
||||
@@ -137,12 +171,19 @@ func parseRcloneURL(rawURL string) (*URL, error) {
|
||||
return nil, ErrURLCredentials
|
||||
}
|
||||
|
||||
remote := u.Host
|
||||
remote = u.Host
|
||||
if remote == "" {
|
||||
return nil, ErrMissingRemote
|
||||
}
|
||||
|
||||
err = rejectUnknownParams(u.Query())
|
||||
// As in parseS3URL: u.Query() would drop a part it cannot decode, and
|
||||
// the decode error can quote the value.
|
||||
query, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
return nil, ErrURLBadQuery
|
||||
}
|
||||
|
||||
err = rejectUnknownParams(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -150,6 +150,19 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{key, secret, "mybucket"},
|
||||
},
|
||||
{
|
||||
name: "s3 userinfo in endpoint",
|
||||
raw: "s3://mybucket?endpoint=https://" +
|
||||
key + ":" + secret + "@minio.example.com",
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{key, secret},
|
||||
},
|
||||
{
|
||||
name: "s3 userinfo in endpoint without scheme",
|
||||
raw: "s3://mybucket?endpoint=" + key + ":" + secret + "@minio.example.com",
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{key, secret},
|
||||
},
|
||||
{
|
||||
name: "s3 unknown query param",
|
||||
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
||||
@@ -203,6 +216,112 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
|
||||
// remote name carrying parameters (rclone's "remote,param=value" form,
|
||||
// which can hold a credential) is rejected without echoing the value,
|
||||
// including the URLs that url.Parse itself refuses.
|
||||
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
secrets []string // must not appear in the error message
|
||||
}{
|
||||
{
|
||||
name: "configured remote",
|
||||
raw: "rclone://gdrive,client_secret=topsecret/backups",
|
||||
secrets: []string{"topsecret"},
|
||||
},
|
||||
{
|
||||
name: "remote defined inline",
|
||||
raw: "rclone://:s3,access_key_id=KEY,secret_access_key=SECRET/bucket",
|
||||
secrets: []string{"KEY", "SECRET"},
|
||||
},
|
||||
{
|
||||
name: "colon in a parameter value",
|
||||
raw: "rclone://gdrive,client_secret=ab:cd/backups",
|
||||
secrets: []string{"cd"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := storage.ParseStorageURL(tc.raw)
|
||||
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
|
||||
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
||||
tc.raw, err, storage.ErrRcloneRemoteParams)
|
||||
}
|
||||
|
||||
for _, secret := range tc.secrets {
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLRejectsUndecodableQuery checks that an s3 or rclone
|
||||
// URL whose query cannot be decoded is rejected, not read with that part
|
||||
// of the query left out, and that the error does not echo it.
|
||||
func TestParseStorageURLRejectsUndecodableQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
key = "AKIAKEY"
|
||||
secret = "topsecret"
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{
|
||||
name: "semicolon",
|
||||
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||
"@minio.example.com:9000;region=us-east-1",
|
||||
},
|
||||
{
|
||||
name: "bad percent escape",
|
||||
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||
"%zz@minio.example.com:9000",
|
||||
},
|
||||
{
|
||||
name: "rclone semicolon",
|
||||
raw: "rclone://gdrive/backups?token=" + secret + ";x=1",
|
||||
},
|
||||
{
|
||||
name: "rclone bad percent escape",
|
||||
raw: "rclone://gdrive/backups?token=" + secret + "%zz",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := storage.ParseStorageURL(tc.raw)
|
||||
if err == nil {
|
||||
t.Fatalf("ParseStorageURL(%q) returned no error", tc.raw)
|
||||
}
|
||||
|
||||
if !errors.Is(err, storage.ErrURLBadQuery) {
|
||||
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
|
||||
tc.raw, err, storage.ErrURLBadQuery)
|
||||
}
|
||||
|
||||
for _, s := range []string{key, secret, "minio.example.com"} {
|
||||
if strings.Contains(err.Error(), s) {
|
||||
t.Errorf("error message leaked %q: %v", s, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
||||
// itself fails, the wrapped error carries only the inner cause, not the
|
||||
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
||||
|
||||
Reference in New Issue
Block a user