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