check / check (pull_request) Successful in 3m31s
config set now prints only the key name after a write, never the value: a value may be a secret such as s3.secret_access_key, and echoing it leaks into captured stdout and pasted terminals. The set logic moves into writeConfigSet so this is testable. config set also tightens a pre-existing group- or world-readable config to 0600 after writing. os.WriteFile does not change an existing file's mode, so the previous stat-and-preserve-mode block had no effect; it is removed. ParseStorageURL now rejects s3:// and rclone:// URLs that carry credentials in the userinfo or an unknown query parameter, and names s3.access_key_id and s3.secret_access_key as where credentials belong; rclone:// accepts no parameters, so a misspelt one is caught rather than silently sending the backup to the default endpoint. On a url.Parse failure only the inner cause is wrapped, so the raw URL is not echoed. file:// is unchanged. Model: opus-4-8
209 lines
5.5 KiB
Go
209 lines
5.5 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"
|
|
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want *storage.URL
|
|
}{
|
|
{
|
|
name: "file absolute path",
|
|
raw: "file:///var/backups/vaultik",
|
|
want: &storage.URL{Scheme: "file", Prefix: "/var/backups/vaultik"},
|
|
},
|
|
{
|
|
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},
|
|
{"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 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())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|