check / check (push) Waiting to run
storage_url refused only the outer URL's userinfo and unknown query parameters. Userinfo inside the s3 endpoint= value, and rclone's remote,param=value parameters in the remote name, were accepted and then printed by info, remote info and snapshot create. Both are now refused when the storage URL is parsed, without echoing the value: a host name never contains "@", and an rclone remote name never contains a comma. The rclone case has its own error pointing at rclone config, since the existing credentials error names the s3.* keys. Model: opus-5-5
256 lines
7.1 KiB
Go
256 lines
7.1 KiB
Go
package storage_test
|
|
|
|
import (
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
)
|
|
|
|
// TestParseStorageURLValid checks that each supported scheme parses into
|
|
// the expected fields, since those fields decide which backend is built.
|
|
func TestParseStorageURLValid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
bucket = "mybucket"
|
|
fileScheme = "file"
|
|
)
|
|
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want *storage.URL
|
|
}{
|
|
{
|
|
name: "file absolute path",
|
|
raw: "file:///var/backups/vaultik",
|
|
want: &storage.URL{Scheme: fileScheme, Prefix: "/var/backups/vaultik"},
|
|
},
|
|
{
|
|
name: "file percent-escaped path is decoded",
|
|
raw: "file:///Volumes/my%20stick/backup",
|
|
want: &storage.URL{Scheme: fileScheme, Prefix: "/Volumes/my stick/backup"},
|
|
},
|
|
{
|
|
name: "file path with a literal space",
|
|
raw: "file:///Volumes/my stick/backup",
|
|
want: &storage.URL{Scheme: fileScheme, Prefix: "/Volumes/my stick/backup"},
|
|
},
|
|
{
|
|
name: "s3 bucket and prefix, ssl defaults on",
|
|
raw: "s3://mybucket/backups/host",
|
|
want: &storage.URL{
|
|
Scheme: "s3", Bucket: bucket,
|
|
Prefix: "backups/host", UseSSL: true,
|
|
},
|
|
},
|
|
{
|
|
name: "s3 bucket only",
|
|
raw: "s3://mybucket",
|
|
want: &storage.URL{Scheme: "s3", Bucket: bucket, UseSSL: true},
|
|
},
|
|
{
|
|
name: "s3 with endpoint, region, ssl off",
|
|
raw: "s3://mybucket?endpoint=minio.example.com®ion=us-west-2&ssl=false",
|
|
want: &storage.URL{
|
|
Scheme: "s3", Bucket: bucket,
|
|
Endpoint: "minio.example.com", Region: "us-west-2", UseSSL: false,
|
|
},
|
|
},
|
|
{
|
|
name: "rclone remote and path",
|
|
raw: "rclone://gdrive/backups/host",
|
|
want: &storage.URL{
|
|
Scheme: "rclone", RcloneRemote: "gdrive", Prefix: "backups/host",
|
|
},
|
|
},
|
|
{
|
|
name: "rclone remote only",
|
|
raw: "rclone://gdrive",
|
|
want: &storage.URL{Scheme: "rclone", RcloneRemote: "gdrive"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := storage.ParseStorageURL(tc.raw)
|
|
if err != nil {
|
|
t.Fatalf("ParseStorageURL(%q) returned error: %v", tc.raw, err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("ParseStorageURL(%q) = %+v, want %+v", tc.raw, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseStorageURLErrors checks that empty, missing, and unknown-scheme
|
|
// inputs fail with the documented sentinel errors instead of parsing to a
|
|
// wrong destination.
|
|
func TestParseStorageURLErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
wantErr error
|
|
}{
|
|
{"empty url", "", storage.ErrEmptyStorageURL},
|
|
{"file empty path", "file://", storage.ErrEmptyFilePath},
|
|
{"file relative path", "file://dest", storage.ErrRelativeFilePath},
|
|
{"s3 missing bucket", "s3://", storage.ErrMissingBucket},
|
|
{"s3 missing bucket with path", "s3:///justprefix", storage.ErrMissingBucket},
|
|
{"rclone missing remote", "rclone://", storage.ErrMissingRemote},
|
|
{"unknown scheme", "gs://bucket/x", storage.ErrUnsupportedScheme},
|
|
{"no scheme", "/local/path", storage.ErrUnsupportedScheme},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := storage.ParseStorageURL(tc.raw)
|
|
if !errors.Is(err, tc.wantErr) {
|
|
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
|
|
tc.raw, err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseStorageURLRejectsCredentials checks that a URL carrying
|
|
// credentials in its userinfo or in an unknown query parameter is
|
|
// rejected, and that the error never echoes the secret-bearing URL back
|
|
// into logs or output.
|
|
func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Split so the literals never form a "user:pass@" URL pattern that
|
|
// tooling would flag as a real hardcoded credential.
|
|
const (
|
|
key = "AKIAKEY"
|
|
secret = "topsecret"
|
|
)
|
|
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
wantErr error
|
|
secrets []string // must not appear in the error message
|
|
}{
|
|
{
|
|
name: "s3 userinfo",
|
|
raw: "s3://" + key + ":" + secret + "@mybucket/prefix",
|
|
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,
|
|
wantErr: storage.ErrURLUnknownParam,
|
|
secrets: []string{key, secret},
|
|
},
|
|
{
|
|
name: "s3 misspelt endpoint",
|
|
raw: "s3://mybucket?endpiont=minio.example.com",
|
|
wantErr: storage.ErrURLUnknownParam,
|
|
secrets: nil,
|
|
},
|
|
{
|
|
name: "rclone userinfo",
|
|
raw: "rclone://user:" + secret + "@gdrive/backups",
|
|
wantErr: storage.ErrURLCredentials,
|
|
secrets: []string{secret},
|
|
},
|
|
{
|
|
name: "rclone query param",
|
|
raw: "rclone://gdrive/backups?token=" + secret,
|
|
wantErr: storage.ErrURLUnknownParam,
|
|
secrets: []string{secret},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := storage.ParseStorageURL(tc.raw)
|
|
if !errors.Is(err, tc.wantErr) {
|
|
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
|
tc.raw, err, tc.wantErr)
|
|
}
|
|
|
|
// The rejection must name the proper config keys so the
|
|
// operator knows where credentials belong.
|
|
for _, key := range []string{"s3.access_key_id", "s3.secret_access_key"} {
|
|
if !strings.Contains(err.Error(), key) {
|
|
t.Errorf("error %q does not name %q", err.Error(), key)
|
|
}
|
|
}
|
|
|
|
for _, secret := range tc.secrets {
|
|
if strings.Contains(err.Error(), secret) {
|
|
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const secret = "topsecret"
|
|
|
|
raw := "rclone://gdrive,client_secret=" + secret + "/backups"
|
|
|
|
_, err := storage.ParseStorageURL(raw)
|
|
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
|
|
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
|
raw, err, storage.ErrRcloneRemoteParams)
|
|
}
|
|
|
|
if strings.Contains(err.Error(), secret) {
|
|
t.Errorf("error message leaked %q: %v", secret, 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.
|
|
func TestParseStorageURLParseFailureHidesURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const raw = "s3://mybucket/%zz"
|
|
|
|
_, err := storage.ParseStorageURL(raw)
|
|
if err == nil {
|
|
t.Fatalf("ParseStorageURL(%q) returned no error", raw)
|
|
}
|
|
|
|
if strings.Contains(err.Error(), "mybucket") {
|
|
t.Errorf("error message echoed the raw URL: %v", err.Error())
|
|
}
|
|
}
|