diff --git a/internal/accounts/account.go b/internal/accounts/account.go index 6b6079499..271d2153d 100644 --- a/internal/accounts/account.go +++ b/internal/accounts/account.go @@ -12,18 +12,11 @@ type Account struct { Certificate string `json:"-"` // Root CA certificate, if server cert is signed by a private CA AccountName string `json:"account_name"` // Username, if known ApiKey string `json:"-"` // For Connect servers - Token string `json:"-"` // If IDE token auth is being used (requires secret or private key) - Secret string `json:"-"` // token auth for Connect - PrivateKey string `json:"-"` // token auth for shinyapps.io and Posit Cloud } func (acct *Account) InferAuthType() AccountAuthType { if acct.ApiKey != "" { return AuthTypeAPIKey - } else if acct.Token != "" && acct.Secret != "" { - return AuthTypeTokenSecret - } else if acct.Token != "" && acct.PrivateKey != "" { - return AuthTypeTokenKey } return AuthTypeNone } diff --git a/internal/accounts/account_auth_type.go b/internal/accounts/account_auth_type.go index a9641fca7..441538502 100644 --- a/internal/accounts/account_auth_type.go +++ b/internal/accounts/account_auth_type.go @@ -5,17 +5,13 @@ package accounts type AccountAuthType string const ( - AuthTypeNone AccountAuthType = "none" // No saved credentials - AuthTypeAPIKey AccountAuthType = "api-key" // Connect API key - AuthTypeTokenKey AccountAuthType = "token-key" // rsconnect token & private key (Connect) - AuthTypeTokenSecret AccountAuthType = "token-secret" // rsconnect token & secret (Cloud, shinyapps.io) + AuthTypeNone AccountAuthType = "none" // No saved credentials + AuthTypeAPIKey AccountAuthType = "api-key" // Connect API key ) var authTypeDescriptions = map[AccountAuthType]string{ - AuthTypeNone: "No saved credentials", - AuthTypeAPIKey: "Connect API key", - AuthTypeTokenKey: "RStudio IDE/rsconnect token+key", - AuthTypeTokenSecret: "RStudio IDE/rsconnect token+secret", + AuthTypeNone: "No saved credentials", + AuthTypeAPIKey: "Connect API key", } func (auth AccountAuthType) Description() string { diff --git a/internal/accounts/account_auth_type_test.go b/internal/accounts/account_auth_type_test.go index 515be9c07..551a223da 100644 --- a/internal/accounts/account_auth_type_test.go +++ b/internal/accounts/account_auth_type_test.go @@ -20,7 +20,5 @@ func TestAccountAuthTypeSuite(t *testing.T) { func (s *AccountAuthTypeSuite) TestDescription() { s.Equal("No saved credentials", AuthTypeNone.Description()) s.Equal("Connect API key", AuthTypeAPIKey.Description()) - s.Equal("RStudio IDE/rsconnect token+key", AuthTypeTokenKey.Description()) - s.Equal("RStudio IDE/rsconnect token+secret", AuthTypeTokenSecret.Description()) s.Equal("hey", AccountAuthType("hey").Description()) } diff --git a/internal/accounts/account_test.go b/internal/accounts/account_test.go index dffe6a058..689f4017d 100644 --- a/internal/accounts/account_test.go +++ b/internal/accounts/account_test.go @@ -30,21 +30,3 @@ func (s *AccountSuite) TestInferAuthTypeApiKey() { auth := account.InferAuthType() s.Equal(AuthTypeAPIKey, auth) } - -func (s *AccountSuite) TestInferAuthTypeTokenSecret() { - account := Account{ - Token: "abc", - Secret: "def", - } - auth := account.InferAuthType() - s.Equal(AuthTypeTokenSecret, auth) -} - -func (s *AccountSuite) TestInferAuthTypeTokenKey() { - account := Account{ - Token: "abc", - PrivateKey: "def", - } - auth := account.InferAuthType() - s.Equal(AuthTypeTokenKey, auth) -} diff --git a/internal/accounts/provider_rsconnect.go b/internal/accounts/provider_rsconnect.go deleted file mode 100644 index d781d29ff..000000000 --- a/internal/accounts/provider_rsconnect.go +++ /dev/null @@ -1,205 +0,0 @@ -package accounts - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "fmt" - "runtime" - "sort" - "strings" - - "github.com/rstudio/connect-client/internal/logging" - "github.com/rstudio/connect-client/internal/util" - "github.com/rstudio/connect-client/internal/util/dcf" - - "github.com/spf13/afero" -) - -type rsconnectProvider struct { - fs afero.Fs - goos string - dcfReader dcf.FileReader - log logging.Logger -} - -func newRSConnectProvider(fs afero.Fs, log logging.Logger) *rsconnectProvider { - return &rsconnectProvider{ - fs: fs, - goos: runtime.GOOS, - dcfReader: dcf.NewFileReader(nil), - log: log, - } -} - -// configDir returns the directory where the rsconnect -// R package stores its configuration. -func (p *rsconnectProvider) configDir() (util.AbsolutePath, error) { - // https://github.com/rstudio/rsconnect/blob/main/R/config.R - baseDir := util.PathFromEnvironment("R_USER_CONFIG_DIR", p.fs) - if baseDir.String() != "" { - p.log.Debug("rsconnect: using R_USER_CONFIG_DIR", "path", baseDir) - } else { - baseDir = util.PathFromEnvironment("XDG_CONFIG_HOME", p.fs) - if baseDir.String() != "" { - p.log.Debug("rsconnect: using XDG_CONFIG_HOME", "path", baseDir) - } - } - if baseDir.String() == "" { - switch p.goos { - case "windows": - baseDir = util.PathFromEnvironment("APPDATA", p.fs).Join("R", "config") - case "darwin": - home, err := util.UserHomeDir(p.fs) - if err != nil { - return util.AbsolutePath{}, err - } - baseDir = home.Join("Library", "Preferences", "org.R-project.R").Path - default: - home, err := util.UserHomeDir(p.fs) - if err != nil { - return util.AbsolutePath{}, err - } - baseDir = home.Join(".config").Path - } - } - return baseDir.Join("R", "rsconnect").Abs() -} - -func (p *rsconnectProvider) oldConfigDir() (util.AbsolutePath, error) { - home, err := util.UserHomeDir(p.fs) - if err != nil { - return util.AbsolutePath{}, err - } - configDir := util.PathFromEnvironment("R_USER_CONFIG_DIR", p.fs) - - if configDir.String() != "" { - p.log.Debug("rsconnect: using R_USER_CONFIG_DIR", "path", configDir) - configDir = configDir.Join("rsconnect") - } else { - switch p.goos { - case "windows": - configDir = util.PathFromEnvironment("APPDATA", p.fs) - case "darwin": - configDir = home.Join("Library", "Application Support").Path - default: - configDir = util.PathFromEnvironment("XDG_CONFIG_HOME", p.fs) - if configDir.String() != "" { - p.log.Debug("rsconnect: using XDG_CONFIG_HOME", "path", configDir) - } else { - configDir = home.Join(".config").Path - } - } - configDir = configDir.Join("R", "rsconnect") - } - p.log.Debug("rsconnect: candidate old config directory", "path", configDir) - return configDir.Abs() -} - -// makeServerNameMap constructs a server name-to-url map -// from the provided rsconnect server list. -func makeServerNameMap(rscServers dcf.Records) map[string]string { - serverNameToURL := map[string]string{} - for _, server := range rscServers { - name := server["name"] - url := strings.TrimSuffix(server["url"], "/__api__") - serverNameToURL[name] = url - } - // rsconnect does not make server entries for public instances - serverNameToURL["shinyapps.io"] = "https://api.shinyapps.io" - serverNameToURL["posit.cloud"] = "https://api.posit.cloud" - serverNameToURL["rstudio.cloud"] = "https://api.posit.cloud" - return serverNameToURL -} - -// accountsFromConfig constructs Account objects from the -// provided rsconnect server and account lists. Primarily, -// this is a join between the two on account.server = server.name. -func (p *rsconnectProvider) accountsFromConfig(rscServers, rscAccounts dcf.Records) ([]Account, error) { - accounts := []Account{} - serverNameToURL := makeServerNameMap(rscServers) - for _, account := range rscAccounts { - serverName := account["server"] - if serverName == "" { - return accounts, fmt.Errorf("missing server name in rsconnect account") - } - url, ok := serverNameToURL[serverName] - if !ok { - return accounts, fmt.Errorf("Account references nonexistent server name '%s'", serverName) - } - serverURL, err := normalizeServerURL(url) - if err != nil { - return nil, err - } - account := Account{ - Source: AccountSourceRsconnect, - ServerType: serverTypeFromURL(serverURL), - Name: serverName, - URL: serverURL, - AccountName: account["username"], - Token: account["token"], - Secret: account["secret"], - PrivateKey: account["private_key"], - } - account.AuthType = account.InferAuthType() - accounts = append(accounts, account) - } - sort.Slice(accounts, func(i, j int) bool { - return accounts[i].Name < accounts[j].Name - }) - return accounts, nil -} - -func (p *rsconnectProvider) Load() ([]Account, error) { - configDir, err := p.configDir() - if err != nil { - return nil, fmt.Errorf("error getting rsconnect config directory: %w", err) - } - exists, err := configDir.Exists() - if err == nil && exists { - return p.loadFromConfigDir(configDir) - } - p.log.Debug("rsconnect config directory does not exist, checking old config directory", "path", configDir) - oldConfigDir, err := p.oldConfigDir() - if err != nil { - return nil, err - } - exists, err = oldConfigDir.Exists() - if err != nil { - return nil, err - } - if !exists { - p.log.Debug("Old rsconnect config directory does not exist") - return nil, nil - } - - // TODO: afero doesn't support EvalSymlinks; make our own using fs.Lstat? - // oldConfigDir, err = filepath.EvalSymlinks(oldConfigDir) - // if err != nil { - // if errors.Is(err, fs.ErrNotExist) { - // p.log.Debug("Old rsconnect config directory does not exist") - // return nil, nil - // } else { - // return nil, fmt.Errorf("Error getting old rsconnect config directory: %s", err) - // } - // } - return p.loadFromConfigDir(oldConfigDir) -} - -// Load loads the list of accounts stored by -// rsconnect, by reading its servers and account DCF files. -func (p *rsconnectProvider) loadFromConfigDir(configDir util.AbsolutePath) ([]Account, error) { - p.log.Info("Loading rsconnect accounts", "path", configDir) - rscServers, err := p.dcfReader.ReadFiles(configDir.Join("servers"), "*.dcf") - if err != nil { - return nil, err - } - rscAccounts, err := p.dcfReader.ReadFiles(configDir.Join("accounts", "*"), "*.dcf") - if err != nil { - return nil, err - } - accounts, err := p.accountsFromConfig(rscServers, rscAccounts) - if err != nil { - return nil, err - } - return accounts, nil -} diff --git a/internal/accounts/provider_rsconnect_python.go b/internal/accounts/provider_rsconnect_python.go deleted file mode 100644 index 883f6b9de..000000000 --- a/internal/accounts/provider_rsconnect_python.go +++ /dev/null @@ -1,152 +0,0 @@ -package accounts - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - "runtime" - "sort" - - "github.com/rstudio/connect-client/internal/logging" - "github.com/spf13/afero" -) - -type rsconnectPythonProvider struct { - fs afero.Fs - log logging.Logger -} - -func newRSConnectPythonProvider(fs afero.Fs, log logging.Logger) *rsconnectPythonProvider { - return &rsconnectPythonProvider{ - fs: fs, - log: log, - } -} - -// Returns the path to rsconnect-python's configuration directory. -// The config directory is where the server list (servers.json) is -// stored, along with deployment metadata for any deployments that -// were made from read-only directories. -func (p *rsconnectPythonProvider) configDir(goos string) (string, error) { - // https://github.com/rstudio/rsconnect-python/blob/master/rsconnect/metadata.py - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - var baseDir string - - switch goos { - case "linux": - baseDir = os.Getenv("XDG_CONFIG_HOME") - if baseDir != "" { - p.log.Debug("rsconnect-python: using XDG_CONFIG_HOME", "dir", baseDir) - } - case "windows": - baseDir = os.Getenv("APPDATA") - case "darwin": - baseDir = filepath.Join(home, "Library", "Application Support") - } - if baseDir == "" { - return filepath.Join(home, ".rsconnect-python"), nil - } else { - return filepath.Join(baseDir, "rsconnect-python"), nil - } -} - -// Returns the path to rsconnect-python's servers.json file. -func (p *rsconnectPythonProvider) serverListPath(goos string) (string, error) { - dir, err := p.configDir(goos) - if err != nil { - return "", err - } - return filepath.Join(dir, "servers.json"), nil -} - -type rsconnectPythonAccount struct { - Name string `json:"name"` // Nickname - URL string `json:"url"` // Server URL, e.g. https://connect.example.com/rsc - Insecure bool `json:"insecure"` // Skip https server verification - Certificate string `json:"ca_cert"` // Root CA certificate, if server cert is signed by a private CA - ApiKey string `json:"api_key"` // For Connect servers - AccountName string `json:"account_name"` // For shinyapps.io and Posit Cloud servers - Token string `json:"token"` // ... - Secret string `json:"secret"` // ... -} - -func (r *rsconnectPythonAccount) toAccount() (*Account, error) { - serverURL, err := normalizeServerURL(r.URL) - if err != nil { - return nil, err - } - account := &Account{ - Name: r.Name, - URL: serverURL, - Insecure: r.Insecure, - Certificate: r.Certificate, - ApiKey: r.ApiKey, - AccountName: r.AccountName, - Token: r.Token, - Secret: r.Secret, - } - account.Source = AccountSourceRsconnectPython - - // rsconnect-python does not store the server - // type, so infer it from the URL. - account.ServerType = serverTypeFromURL(account.URL) - account.AuthType = account.InferAuthType() - - // Migrate existing rstudio.cloud entries. - if account.URL == "https://api.rstudio.cloud" { - account.URL = "https://api.posit.cloud" - } - return account, nil -} - -func (p *rsconnectPythonProvider) decodeServerStore(data []byte) ([]Account, error) { - // rsconnect-python stores a map of nicknames to servers - var accountMap map[string]rsconnectPythonAccount - err := json.Unmarshal(data, &accountMap) - if err != nil { - return nil, err - } - - accounts := []Account{} - for _, rscpAccount := range accountMap { - acct, err := rscpAccount.toAccount() - if err != nil { - return nil, err - } - accounts = append(accounts, *acct) - } - sort.Slice(accounts, func(i, j int) bool { - return accounts[i].Name < accounts[j].Name - }) - return accounts, nil -} - -// Load loads the list of accounts stored by rsconnect-python -// by reading its servers.json file. -func (p *rsconnectPythonProvider) Load() ([]Account, error) { - path, err := p.serverListPath(runtime.GOOS) - if err != nil { - return nil, err - } - data, err := afero.ReadFile(p.fs, path) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - p.log.Debug("rsconnect-python config file does not exist, checking old config directory", "path", path) - return nil, nil - } - return nil, err - } - p.log.Info("Loading rsconnect-python accounts", "path", path) - accounts, err := p.decodeServerStore(data) - if err != nil { - return nil, err - } - return accounts, nil -} diff --git a/internal/accounts/provider_rsconnect_python_test.go b/internal/accounts/provider_rsconnect_python_test.go deleted file mode 100644 index a34543162..000000000 --- a/internal/accounts/provider_rsconnect_python_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package accounts - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/rstudio/connect-client/internal/logging" - "github.com/rstudio/connect-client/internal/util/utiltest" - "github.com/spf13/afero" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" -) - -type RsconnectPythonProviderSuite struct { - utiltest.Suite - envVarHelper utiltest.EnvVarHelper - provider *rsconnectPythonProvider -} - -func setHome(home string) { - if runtime.GOOS == "windows" { - os.Setenv("USERPROFILE", home) - } else { - os.Setenv("HOME", home) - } -} - -func TestRsconnectPythonProviderSuite(t *testing.T) { - suite.Run(t, new(RsconnectPythonProviderSuite)) -} - -func (s *RsconnectPythonProviderSuite) SetupSuite() { - fs := utiltest.NewMockFs() - log := logging.New() - s.provider = newRSConnectPythonProvider(fs, log) -} - -func (s *RsconnectPythonProviderSuite) SetupTest() { - s.envVarHelper.Setup("HOME", "XDG_CONFIG_HOME", "USERPROFILE", "APPDATA") -} - -func (s *RsconnectPythonProviderSuite) TeardownTest() { - s.envVarHelper.Teardown() -} - -func (s *RsconnectPythonProviderSuite) TestNewRSConnectPythonProvider() { - log := logging.New() - fs := utiltest.NewMockFs() - provider := newRSConnectPythonProvider(fs, log) - s.Equal(fs, provider.fs) - s.Equal(log, provider.log) -} - -func (s *RsconnectPythonProviderSuite) TestConfigDirNoHome() { - dir, err := s.provider.configDir("linux") - s.ErrorContains(err, "is not defined") - s.Equal("", dir) -} - -func (s *RsconnectPythonProviderSuite) TestConfigDirXdgConfig() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/home/me") - os.Setenv("XDG_CONFIG_HOME", "/home/myconfig") - dir, err := s.provider.configDir("linux") - s.Nil(err) - s.Equal("/home/myconfig/rsconnect-python", dir) -} - -func (s *RsconnectPythonProviderSuite) TestConfigDirLinux() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/home/somebody") - dir, err := s.provider.configDir("linux") - s.Nil(err) - s.Equal("/home/somebody/.rsconnect-python", dir) -} - -func (s *RsconnectPythonProviderSuite) TestConfigDirMac() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/Users/somebody") - dir, err := s.provider.configDir("darwin") - s.Nil(err) - s.Equal("/Users/somebody/Library/Application Support/rsconnect-python", dir) -} - -func (s *RsconnectPythonProviderSuite) TestConfigDirWindows() { - setHome(`C:\Users\somebody`) - os.Setenv("APPDATA", `C:\Users\somebody\AppData`) - dir, err := s.provider.configDir("windows") - s.Nil(err) - s.Equal(filepath.Join(`C:\Users\somebody\AppData`, "rsconnect-python"), dir) -} - -func (s *RsconnectPythonProviderSuite) TestServerListPath() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/home/somebody") - dir, err := s.provider.serverListPath("linux") - s.Nil(err) - s.Equal("/home/somebody/.rsconnect-python/servers.json", dir) -} - -func (s *RsconnectPythonProviderSuite) TestServerListPathNoHome() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - dir, err := s.provider.serverListPath("linux") - s.ErrorContains(err, "$HOME is not defined") - s.Equal("", dir) -} - -func (s *RsconnectPythonProviderSuite) TestDecodeServerStoreInvalidJSON() { - data := []byte{} - accounts, err := s.provider.decodeServerStore(data) - s.NotNil(err) - s.Nil(accounts) -} - -func (s *RsconnectPythonProviderSuite) TestLoadNoHome() { - accounts, err := s.provider.Load() - s.NotNil(err) - s.Nil(accounts) -} - -func (s *RsconnectPythonProviderSuite) TestLoadNonexistentFile() { - setHome("/home/me") - fs := utiltest.NewMockFs() - fs.On("Open", mock.Anything).Return(nil, os.ErrNotExist) - log := logging.New() - provider := newRSConnectPythonProvider(fs, log) - accounts, err := provider.Load() - s.Nil(err) - s.Nil(accounts) -} - -func (s *RsconnectPythonProviderSuite) TestLoadFileError() { - setHome("/home/me") - testError := errors.New("kaboom!") - fs := utiltest.NewMockFs() - fs.On("Open", mock.Anything).Return(nil, testError) - log := logging.New() - provider := newRSConnectPythonProvider(fs, log) - accounts, err := provider.Load() - s.NotNil(err) - s.ErrorIs(err, testError) - s.Nil(accounts) -} - -func (s *RsconnectPythonProviderSuite) TestLoadBadFile() { - setHome("/home/me") - fs := afero.NewMemMapFs() - serverPath, err := s.provider.serverListPath(runtime.GOOS) - s.Nil(err) - err = afero.WriteFile(fs, serverPath, []byte{}, 0600) - s.Nil(err) - - log := logging.New() - provider := newRSConnectPythonProvider(fs, log) - accounts, err := provider.Load() - s.NotNil(err) - s.Nil(accounts) -} - -func (s *RsconnectPythonProviderSuite) TestLoad() { - data := []byte(`{ - "local": { - "name": "local", - "url": "http://localhost:3939/", - "api_key": "0123456789ABCDEF0123456789ABCDEF", - "insecure": true, - "ca_cert": null - }, - "shinyapps": { - "name": "shinyapps", - "url": "https://api.shinyapps.io", - "account_name": "mmarchetti1", - "token": "0123456789ABCDEF", - "secret": "FEDCBA9876543210" - } - }`) - - setHome("/home/me") - serverPath, err := s.provider.serverListPath(runtime.GOOS) - s.Nil(err) - - fs := afero.NewMemMapFs() - err = afero.WriteFile(fs, serverPath, data, 0600) - s.Nil(err) - - log := logging.New() - provider := newRSConnectPythonProvider(fs, log) - accounts, err := provider.Load() - s.Nil(err) - s.Equal([]Account{ - { - ServerType: ServerTypeConnect, - Source: AccountSourceRsconnectPython, - AuthType: AuthTypeAPIKey, - Name: "local", - URL: "http://localhost:3939", - Insecure: true, - ApiKey: "0123456789ABCDEF0123456789ABCDEF", - }, - { - ServerType: ServerTypeShinyappsIO, - Source: AccountSourceRsconnectPython, - AuthType: AuthTypeTokenSecret, - Name: "shinyapps", - URL: "https://api.shinyapps.io", - AccountName: "mmarchetti1", - Token: "0123456789ABCDEF", - Secret: "FEDCBA9876543210", - }, - }, accounts) -} diff --git a/internal/accounts/provider_rsconnect_test.go b/internal/accounts/provider_rsconnect_test.go deleted file mode 100644 index a5b0c1908..000000000 --- a/internal/accounts/provider_rsconnect_test.go +++ /dev/null @@ -1,465 +0,0 @@ -package accounts - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/rstudio/connect-client/internal/logging" - "github.com/rstudio/connect-client/internal/util" - "github.com/rstudio/connect-client/internal/util/dcf" - "github.com/rstudio/connect-client/internal/util/utiltest" - "github.com/spf13/afero" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" -) - -type RsconnectProviderSuite struct { - utiltest.Suite - envVarHelper utiltest.EnvVarHelper - provider *rsconnectProvider -} - -func TestRsconnectProviderSuite(t *testing.T) { - suite.Run(t, new(RsconnectProviderSuite)) -} - -func (s *RsconnectProviderSuite) SetupSuite() { - log := logging.New() - fs := utiltest.NewMockFs() - s.provider = newRSConnectProvider(fs, log) -} - -func (s *RsconnectProviderSuite) SetupTest() { - s.envVarHelper.Setup("HOME", "R_USER_CONFIG_DIR", "XDG_CONFIG_HOME", "APPDATA") -} - -func (s *RsconnectProviderSuite) TeardownTest() { - s.envVarHelper.Teardown() -} - -func (s *RsconnectProviderSuite) TestNewRSConnectProvider() { - log := logging.New() - fs := utiltest.NewMockFs() - provider := newRSConnectProvider(fs, log) - s.Equal(fs, provider.fs) - s.Equal(log, provider.log) -} - -func (s *RsconnectProviderSuite) TestConfigDirRUserConfig() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - os.Setenv("R_USER_CONFIG_DIR", "/r/config") - s.provider.goos = "linux" - dir, err := s.provider.configDir() - s.Nil(err) - s.Equal("/r/config/R/rsconnect", dir.String()) -} - -func (s *RsconnectProviderSuite) TestConfigDirXdgConfig() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - os.Setenv("XDG_CONFIG_HOME", "/home/myconfig") - s.provider.goos = "linux" - dir, err := s.provider.configDir() - s.Nil(err) - s.Equal("/home/myconfig/R/rsconnect", dir.String()) -} - -func (s *RsconnectProviderSuite) TestConfigDirLinux() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/home/somebody") - s.provider.goos = "linux" - dir, err := s.provider.configDir() - s.Nil(err) - s.Equal("/home/somebody/.config/R/rsconnect", dir.String()) -} - -func (s *RsconnectProviderSuite) TestConfigDirLinuxNoHome() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - s.provider.goos = "linux" - dir, err := s.provider.configDir() - s.ErrorContains(err, "$HOME is not defined") - s.Equal("", dir.String()) -} - -func (s *RsconnectProviderSuite) TestConfigDirMac() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/Users/somebody") - s.provider.goos = "darwin" - dir, err := s.provider.configDir() - s.Nil(err) - s.Equal("/Users/somebody/Library/Preferences/org.R-project.R/R/rsconnect", dir.String()) -} - -func (s *RsconnectProviderSuite) TestConfigDirMacNoHome() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - s.provider.goos = "darwin" - dir, err := s.provider.configDir() - s.ErrorContains(err, "$HOME is not defined") - s.Equal("", dir.String()) -} - -func (s *RsconnectProviderSuite) TestConfigDirWindows() { - if runtime.GOOS != "windows" { - s.T().Skip() - } - os.Setenv("APPDATA", `C:\Users\somebody\AppData`) - s.provider.goos = "windows" - dir, err := s.provider.configDir() - s.Nil(err) - s.Equal(filepath.Join(`C:\Users\somebody\AppData`, "R", "config", "R", "rsconnect"), dir.String()) -} - -func (s *RsconnectProviderSuite) TestOldConfigDirNoHome() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - s.provider.goos = "linux" - dir, err := s.provider.oldConfigDir() - s.ErrorContains(err, "$HOME is not defined") - s.Equal("", dir.String()) -} - -func (s *RsconnectProviderSuite) TestOldConfigDirRUserConfig() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/home/someuser") - os.Setenv("R_USER_CONFIG_DIR", "/r/config") - s.provider.goos = "linux" - dir, err := s.provider.oldConfigDir() - s.Nil(err) - s.Equal("/r/config/rsconnect", dir.String()) -} - -func (s *RsconnectProviderSuite) TestOldConfigDirXdgConfig() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/home/someuser") - os.Setenv("XDG_CONFIG_HOME", "/home/myconfig") - s.provider.goos = "linux" - dir, err := s.provider.oldConfigDir() - s.Nil(err) - s.Equal("/home/myconfig/R/rsconnect", dir.String()) -} - -func (s *RsconnectProviderSuite) TestOldConfigDirLinux() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/home/somebody") - s.provider.goos = "linux" - dir, err := s.provider.oldConfigDir() - s.Nil(err) - s.Equal("/home/somebody/.config/R/rsconnect", dir.String()) -} - -func (s *RsconnectProviderSuite) TestOldConfigDirMac() { - if runtime.GOOS == "windows" { - s.T().Skip() - } - setHome("/Users/somebody") - s.provider.goos = "darwin" - dir, err := s.provider.oldConfigDir() - s.Nil(err) - s.Equal("/Users/somebody/Library/Application Support/R/rsconnect", dir.String()) -} - -func (s *RsconnectProviderSuite) TestOldConfigDirWindows() { - // When running these tests on Mac/Linux, the call to Abs in oldConfigDir - // does not recognize C:\Users as an absolute path. - if runtime.GOOS != "windows" { - s.T().Skip() - } - setHome(`C:\Users\somebody`) - os.Setenv("APPDATA", `C:\Users\somebody\AppData\Roaming`) - s.provider.goos = "windows" - dir, err := s.provider.oldConfigDir() - s.Nil(err) - s.Equal(`C:\Users\somebody\AppData\Roaming\R\rsconnect`, dir.String()) -} - -func (s *RsconnectProviderSuite) TestAccountsFromConfigShinyapps() { - configServers := dcf.Records{{ - "name": "connect", - "url": "https://connect.example.com/__api__", - }} - configAccounts := dcf.Records{{ - "name": "myaccount", - "server": "shinyapps.io", - "userId": "123", - "accountId": "456", - "token": "0123456789ABCDEF", - "secret": "FEDCBA9876543210", - }} - accounts, err := s.provider.accountsFromConfig(configServers, configAccounts) - s.Nil(err) - s.Equal([]Account{{ - ServerType: ServerTypeShinyappsIO, - Source: AccountSourceRsconnect, - AuthType: AuthTypeTokenSecret, - Name: "shinyapps.io", - URL: "https://api.shinyapps.io", - Token: "0123456789ABCDEF", - Secret: "FEDCBA9876543210", - }}, accounts) -} - -func (s *RsconnectProviderSuite) TestAccountsFromConfigConnect() { - configServers := dcf.Records{{ - "name": "connect.example.com", - "url": "https://connect.example.com/__api__", - }} - configAccounts := dcf.Records{{ - "username": "rootUser", - "accountId": "1", - "server": "connect.example.com", - "token": "0123456789ABCDEF", - "private_key": "FEDCBA9876543210", - }} - - accounts, err := s.provider.accountsFromConfig(configServers, configAccounts) - s.Nil(err) - s.Equal([]Account{{ - ServerType: ServerTypeConnect, - Source: AccountSourceRsconnect, - AuthType: AuthTypeTokenKey, - Name: "connect.example.com", - URL: "https://connect.example.com", - AccountName: "rootUser", - Token: "0123456789ABCDEF", - PrivateKey: "FEDCBA9876543210", - }}, accounts) -} - -func (s *RsconnectProviderSuite) TestAccountsFromConfigNone() { - configServers := dcf.Records{} - configAccounts := dcf.Records{} - - accounts, err := s.provider.accountsFromConfig(configServers, configAccounts) - s.Nil(err) - s.Equal([]Account{}, accounts) -} - -func (s *RsconnectProviderSuite) TestAccountsFromConfigMissingServer() { - configServers := dcf.Records{} - configAccounts := dcf.Records{{ - "username": "rootUser", - "accountId": "1", - "server": "connect.example.com", - "token": "0123456789ABCDEF", - "private_key": "FEDCBA9876543210", - }} - - accounts, err := s.provider.accountsFromConfig(configServers, configAccounts) - s.ErrorContains(err, "Account references nonexistent server name") - s.Equal([]Account{}, accounts) -} - -func (s *RsconnectProviderSuite) TestAccountsFromConfigMissingName() { - configServers := dcf.Records{} - configAccounts := dcf.Records{{ - "username": "rootUser", - }} - - accounts, err := s.provider.accountsFromConfig(configServers, configAccounts) - s.ErrorContains(err, "missing server name") - s.Equal([]Account{}, accounts) -} - -type RsconnectProviderLoadSuite struct { - utiltest.Suite - - envVarHelper utiltest.EnvVarHelper - provider *rsconnectProvider - fs *utiltest.MockFs - configDir util.AbsolutePath - oldConfigDir util.AbsolutePath -} - -func TestRsconnectProviderLoadSuite(t *testing.T) { - suite.Run(t, new(RsconnectProviderLoadSuite)) -} - -func (s *RsconnectProviderLoadSuite) SetupTest() { - s.envVarHelper.Setup("HOME", "R_USER_CONFIG_DIR", "XDG_CONFIG_HOME", "USERPROFILE", "APPDATA") - log := logging.New() - s.fs = utiltest.NewMockFs() - s.provider = newRSConnectProvider(s.fs, log) - - // Record some config paths so we don't need to keep inventing them - var err error - setHome("/home/someuser") - s.configDir, err = s.provider.configDir() - s.Nil(err) - s.oldConfigDir, err = s.provider.oldConfigDir() - s.Nil(err) -} - -func (s *RsconnectProviderLoadSuite) TestLoadNewConfigDir() { - fs := afero.NewMemMapFs() - log := logging.New() - provider := newRSConnectProvider(fs, log) - configDir := util.NewAbsolutePath(s.configDir.String(), fs) - s.loadUsingConfigDir(configDir, provider) -} - -func (s *RsconnectProviderLoadSuite) TestLoadOldConfigDir() { - fs := afero.NewMemMapFs() - log := logging.New() - provider := newRSConnectProvider(fs, log) - oldConfigDir := util.NewAbsolutePath(s.oldConfigDir.String(), fs) - s.loadUsingConfigDir(oldConfigDir, provider) -} - -func (s *RsconnectProviderLoadSuite) loadUsingConfigDir(configDir util.AbsolutePath, provider *rsconnectProvider) { - serverDir := configDir.Join("servers") - err := serverDir.MkdirAll(0600) - s.Nil(err) - - connectServerPath := serverDir.Join("connect.example.com.dcf") - connectServerData := []byte( - `name: connect.example.com -url: https://connect.example.com/__api__ - `) - - err = connectServerPath.WriteFile(connectServerData, 0600) - s.Nil(err) - - accountDir := configDir.Join("accounts") - err = accountDir.MkdirAll(0600) - s.Nil(err) - - connectAccountPath := accountDir.Join("connect.example.com", "rootUser.dcf") - connectAccountData := []byte( - `username: rootUser -accountId: 1 -server: connect.example.com -token: 0123456789ABCDEF -private_key: FEDCBA9876543210 - `) - err = connectAccountPath.WriteFile(connectAccountData, 0600) - s.Nil(err) - - // shinyapps.io doesn't get a server file, just an account file - - shinyappsAccountPath := configDir.Join("accounts", "shinyapps.io", "myaccount.dcf") - shinyappsAccountData := []byte( - `name: myaccount -server: shinyapps.io -userId: 123 -accountId: 456 -token: 0123456789ABCDEF -secret: FEDCBA9876543210 - `) - err = shinyappsAccountPath.WriteFile(shinyappsAccountData, 0600) - s.Nil(err) - accountList, err := provider.Load() - s.Nil(err) - s.Len(accountList, 2) -} - -func (s *RsconnectProviderLoadSuite) TestLoadNoHome() { - if runtime.GOOS == "windows" { - // configDir passes on windows without HOME set - s.T().Skip() - } - os.Unsetenv("HOME") - accountList, err := s.provider.Load() - s.NotNil(err) - s.Nil(accountList) -} - -func (s *RsconnectProviderLoadSuite) TestLoadOldConfigDirErr() { - if s.configDir == s.oldConfigDir { - // The mock calls will both return os.ErrNotExist so err will be nil. - s.T().Skip() - } - testError := errors.New("stat error on oldConfigDir") - s.fs.On("Stat", s.configDir.String()).Return(utiltest.NewMockFileInfo(), os.ErrNotExist) - s.fs.On("Stat", s.oldConfigDir.String()).Return(utiltest.NewMockFileInfo(), testError) - - accountList, err := s.provider.Load() - s.NotNil(err) - s.ErrorIs(err, testError) - s.Nil(accountList) -} - -func (s *RsconnectProviderLoadSuite) TestLoadNoOldConfigDir() { - s.fs.On("Stat", s.configDir.String()).Return(utiltest.NewMockFileInfo(), os.ErrNotExist) - s.fs.On("Stat", s.oldConfigDir.String()).Return(utiltest.NewMockFileInfo(), os.ErrNotExist) - - accountList, err := s.provider.Load() - s.Nil(err) - s.Nil(accountList) -} - -func (s *RsconnectProviderLoadSuite) TestLoadServersFails() { - dcfReader := dcf.NewMockFileReader() - s.provider.dcfReader = dcfReader - testError := errors.New("Fake DCF error") - dcfReader.On("ReadFiles", - mock.MatchedBy(func(p util.AbsolutePath) bool { - return strings.Contains(p.String(), "servers") - }), "*.dcf").Return(nil, testError) - - accountList, err := s.provider.loadFromConfigDir(s.configDir) - s.ErrorIs(err, testError) - s.Nil(accountList) -} - -func (s *RsconnectProviderLoadSuite) TestLoadAccountsFails() { - dcfReader := dcf.NewMockFileReader() - s.provider.dcfReader = dcfReader - testError := errors.New("Fake DCF error") - dcfReader.On("ReadFiles", - mock.MatchedBy(func(p util.AbsolutePath) bool { - return strings.Contains(p.String(), "servers") - }), "*.dcf").Return(nil, nil) - dcfReader.On("ReadFiles", - mock.MatchedBy(func(p util.AbsolutePath) bool { - return strings.Contains(p.String(), "accounts") - }), "*.dcf").Return(nil, testError) - - accountList, err := s.provider.loadFromConfigDir(s.configDir) - s.ErrorIs(err, testError) - s.Nil(accountList) -} - -func (s *RsconnectProviderLoadSuite) TestLoadAccountsFromConfigFails() { - dcfReader := dcf.NewMockFileReader() - s.provider.dcfReader = dcfReader - dcfReader.On("ReadFiles", - mock.MatchedBy(func(p util.AbsolutePath) bool { - return strings.Contains(p.String(), "servers") - }), "*.dcf").Return(nil, nil) - - badAccounts := dcf.Records{{ - "server": "nonexistent", - }} - dcfReader.On("ReadFiles", - mock.MatchedBy(func(p util.AbsolutePath) bool { - return strings.Contains(p.String(), "accounts") - }), "*.dcf").Return(badAccounts, nil) - - accountList, err := s.provider.loadFromConfigDir(s.configDir) - s.NotNil(err) - s.Nil(accountList) -} diff --git a/internal/api_client/auth/auth.go b/internal/api_client/auth/auth.go index 122fa1d66..d95b989c7 100644 --- a/internal/api_client/auth/auth.go +++ b/internal/api_client/auth/auth.go @@ -16,8 +16,6 @@ func NewClientAuth(acct *accounts.Account) AuthMethod { switch acct.AuthType { case accounts.AuthTypeAPIKey: return NewApiKeyAuthenticator(acct.ApiKey, "") - case accounts.AuthTypeTokenKey, accounts.AuthTypeTokenSecret: - return NewTokenAuthenticator(acct.Token, acct.Secret, acct.PrivateKey) case accounts.AuthTypeNone: // This is bogus since we know we can't publish // without authentication. Our workflow needs to do one diff --git a/internal/api_client/auth/token.go b/internal/api_client/auth/token.go deleted file mode 100644 index f1e244f20..000000000 --- a/internal/api_client/auth/token.go +++ /dev/null @@ -1,118 +0,0 @@ -package auth - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "crypto" - "crypto/hmac" - "crypto/md5" - "crypto/rand" - "crypto/rsa" - "crypto/sha1" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/hex" - "errors" - "net/http" - "strings" - "time" - - "github.com/rstudio/connect-client/internal/util" -) - -type tokenAuthenticator struct { - token string - secret string - privateKey string -} - -// NewTokenAuthenticator creates an AuthMethod that will sign -// requests using the provided secret (for Cloud and shinyapps.io) -// or private key (for Connect). -func NewTokenAuthenticator(token, secret, privateKey string) AuthMethod { - // Allow an alternative header name. - // Connect also accepts "X-Rsc-Authorization". - return &tokenAuthenticator{ - token: token, - secret: secret, - privateKey: privateKey, - } -} - -var errMissingToken = errors.New("token authentication requires a token") -var errMissingKeyOrSecret = errors.New("token authentication requires secret or private key") - -func (a *tokenAuthenticator) AddAuthHeaders(req *http.Request) error { - if a.token == "" { - return errMissingToken - } - if a.secret == "" && a.privateKey == "" { - return errMissingKeyOrSecret - } - err := a.signRequest(req, time.Now()) - if err != nil { - return err - } - return nil -} - -func MD5(data []byte) []byte { - hash := md5.Sum([]byte(data)) - return hash[:] -} - -func SHA1(data []byte) []byte { - hash := sha1.Sum([]byte(data)) - return hash[:] -} - -func (a *tokenAuthenticator) signRequest(req *http.Request, now time.Time) error { - // This is modeled after the implementation in - // https://github.com/rstudio/rsconnect/blob/main/R/http.R - date := now.UTC().Format(http.TimeFormat) - body, err := util.GetRequestBody(req) - if err != nil { - return err - } - - var bodyMD5 string - var signature string - - if a.secret != "" { - // Secret (Posit Cloud, shinyapps.io) - bodyMD5 = hex.EncodeToString(MD5(body)) - canonicalRequest := strings.Join([]string{req.Method, req.URL.Path, date, bodyMD5}, "\n") - decodedSecret, err := base64.StdEncoding.DecodeString(a.secret) - if err != nil { - return err - } - hash := hmac.New(sha256.New, decodedSecret) - requestHMAC := hash.Sum([]byte(canonicalRequest)) - signature = base64.StdEncoding.EncodeToString(requestHMAC) + "; version=1" - } else { - // Private key (Posit Connect) - bodyMD5 = base64.StdEncoding.EncodeToString(MD5(body)) - decodedKey, err := base64.StdEncoding.DecodeString(a.privateKey) - if err != nil { - return err - } - key, err := x509.ParsePKCS1PrivateKey(decodedKey) - if err != nil { - return err - } - canonicalRequest := strings.Join([]string{req.Method, req.URL.Path, date, bodyMD5}, "\n") - requestSHA := SHA1([]byte(canonicalRequest)) - rsaSignature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, requestSHA) - if err != nil { - return err - } - signature = base64.StdEncoding.EncodeToString(rsaSignature) - } - - req.Header.Set("Date", date) - req.Header.Set("X-Auth-Token", a.token) - req.Header.Set("X-Auth-Signature", signature) - req.Header.Set("X-Content-Checksum", bodyMD5) - return nil -}