1use std::collections::BTreeMap;
67use std::fmt;
68use std::net::SocketAddr;
69use std::path::{Path, PathBuf};
70use std::str::FromStr;
71
72pub const DEFAULT_STATE_DIR: &str = ".cargoless";
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Source {
81 Default,
83 TfToml,
85 Env,
87 Cli,
89}
90
91impl Source {
92 pub fn describe(self) -> &'static str {
93 match self {
94 Source::Default => "default (v0-compatible)",
95 Source::TfToml => "tf.toml",
96 Source::Env => "environment",
97 Source::Cli => "CLI flag",
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct Provenance {
106 pub cas_dir: Source,
107 pub state_dir: Source,
108 pub repo: Source,
109 pub bind: Source,
110 pub corun: Source,
111 pub auth_token: Source,
112}
113
114impl Default for Provenance {
115 fn default() -> Self {
116 Self {
117 cas_dir: Source::Default,
118 state_dir: Source::Default,
119 repo: Source::Default,
120 bind: Source::Default,
121 corun: Source::Default,
122 auth_token: Source::Default,
123 }
124 }
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct FleetOverrides {
137 pub cas_dir: Option<PathBuf>,
138 pub state_dir: Option<PathBuf>,
139 pub repo: Option<PathBuf>,
140 pub bind: Option<String>,
141 pub corun: Option<bool>,
142 pub auth_token: Option<String>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct FleetConfig {
150 pub cas_dir: Option<PathBuf>,
153 pub state_dir: PathBuf,
156 pub repo: Option<PathBuf>,
159 pub bind: Option<SocketAddr>,
162 pub corun: bool,
165 pub auth_token: Option<String>,
168 pub provenance: Provenance,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum FleetConfigError {
176 BadTfToml {
177 line_no: usize,
178 line: String,
179 why: String,
180 },
181 BadBind {
182 value: String,
183 why: String,
184 },
185 BadBool {
186 origin: &'static str,
187 key: String,
188 value: String,
189 },
190}
191
192impl fmt::Display for FleetConfigError {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 match self {
195 FleetConfigError::BadTfToml { line_no, line, why } => write!(
196 f,
197 "tf.toml: {why} (line {line_no}: `{line}`).\n \
198 [fleet] keys: repo, bind, corun, auth_token. \
199 [project] state_dir. [cache] cas_dir."
200 ),
201 FleetConfigError::BadBind { value, why } => write!(
202 f,
203 "invalid bind address `{value}`: {why}.\n \
204 expected `HOST:PORT`, e.g. `127.0.0.1:8080` (loopback, \
205 safe) or `0.0.0.0:8080` (network — requires --auth-token)."
206 ),
207 FleetConfigError::BadBool { origin, key, value } => write!(
208 f,
209 "{origin}: `{key}` expects a boolean (`true`/`false`), \
210 got `{value}`."
211 ),
212 }
213 }
214}
215
216impl std::error::Error for FleetConfigError {}
217
218impl FleetConfig {
219 pub fn defaults() -> Self {
221 Self {
222 cas_dir: None,
223 state_dir: PathBuf::from(DEFAULT_STATE_DIR),
224 repo: None,
225 bind: None,
226 corun: true,
227 auth_token: None,
228 provenance: Provenance::default(),
229 }
230 }
231
232 pub fn resolve(
237 repo_root: impl AsRef<Path>,
238 overrides: FleetOverrides,
239 ) -> Result<Self, FleetConfigError> {
240 let env = |k: &str| std::env::var(k).ok();
241 Self::resolve_layered(repo_root.as_ref(), overrides, &env)
242 }
243
244 pub fn resolve_layered(
248 repo_root: &Path,
249 ov: FleetOverrides,
250 env: &dyn Fn(&str) -> Option<String>,
251 ) -> Result<Self, FleetConfigError> {
252 let mut cfg = Self::defaults();
253
254 if let Ok(text) = std::fs::read_to_string(repo_root.join("tf.toml")) {
256 apply_tf_toml_overlay(&mut cfg, &text)?;
257 }
258
259 if let Some(v) = env("TF_CAS_DIR").filter(|s| !s.is_empty()) {
261 cfg.cas_dir = Some(PathBuf::from(v));
262 cfg.provenance.cas_dir = Source::Env;
263 }
264 if let Some(v) = env("TF_STATE_DIR").filter(|s| !s.is_empty()) {
265 cfg.state_dir = PathBuf::from(v);
266 cfg.provenance.state_dir = Source::Env;
267 }
268 if let Some(v) = env("TF_REPO").filter(|s| !s.is_empty()) {
269 cfg.repo = Some(PathBuf::from(v));
270 cfg.provenance.repo = Source::Env;
271 }
272 if let Some(v) = env("TF_BIND").filter(|s| !s.is_empty()) {
273 cfg.bind = Some(parse_bind(&v)?);
274 cfg.provenance.bind = Source::Env;
275 }
276 if let Some(v) = env("TF_NO_CORUN").filter(|s| !s.is_empty()) {
277 if parse_bool("env TF_NO_CORUN", "TF_NO_CORUN", &v)? {
279 cfg.corun = false;
280 cfg.provenance.corun = Source::Env;
281 }
282 }
283 if let Some(v) = env("CARGOLESS_AUTH_TOKEN").filter(|s| !s.trim().is_empty()) {
284 cfg.auth_token = Some(v);
285 cfg.provenance.auth_token = Source::Env;
286 }
287
288 if let Some(v) = ov.cas_dir {
290 cfg.cas_dir = Some(v);
291 cfg.provenance.cas_dir = Source::Cli;
292 }
293 if let Some(v) = ov.state_dir {
294 cfg.state_dir = v;
295 cfg.provenance.state_dir = Source::Cli;
296 }
297 if let Some(v) = ov.repo {
298 cfg.repo = Some(v);
299 cfg.provenance.repo = Source::Cli;
300 }
301 if let Some(v) = ov.bind {
302 cfg.bind = Some(parse_bind(&v)?);
303 cfg.provenance.bind = Source::Cli;
304 }
305 if let Some(b) = ov.corun {
306 cfg.corun = b;
307 cfg.provenance.corun = Source::Cli;
308 }
309 if let Some(v) = ov.auth_token.filter(|s| !s.trim().is_empty()) {
310 cfg.auth_token = Some(v);
311 cfg.provenance.auth_token = Source::Cli;
312 }
313
314 Ok(cfg)
315 }
316
317 pub fn daemon_mode(&self) -> bool {
320 self.repo.is_some()
321 }
322
323 pub fn requires_auth(&self) -> bool {
330 match self.bind {
331 Some(addr) => !addr.ip().is_loopback(),
332 None => false,
333 }
334 }
335
336 pub fn effective_auth_token(&self) -> Option<&str> {
348 self.auth_token.as_deref().filter(|t| !t.trim().is_empty())
349 }
350
351 pub fn security_check(&self) -> Result<(), FleetConfigError> {
355 if self.requires_auth() && self.effective_auth_token().is_none() {
356 let value = self.bind.map(|a| a.to_string()).unwrap_or_default();
357 return Err(FleetConfigError::BadBind {
358 value,
359 why: "non-loopback bind requires --auth-token / \
360 CARGOLESS_AUTH_TOKEN (refusing unauthenticated \
361 network exposure)"
362 .to_string(),
363 });
364 }
365 Ok(())
366 }
367
368 pub fn state_dir_abs(&self, repo_root: &Path) -> PathBuf {
372 if self.state_dir.is_absolute() {
373 self.state_dir.clone()
374 } else {
375 repo_root.join(&self.state_dir)
376 }
377 }
378}
379
380fn parse_bind(s: &str) -> Result<SocketAddr, FleetConfigError> {
382 SocketAddr::from_str(s.trim()).map_err(|e| FleetConfigError::BadBind {
383 value: s.to_string(),
384 why: e.to_string(),
385 })
386}
387
388fn parse_bool(origin: &'static str, key: &str, v: &str) -> Result<bool, FleetConfigError> {
391 match v.trim().to_ascii_lowercase().as_str() {
392 "true" | "1" | "yes" | "on" => Ok(true),
393 "false" | "0" | "no" | "off" => Ok(false),
394 _ => Err(FleetConfigError::BadBool {
395 origin,
396 key: key.to_string(),
397 value: v.to_string(),
398 }),
399 }
400}
401
402fn apply_tf_toml_overlay(cfg: &mut FleetConfig, text: &str) -> Result<(), FleetConfigError> {
418 let mut section = String::new();
419 for (idx, raw) in text.lines().enumerate() {
420 let line_no = idx + 1;
421 let line = strip_comment(raw).trim();
422 if line.is_empty() {
423 continue;
424 }
425 if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
426 section = name.trim().to_string();
427 continue; }
429 let Some((key, val)) = line.split_once('=') else {
430 continue; };
432 let key = key.trim();
433 let val = unquote(val.trim());
434 match (section.as_str(), key) {
435 ("project", "state_dir") => {
436 cfg.state_dir = PathBuf::from(&val);
437 cfg.provenance.state_dir = Source::TfToml;
438 }
439 ("cache", "cas_dir") => {
440 cfg.cas_dir = Some(PathBuf::from(&val));
441 cfg.provenance.cas_dir = Source::TfToml;
442 }
443 ("fleet", "repo") => {
444 cfg.repo = Some(PathBuf::from(&val));
445 cfg.provenance.repo = Source::TfToml;
446 }
447 ("fleet", "bind") => {
448 cfg.bind = Some(parse_bind(&val).map_err(|_| FleetConfigError::BadTfToml {
449 line_no,
450 line: raw.trim().to_string(),
451 why: format!("invalid bind address `{val}`"),
452 })?);
453 cfg.provenance.bind = Source::TfToml;
454 }
455 ("fleet", "corun") => {
456 let b = parse_bool("tf.toml", "corun", &val).map_err(|_| {
457 FleetConfigError::BadTfToml {
458 line_no,
459 line: raw.trim().to_string(),
460 why: format!("`corun` expects true/false, got `{val}`"),
461 }
462 })?;
463 cfg.corun = b;
464 cfg.provenance.corun = Source::TfToml;
465 }
466 ("fleet", "auth_token") if !val.trim().is_empty() => {
471 cfg.auth_token = Some(val);
472 cfg.provenance.auth_token = Source::TfToml;
473 }
474 _ => {}
478 }
479 }
480 Ok(())
481}
482
483fn strip_comment(line: &str) -> &str {
487 let mut in_str = false;
488 for (i, c) in line.char_indices() {
489 match c {
490 '"' => in_str = !in_str,
491 '#' if !in_str => return &line[..i],
492 _ => {}
493 }
494 }
495 line
496}
497
498fn unquote(s: &str) -> String {
499 s.strip_prefix('"')
500 .and_then(|s| s.strip_suffix('"'))
501 .unwrap_or(s)
502 .to_string()
503}
504
505pub fn overrides_from_map(m: &BTreeMap<String, String>) -> FleetOverrides {
509 FleetOverrides {
510 cas_dir: m.get("cas-dir").map(PathBuf::from),
511 state_dir: m.get("state-dir").map(PathBuf::from),
512 repo: m.get("repo").map(PathBuf::from),
513 bind: m.get("bind").cloned(),
514 corun: if m.contains_key("no-corun") {
515 Some(false)
516 } else {
517 None
518 },
519 auth_token: m.get("auth-token").cloned(),
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 fn no_env(_: &str) -> Option<String> {
528 None
529 }
530
531 #[test]
532 fn defaults_are_v0() {
533 let c = FleetConfig::defaults();
536 assert_eq!(c.cas_dir, None, "v0: per-process PID-scoped CAS");
537 assert_eq!(c.state_dir, PathBuf::from(".cargoless"));
538 assert_eq!(c.repo, None, "v0: no daemon mode");
539 assert_eq!(c.bind, None, "v0: no network transport");
540 assert!(c.corun, "corun default-on (inert until repo set)");
541 assert_eq!(c.auth_token, None);
542 assert!(!c.daemon_mode());
543 assert!(!c.requires_auth());
544 assert!(c.security_check().is_ok());
545 }
546
547 #[test]
548 fn resolve_no_inputs_equals_defaults() {
549 let tmp = std::env::temp_dir().join("cl-cfg-empty-xyz");
550 let _ = std::fs::create_dir_all(&tmp);
551 let c = FleetConfig::resolve_layered(&tmp, FleetOverrides::default(), &no_env).unwrap();
552 assert_eq!(c, FleetConfig::defaults());
553 }
554
555 #[test]
556 fn precedence_cli_beats_env_beats_toml_beats_default() {
557 let dir = std::env::temp_dir().join(format!("cl-cfg-prec-{}", std::process::id()));
558 std::fs::create_dir_all(&dir).unwrap();
559 std::fs::write(
560 dir.join("tf.toml"),
561 "[project]\nstate_dir = \".triform/cargoless\"\n\
562 [fleet]\nrepo = \"/from/toml\"\ncorun = false\n",
563 )
564 .unwrap();
565
566 let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &no_env).unwrap();
568 assert_eq!(c.state_dir, PathBuf::from(".triform/cargoless"));
569 assert_eq!(c.provenance.state_dir, Source::TfToml);
570 assert_eq!(c.repo, Some(PathBuf::from("/from/toml")));
571 assert!(!c.corun);
572 assert_eq!(c.provenance.corun, Source::TfToml);
573
574 let env = |k: &str| match k {
576 "TF_REPO" => Some("/from/env".to_string()),
577 _ => None,
578 };
579 let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &env).unwrap();
580 assert_eq!(c.repo, Some(PathBuf::from("/from/env")));
581 assert_eq!(c.provenance.repo, Source::Env);
582 assert_eq!(c.state_dir, PathBuf::from(".triform/cargoless"));
584
585 let ov = FleetOverrides {
587 repo: Some(PathBuf::from("/from/cli")),
588 ..Default::default()
589 };
590 let c = FleetConfig::resolve_layered(&dir, ov, &env).unwrap();
591 assert_eq!(c.repo, Some(PathBuf::from("/from/cli")));
592 assert_eq!(c.provenance.repo, Source::Cli);
593
594 let _ = std::fs::remove_dir_all(&dir);
595 }
596
597 #[test]
598 fn tf_toml_overlay_is_tolerant_of_cli_owned_keys() {
599 let dir = std::env::temp_dir().join(format!("cl-cfg-tol-{}", std::process::id()));
603 std::fs::create_dir_all(&dir).unwrap();
604 std::fs::write(
605 dir.join("tf.toml"),
606 "[project]\nroot = \"/proj\"\ntarget = \"wasm32-unknown-unknown\"\n\
607 state_dir = \".triform/cargoless\"\n\
608 [cache]\ndir = \"/tmp/cache\"\ncas_dir = \"/shared/cas\"\n\
609 [serve]\nport = 8080\n",
610 )
611 .unwrap();
612 let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &no_env).unwrap();
613 assert_eq!(c.state_dir, PathBuf::from(".triform/cargoless"));
615 assert_eq!(c.cas_dir, Some(PathBuf::from("/shared/cas")));
616 assert_eq!(c.repo, None);
619 assert_eq!(c.bind, None);
620 let _ = std::fs::remove_dir_all(&dir);
621 }
622
623 #[test]
624 fn bind_parsing_and_auth_predicate() {
625 let ov = FleetOverrides {
627 bind: Some("127.0.0.1:8080".to_string()),
628 ..Default::default()
629 };
630 let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
631 .unwrap();
632 assert_eq!(c.bind.unwrap().to_string(), "127.0.0.1:8080");
633 assert!(!c.requires_auth());
634 assert!(c.security_check().is_ok());
635
636 let ov = FleetOverrides {
638 bind: Some("0.0.0.0:8080".to_string()),
639 ..Default::default()
640 };
641 let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
642 .unwrap();
643 assert!(c.requires_auth());
644 assert!(c.security_check().is_err());
645
646 let ov = FleetOverrides {
648 bind: Some("0.0.0.0:8080".to_string()),
649 auth_token: Some("s3cr3t".to_string()),
650 ..Default::default()
651 };
652 let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
653 .unwrap();
654 assert!(c.security_check().is_ok());
655 }
656
657 #[test]
658 fn bad_bind_is_actionable() {
659 let ov = FleetOverrides {
660 bind: Some("not-an-addr".to_string()),
661 ..Default::default()
662 };
663 let e = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
664 .unwrap_err();
665 assert!(matches!(e, FleetConfigError::BadBind { .. }));
666 assert!(e.to_string().contains("--auth-token"));
667 }
668
669 #[test]
670 fn no_corun_via_env_and_cli() {
671 let env = |k: &str| (k == "TF_NO_CORUN").then(|| "1".to_string());
673 let c = FleetConfig::resolve_layered(
674 std::path::Path::new("/nonexistent"),
675 FleetOverrides::default(),
676 &env,
677 )
678 .unwrap();
679 assert!(!c.corun);
680 assert_eq!(c.provenance.corun, Source::Env);
681
682 let ov = FleetOverrides {
684 corun: Some(false),
685 ..Default::default()
686 };
687 let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
688 .unwrap();
689 assert!(!c.corun);
690 assert_eq!(c.provenance.corun, Source::Cli);
691 }
692
693 #[test]
694 fn auth_token_prefers_env_over_toml() {
695 let dir = std::env::temp_dir().join(format!("cl-cfg-tok-{}", std::process::id()));
696 std::fs::create_dir_all(&dir).unwrap();
697 std::fs::write(dir.join("tf.toml"), "[fleet]\nauth_token = \"from-toml\"\n").unwrap();
698 let env = |k: &str| (k == "CARGOLESS_AUTH_TOKEN").then(|| "from-env".to_string());
699 let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &env).unwrap();
700 assert_eq!(c.auth_token.as_deref(), Some("from-env"));
701 assert_eq!(c.provenance.auth_token, Source::Env);
702 let _ = std::fs::remove_dir_all(&dir);
703 }
704
705 #[test]
708 fn blank_auth_token_is_no_token_via_cli_env_toml() {
709 for blank in ["", " ", "\t", " \t "] {
715 let ov = FleetOverrides {
717 auth_token: Some(blank.to_string()),
718 ..FleetOverrides::default()
719 };
720 let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
721 .unwrap();
722 assert_eq!(c.auth_token, None, "CLI blank {blank:?} ⇒ no token");
723 assert_eq!(c.effective_auth_token(), None);
724
725 let env = |k: &str| (k == "CARGOLESS_AUTH_TOKEN").then(|| blank.to_string());
727 let c = FleetConfig::resolve_layered(
728 std::path::Path::new("/nonexistent"),
729 FleetOverrides::default(),
730 &env,
731 )
732 .unwrap();
733 assert_eq!(c.auth_token, None, "env blank {blank:?} ⇒ no token");
734 assert_eq!(c.effective_auth_token(), None);
735
736 let dir = std::env::temp_dir().join(format!(
738 "cl-cfg-blank-{}-{}",
739 std::process::id(),
740 blank.len()
741 ));
742 std::fs::create_dir_all(&dir).unwrap();
743 std::fs::write(
744 dir.join("tf.toml"),
745 format!("[fleet]\nauth_token = \"{blank}\"\n"),
746 )
747 .unwrap();
748 let c = FleetConfig::resolve_layered(&dir, FleetOverrides::default(), &no_env).unwrap();
749 assert_eq!(c.auth_token, None, "toml blank {blank:?} ⇒ no token");
750 assert_eq!(c.effective_auth_token(), None);
751 let _ = std::fs::remove_dir_all(&dir);
752 }
753 }
754
755 #[test]
756 fn blank_auth_token_nonloopback_refuses_security_check() {
757 let ov = FleetOverrides {
762 bind: Some("0.0.0.0:8080".to_string()),
763 auth_token: Some(" ".to_string()),
764 ..FleetOverrides::default()
765 };
766 let c = FleetConfig::resolve_layered(std::path::Path::new("/nonexistent"), ov, &no_env)
767 .unwrap();
768 assert!(
769 matches!(c.security_check(), Err(FleetConfigError::BadBind { .. })),
770 "non-loopback + blank CLI token MUST refuse (no unauth socket)"
771 );
772
773 let mut c2 = FleetConfig::defaults();
777 c2.bind = Some("0.0.0.0:9090".parse().unwrap());
778 c2.auth_token = Some(" \t ".to_string());
779 assert_eq!(c2.effective_auth_token(), None);
780 assert!(
781 matches!(c2.security_check(), Err(FleetConfigError::BadBind { .. })),
782 "blank token in FleetConfig + non-loopback MUST still refuse"
783 );
784
785 let mut ok = FleetConfig::defaults();
787 ok.bind = Some("0.0.0.0:9090".parse().unwrap());
788 ok.auth_token = Some("s3cr3t".to_string());
789 assert!(ok.security_check().is_ok());
790 assert_eq!(ok.effective_auth_token(), Some("s3cr3t"));
791 }
792
793 #[test]
794 fn state_dir_abs_resolution() {
795 let c = FleetConfig::defaults();
796 assert_eq!(
797 c.state_dir_abs(std::path::Path::new("/repo")),
798 PathBuf::from("/repo/.cargoless")
799 );
800 let mut c = FleetConfig::defaults();
801 c.state_dir = PathBuf::from("/abs/state");
802 assert_eq!(
803 c.state_dir_abs(std::path::Path::new("/repo")),
804 PathBuf::from("/abs/state")
805 );
806 }
807
808 #[test]
809 fn overrides_from_map_helper() {
810 let mut m = BTreeMap::new();
811 m.insert("repo".to_string(), "/r".to_string());
812 m.insert("no-corun".to_string(), String::new());
813 let ov = overrides_from_map(&m);
814 assert_eq!(ov.repo, Some(PathBuf::from("/r")));
815 assert_eq!(ov.corun, Some(false));
816 assert_eq!(ov.cas_dir, None);
817 }
818}