From 189573fa1b4d7693279a41198a206e6d8b201623 Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Tue, 31 Mar 2026 10:37:13 +0300 Subject: [PATCH] FIX (storages): Validat only single rclone storage is passed --- .../features/storages/models/rclone/model.go | 28 +++++ .../storages/models/rclone/model_test.go | 118 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 backend/internal/features/storages/models/rclone/model_test.go diff --git a/backend/internal/features/storages/models/rclone/model.go b/backend/internal/features/storages/models/rclone/model.go index 6b9f9fb..b92354d 100644 --- a/backend/internal/features/storages/models/rclone/model.go +++ b/backend/internal/features/storages/models/rclone/model.go @@ -144,6 +144,27 @@ func (r *RcloneStorage) Validate(encryptor encryption.FieldEncryptor) error { return errors.New("rclone config content is required") } + configContent, err := encryptor.Decrypt(r.StorageID, r.ConfigContent) + if err != nil { + return fmt.Errorf("failed to decrypt rclone config content: %w", err) + } + + parsedConfig, err := parseConfigContent(configContent) + if err != nil { + return fmt.Errorf("failed to parse rclone config: %w", err) + } + + if len(parsedConfig) == 0 { + return errors.New("rclone config must contain at least one remote section") + } + + if len(parsedConfig) > 1 { + return fmt.Errorf( + "rclone config must contain exactly one remote section, but found %d; create a separate storage for each remote", + len(parsedConfig), + ) + } + return nil } @@ -230,6 +251,13 @@ func (r *RcloneStorage) getFs( return nil, errors.New("rclone config must contain at least one remote section") } + if len(parsedConfig) > 1 { + return nil, fmt.Errorf( + "rclone config must contain exactly one remote section, but found %d; create a separate storage for each remote", + len(parsedConfig), + ) + } + var remoteName string for section, values := range parsedConfig { remoteName = section diff --git a/backend/internal/features/storages/models/rclone/model_test.go b/backend/internal/features/storages/models/rclone/model_test.go new file mode 100644 index 0000000..a931e4f --- /dev/null +++ b/backend/internal/features/storages/models/rclone/model_test.go @@ -0,0 +1,118 @@ +package rclone_storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ParseConfigContent_SingleRemote_ParsedCorrectly(t *testing.T) { + content := `[myremote] +type = s3 +provider = AWS +access_key_id = AKIAIOSFODNN7EXAMPLE +secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +region = us-east-1` + + sections, err := parseConfigContent(content) + + require.NoError(t, err) + require.Len(t, sections, 1) + assert.Equal(t, "s3", sections["myremote"]["type"]) + assert.Equal(t, "AWS", sections["myremote"]["provider"]) + assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", sections["myremote"]["access_key_id"]) + assert.Equal(t, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", sections["myremote"]["secret_access_key"]) + assert.Equal(t, "us-east-1", sections["myremote"]["region"]) +} + +func Test_ParseConfigContent_MultipleRemotes_AllParsed(t *testing.T) { + content := `[remote1] +type = s3 +region = us-east-1 + +[remote2] +type = drive +client_id = abc123` + + sections, err := parseConfigContent(content) + + require.NoError(t, err) + assert.Len(t, sections, 2) + assert.Equal(t, "s3", sections["remote1"]["type"]) + assert.Equal(t, "us-east-1", sections["remote1"]["region"]) + assert.Equal(t, "drive", sections["remote2"]["type"]) + assert.Equal(t, "abc123", sections["remote2"]["client_id"]) +} + +func Test_ParseConfigContent_EmptyContent_ReturnsEmptyMap(t *testing.T) { + sections, err := parseConfigContent("") + + require.NoError(t, err) + assert.Empty(t, sections) +} + +func Test_ParseConfigContent_CommentsAndBlankLines_Ignored(t *testing.T) { + content := `# This is a comment +; Another comment + +[myremote] +type = s3 + +# inline comment line +region = eu-west-1` + + sections, err := parseConfigContent(content) + + require.NoError(t, err) + require.Len(t, sections, 1) + assert.Equal(t, "s3", sections["myremote"]["type"]) + assert.Equal(t, "eu-west-1", sections["myremote"]["region"]) +} + +func Test_ParseConfigContent_ValueWithEqualsSign_PreservesFullValue(t *testing.T) { + content := `[myremote] +type = s3 +secret_access_key = abc=def=ghi` + + sections, err := parseConfigContent(content) + + require.NoError(t, err) + assert.Equal(t, "abc=def=ghi", sections["myremote"]["secret_access_key"]) +} + +func Test_ParseConfigContent_KeyWithoutValue_EmptyString(t *testing.T) { + content := `[myremote] +type = +provider = AWS` + + sections, err := parseConfigContent(content) + + require.NoError(t, err) + assert.Equal(t, "", sections["myremote"]["type"]) + assert.Equal(t, "AWS", sections["myremote"]["provider"]) +} + +func Test_ParseConfigContent_KeyValueOutsideSection_Ignored(t *testing.T) { + content := `orphan_key = orphan_value +[myremote] +type = s3` + + sections, err := parseConfigContent(content) + + require.NoError(t, err) + assert.Len(t, sections, 1) + assert.Equal(t, "s3", sections["myremote"]["type"]) +} + +func Test_ParseConfigContent_WhitespaceAroundKeysAndValues_Trimmed(t *testing.T) { + content := `[myremote] + type = s3 + region = us-west-2 ` + + sections, err := parseConfigContent(content) + + require.NoError(t, err) + assert.Equal(t, "s3", sections["myremote"]["type"]) + assert.Equal(t, "us-west-2", sections["myremote"]["region"]) +}