spacetimedb_cli/
config.rs

1use crate::errors::CliError;
2use crate::util::{contains_protocol, host_or_url_to_host_and_protocol};
3use anyhow::Context;
4use jsonwebtoken::DecodingKey;
5use spacetimedb::config::{set_opt_value, set_table_opt_value};
6use spacetimedb_fs_utils::atomic_write;
7use spacetimedb_paths::cli::CliTomlPath;
8use std::collections::HashMap;
9use std::io;
10use std::path::Path;
11use toml_edit::ArrayOfTables;
12
13const DEFAULT_SERVER_KEY: &str = "default_server";
14const WEB_SESSION_TOKEN_KEY: &str = "web_session_token";
15const SPACETIMEDB_TOKEN_KEY: &str = "spacetimedb_token";
16const SERVER_CONFIGS_KEY: &str = "server_configs";
17const NICKNAME_KEY: &str = "nickname";
18const HOST_KEY: &str = "host";
19const PROTOCOL_KEY: &str = "protocol";
20const ECDSA_PUBLIC_KEY: &str = "ecdsa_public_key";
21
22#[derive(Clone, Debug)]
23pub struct ServerConfig {
24    pub nickname: Option<String>,
25    pub host: String,
26    pub protocol: String,
27    pub ecdsa_public_key: Option<String>,
28}
29
30impl ServerConfig {
31    /// Generate a new [`Table`] representing this [`ServerConfig`].
32    pub fn as_table(&self) -> toml_edit::Table {
33        let mut table = toml_edit::Table::new();
34        Self::update_table(&mut table, self);
35        table
36    }
37
38    /// Update an existing [`Table`] with the values of a [`ServerConfig`].
39    pub fn update_table(edit: &mut toml_edit::Table, from: &ServerConfig) {
40        set_table_opt_value(edit, NICKNAME_KEY, from.nickname.as_deref());
41        set_table_opt_value(edit, HOST_KEY, Some(&from.host));
42        set_table_opt_value(edit, PROTOCOL_KEY, Some(&from.protocol));
43        set_table_opt_value(edit, ECDSA_PUBLIC_KEY, from.ecdsa_public_key.as_deref());
44    }
45
46    fn nick_or_host(&self) -> &str {
47        if let Some(nick) = &self.nickname {
48            nick
49        } else {
50            &self.host
51        }
52    }
53    pub fn get_host_url(&self) -> String {
54        format!("{}://{}", self.protocol, self.host)
55    }
56
57    pub fn nick_or_host_or_url_is(&self, name: &str) -> bool {
58        self.nickname.as_deref() == Some(name) || self.host == name || {
59            let (host, _) = host_or_url_to_host_and_protocol(name);
60            self.host == host
61        }
62    }
63}
64
65fn read_table<'a>(table: &'a toml_edit::Table, key: &'a str) -> Result<Option<&'a ArrayOfTables>, CliError> {
66    if let Some(value) = table.get(key) {
67        if value.is_array_of_tables() {
68            Ok(value.as_array_of_tables())
69        } else {
70            Err(CliError::ConfigType {
71                key: key.to_string(),
72                kind: "table array",
73                found: Box::new(value.clone()),
74            })
75        }
76    } else {
77        Ok(None)
78    }
79}
80
81fn read_opt_str(table: &toml_edit::Table, key: &str) -> Result<Option<String>, CliError> {
82    if let Some(value) = table.get(key) {
83        if value.is_str() {
84            Ok(value.as_str().map(String::from))
85        } else {
86            Err(CliError::ConfigType {
87                key: key.to_string(),
88                kind: "string",
89                found: Box::new(value.clone()),
90            })
91        }
92    } else {
93        Ok(None)
94    }
95}
96
97fn read_str(table: &toml_edit::Table, key: &str) -> Result<String, CliError> {
98    read_opt_str(table, key)?.ok_or_else(|| CliError::Config { key: key.to_string() })
99}
100
101impl TryFrom<&toml_edit::Table> for ServerConfig {
102    type Error = CliError;
103
104    fn try_from(table: &toml_edit::Table) -> Result<Self, Self::Error> {
105        let nickname = read_opt_str(table, NICKNAME_KEY)?;
106        let host = read_str(table, HOST_KEY)?;
107        let protocol = read_str(table, PROTOCOL_KEY)?;
108        let ecdsa_public_key = read_opt_str(table, ECDSA_PUBLIC_KEY)?;
109        Ok(ServerConfig {
110            nickname,
111            host,
112            protocol,
113            ecdsa_public_key,
114        })
115    }
116}
117
118// Any change here in the fields definition must be coordinated with Config::doc,
119// because the deserialize and serialize methods are manually implemented.
120#[derive(Default, Debug, Clone)]
121pub struct RawConfig {
122    default_server: Option<String>,
123    server_configs: Vec<ServerConfig>,
124    // TODO: Consider how these tokens should look to be backwards-compatible with the future changes (e.g. we may want to allow users to `login` to switch between multiple accounts - what will we cache and where?)
125    // TODO: Move these IDs/tokens out of config so we're no longer storing sensitive tokens in a human-edited file.
126    web_session_token: Option<String>,
127    spacetimedb_token: Option<String>,
128}
129
130#[derive(Debug, Clone)]
131pub struct Config {
132    home: RawConfig,
133    home_path: CliTomlPath,
134    /// The TOML document that was parsed to create `home`.
135    ///
136    /// We need to keep it to preserve comments and formatting when saving the config.
137    doc: toml_edit::DocumentMut,
138}
139
140const NO_DEFAULT_SERVER_ERROR_MESSAGE: &str = "No default server configuration.
141Set an existing server as the default with:
142\tspacetime server set-default <server>
143Or add a new server which will become the default:
144\tspacetime server add {server} <url> --default";
145
146fn no_such_server_error(server: &str) -> anyhow::Error {
147    anyhow::anyhow!(
148        "No such saved server configuration: {server}
149Add a new server configuration with:
150\tspacetime server add {server} --url <url>",
151    )
152}
153
154fn hanging_default_server_context(server: &str) -> String {
155    format!("Default server does not refer to a saved server configuration: {server}")
156}
157
158impl RawConfig {
159    fn new_with_localhost() -> Self {
160        let local = ServerConfig {
161            host: "127.0.0.1:3000".to_string(),
162            protocol: "http".to_string(),
163            nickname: Some("local".to_string()),
164            ecdsa_public_key: None,
165        };
166        let maincloud = ServerConfig {
167            host: "maincloud.spacetimedb.com".to_string(),
168            protocol: "https".to_string(),
169            nickname: Some("maincloud".to_string()),
170            ecdsa_public_key: None,
171        };
172        RawConfig {
173            default_server: local.nickname.clone(),
174            server_configs: vec![local, maincloud],
175            web_session_token: None,
176            spacetimedb_token: None,
177        }
178    }
179
180    fn find_server(&self, name_or_host: &str) -> anyhow::Result<&ServerConfig> {
181        for cfg in &self.server_configs {
182            if cfg.nickname.as_deref() == Some(name_or_host) || cfg.host == name_or_host {
183                return Ok(cfg);
184            }
185        }
186        Err(no_such_server_error(name_or_host))
187    }
188
189    fn find_server_mut(&mut self, name_or_host: &str) -> anyhow::Result<&mut ServerConfig> {
190        for cfg in &mut self.server_configs {
191            if cfg.nickname.as_deref() == Some(name_or_host) || cfg.host == name_or_host {
192                return Ok(cfg);
193            }
194        }
195        Err(no_such_server_error(name_or_host))
196    }
197
198    fn default_server(&self) -> anyhow::Result<&ServerConfig> {
199        if let Some(default_server) = self.default_server.as_ref() {
200            self.find_server(default_server)
201                .with_context(|| hanging_default_server_context(default_server))
202        } else {
203            Err(anyhow::anyhow!(NO_DEFAULT_SERVER_ERROR_MESSAGE))
204        }
205    }
206
207    fn default_server_mut(&mut self) -> anyhow::Result<&mut ServerConfig> {
208        if let Some(default_server) = self.default_server.as_ref() {
209            let default = default_server.to_string();
210            self.find_server_mut(&default)
211                .with_context(|| hanging_default_server_context(&default))
212        } else {
213            Err(anyhow::anyhow!(NO_DEFAULT_SERVER_ERROR_MESSAGE))
214        }
215    }
216
217    fn add_server(
218        &mut self,
219        host: String,
220        protocol: String,
221        ecdsa_public_key: Option<String>,
222        nickname: Option<String>,
223    ) -> anyhow::Result<()> {
224        if let Some(nickname) = &nickname {
225            if let Ok(cfg) = self.find_server(nickname) {
226                anyhow::bail!(
227                    "Server nickname {} already in use: {}://{}",
228                    nickname,
229                    cfg.protocol,
230                    cfg.host,
231                );
232            }
233        }
234
235        if let Ok(cfg) = self.find_server(&host) {
236            if let Some(nick) = &cfg.nickname {
237                if nick == &host {
238                    anyhow::bail!("Server host name is ambiguous with existing server nickname: {}", nick);
239                }
240            }
241            anyhow::bail!("Server already configured for host: {}", host);
242        }
243
244        self.server_configs.push(ServerConfig {
245            nickname,
246            host,
247            protocol,
248            ecdsa_public_key,
249        });
250        Ok(())
251    }
252
253    fn host(&self, server: &str) -> anyhow::Result<&str> {
254        self.find_server(server)
255            .map(|cfg| cfg.host.as_ref())
256            .with_context(|| format!("Cannot find hostname for unknown server: {server}"))
257    }
258
259    fn default_host(&self) -> anyhow::Result<&str> {
260        self.default_server()
261            .with_context(|| "Cannot find hostname for default server")
262            .map(|cfg| cfg.host.as_ref())
263    }
264
265    fn protocol(&self, server: &str) -> anyhow::Result<&str> {
266        self.find_server(server).map(|cfg| cfg.protocol.as_ref())
267    }
268
269    fn default_protocol(&self) -> anyhow::Result<&str> {
270        self.default_server()
271            .with_context(|| "Cannot find protocol for default server")
272            .map(|cfg| cfg.protocol.as_ref())
273    }
274
275    fn set_default_server(&mut self, server: &str) -> anyhow::Result<()> {
276        // Check that such a server exists before setting the default.
277        self.find_server(server)
278            .with_context(|| format!("Cannot set default server to unknown server {server}"))?;
279
280        self.default_server = Some(server.to_string());
281
282        Ok(())
283    }
284
285    /// Implements `spacetime server remove`.
286    fn remove_server(&mut self, server: &str) -> anyhow::Result<()> {
287        // Have to find the server config manually instead of doing `find_server_mut`
288        // because we need to mutably borrow multiple components of `self`.
289        if let Some(idx) = self
290            .server_configs
291            .iter()
292            .position(|cfg| cfg.nick_or_host_or_url_is(server))
293        {
294            // Actually remove the config.
295            let cfg = self.server_configs.remove(idx);
296
297            // If we're removing the default server,
298            // unset the default server.
299            if let Some(default_server) = &self.default_server {
300                if cfg.nick_or_host_or_url_is(default_server) {
301                    self.default_server = None;
302                }
303            }
304
305            return Ok(());
306        }
307        Err(no_such_server_error(server))
308    }
309
310    /// Return the ECDSA public key in PEM format for the server named by `server`.
311    ///
312    /// Returns an `Err` if there is no such server configuration.
313    /// Returns `None` if the server configuration exists, but does not have a fingerprint saved.
314    fn server_fingerprint(&self, server: &str) -> anyhow::Result<Option<&str>> {
315        self.find_server(server)
316            .with_context(|| {
317                format!(
318                    "No saved fingerprint for server: {server}
319Fetch the server's fingerprint with:
320\tspacetime server fingerprint -s {server}"
321                )
322            })
323            .map(|cfg| cfg.ecdsa_public_key.as_deref())
324    }
325
326    /// Return the ECDSA public key in PEM format for the default server.
327    ///
328    /// Returns an `Err` if there is no default server configuration.
329    /// Returns `None` if the server configuration exists, but does not have a fingerprint saved.
330    fn default_server_fingerprint(&self) -> anyhow::Result<Option<&str>> {
331        if let Some(server) = &self.default_server {
332            self.server_fingerprint(server)
333        } else {
334            Err(anyhow::anyhow!(NO_DEFAULT_SERVER_ERROR_MESSAGE))
335        }
336    }
337
338    /// Store the fingerprint for the server named `server`.
339    ///
340    /// Returns an `Err` if no such server configuration exists.
341    /// On success, any existing fingerprint is dropped.
342    fn set_server_fingerprint(&mut self, server: &str, ecdsa_public_key: String) -> anyhow::Result<()> {
343        let cfg = self.find_server_mut(server)?;
344        cfg.ecdsa_public_key = Some(ecdsa_public_key);
345        Ok(())
346    }
347
348    /// Store the fingerprint for the default server.
349    ///
350    /// Returns an `Err` if no default server configuration exists.
351    /// On success, any existing fingerprint is dropped.
352    fn set_default_server_fingerprint(&mut self, ecdsa_public_key: String) -> anyhow::Result<()> {
353        let cfg = self.default_server_mut()?;
354        cfg.ecdsa_public_key = Some(ecdsa_public_key);
355        Ok(())
356    }
357
358    /// Edit a saved server configuration.
359    ///
360    /// Implements `spacetime server edit`.
361    ///
362    /// Returns `Err` if no such server exists.
363    /// On success, returns `(old_nickname, old_host, hold_protocol)`,
364    /// with `Some` for each field that was changed.
365    pub fn edit_server(
366        &mut self,
367        server: &str,
368        new_nickname: Option<&str>,
369        new_host: Option<&str>,
370        new_protocol: Option<&str>,
371    ) -> anyhow::Result<(Option<String>, Option<String>, Option<String>)> {
372        // Check if the new nickname or host name would introduce ambiguities between
373        // server configurations.
374        if let Some(new_nick) = new_nickname {
375            if let Ok(other_server) = self.find_server(new_nick) {
376                anyhow::bail!(
377                    "Nickname {} conflicts with saved configuration for server {}: {}://{}",
378                    new_nick,
379                    other_server.nick_or_host(),
380                    other_server.protocol,
381                    other_server.host
382                );
383            }
384        }
385        if let Some(new_host) = new_host {
386            if let Ok(other_server) = self.find_server(new_host) {
387                anyhow::bail!(
388                    "Host {} conflicts with saved configuration for server {}: {}://{}",
389                    new_host,
390                    other_server.nick_or_host(),
391                    other_server.protocol,
392                    other_server.host
393                );
394            }
395        }
396
397        let cfg = self.find_server_mut(server)?;
398        let old_nickname = if let Some(new_nickname) = new_nickname {
399            std::mem::replace(&mut cfg.nickname, Some(new_nickname.to_string()))
400        } else {
401            None
402        };
403        let old_host = if let Some(new_host) = new_host {
404            Some(std::mem::replace(&mut cfg.host, new_host.to_string()))
405        } else {
406            None
407        };
408        let old_protocol = if let Some(new_protocol) = new_protocol {
409            Some(std::mem::replace(&mut cfg.protocol, new_protocol.to_string()))
410        } else {
411            None
412        };
413
414        // If the server we edited was the default server,
415        // and we changed the identifier stored in the `default_server` field,
416        // update that field.
417        if let Some(default_server) = &mut self.default_server {
418            if let Some(old_host) = &old_host {
419                if default_server == old_host {
420                    *default_server = new_host.unwrap().to_string();
421                }
422            } else if let Some(old_nick) = &old_nickname {
423                if default_server == old_nick {
424                    *default_server = new_nickname.unwrap().to_string();
425                }
426            }
427        }
428
429        Ok((old_nickname, old_host, old_protocol))
430    }
431
432    pub fn delete_server_fingerprint(&mut self, server: &str) -> anyhow::Result<()> {
433        let cfg = self.find_server_mut(server)?;
434        cfg.ecdsa_public_key = None;
435        Ok(())
436    }
437
438    pub fn delete_default_server_fingerprint(&mut self) -> anyhow::Result<()> {
439        let cfg = self.default_server_mut()?;
440        cfg.ecdsa_public_key = None;
441        Ok(())
442    }
443
444    pub fn set_web_session_token(&mut self, token: String) {
445        self.web_session_token = Some(token);
446    }
447
448    pub fn set_spacetimedb_token(&mut self, token: String) {
449        self.spacetimedb_token = Some(token);
450    }
451
452    pub fn clear_login_tokens(&mut self) {
453        self.web_session_token = None;
454        self.spacetimedb_token = None;
455    }
456}
457
458impl TryFrom<&toml_edit::DocumentMut> for RawConfig {
459    type Error = CliError;
460
461    fn try_from(value: &toml_edit::DocumentMut) -> Result<Self, Self::Error> {
462        let default_server = read_opt_str(value, DEFAULT_SERVER_KEY)?;
463        let web_session_token = read_opt_str(value, WEB_SESSION_TOKEN_KEY)?;
464        let spacetimedb_token = read_opt_str(value, SPACETIMEDB_TOKEN_KEY)?;
465
466        let mut server_configs = Vec::new();
467        if let Some(arr) = read_table(value, SERVER_CONFIGS_KEY)? {
468            for table in arr {
469                server_configs.push(ServerConfig::try_from(table)?);
470            }
471        }
472
473        Ok(RawConfig {
474            default_server,
475            server_configs,
476            web_session_token,
477            spacetimedb_token,
478        })
479    }
480}
481
482impl Config {
483    pub fn default_server_name(&self) -> Option<&str> {
484        self.home.default_server.as_deref()
485    }
486
487    /// Add a `ServerConfig` to the home configuration.
488    ///
489    /// Returns an `Err` on name conflict,
490    /// i.e. if a `ServerConfig` with the `nickname` or `host` already exists.
491    ///
492    /// Callers should call `Config::save` afterwards
493    /// to ensure modifications are persisted to disk.
494    pub fn add_server(
495        &mut self,
496        host: String,
497        protocol: String,
498        ecdsa_public_key: Option<String>,
499        nickname: Option<String>,
500    ) -> anyhow::Result<()> {
501        self.home.add_server(host, protocol, ecdsa_public_key, nickname)
502    }
503
504    /// Set the default server in the home configuration.
505    ///
506    /// Returns an `Err` if `nickname_or_host_or_url`
507    /// does not refer to an existing `ServerConfig`
508    /// in the home configuration.
509    ///
510    /// Callers should call `Config::save` afterwards
511    /// to ensure modifications are persisted to disk.
512    pub fn set_default_server(&mut self, nickname_or_host_or_url: &str) -> anyhow::Result<()> {
513        let (host, _) = host_or_url_to_host_and_protocol(nickname_or_host_or_url);
514        self.home.set_default_server(host)
515    }
516
517    /// Delete a `ServerConfig` from the home configuration.
518    ///
519    /// Returns an `Err` if `nickname_or_host_or_url`
520    /// does not refer to an existing `ServerConfig`
521    /// in the home configuration.
522    ///
523    /// Callers should call `Config::save` afterwards
524    /// to ensure modifications are persisted to disk.
525    pub fn remove_server(&mut self, nickname_or_host_or_url: &str) -> anyhow::Result<()> {
526        let (host, _) = host_or_url_to_host_and_protocol(nickname_or_host_or_url);
527        self.home.remove_server(host)
528    }
529
530    /// Get a URL for the specified `server`.
531    ///
532    /// Returns the URL of the default server if `server` is `None`.
533    ///
534    /// If `server` is `Some` and is a complete URL,
535    /// including protocol and hostname,
536    /// returns that URL without accessing the configuration.
537    ///
538    /// Returns an `Err` if:
539    /// - `server` is `Some`, but not a complete URL,
540    ///   and the supplied name does not refer to any server
541    ///   in the configuration.
542    /// - `server` is `None`, but the configuration does not have a default server.
543    pub fn get_host_url(&self, server: Option<&str>) -> anyhow::Result<String> {
544        Ok(format!("{}://{}", self.protocol(server)?, self.host(server)?))
545    }
546
547    /// Get the hostname of the specified `server`.
548    ///
549    /// Returns the hostname of the default server if `server` is `None`.
550    ///
551    /// If `server` is `Some` and is a complete URL,
552    /// including protocol and hostname,
553    /// returns that hostname without accessing the configuration.
554    ///
555    /// Returns an `Err` if:
556    /// - `server` is `Some`, but not a complete URL,
557    ///   and the supplied name does not refer to any server
558    ///   in the configuration.
559    /// - `server` is `None`, but the configuration does not
560    ///   have a default server.
561    pub fn host<'a>(&'a self, server: Option<&'a str>) -> anyhow::Result<&'a str> {
562        if let Some(server) = server {
563            if contains_protocol(server) {
564                Ok(host_or_url_to_host_and_protocol(server).0)
565            } else {
566                self.home.host(server)
567            }
568        } else {
569            self.home.default_host()
570        }
571    }
572
573    /// Get the protocol of the specified `server`, either `"http"` or `"https"`.
574    ///
575    /// Returns the protocol of the default server if `server` is `None`.
576    ///
577    /// If `server` is `Some` and is a complete URL,
578    /// including protocol and hostname,
579    /// returns that protocol without accessing the configuration.
580    /// In that case, the protocol is not validated.
581    ///
582    /// Returns an `Err` if:
583    /// - `server` is `Some`, but not a complete URL,
584    ///   and the supplied name does not refer to any server
585    ///   in the configuration.
586    /// - `server` is `None`, but the configuration does not have a default server.
587    pub fn protocol<'a>(&'a self, server: Option<&'a str>) -> anyhow::Result<&'a str> {
588        if let Some(server) = server {
589            if contains_protocol(server) {
590                Ok(host_or_url_to_host_and_protocol(server).1.unwrap())
591            } else {
592                self.home.protocol(server)
593            }
594        } else {
595            self.home.default_protocol()
596        }
597    }
598
599    pub fn server_configs(&self) -> &[ServerConfig] {
600        &self.home.server_configs
601    }
602
603    /// Parse [`RawConfig`] from a TOML file at the given path, returning `None` if the file does not exist.
604    ///
605    /// **NOTE**: Comments and formatting in the file will be preserved.
606    fn parse_config(path: &Path) -> anyhow::Result<Option<(toml_edit::DocumentMut, RawConfig)>> {
607        match std::fs::read_to_string(path) {
608            Ok(contents) => {
609                let doc = contents.parse::<toml_edit::DocumentMut>()?;
610                let config = RawConfig::try_from(&doc)?;
611                Ok(Some((doc, config)))
612            }
613            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
614            Err(e) => Err(e.into()),
615        }
616    }
617
618    pub fn load(home_path: CliTomlPath) -> anyhow::Result<Self> {
619        let home = Self::parse_config(home_path.as_ref())
620            .with_context(|| format!("config file {} is invalid", home_path.display()))?;
621        Ok(match home {
622            Some((doc, home)) => Self { home, home_path, doc },
623            None => {
624                let config = Self {
625                    home: RawConfig::new_with_localhost(),
626                    home_path,
627                    doc: Default::default(),
628                };
629                config.save();
630                config
631            }
632        })
633    }
634
635    #[doc(hidden)]
636    /// Used in tests.
637    pub fn new_with_localhost(home_path: CliTomlPath) -> Self {
638        Self {
639            home: RawConfig::new_with_localhost(),
640            home_path,
641            doc: Default::default(),
642        }
643    }
644
645    /// Returns a preserving copy of [`Config`].
646    fn doc(&self) -> toml_edit::DocumentMut {
647        let mut doc = self.doc.clone();
648
649        let mut set_value = |key: &str, value: Option<&str>| {
650            set_opt_value(&mut doc, key, value);
651        };
652        // Intentionally use a destructuring assignment in case the fields change...
653        let RawConfig {
654            default_server,
655            server_configs: old_server_configs,
656            web_session_token,
657            spacetimedb_token,
658        } = &self.home;
659
660        set_value(DEFAULT_SERVER_KEY, default_server.as_deref());
661        set_value(WEB_SESSION_TOKEN_KEY, web_session_token.as_deref());
662        set_value(SPACETIMEDB_TOKEN_KEY, spacetimedb_token.as_deref());
663
664        // Short-circuit if there are no servers.
665        if old_server_configs.is_empty() {
666            doc.remove(SERVER_CONFIGS_KEY);
667            return doc;
668        }
669        // ... or if there are no server_configs to edit.
670        let new_server_configs = if let Some(cfg) = doc
671            .get_mut(SERVER_CONFIGS_KEY)
672            .and_then(toml_edit::Item::as_array_of_tables_mut)
673        {
674            cfg
675        } else {
676            doc[SERVER_CONFIGS_KEY] =
677                toml_edit::Item::ArrayOfTables(old_server_configs.iter().map(ServerConfig::as_table).collect());
678            return doc;
679        };
680
681        let mut new_configs = self
682            .home
683            .server_configs
684            .iter()
685            .map(|cfg| (cfg.nick_or_host(), cfg))
686            .collect::<HashMap<_, _>>();
687
688        // Update the existing servers, and remove deleted servers.
689        // We'll add new servers later.
690        // We do this somewhat elaborate dance rather than just overwriting the config
691        // in order to preserve the order and formatting of pre-existing server configs in the file.
692        let mut new_vec = Vec::with_capacity(new_server_configs.len());
693        for old_config in new_server_configs.iter_mut() {
694            let nick_or_host = old_config
695                .get(NICKNAME_KEY)
696                .or_else(|| old_config.get(HOST_KEY))
697                .and_then(|v| v.as_str())
698                .unwrap();
699
700            if let Some(new_config) = new_configs.remove(nick_or_host) {
701                ServerConfig::update_table(old_config, new_config);
702                new_vec.push(old_config.clone());
703            }
704        }
705
706        // Add the new servers. This appends them to the end of the config file,
707        // after the (preserved) existing configs.
708        new_vec.extend(new_configs.values().cloned().map(ServerConfig::as_table));
709        *new_server_configs = toml_edit::ArrayOfTables::from_iter(new_vec);
710
711        doc
712    }
713
714    pub fn save(&self) {
715        let home_path = &self.home_path;
716        // If the `home_path` is in a directory, ensure it exists.
717        home_path.create_parent().unwrap();
718
719        let config = self.doc().to_string();
720
721        eprintln!("Saving config to {}.", home_path.display());
722        // TODO: We currently have a race condition if multiple processes are modifying the config.
723        // If process X and process Y read the config, each make independent changes, and then save
724        // the config, the first writer will have its changes clobbered by the second writer.
725        //
726        // We used to use `Lockfile` to prevent this from happening, but we had other issues with
727        // that approach (see https://github.com/clockworklabs/SpacetimeDB/issues/1339, and the
728        // TODO in `lockfile.rs`).
729        //
730        // We should address this issue, but we currently don't expect it to arise very frequently
731        // (see https://github.com/clockworklabs/SpacetimeDB/pull/1341#issuecomment-2150857432).
732        if let Err(e) = atomic_write(&home_path.0, config) {
733            eprintln!("Could not save config file: {e}")
734        }
735    }
736
737    pub fn server_decoding_key(&self, server: Option<&str>) -> anyhow::Result<DecodingKey> {
738        self.server_fingerprint(server).and_then(|fing| {
739            if let Some(fing) = fing {
740                DecodingKey::from_ec_pem(fing.as_bytes()).with_context(|| {
741                    format!(
742                        "Unable to parse invalid saved server fingerprint as ECDSA public key.
743Update the server's fingerprint with:
744\tspacetime server fingerprint {}",
745                        server.unwrap_or("")
746                    )
747                })
748            } else {
749                Err(anyhow::anyhow!(
750                    "No fingerprint saved for server: {}",
751                    self.server_nick_or_host(server)?,
752                ))
753            }
754        })
755    }
756
757    pub fn server_nick_or_host<'a>(&'a self, server: Option<&'a str>) -> anyhow::Result<&'a str> {
758        if let Some(server) = server {
759            let (host, _) = host_or_url_to_host_and_protocol(server);
760            Ok(host)
761        } else {
762            self.home.default_server().map(ServerConfig::nick_or_host)
763        }
764    }
765
766    pub fn server_fingerprint(&self, server: Option<&str>) -> anyhow::Result<Option<&str>> {
767        if let Some(server) = server {
768            let (host, _) = host_or_url_to_host_and_protocol(server);
769            self.home.server_fingerprint(host)
770        } else {
771            self.home.default_server_fingerprint()
772        }
773    }
774
775    pub fn set_server_fingerprint(&mut self, server: Option<&str>, new_fingerprint: String) -> anyhow::Result<()> {
776        if let Some(server) = server {
777            let (host, _) = host_or_url_to_host_and_protocol(server);
778            self.home.set_server_fingerprint(host, new_fingerprint)
779        } else {
780            self.home.set_default_server_fingerprint(new_fingerprint)
781        }
782    }
783
784    pub fn edit_server(
785        &mut self,
786        server: &str,
787        new_nickname: Option<&str>,
788        new_host: Option<&str>,
789        new_protocol: Option<&str>,
790    ) -> anyhow::Result<(Option<String>, Option<String>, Option<String>)> {
791        let (host, _) = host_or_url_to_host_and_protocol(server);
792        self.home.edit_server(host, new_nickname, new_host, new_protocol)
793    }
794
795    pub fn delete_server_fingerprint(&mut self, server: Option<&str>) -> anyhow::Result<()> {
796        if let Some(server) = server {
797            let (host, _) = host_or_url_to_host_and_protocol(server);
798            self.home.delete_server_fingerprint(host)
799        } else {
800            self.home.delete_default_server_fingerprint()
801        }
802    }
803
804    pub fn set_web_session_token(&mut self, token: String) {
805        self.home.set_web_session_token(token);
806    }
807
808    pub fn set_spacetimedb_token(&mut self, token: String) {
809        self.home.set_spacetimedb_token(token);
810    }
811
812    pub fn clear_login_tokens(&mut self) {
813        self.home.clear_login_tokens();
814    }
815
816    pub fn web_session_token(&self) -> Option<&String> {
817        self.home.web_session_token.as_ref()
818    }
819
820    pub fn spacetimedb_token(&self) -> Option<&String> {
821        self.home.spacetimedb_token.as_ref()
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use spacetimedb_lib::error::ResultTest;
829    use spacetimedb_paths::cli::CliTomlPath;
830    use spacetimedb_paths::FromPathUnchecked;
831    use std::fs;
832    use std::thread;
833
834    const CONFIG_FULL: &str = r#"default_server = "local"
835web_session_token = "web_session"
836spacetimedb_token = "26ac38857c2bd6c5b60ec557ecd4f9add918fef577dc92c01ca96ff08af5b84d"
837
838# comment on table
839[[server_configs]]
840nickname = "local"
841host = "127.0.0.1:3000"
842protocol = "http"
843
844# comment on table
845[[server_configs]]
846# comment on table
847nickname = "testnet" # Comment nickname
848host = "testnet.spacetimedb.com" # Comment host
849# Comment protocol
850protocol = "https"
851
852# Comment end
853"#;
854    const CONFIG_FULL_NO_COMMENT: &str = r#"default_server = "local"
855web_session_token = "web_session"
856spacetimedb_token = "26ac38857c2bd6c5b60ec557ecd4f9add918fef577dc92c01ca96ff08af5b84d"
857
858[[server_configs]]
859nickname = "local"
860host = "127.0.0.1:3000"
861protocol = "http"
862
863[[server_configs]]
864nickname = "testnet"
865host = "testnet.spacetimedb.com"
866protocol = "https"
867
868# Comment end
869"#;
870    const CONFIG_CHANGE_SERVER: &str = r#"default_server = "local"
871web_session_token = "web_session"
872spacetimedb_token = "26ac38857c2bd6c5b60ec557ecd4f9add918fef577dc92c01ca96ff08af5b84d"
873
874# comment on table
875[[server_configs]]
876# comment on table
877nickname = "testnet" # Comment nickname
878host = "prod.spacetimedb.com" # Comment host
879# Comment protocol
880protocol = "https"
881
882# Comment end
883"#;
884    const CONFIG_EMPTY: &str = r#"
885# Comment end
886"#;
887    const CONFIG_INVALID_START: &str = r#"
888this="not a valid key"
889"#;
890    const CONFIG_INVALID_END: &str = r#"
891this="not a valid key"
892default_server = "local"
893"#;
894
895    fn check_invalid(contents: &str, expect: CliError) -> ResultTest<()> {
896        let doc = contents.parse::<toml_edit::DocumentMut>()?;
897        let err = RawConfig::try_from(&doc);
898        assert_eq!(err.unwrap_err().to_string(), expect.to_string());
899
900        Ok(())
901    }
902
903    fn check_config<F>(input: &str, output: &str, f: F) -> ResultTest<()>
904    where
905        F: FnOnce(&mut Config) -> ResultTest<()>,
906    {
907        let tmp = tempfile::tempdir()?;
908        let config_path = CliTomlPath::from_path_unchecked(tmp.path().join("config.toml"));
909
910        fs::write(&config_path, input)?;
911
912        let mut config = Config::load(config_path.clone()).unwrap();
913        f(&mut config)?;
914        config.save();
915
916        let contents = fs::read_to_string(&config_path)?;
917
918        assert_eq!(contents, output);
919
920        Ok(())
921    }
922
923    // Test editing the config file.
924    #[test]
925    fn test_config_edits() -> ResultTest<()> {
926        check_config(CONFIG_FULL, CONFIG_EMPTY, |config| {
927            config.home.default_server = None;
928            config.home.server_configs.clear();
929            config.home.spacetimedb_token = None;
930            config.home.web_session_token = None;
931
932            Ok(())
933        })?;
934
935        check_config(CONFIG_FULL, CONFIG_CHANGE_SERVER, |config| {
936            config.home.server_configs.remove(0);
937            config.home.server_configs[0].host = "prod.spacetimedb.com".to_string();
938            Ok(())
939        })?;
940
941        Ok(())
942    }
943
944    // Test adding to the config file.
945    #[test]
946    fn test_config_adds() -> ResultTest<()> {
947        check_config(CONFIG_FULL, CONFIG_FULL, |_| Ok(()))?;
948        check_config(CONFIG_EMPTY, CONFIG_EMPTY, |_| Ok(()))?;
949
950        check_config(CONFIG_EMPTY, CONFIG_FULL_NO_COMMENT, |config| {
951            config.home.default_server = Some("local".to_string());
952            config.home.server_configs = vec![
953                ServerConfig {
954                    nickname: Some("local".to_string()),
955                    host: "127.0.0.1:3000".to_string(),
956                    protocol: "http".to_string(),
957                    ecdsa_public_key: None,
958                },
959                ServerConfig {
960                    nickname: Some("testnet".to_string()),
961                    host: "testnet.spacetimedb.com".to_string(),
962                    protocol: "https".to_string(),
963                    ecdsa_public_key: None,
964                },
965            ];
966            config.home.spacetimedb_token =
967                Some("26ac38857c2bd6c5b60ec557ecd4f9add918fef577dc92c01ca96ff08af5b84d".to_string());
968            config.home.web_session_token = Some("web_session".to_string());
969
970            Ok(())
971        })?;
972
973        Ok(())
974    }
975
976    // Test that modify a config file with wrong extra configs is fine
977    #[test]
978    fn test_config_invalid_mut() -> ResultTest<()> {
979        check_config(CONFIG_INVALID_START, CONFIG_INVALID_END, |config| {
980            config.home.default_server = Some("local".to_string());
981            Ok(())
982        })?;
983
984        Ok(())
985    }
986
987    // Test invalid types in the config file.
988    #[test]
989    fn test_config_invalid() -> ResultTest<()> {
990        check_invalid(
991            r#"default_server =1"#,
992            CliError::ConfigType {
993                key: "default_server".to_string(),
994                kind: "string",
995                found: Box::new(toml_edit::value(1)),
996            },
997        )?;
998        check_invalid(
999            r#"web_session_token =1"#,
1000            CliError::ConfigType {
1001                key: "web_session_token".to_string(),
1002                kind: "string",
1003                found: Box::new(toml_edit::value(1)),
1004            },
1005        )?;
1006        check_invalid(
1007            r#"spacetimedb_token =1"#,
1008            CliError::ConfigType {
1009                key: "spacetimedb_token".to_string(),
1010                kind: "string",
1011                found: Box::new(toml_edit::value(1)),
1012            },
1013        )?;
1014        check_invalid(
1015            r#"
1016[server_configs]
1017"#,
1018            CliError::ConfigType {
1019                key: "server_configs".to_string(),
1020                kind: "table array",
1021                found: Box::new(toml_edit::table()),
1022            },
1023        )?;
1024        check_invalid(
1025            r#"
1026[[server_configs]]
1027nickname =1
1028"#,
1029            CliError::ConfigType {
1030                key: "nickname".to_string(),
1031                kind: "string",
1032                found: Box::new(toml_edit::value(1)),
1033            },
1034        )?;
1035        check_invalid(
1036            r#"
1037[[server_configs]]
1038host =1
1039"#,
1040            CliError::ConfigType {
1041                key: "host".to_string(),
1042                kind: "string",
1043                found: Box::new(toml_edit::value(1)),
1044            },
1045        )?;
1046
1047        check_invalid(
1048            r#"
1049[[server_configs]]
1050host = "127.0.0.1:3000"
1051protocol =1
1052"#,
1053            CliError::ConfigType {
1054                key: "protocol".to_string(),
1055                kind: "string",
1056                found: Box::new(toml_edit::value(1)),
1057            },
1058        )?;
1059        Ok(())
1060    }
1061
1062    // Test editing the config file concurrently don't corrupt the file.
1063    //
1064    // The test only confirms that the file is not corrupted, not that the changes are deterministic.
1065    #[test]
1066    fn test_config_concurrent() -> ResultTest<()> {
1067        let tmp = tempfile::tempdir()?;
1068        let config_path = CliTomlPath::from_path_unchecked(tmp.path().join("config.toml"));
1069
1070        let mut local = Config::load(config_path.clone()).unwrap();
1071        let mut testnet = Config::load(config_path.clone()).unwrap();
1072
1073        local.home.default_server = Some("local".to_string());
1074        testnet.home.default_server = Some("testnet".to_string());
1075
1076        let mut handles = vec![];
1077        let total_threads: usize = 8;
1078
1079        // Writer threads
1080        for i in 0..total_threads {
1081            let local = local.clone();
1082            let testnet = testnet.clone();
1083            handles.push(thread::spawn(move || {
1084                if i % 2 == 0 {
1085                    local.save();
1086                    local
1087                } else {
1088                    testnet.save();
1089                    testnet
1090                }
1091                .doc()
1092                .to_string()
1093            }));
1094        }
1095
1096        // Reader threads
1097        for _ in 0..total_threads {
1098            let config_path = config_path.clone();
1099            handles.push(thread::spawn(move || {
1100                let config = Config::load(config_path).unwrap();
1101                config.doc().to_string()
1102            }));
1103        }
1104
1105        let mut results = vec![];
1106        for handle in handles {
1107            results.push(handle.join().unwrap());
1108        }
1109        let local = local.doc().to_string();
1110        let testnet = testnet.doc().to_string();
1111
1112        // As long the results are any valid config, we're good.
1113        assert!(results
1114            .iter()
1115            .all(|r| r.trim() == local.trim() || r.trim() == testnet.trim()));
1116        Ok(())
1117    }
1118}