1use 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
36pub const DB_USER: &str = "teamcraft";
38pub const DB_NAME: &str = "teamcraft";
39
40pub const SIDECAR_NAME: &str = "teamcraft-server";
43
44pub const DEFAULT_PORT: u16 = 8080;
46
47pub const BUNDLED_PG_DIR: &str = "postgresql";
50
51const PG_CTL_TIMEOUT_SECS: u32 = 120;
53
54const 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
67pub 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
98pub 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
105pub 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 return out;
116 }
117 if let Some(dir) = exe_dir {
118 out.push(dir.join(BUNDLED_PG_DIR).join("bin"));
119 out.push(
121 dir.join("..")
122 .join("Resources")
123 .join(BUNDLED_PG_DIR)
124 .join("bin"),
125 );
126 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
181pub 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
203pub 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
210fn 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
221fn 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
238pub 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
250pub 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
278const MASKED: &[&str] = &["DATABASE_URL", "TEAMCRAFT_DATABASE_URL"];
281
282pub 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
304fn 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
348pub struct Cluster {
350 bin: PathBuf,
351 pgdata: PathBuf,
352 pub port: u16,
354 pub password: String,
356}
357
358impl Cluster {
359 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 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 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 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 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 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 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 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
539const PORT_FILE: &str = "local-server.port";
542
543pub 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
549pub fn remove_port_file(data_dir: &Path) {
551 let _ = std::fs::remove_file(data_dir.join(PORT_FILE));
552}
553
554pub 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
581pub 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 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 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 #[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}