MM-32133 shared channel username collisions (#17347)

Support for handling username collisions between remote clusters. Users belonging to remote clusters have their username changed to include the remote name e.g. wiggin becomes wiggin:mattermost.

@mentions are also modified so the munged username is replaced with the original username when the post is sync'd with the remote the user belongs to.

When adding remote users:
- append the remote name to the username with colon separator
- append the remote name to the email address with colon separator
- store the original username and email address in user props
- when resolving @mentions replace with the stored original username
Этот коммит содержится в:
Doug Lauder
2021-04-13 10:40:12 -04:00
коммит произвёл GitHub
родитель 869da7a78b
Коммит f69cb38249
28 изменённых файлов: 544 добавлений и 147 удалений

Просмотреть файл

@@ -12,11 +12,12 @@ import (
)
// AcceptInvitation is called when accepting an invitation to connect with a remote cluster.
func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error) {
func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error) {
rc := &model.RemoteCluster{
RemoteId: invite.RemoteId,
RemoteTeamId: invite.RemoteTeamId,
DisplayName: name,
Name: name,
DisplayName: displayName,
Token: model.NewId(),
RemoteToken: invite.Token,
SiteURL: invite.SiteURL,

Просмотреть файл

@@ -177,14 +177,14 @@ func makeRemoteClusters(num int, siteURL string) []*model.RemoteCluster {
func makeRemoteCluster(name string, siteURL string, topics string) *model.RemoteCluster {
return &model.RemoteCluster{
RemoteId: model.NewId(),
DisplayName: name,
SiteURL: siteURL,
Token: model.NewId(),
Topics: topics,
CreateAt: model.GetMillis(),
LastPingAt: model.GetMillis(),
CreatorId: model.NewId(),
RemoteId: model.NewId(),
Name: name,
SiteURL: siteURL,
Token: model.NewId(),
Topics: topics,
CreateAt: model.GetMillis(),
LastPingAt: model.GetMillis(),
CreatorId: model.NewId(),
}
}

Просмотреть файл

@@ -84,7 +84,9 @@ func (rcs *Service) sendMsg(task sendMsgTask) {
defer func() {
if r := recover(); r != nil {
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Remote Cluster sendMsg panic",
mlog.String("remote", task.rc.DisplayName), mlog.String("msgId", task.msg.Id), mlog.Any("panic", r))
mlog.String("remote", task.rc.DisplayName),
mlog.String("msgId", task.msg.Id), mlog.Any("panic", r),
)
}
if errResp != nil {

Просмотреть файл

@@ -63,7 +63,7 @@ type RemoteClusterServiceIFace interface {
RemoveConnectionStateListener(listenerId string)
SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error
SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error
AcceptInvitation(invite *model.RemoteClusterInvite, name string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error)
AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error)
ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response
}

Просмотреть файл

@@ -59,7 +59,7 @@ func TestOnReceiveChannelInvite(t *testing.T) {
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{DisplayName: "test"}
remoteCluster := &model.RemoteCluster{Name: "test"}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
@@ -119,7 +119,7 @@ func TestOnReceiveChannelInvite(t *testing.T) {
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{DisplayName: "test"}
remoteCluster := &model.RemoteCluster{Name: "test2"}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),
@@ -161,7 +161,7 @@ func TestOnReceiveChannelInvite(t *testing.T) {
}
mockStore := &mocks.Store{}
remoteCluster := &model.RemoteCluster{DisplayName: "test", CreatorId: model.NewId()}
remoteCluster := &model.RemoteCluster{Name: "test3", CreatorId: model.NewId()}
invitation := channelInviteMsg{
ChannelId: model.NewId(),
TeamId: model.NewId(),

Просмотреть файл

@@ -230,6 +230,22 @@ func (_m *MockAppIface) GetOrCreateDirectChannel(userId string, otherUserId stri
return r0, r1
}
// MentionsToTeamMembers provides a mock function with given fields: message, teamID
func (_m *MockAppIface) MentionsToTeamMembers(message string, teamID string) model.UserMentionMap {
ret := _m.Called(message, teamID)
var r0 model.UserMentionMap
if rf, ok := ret.Get(0).(func(string, string) model.UserMentionMap); ok {
r0 = rf(message, teamID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(model.UserMentionMap)
}
}
return r0
}
// PatchChannelModerationsForChannel provides a mock function with given fields: channel, channelModerationsPatch
func (_m *MockAppIface) PatchChannelModerationsForChannel(channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError) {
ret := _m.Called(channel, channelModerationsPatch)

Просмотреть файл

@@ -6,6 +6,7 @@ package sharedchannel
import (
"context"
"encoding/json"
"strings"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/shared/mlog"
@@ -54,6 +55,7 @@ func (u userCache) Add(id string) {
func (scs *Service) postsToSyncMessages(posts []*model.Post, rc *model.RemoteCluster, nextSyncAt int64) ([]syncMsg, error) {
syncMessages := make([]syncMsg, 0, len(posts))
var teamId string
uCache := make(userCache)
for _, p := range posts {
@@ -61,6 +63,19 @@ func (scs *Service) postsToSyncMessages(posts []*model.Post, rc *model.RemoteClu
continue
}
// lookup team id once
if teamId == "" {
sc, err := scs.server.GetStore().SharedChannel().Get(p.ChannelId)
if err != nil {
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Could not get shared channel for post",
mlog.String("post_id", p.Id),
mlog.Err(err),
)
continue
}
teamId = sc.TeamId
}
// any reactions originating from the remote cluster are filtered out
reactions, err := scs.server.GetStore().Reaction().GetForPostSince(p.Id, nextSyncAt, rc.RemoteId, true)
if err != nil {
@@ -104,7 +119,7 @@ func (scs *Service) postsToSyncMessages(posts []*model.Post, rc *model.RemoteClu
}
// any users originating from the remote cluster are filtered out
users := scs.usersForPost(postSync, reactions, rc, uCache)
users := scs.usersForPost(postSync, reactions, teamId, rc, uCache)
// if everything was filtered out then don't send an empty message.
if postSync == nil && len(reactions) == 0 && len(users) == 0 {
@@ -127,8 +142,9 @@ func (scs *Service) postsToSyncMessages(posts []*model.Post, rc *model.RemoteClu
// usersForPost provides a list of Users associated with the post that need to be synchronized.
// The user cache ensures the same user is not synchronized redundantly if they appear in multiple
// posts for this sync batch.
func (scs *Service) usersForPost(post *model.Post, reactions []*model.Reaction, rc *model.RemoteCluster, uCache userCache) []*model.User {
func (scs *Service) usersForPost(post *model.Post, reactions []*model.Reaction, teamID string, rc *model.RemoteCluster, uCache userCache) []*model.User {
userIds := make([]string, 0)
var mentionMap model.UserMentionMap
if post != nil && !uCache.Has(post.UserId) {
userIds = append(userIds, post.UserId)
@@ -142,7 +158,20 @@ func (scs *Service) usersForPost(post *model.Post, reactions []*model.Reaction,
}
}
// TODO: extract @mentions to local users and sync those as well?
// get mentions and userids for each mention
if post != nil {
mentionMap = scs.app.MentionsToTeamMembers(post.Message, teamID)
for mention, id := range mentionMap {
if !uCache.Has(id) {
userIds = append(userIds, id)
uCache.Add(id)
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "Found mention",
mlog.String("mention", mention),
mlog.String("user_id", id),
)
}
}
}
users := make([]*model.User, 0)
@@ -152,27 +181,52 @@ func (scs *Service) usersForPost(post *model.Post, reactions []*model.Reaction,
if sync, err2 := scs.shouldUserSync(user, rc); err2 != nil {
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Could not find user for post",
mlog.String("user_id", id),
mlog.Err(err2))
mlog.Err(err2),
)
continue
} else if sync {
users = append(users, sanitizeUserForSync(user))
}
// if this was a mention then put the real username in place of the username+remotename, but only
// when sending to the remote that the user belongs to.
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
fixMention(post, mentionMap, user)
}
} else {
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error checking if user should sync",
mlog.String("user_id", id),
mlog.Err(err))
mlog.Err(err),
)
}
}
return users
}
// fixMention replaces any mentions in a post for the user with the user's real username.
func fixMention(post *model.Post, mentionMap model.UserMentionMap, user *model.User) {
if post == nil || len(mentionMap) == 0 {
return
}
realUsername, ok := user.GetProp(KeyRemoteUsername)
if !ok {
return
}
// there may be more than one mention for each user so we have to walk the whole map.
for mention, id := range mentionMap {
if id == user.Id && strings.Contains(mention, ":") {
post.Message = strings.ReplaceAll(post.Message, "@"+mention, "@"+realUsername)
}
}
}
func sanitizeUserForSync(user *model.User) *model.User {
user.Password = model.NewId()
user.AuthData = nil
user.AuthService = ""
user.Roles = "system_user"
user.AllowMarketing = false
user.Props = model.StringMap{}
user.NotifyProps = model.StringMap{}
user.LastPasswordUpdate = 0
user.LastPictureUpdate = 0

Просмотреть файл

@@ -25,6 +25,9 @@ const (
MaxPostsPerSync = 12 // a bit more than one typical screenfull of posts
NotifyRemoteOfflineThreshold = time.Second * 10
NotifyMinimumDelay = time.Second * 2
MaxUpsertRetries = 25
KeyRemoteUsername = "RemoteUsername"
KeyRemoteEmail = "RemoteEmail"
)
// Mocks can be re-generated with `make sharedchannel-mocks`.
@@ -53,6 +56,7 @@ type AppIface interface {
PatchChannelModerationsForChannel(channel *model.Channel, channelModerationsPatch []*model.ChannelModerationPatch) ([]*model.ChannelModeration, *model.AppError)
CreateUploadSession(us *model.UploadSession) (*model.UploadSession, *model.AppError)
FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError)
MentionsToTeamMembers(message, teamID string) model.UserMentionMap
}
// errNotFound allows checking against Store.ErrNotFound errors without making Store a dependency.

Просмотреть файл

@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
@@ -117,7 +118,7 @@ func (scs *Service) processSyncMessages(syncMessages []syncMsg, rc *model.Remote
sm.Post.Message = scs.processPermalinkFromRemote(sm.Post, team)
}
// add/update post (may be nil if only reactions changed)
// add/update post
rpost, err := scs.upsertSyncPost(sm.Post, channel, rc)
if err != nil {
postErrors = append(postErrors, sm.Post.Id)
@@ -169,11 +170,11 @@ func (scs *Service) processSyncMessages(syncMessages []syncMsg, rc *model.Remote
func (scs *Service) upsertSyncUser(user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
var userSaved *model.User
if user.RemoteId == nil || *user.RemoteId == "" {
user.RemoteId = model.NewString(rc.RemoteId)
}
user.RemoteId = model.NewString(rc.RemoteId)
// does the user already exist?
// Check if user already exists
euser, err := scs.server.GetStore().User().Get(context.Background(), user.Id)
if err != nil {
if _, ok := err.(errNotFound); !ok {
@@ -181,41 +182,33 @@ func (scs *Service) upsertSyncUser(user *model.User, channel *model.Channel, rc
}
}
var userSaved *model.User
if euser == nil {
if userSaved, err = scs.server.GetStore().User().Save(user); err != nil {
if e, ok := err.(errInvalidInput); ok {
_, field, value := e.InvalidInputInfo()
if field == "email" || field == "username" {
// username or email collision
// TODO: handle collision by modifying username/email (MM-32133)
return nil, fmt.Errorf("collision inserting sync user (%s=%s): %w", field, value, err)
}
}
return nil, fmt.Errorf("error inserting sync user: %w", err)
if userSaved, err = scs.insertSyncUser(user, channel, rc); err != nil {
return nil, err
}
} else {
patch := &model.UserPatch{
Username: &user.Username,
Nickname: &user.Nickname,
FirstName: &user.FirstName,
LastName: &user.LastName,
Email: &user.Email,
Position: &user.Position,
Locale: &user.Locale,
Timezone: user.Timezone,
RemoteId: user.RemoteId,
}
euser.Patch(patch)
userUpdated, err := scs.server.GetStore().User().Update(euser, false)
if err != nil {
return nil, fmt.Errorf("error updating sync user: %w", err)
if userSaved, err = scs.updateSyncUser(patch, euser, channel, rc); err != nil {
return nil, err
}
userSaved = userUpdated.New
}
// add user to team. We do this here regardless of whether the user was
// Add user to team. We do this here regardless of whether the user was
// just created or patched since there are three steps to adding a user
// (insert rec, add to team, add to channel) and any one could fail.
// Instead of undoing what succeeded on any failure we simply do all steps each
// time. AddUserToChannel & AddUserToTeamByTeamId do not error if user already
// time. AddUserToChannel & AddUserToTeamByTeamId do not error if user was already
// added and exit quickly.
if err := scs.app.AddUserToTeamByTeamId(channel.TeamId, userSaved); err != nil {
return nil, fmt.Errorf("error adding sync user to Team: %w", err)
@@ -228,6 +221,97 @@ func (scs *Service) upsertSyncUser(user *model.User, channel *model.Channel, rc
return userSaved, nil
}
func (scs *Service) insertSyncUser(user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
var userSaved *model.User
var suffix string
// save the originals in props (if not already done by another remote)
if _, ok := user.GetProp(KeyRemoteUsername); !ok {
user.SetProp(KeyRemoteUsername, user.Username)
}
if _, ok := user.GetProp(KeyRemoteEmail); !ok {
user.SetProp(KeyRemoteEmail, user.Email)
}
// Apply a suffix to the username until it is unique. Collisions will be quite
// rare since we are joining a username that is unique at a remote site with a unique
// name for that site. However we need to truncate the combined name to 64 chars and
// that might introduce a collision.
for i := 1; i <= MaxUpsertRetries; i++ {
if i > 1 {
suffix = strconv.FormatInt(int64(i), 10)
}
user.Username = mungUsername(user.Username, rc.Name, suffix, model.USER_NAME_MAX_LENGTH)
user.Email = mungEmail(rc.Name, model.USER_EMAIL_MAX_LENGTH)
if userSaved, err = scs.server.GetStore().User().Save(user); err != nil {
e, ok := err.(errInvalidInput)
if !ok {
break
}
_, field, value := e.InvalidInputInfo()
if field == "email" || field == "username" {
// username or email collision; try again with different suffix
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceWarn, "Collision inserting sync user",
mlog.String("field", field),
mlog.Any("value", value),
mlog.Int("attempt", i),
mlog.Err(err),
)
}
} else {
return userSaved, nil
}
}
return nil, fmt.Errorf("error inserting sync user %s: %w", user.Id, err)
}
func (scs *Service) updateSyncUser(patch *model.UserPatch, user *model.User, channel *model.Channel, rc *model.RemoteCluster) (*model.User, error) {
var err error
var update *model.UserUpdate
var suffix string
if patch.Username != nil {
user.SetProp(KeyRemoteUsername, *patch.Username)
}
if patch.Email != nil {
user.SetProp(KeyRemoteEmail, *patch.Email)
}
user.Patch(patch)
// Apply a suffix to the username until it is unique.
for i := 1; i <= MaxUpsertRetries; i++ {
if i > 1 {
suffix = strconv.FormatInt(int64(i), 10)
}
user.Username = mungUsername(user.Username, rc.Name, suffix, model.USER_NAME_MAX_LENGTH)
user.Email = mungEmail(rc.Name, model.USER_EMAIL_MAX_LENGTH)
if update, err = scs.server.GetStore().User().Update(user, false); err != nil {
e, ok := err.(errInvalidInput)
if !ok {
break
}
_, field, value := e.InvalidInputInfo()
if field == "email" || field == "username" {
// username or email collision; try again with different suffix
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceWarn, "Collision updating sync user",
mlog.String("field", field),
mlog.Any("value", value),
mlog.Int("attempt", i),
mlog.Err(err),
)
}
} else {
return update.New, nil
}
}
return nil, fmt.Errorf("error updating sync user %s: %w", user.Id, err)
}
func (scs *Service) upsertSyncPost(post *model.Post, channel *model.Channel, rc *model.RemoteCluster) (*model.Post, error) {
var appErr *model.AppError

Просмотреть файл

@@ -315,7 +315,7 @@ func (scs *Service) updateForRemote(task syncTask, rc *model.RemoteCluster) erro
// All posts were filtered out, meaning no need to send them. Fast forward SharedChannelRemote's NextSyncAt.
scs.updateNextSyncForRemote(scr.Id, rc, nextSince)
// everything was filtered out, nothing to send.
// if there are more posts eligible to sync then schedule another sync
if repeat {
scs.addTask(newSyncTask(task.channelId, task.remoteId, nil))
}

66
services/sharedchannel/util.go Обычный файл
Просмотреть файл

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"fmt"
"strings"
"github.com/mattermost/mattermost-server/v5/model"
)
// mungUsername creates a new username by combining username and remote cluster name, plus
// a suffix to create uniqueness. If the resulting username exceeds the max length then
// it is truncated and ellipses added.
func mungUsername(username string, remotename string, suffix string, maxLen int) string {
if suffix != "" {
suffix = "~" + suffix
}
// If the username already contains a colon then another server already munged it.
// In that case we can split on the colon and use the existing remote name.
// We still need to re-mung with suffix in case of collision.
comps := strings.Split(username, ":")
if len(comps) >= 2 {
username = comps[0]
remotename = strings.Join(comps[1:], "")
}
var userEllipses string
var remoteEllipses string
// The remotename is allowed to use up to half the maxLen, and the username gets the remaining space.
// Username might have a suffix to account for, and remotename always has a preceding colon.
half := maxLen / 2
// If the remotename is less than half the maxLen, then the left over space can be given to
// the username.
extra := half - (len(remotename) + 1)
if extra < 0 {
extra = 0
}
truncUser := (len(username) + len(suffix)) - (half + extra)
if truncUser > 0 {
username = username[:len(username)-truncUser-3]
userEllipses = "..."
}
truncRemote := (len(remotename) + 1) - (maxLen - (len(username) + len(userEllipses) + len(suffix)))
if truncRemote > 0 {
remotename = remotename[:len(remotename)-truncRemote-3]
remoteEllipses = "..."
}
return fmt.Sprintf("%s%s%s:%s%s", username, suffix, userEllipses, remotename, remoteEllipses)
}
// mungEmail creates a unique email address using a UID and remote name.
func mungEmail(remotename string, maxLen int) string {
s := fmt.Sprintf("%s@%s", model.NewId(), remotename)
if len(s) > maxLen {
s = s[:maxLen]
}
return s
}

76
services/sharedchannel/util_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sharedchannel
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func Test_mungUsername(t *testing.T) {
type args struct {
username string
remotename string
suffix string
maxLen int
}
tests := []struct {
name string
args args
want string
}{
{"everything empty", args{username: "", remotename: "", suffix: "", maxLen: 64}, ":"},
{"no trunc, no suffix", args{username: "bart", remotename: "example.com", suffix: "", maxLen: 64}, "bart:example.com"},
{"no trunc, suffix", args{username: "bart", remotename: "example.com", suffix: "2", maxLen: 64}, "bart~2:example.com"},
{"trunc remote, no suffix", args{username: "bart", remotename: "example1234567890.com", suffix: "", maxLen: 24}, "bart:example123456789..."},
{"trunc remote, suffix", args{username: "bart", remotename: "example1234567890.com", suffix: "2", maxLen: 24}, "bart~2:example1234567..."},
{"trunc both, no suffix", args{username: R(24, "A"), remotename: R(24, "B"), suffix: "", maxLen: 24}, "AAAAAAAAA...:BBBBBBBB..."},
{"trunc both, suffix", args{username: R(24, "A"), remotename: R(24, "B"), suffix: "10", maxLen: 24}, "AAAAAA~10...:BBBBBBBB..."},
{"trunc user, no suffix", args{username: R(40, "A"), remotename: "abc", suffix: "", maxLen: 24}, "AAAAAAAAAAAAAAAAA...:abc"},
{"trunc user, suffix", args{username: R(40, "A"), remotename: "abc", suffix: "11", maxLen: 24}, "AAAAAAAAAAAAAA~11...:abc"},
{"trunc user, remote, no suffix", args{username: R(40, "A"), remotename: "abcdefghijk", suffix: "", maxLen: 24}, "AAAAAAAAA...:abcdefghijk"},
{"trunc user, remote, suffix", args{username: R(40, "A"), remotename: "abcdefghijk", suffix: "19", maxLen: 24}, "AAAAAA~19...:abcdefghijk"},
{"short user, long remote, no suffix", args{username: "bart", remotename: R(40, "B"), suffix: "", maxLen: 24}, "bart:BBBBBBBBBBBBBBBB..."},
{"long user, short remote, no suffix", args{username: R(40, "A"), remotename: "abc.com", suffix: "", maxLen: 24}, "AAAAAAAAAAAAA...:abc.com"},
{"short user, long remote, suffix", args{username: "bart", remotename: R(40, "B"), suffix: "12", maxLen: 24}, "bart~12:BBBBBBBBBBBBB..."},
{"long user, short remote, suffix", args{username: R(40, "A"), remotename: "abc.com", suffix: "12", maxLen: 24}, "AAAAAAAAAA~12...:abc.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mungUsername(tt.args.username, tt.args.remotename, tt.args.suffix, tt.args.maxLen); got != tt.want {
t.Errorf("mungUsername() = %v, want %v", got, tt.want)
}
})
}
}
func Test_mungUsernameFuzz(t *testing.T) {
// ensure no index out of bounds panic for any combination
for i := 0; i < 70; i++ {
for j := 0; j < 70; j++ {
for k := 0; k < 3; k++ {
username := R(i, "A")
remotename := R(j, "B")
suffix := R(k, "1")
result := mungUsername(username, remotename, suffix, 64)
require.LessOrEqual(t, len(result), 64)
}
}
}
}
// R returns a string with the specified string repeated `count` times.
func R(count int, s string) string {
return strings.Repeat(s, count)
}