Skip to main content

teamcraft/
localserver.rs

1//! `teamcraft local`: a test installation on one computer, for trying
2//! TeamCraft out without Docker or a database administrator. It runs a
3//! private PostgreSQL cluster and the server, both only on `127.0.0.1`, with
4//! every file in one data directory:
5//!
6//! ```text
7//! <data dir>/postgres/        the cluster (initdb on the first start)
8//! <data dir>/postgres.log     PostgreSQL's log
9//! <data dir>/db-password      the generated database password (0600)
10//! <data dir>/vault/           the file vault
11//! ```
12//!
13//! The PostgreSQL programs come from the release bundle (`postgresql/bin`
14//! next to the executable, as the desktop app's *with local server*
15//! installers ship it), from `--pg-bin`, or from an installation on this
16//! computer. The cluster gets its own free port and no Unix socket; it uses
17//! scram-sha-256 with a random password, so other accounts on the computer
18//! cannot connect. The desktop app starts this command for *Try TeamCraft on
19//! this computer* (`desktop/src/localserver.rs`). Never for production:
20//! one user's laptop, no backups, no TLS.
21
22use std::{
23    collections::BTreeMap,
24    ffi::OsStr,
25    path::{Path, PathBuf},
26    process::Output,
27};
28
29use anyhow::{bail, Context, Result};
30use rand::{rngs::OsRng, RngCore};
31use sqlx::{
32    postgres::{PgConnectOptions, PgSslMode},
33    ConnectOptions, Connection,
34};
35
36/// Database role and database of the local installation.
37pub const DB_USER: &str = "teamcraft";
38pub const DB_NAME: &str = "teamcraft";
39
40/// The name of this binary in the desktop downloads *with local server*
41/// (`teamcraft-server[.exe]` next to the app; `desktop/src/localserver.rs`).
42pub const SIDECAR_NAME: &str = "teamcraft-server";
43
44/// HTTP port when none is given (the port of every other quick start).
45pub const DEFAULT_PORT: u16 = 8080;
46
47/// The directory the release bundles put the PostgreSQL programs in, next to
48/// the `teamcraft` executable (`postgresql/bin/initdb`, …).
49pub const BUNDLED_PG_DIR: &str = "postgresql";
50
51/// How long `pg_ctl` may take to start or stop the cluster, in seconds.
52const PG_CTL_TIMEOUT_SECS: u32 = 120;
53
54/// Line added to `postgresql.conf` after initdb: the settings of each start
55/// (port, listen address) are written to this file.
56const INCLUDE_LINE: &str = "include_if_exists = 'teamcraft-local.conf'";
57const LOCAL_CONF: &str = "teamcraft-local.conf";
58
59fn exe(name: &str) -> String {
60    if cfg!(windows) {
61        format!("{name}.exe")
62    } else {
63        name.to_string()
64    }
65}
66
67/// Where the data lives when no `--data-dir` is given: the per-user data
68/// directory of the operating system, so that the desktop app and the
69/// command line share one installation.
70///
71/// * Linux: `$XDG_DATA_HOME/teamcraft/local-server` (`~/.local/share/…`)
72/// * macOS: `~/Library/Application Support/TeamCraft/local-server`
73/// * Windows: `%LOCALAPPDATA%\TeamCraft\local-server`
74pub fn default_data_dir() -> Option<PathBuf> {
75    data_dir_from(|key| std::env::var_os(key).map(PathBuf::from))
76}
77
78fn data_dir_from(var: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
79    let set = |key: &str| var(key).filter(|p| !p.as_os_str().is_empty());
80    if cfg!(windows) {
81        return set("LOCALAPPDATA").map(|d| d.join("TeamCraft").join("local-server"));
82    }
83    let home = set("HOME");
84    if cfg!(target_os = "macos") {
85        return home.map(|h| {
86            h.join("Library")
87                .join("Application Support")
88                .join("TeamCraft")
89                .join("local-server")
90        });
91    }
92    set("XDG_DATA_HOME")
93        .filter(|p| p.is_absolute())
94        .or_else(|| home.map(|h| h.join(".local").join("share")))
95        .map(|d| d.join("teamcraft").join("local-server"))
96}
97
98/// Whether `dir` holds the programs `teamcraft local` runs.
99pub fn is_pg_bin(dir: &Path) -> bool {
100    ["initdb", "pg_ctl", "postgres"]
101        .iter()
102        .all(|name| dir.join(exe(name)).is_file())
103}
104
105/// The directories searched for the PostgreSQL programs, in order:
106/// `--pg-bin`; the bundle next to the executable (and in the macOS app's
107/// `Resources`); `PATH`; the usual installation directories (Debian/Ubuntu
108/// `/usr/lib/postgresql/<n>/bin`, Homebrew, Postgres.app, the Windows
109/// installer), newest version first.
110pub fn pg_bin_candidates(explicit: Option<&Path>, exe_dir: Option<&Path>) -> Vec<PathBuf> {
111    let mut out = Vec::new();
112    if let Some(dir) = explicit {
113        out.push(dir.to_path_buf());
114        // only the given directory: a wrong --pg-bin must not fall back silently
115        return out;
116    }
117    if let Some(dir) = exe_dir {
118        out.push(dir.join(BUNDLED_PG_DIR).join("bin"));
119        // macOS app bundle: Contents/MacOS/teamcraft, Contents/Resources/postgresql
120        out.push(
121            dir.join("..")
122                .join("Resources")
123                .join(BUNDLED_PG_DIR)
124                .join("bin"),
125        );
126        // Linux packages and the AppImage: usr/bin/teamcraft, usr/lib/<app>/postgresql
127        for app in ["teamcraft", "TeamCraft"] {
128            out.push(
129                dir.join("..")
130                    .join("lib")
131                    .join(app)
132                    .join(BUNDLED_PG_DIR)
133                    .join("bin"),
134            );
135        }
136    }
137    if let Some(path) = std::env::var_os("PATH") {
138        out.extend(std::env::split_paths(&path));
139    }
140    let versioned = |parent: &Path, prefix: &str, suffix: &[&str]| -> Vec<PathBuf> {
141        let Ok(entries) = std::fs::read_dir(parent) else {
142            return Vec::new();
143        };
144        let mut found: Vec<(u32, PathBuf)> = entries
145            .flatten()
146            .filter_map(|e| {
147                let name = e.file_name().to_string_lossy().to_string();
148                let version = name.strip_prefix(prefix)?.parse::<u32>().ok()?;
149                let mut path = e.path();
150                for part in suffix {
151                    path.push(part);
152                }
153                Some((version, path))
154            })
155            .collect();
156        found.sort_by_key(|(version, _)| std::cmp::Reverse(*version));
157        found.into_iter().map(|(_, p)| p).collect()
158    };
159    if cfg!(windows) {
160        for root in ["ProgramFiles", "ProgramW6432"] {
161            if let Some(pf) = std::env::var_os(root) {
162                out.extend(versioned(
163                    &PathBuf::from(pf).join("PostgreSQL"),
164                    "",
165                    &["bin"],
166                ));
167            }
168        }
169    } else {
170        out.extend(versioned(Path::new("/usr/lib/postgresql"), "", &["bin"]));
171        for brew in ["/opt/homebrew/opt", "/usr/local/opt"] {
172            out.extend(versioned(Path::new(brew), "postgresql@", &["bin"]));
173        }
174        out.push(PathBuf::from(
175            "/Applications/Postgres.app/Contents/Versions/latest/bin",
176        ));
177    }
178    out
179}
180
181/// The first candidate that holds `initdb`, `pg_ctl` and `postgres`.
182pub fn find_pg_bin(explicit: Option<&Path>) -> Result<PathBuf> {
183    let exe_dir = std::env::current_exe()
184        .ok()
185        .and_then(|p| p.parent().map(Path::to_path_buf));
186    let candidates = pg_bin_candidates(explicit, exe_dir.as_deref());
187    if let Some(found) = candidates.iter().find(|d| is_pg_bin(d)) {
188        return Ok(found.clone());
189    }
190    match explicit {
191        Some(dir) => bail!(
192            "{} does not contain initdb, pg_ctl and postgres",
193            dir.display()
194        ),
195        None => bail!(
196            "PostgreSQL was not found: no {BUNDLED_PG_DIR}/bin next to teamcraft, none on PATH \
197             or in the usual installation directories. Install PostgreSQL (16 or newer), \
198             or give its bin directory with --pg-bin"
199        ),
200    }
201}
202
203/// A free TCP port on 127.0.0.1 (asked from the operating system).
204pub fn free_port() -> Result<u16> {
205    let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
206        .context("cannot find a free port on 127.0.0.1")?;
207    Ok(listener.local_addr()?.port())
208}
209
210/// 32 random alphanumeric characters (about 190 bits).
211fn random_password() -> String {
212    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
213    let mut bytes = [0u8; 32];
214    OsRng.fill_bytes(&mut bytes);
215    bytes
216        .iter()
217        .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
218        .collect()
219}
220
221/// Writes a file only the owner can read (0600 on Unix).
222fn write_private(path: &Path, content: &str) -> Result<()> {
223    let mut options = std::fs::OpenOptions::new();
224    options.write(true).create(true).truncate(true);
225    #[cfg(unix)]
226    {
227        use std::os::unix::fs::OpenOptionsExt;
228        options.mode(0o600);
229    }
230    let mut file = options
231        .open(path)
232        .with_context(|| format!("cannot write {}", path.display()))?;
233    std::io::Write::write_all(&mut file, content.as_bytes())
234        .with_context(|| format!("cannot write {}", path.display()))?;
235    Ok(())
236}
237
238/// The settings of one start, written to `teamcraft-local.conf` in the
239/// cluster: only 127.0.0.1, the given port, no Unix socket (nothing in /tmp,
240/// no socket directory that would need root).
241pub fn local_conf(port: u16) -> String {
242    format!(
243        "# written by `teamcraft local` at every start – changes are overwritten\n\
244         listen_addresses = '127.0.0.1'\n\
245         port = {port}\n\
246         unix_socket_directories = ''\n"
247    )
248}
249
250/// The configuration variables of the local installation. They replace the
251/// same variables (and their `_FILE` forms) from the environment; see
252/// [`overlay_lookup`].
253pub fn local_variables(
254    data_dir: &Path,
255    http_port: u16,
256    db_port: u16,
257    password: &str,
258) -> BTreeMap<&'static str, String> {
259    BTreeMap::from([
260        ("TEAMCRAFT_BIND_ADDR", format!("127.0.0.1:{http_port}")),
261        (
262            "TEAMCRAFT_PUBLIC_URL",
263            format!("http://127.0.0.1:{http_port}"),
264        ),
265        ("TEAMCRAFT_DB_HOST", "127.0.0.1".to_string()),
266        ("TEAMCRAFT_DB_PORT", db_port.to_string()),
267        ("TEAMCRAFT_DB_USER", DB_USER.to_string()),
268        ("TEAMCRAFT_DB_NAME", DB_NAME.to_string()),
269        ("TEAMCRAFT_DB_PASSWORD", password.to_string()),
270        ("TEAMCRAFT_DB_SSLMODE", "disable".to_string()),
271        (
272            "TEAMCRAFT_VAULT_DIR",
273            data_dir.join("vault").to_string_lossy().to_string(),
274        ),
275    ])
276}
277
278/// Variables that would point the server at another database; hidden from
279/// the configuration of the local installation.
280const MASKED: &[&str] = &["DATABASE_URL", "TEAMCRAFT_DATABASE_URL"];
281
282/// A lookup for [`crate::config::Config::from_lookup`]: the local variables
283/// win, their `_FILE` forms and the database URLs are hidden, everything
284/// else (an assistant key, OIDC, …) comes from `env`.
285pub fn overlay_lookup<F>(
286    local: BTreeMap<&'static str, String>,
287    env: F,
288) -> impl Fn(&str) -> Option<String>
289where
290    F: Fn(&str) -> Option<String>,
291{
292    move |key: &str| {
293        if let Some(value) = local.get(key) {
294            return Some(value.clone());
295        }
296        let base = key.strip_suffix("_FILE").unwrap_or(key);
297        if local.contains_key(base) || MASKED.contains(&base) {
298            return None;
299        }
300        env(key)
301    }
302}
303
304/// A command for a PostgreSQL program. On Windows without a console window:
305/// the desktop app starts `teamcraft local` without one, and every program
306/// started from there would otherwise open its own.
307fn pg_command(bin: &Path, name: &str) -> tokio::process::Command {
308    #[allow(unused_mut)]
309    let mut command = tokio::process::Command::new(bin.join(exe(name)));
310    command.stdin(std::process::Stdio::null());
311    #[cfg(windows)]
312    {
313        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
314        command.creation_flags(CREATE_NO_WINDOW);
315    }
316    command
317}
318
319fn output_text(output: &Output) -> String {
320    let mut text = String::from_utf8_lossy(&output.stderr).trim().to_string();
321    let stdout = String::from_utf8_lossy(&output.stdout);
322    if !stdout.trim().is_empty() {
323        if !text.is_empty() {
324            text.push('\n');
325        }
326        text.push_str(stdout.trim());
327    }
328    text
329}
330
331async fn run(mut command: tokio::process::Command, what: &str) -> Result<Output> {
332    let output = command
333        .output()
334        .await
335        .with_context(|| format!("cannot run {what}"))?;
336    if !output.status.success() {
337        let text = output_text(&output);
338        if text.contains("cannot be run as root") {
339            bail!(
340                "PostgreSQL refuses to run as root: start `teamcraft local` as a normal user\n{text}"
341            );
342        }
343        bail!("{what} failed ({}):\n{text}", output.status);
344    }
345    Ok(output)
346}
347
348/// The running PostgreSQL cluster of a local installation.
349pub struct Cluster {
350    bin: PathBuf,
351    pgdata: PathBuf,
352    /// The port of this start.
353    pub port: u16,
354    /// The database password (from `<data dir>/db-password`).
355    pub password: String,
356}
357
358impl Cluster {
359    /// Creates the cluster on the first start, then starts it on a free port
360    /// and creates the database if it is missing. A cluster left running by
361    /// a previous start that did not stop it (killed, power loss) is stopped
362    /// first.
363    pub async fn start(bin: &Path, data_dir: &Path) -> Result<Self> {
364        std::fs::create_dir_all(data_dir)
365            .with_context(|| format!("cannot create {}", data_dir.display()))?;
366        let pgdata = data_dir.join("postgres");
367        let password_file = data_dir.join("db-password");
368
369        if !pgdata.join("PG_VERSION").is_file() {
370            if pgdata.exists() && std::fs::read_dir(&pgdata)?.next().is_some() {
371                bail!(
372                    "{} exists but is not a PostgreSQL cluster; move it away or choose another --data-dir",
373                    pgdata.display()
374                );
375            }
376            tracing::info!(dir = %pgdata.display(), "creating the PostgreSQL cluster (first start)");
377            let password = random_password();
378            write_private(&password_file, &password)?;
379            // initdb reads the password from a file; it must not end up on
380            // the command line, where other users could see it
381            let pwfile = data_dir.join("initdb-password.tmp");
382            write_private(&pwfile, &format!("{password}\n"))?;
383            let mut initdb = pg_command(bin, "initdb");
384            initdb
385                .arg("--pgdata")
386                .arg(&pgdata)
387                .args(["--username", DB_USER])
388                .args(["--auth", "scram-sha-256"])
389                .arg(format!("--pwfile={}", pwfile.display()))
390                .args(["--encoding", "UTF8", "--no-locale"]);
391            let result = run(initdb, "initdb").await;
392            let _ = std::fs::remove_file(&pwfile);
393            if let Err(e) = result {
394                // a half-created cluster would block the next attempt
395                let _ = std::fs::remove_dir_all(&pgdata);
396                return Err(e);
397            }
398            let conf = pgdata.join("postgresql.conf");
399            let mut text = std::fs::read_to_string(&conf)
400                .with_context(|| format!("cannot read {}", conf.display()))?;
401            text.push_str(&format!("\n# added by `teamcraft local`\n{INCLUDE_LINE}\n"));
402            std::fs::write(&conf, text)
403                .with_context(|| format!("cannot write {}", conf.display()))?;
404        }
405        let password = std::fs::read_to_string(&password_file)
406            .with_context(|| format!("cannot read {}", password_file.display()))?
407            .trim()
408            .to_string();
409        if password.is_empty() {
410            bail!("{} is empty", password_file.display());
411        }
412
413        let mut cluster = Self {
414            bin: bin.to_path_buf(),
415            pgdata,
416            port: 0,
417            password,
418        };
419        if cluster.running().await {
420            tracing::info!("stopping the PostgreSQL cluster left running by an earlier start");
421            cluster.stop_async().await?;
422        }
423        cluster.port = free_port()?;
424        std::fs::write(cluster.pgdata.join(LOCAL_CONF), local_conf(cluster.port))
425            .context("cannot write the cluster settings")?;
426        // pg_ctl's own output goes to a file, never to a pipe: on Windows
427        // pg_ctl starts postgres through cmd.exe with handle inheritance, so
428        // the postmaster – which keeps running – would hold the write end of
429        // a pipe, and reading the pipe to its end would wait forever.
430        let pg_ctl_log = data_dir.join("pg_ctl.log");
431        let out = std::fs::File::create(&pg_ctl_log)
432            .with_context(|| format!("cannot write {}", pg_ctl_log.display()))?;
433        let err = out.try_clone()?;
434        let mut start = pg_command(bin, "pg_ctl");
435        start
436            .arg("start")
437            .arg("--pgdata")
438            .arg(&cluster.pgdata)
439            .arg("--log")
440            .arg(data_dir.join("postgres.log"))
441            .args(["--wait", "--silent"])
442            .arg(format!("--timeout={PG_CTL_TIMEOUT_SECS}"))
443            .stdout(out)
444            .stderr(err);
445        let status = start.status().await.context("cannot run pg_ctl start")?;
446        if !status.success() {
447            let said = std::fs::read_to_string(&pg_ctl_log).unwrap_or_default();
448            bail!(
449                "PostgreSQL did not start ({status}); see {}\n{}",
450                data_dir.join("postgres.log").display(),
451                said.trim()
452            );
453        }
454        tracing::info!(port = cluster.port, "PostgreSQL is running on 127.0.0.1");
455        if let Err(e) = cluster.ensure_database().await {
456            cluster.stop();
457            return Err(e);
458        }
459        Ok(cluster)
460    }
461
462    fn options(&self, database: &str) -> PgConnectOptions {
463        PgConnectOptions::new()
464            .host("127.0.0.1")
465            .port(self.port)
466            .username(DB_USER)
467            .password(&self.password)
468            .database(database)
469            .ssl_mode(PgSslMode::Disable)
470            .application_name("teamcraft local")
471    }
472
473    /// Creates the database on the first start.
474    async fn ensure_database(&self) -> Result<()> {
475        let mut conn = self
476            .options("postgres")
477            .connect()
478            .await
479            .context("cannot connect to the local PostgreSQL cluster")?;
480        let exists: bool =
481            sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1)")
482                .bind(DB_NAME)
483                .fetch_one(&mut conn)
484                .await?;
485        if !exists {
486            // the name is a constant: no quoting needed
487            sqlx::query(&format!("CREATE DATABASE {DB_NAME}"))
488                .execute(&mut conn)
489                .await
490                .context("cannot create the database")?;
491        }
492        conn.close().await.ok();
493        Ok(())
494    }
495
496    /// Whether a postmaster runs on this data directory (`pg_ctl status`).
497    async fn running(&self) -> bool {
498        let mut status = pg_command(&self.bin, "pg_ctl");
499        status.arg("status").arg("--pgdata").arg(&self.pgdata);
500        matches!(status.output().await, Ok(o) if o.status.success())
501    }
502
503    async fn stop_async(&self) -> Result<()> {
504        let mut stop = pg_command(&self.bin, "pg_ctl");
505        stop.args(stop_args(&self.pgdata));
506        run(stop, "pg_ctl stop").await.map(|_| ())
507    }
508
509    /// Stops the cluster (fast shutdown: open connections are closed). Used
510    /// after the server stopped; errors are logged, not returned.
511    pub fn stop(&self) {
512        let mut stop = std::process::Command::new(self.bin.join(exe("pg_ctl")));
513        stop.args(stop_args(&self.pgdata))
514            .stdin(std::process::Stdio::null());
515        #[cfg(windows)]
516        {
517            use std::os::windows::process::CommandExt;
518            stop.creation_flags(0x0800_0000);
519        }
520        match stop.output() {
521            Ok(o) if o.status.success() => tracing::info!("PostgreSQL stopped"),
522            Ok(o) => tracing::warn!(error = %output_text(&o), "pg_ctl stop failed"),
523            Err(e) => tracing::warn!(error = %e, "cannot run pg_ctl stop"),
524        }
525    }
526}
527
528fn stop_args(pgdata: &Path) -> Vec<&OsStr> {
529    vec![
530        OsStr::new("stop"),
531        OsStr::new("--pgdata"),
532        pgdata.as_os_str(),
533        OsStr::new("--mode=fast"),
534        OsStr::new("--wait"),
535        OsStr::new("--silent"),
536    ]
537}
538
539/// `<data dir>/local-server.port`: the HTTP port of the running instance,
540/// written once PostgreSQL runs and removed when it stopped.
541const PORT_FILE: &str = "local-server.port";
542
543/// Records the HTTP port of this instance in the data directory.
544pub fn write_port_file(data_dir: &Path, http_port: u16) -> Result<()> {
545    std::fs::write(data_dir.join(PORT_FILE), format!("{http_port}\n"))
546        .context("cannot write the port file")
547}
548
549/// Removes the port file again (after the cluster stopped).
550pub fn remove_port_file(data_dir: &Path) {
551    let _ = std::fs::remove_file(data_dir.join(PORT_FILE));
552}
553
554/// The port of another `teamcraft local` that still serves this data
555/// directory: its port file names a port whose `/readyz` answers like
556/// TeamCraft. Starting a second one would stop its PostgreSQL (it looks
557/// left running), so the caller refuses instead. A port file left behind by
558/// a killed instance does not count.
559pub async fn running_instance(data_dir: &Path) -> Option<u16> {
560    let port: u16 = std::fs::read_to_string(data_dir.join(PORT_FILE))
561        .ok()?
562        .trim()
563        .parse()
564        .ok()?;
565    let client = reqwest::Client::builder()
566        .no_proxy()
567        .timeout(std::time::Duration::from_secs(3))
568        .build()
569        .ok()?;
570    let body: serde_json::Value = client
571        .get(format!("http://127.0.0.1:{port}/readyz"))
572        .send()
573        .await
574        .ok()?
575        .json()
576        .await
577        .ok()?;
578    body.get("status").is_some().then_some(port)
579}
580
581/// Resolves when standard input reaches its end (or fails). The desktop app
582/// starts `teamcraft local --exit-with-stdin` with a pipe it never writes
583/// to: when the app ends – also when it crashes or is killed – the pipe
584/// closes and the server and PostgreSQL shut down cleanly. A plain thread,
585/// not a blocking tokio task: the runtime would wait for it at shutdown.
586pub async fn stdin_closed() {
587    let (tx, rx) = tokio::sync::oneshot::channel::<()>();
588    std::thread::spawn(move || {
589        let mut buf = [0u8; 256];
590        let mut stdin = std::io::stdin();
591        loop {
592            match std::io::Read::read(&mut stdin, &mut buf) {
593                Ok(0) | Err(_) => break,
594                Ok(_) => {}
595            }
596        }
597        let _ = tx.send(());
598    });
599    let _ = rx.await;
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn data_dir_per_os() {
608        let vars = |pairs: &'static [(&'static str, &'static str)]| {
609            move |key: &str| {
610                pairs
611                    .iter()
612                    .find(|(k, _)| *k == key)
613                    .map(|(_, v)| PathBuf::from(v))
614            }
615        };
616        if cfg!(windows) {
617            assert_eq!(
618                data_dir_from(vars(&[("LOCALAPPDATA", r"C:\Users\a\AppData\Local")])),
619                Some(PathBuf::from(
620                    r"C:\Users\a\AppData\Local\TeamCraft\local-server"
621                ))
622            );
623            assert_eq!(data_dir_from(vars(&[])), None);
624        } else if cfg!(target_os = "macos") {
625            assert_eq!(
626                data_dir_from(vars(&[("HOME", "/Users/a")])),
627                Some(PathBuf::from(
628                    "/Users/a/Library/Application Support/TeamCraft/local-server"
629                ))
630            );
631        } else {
632            assert_eq!(
633                data_dir_from(vars(&[("HOME", "/home/a")])),
634                Some(PathBuf::from("/home/a/.local/share/teamcraft/local-server"))
635            );
636            assert_eq!(
637                data_dir_from(vars(&[("HOME", "/home/a"), ("XDG_DATA_HOME", "/data")])),
638                Some(PathBuf::from("/data/teamcraft/local-server"))
639            );
640            // a relative XDG_DATA_HOME is invalid by the specification
641            assert_eq!(
642                data_dir_from(vars(&[("HOME", "/home/a"), ("XDG_DATA_HOME", "rel")])),
643                Some(PathBuf::from("/home/a/.local/share/teamcraft/local-server"))
644            );
645            assert_eq!(data_dir_from(vars(&[("HOME", "")])), None);
646        }
647    }
648
649    #[test]
650    fn explicit_pg_bin_is_the_only_candidate() {
651        let dir = Path::new("/somewhere/pg/bin");
652        assert_eq!(
653            pg_bin_candidates(Some(dir), Some(Path::new("/opt/tc"))),
654            vec![dir.to_path_buf()]
655        );
656    }
657
658    #[test]
659    fn bundle_next_to_the_executable_comes_first() {
660        let exe_dir = Path::new("/opt/tc");
661        let candidates = pg_bin_candidates(None, Some(exe_dir));
662        assert_eq!(candidates[0], exe_dir.join("postgresql").join("bin"));
663        assert_eq!(
664            candidates[1],
665            exe_dir
666                .join("..")
667                .join("Resources")
668                .join("postgresql")
669                .join("bin")
670        );
671    }
672
673    /// A new empty directory below the system's temporary directory.
674    fn scratch(name: &str) -> PathBuf {
675        let dir =
676            std::env::temp_dir().join(format!("teamcraft-local-{name}-{}", uuid::Uuid::new_v4()));
677        std::fs::create_dir_all(&dir).unwrap();
678        dir
679    }
680
681    #[test]
682    fn pg_bin_needs_all_three_programs() {
683        let dir = scratch("bin");
684        assert!(!is_pg_bin(&dir));
685        for name in ["initdb", "pg_ctl"] {
686            std::fs::write(dir.join(exe(name)), "").unwrap();
687        }
688        assert!(!is_pg_bin(&dir));
689        std::fs::write(dir.join(exe("postgres")), "").unwrap();
690        assert!(is_pg_bin(&dir));
691        assert_eq!(find_pg_bin(Some(&dir)).unwrap(), dir);
692        let empty = scratch("empty");
693        let err = find_pg_bin(Some(&empty)).unwrap_err().to_string();
694        assert!(err.contains("does not contain initdb"), "{err}");
695        let _ = std::fs::remove_dir_all(&dir);
696        let _ = std::fs::remove_dir_all(&empty);
697    }
698
699    #[test]
700    fn cluster_settings_listen_on_loopback_only() {
701        let conf = local_conf(54321);
702        assert!(conf.contains("listen_addresses = '127.0.0.1'\n"));
703        assert!(conf.contains("port = 54321\n"));
704        assert!(conf.contains("unix_socket_directories = ''\n"));
705    }
706
707    #[test]
708    fn passwords_are_random_and_alphanumeric() {
709        let (a, b) = (random_password(), random_password());
710        assert_eq!(a.len(), 32);
711        assert_ne!(a, b);
712        assert!(a.chars().all(|c| c.is_ascii_alphanumeric()));
713    }
714
715    #[cfg(unix)]
716    #[test]
717    fn private_files_are_owner_only() {
718        use std::os::unix::fs::PermissionsExt;
719        let dir = scratch("private");
720        let path = dir.join("secret");
721        write_private(&path, "x").unwrap();
722        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
723        assert_eq!(mode & 0o777, 0o600);
724        let _ = std::fs::remove_dir_all(&dir);
725    }
726
727    #[test]
728    fn local_variables_win_over_the_environment() {
729        let password = random_password();
730        let local = local_variables(Path::new("/data"), 8099, 54000, &password);
731        let env = |key: &str| -> Option<String> {
732            match key {
733                "TEAMCRAFT_DATABASE_URL" => Some("postgres://elsewhere/db".into()),
734                "DATABASE_URL_FILE" => Some("/run/secrets/url".into()),
735                "TEAMCRAFT_DB_PASSWORD_FILE" => Some("/run/secrets/pw".into()),
736                "TEAMCRAFT_BIND_ADDR" => Some("0.0.0.0:80".into()),
737                "TEAMCRAFT_INSTANCE_NAME" => Some("Kept".into()),
738                _ => None,
739            }
740        };
741        let lookup = overlay_lookup(local, env);
742        assert_eq!(
743            lookup("TEAMCRAFT_BIND_ADDR").as_deref(),
744            Some("127.0.0.1:8099")
745        );
746        assert_eq!(
747            lookup("TEAMCRAFT_PUBLIC_URL").as_deref(),
748            Some("http://127.0.0.1:8099")
749        );
750        assert_eq!(lookup("TEAMCRAFT_DB_PORT").as_deref(), Some("54000"));
751        assert_eq!(
752            lookup("TEAMCRAFT_DB_PASSWORD").as_deref(),
753            Some(password.as_str())
754        );
755        assert_eq!(lookup("TEAMCRAFT_DB_PASSWORD_FILE"), None);
756        assert_eq!(lookup("TEAMCRAFT_DATABASE_URL"), None);
757        assert_eq!(lookup("DATABASE_URL_FILE"), None);
758        assert_eq!(lookup("TEAMCRAFT_INSTANCE_NAME").as_deref(), Some("Kept"));
759        let vault = PathBuf::from(lookup("TEAMCRAFT_VAULT_DIR").unwrap());
760        assert_eq!(vault, Path::new("/data").join("vault"));
761    }
762
763    /// The overlay gives a valid server configuration on loopback, which the
764    /// first-start setup page needs (`web::auth_pages::setup_open`).
765    #[test]
766    fn local_configuration_is_valid_and_loopback() {
767        let local = local_variables(Path::new("/data"), 8099, 54000, &random_password());
768        let config = crate::config::Config::from_lookup(overlay_lookup(local, |_| None)).unwrap();
769        assert!(config.bind_addr.ip().is_loopback());
770        assert_eq!(config.bind_addr.port(), 8099);
771        assert_eq!(config.public_url, "http://127.0.0.1:8099");
772        assert_eq!(config.vault_dir, Path::new("/data").join("vault"));
773    }
774}