From dc89cef652515d5cfe64670894d97ad36f0ecd91 Mon Sep 17 00:00:00 2001 From: Lauren Verity Date: Tue, 3 Nov 2026 14:51:29 -0800 Subject: [PATCH 1/2] Fix Bundler credentials for usernames containing '@' Bundler assigns a BUNDLE_ credential to the source URI's userinfo and decodes each part with CGI.unescape, so a raw '@' (e.g. an email username) is rejected with URI::InvalidComponentError. Percent-encode the username and password both where `jf ruby bundle` injects the env var and where `jf setup` writes ~/.bundle/config. Co-Authored-By: Claude Opus 5.5 --- artifactory/commands/ruby/native_ruby.go | 16 +++++++++- artifactory/commands/ruby/native_ruby_test.go | 32 +++++++++++++++++++ artifactory/commands/setup/setup.go | 2 +- artifactory/commands/setup/setup_test.go | 17 ++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/artifactory/commands/ruby/native_ruby.go b/artifactory/commands/ruby/native_ruby.go index dced9c70..7e853eef 100644 --- a/artifactory/commands/ruby/native_ruby.go +++ b/artifactory/commands/ruby/native_ruby.go @@ -616,7 +616,7 @@ func (rc *RubyCommand) injectAuth(serverDetails *coreConfig.ServerDetails, sourc var extraEnv []string switch rc.nativeTool { case toolBundle: - cred := fmt.Sprintf("%s:%s", user, pass) + cred := BundleCredential(user, pass) // Bundler's credential lookup varies by version, and falls back from the // host:port form to the bare hostname, so inject every candidate spelling. candidates := BundleCredentialKeys(host) @@ -679,6 +679,20 @@ func rubyHasCredentials(pass string) bool { return pass != "" } +// BundleCredential formats a user/password pair as a Bundler per-host credential (the +// value of BUNDLE_, as an env var or in ~/.bundle/config). Bundler assigns that +// value to the source URI's userinfo, which rejects raw reserved characters such as '@' +// (e.g. an email username) or a ':' in the username, and later decodes each part with +// CGI.unescape. Each part is therefore percent-encoded. +// Spaces are written as %20 rather than '+' so the value also round-trips through +// decoders that do not treat '+' as a space. +func BundleCredential(user, pass string) string { + escape := func(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") + } + return escape(user) + ":" + escape(pass) +} + // BundleEnvKeyForHost converts a host into Bundler's per-host credential env var name, // following Bundler's key normalization: uppercase, "." → "__", "-" → "___", and any // remaining non-alphanumeric character → "_", prefixed with "BUNDLE_". diff --git a/artifactory/commands/ruby/native_ruby_test.go b/artifactory/commands/ruby/native_ruby_test.go index 9e1cc9f5..8b28f40f 100644 --- a/artifactory/commands/ruby/native_ruby_test.go +++ b/artifactory/commands/ruby/native_ruby_test.go @@ -584,3 +584,35 @@ func TestUserHomeDir_PrefersHome(t *testing.T) { assert.Equal(t, expected, fallback) } } + +func TestBundleCredential(t *testing.T) { + cases := []struct { + user, pass, want string + }{ + {"myuser", "mypass", "myuser:mypass"}, + {"jane@example.com", "mypass", "jane%40example.com:mypass"}, + {"user:name", "p@ss:w/rd", "user%3Aname:p%40ss%3Aw%2Frd"}, + {"user", "a+b c", "user:a%2Bb%20c"}, + {"", "token", ":token"}, + } + for _, c := range cases { + assert.Equal(t, c.want, BundleCredential(c.user, c.pass), "user %q pass %q", c.user, c.pass) + } +} + +func TestRubyInjectAuth_BundleEscapesEmailUsername(t *testing.T) { + server := &coreConfig.ServerDetails{ + User: "jane@example.com", + Password: "p@ss", + ArtifactoryUrl: "https://my.jfrog.io/artifactory/", + } + key := BundleEnvKeyForHost("my.jfrog.io") + t.Setenv(key, "") + + rc := NewRubyCommand().SetNativeTool(toolBundle) + env := rc.injectAuth(server, "https://my.jfrog.io/artifactory/api/gems/gems-virtual/") + assert.Contains(t, env, key+"=jane%40example.com:p%40ss") + for _, e := range env { + assert.NotContains(t, e, "jane@example.com", "raw '@' must not reach Bundler's userinfo") + } +} diff --git a/artifactory/commands/setup/setup.go b/artifactory/commands/setup/setup.go index e84a9611..90a584f6 100644 --- a/artifactory/commands/setup/setup.go +++ b/artifactory/commands/setup/setup.go @@ -1086,7 +1086,7 @@ func (sc *SetupCommand) configureRuby() error { // Per-host credentials, for Gemfiles that name the Artifactory source explicitly. if password != "" { - credential := username + ":" + password + credential := ruby.BundleCredential(username, password) for _, key := range ruby.BundleCredentialKeys(repoUrl.Hostname()) { settings[key] = credential } diff --git a/artifactory/commands/setup/setup_test.go b/artifactory/commands/setup/setup_test.go index 7c4d309d..62e1e801 100644 --- a/artifactory/commands/setup/setup_test.go +++ b/artifactory/commands/setup/setup_test.go @@ -1228,6 +1228,23 @@ func TestWriteBundleSettings_BundlerReadsMirrorAndCredentials(t *testing.T) { assert.Equal(t, "admin:secret", parsed["BUNDLE_MY___CO__JFROG__IO"]) } +// TestConfigureRuby_EscapesEmailUsername guards against an email-style username being +// written raw into the per-host credential: Bundler rejects a bare '@' in a URI userinfo, +// so the credential must be percent-encoded. +func TestConfigureRuby_EscapesEmailUsername(t *testing.T) { + tmpHome := withTempHome(t) + + cmd := createTestSetupCommand(project.Ruby) + cmd.serverDetails.User = "jane@example.com" + cmd.serverDetails.Password = "p@ss" + "word" + + require.NoError(t, cmd.configureRuby()) + + parsed := readBundleConfig(t, tmpHome) + assert.Equal(t, "jane%40example.com:p%40ssword", parsed["BUNDLE_ACME__JFROG__IO"]) + assert.NotContains(t, parsed["BUNDLE_ACME__JFROG__IO"], "@", "raw '@' must not reach Bundler's userinfo") +} + func TestAddGemrcSource_EmptyFile(t *testing.T) { tmpHome := withTempHome(t) From 91dfb5f5f407a9683b369096c29afe8b912ba165 Mon Sep 17 00:00:00 2001 From: Uday Date: Wed, 11 Nov 2026 11:29:10 +0530 Subject: [PATCH 2/2] RTECO-0000 trigger tests for PR 585 Co-Authored-By: Claude Sonnet 5.5