1use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use serde::{Deserialize, Serialize};
27
28use crate::error::{Error, Result};
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct Config {
33 #[serde(default)]
35 pub activation: ActivationMode,
36 pub remote: RemoteConfig,
37 #[serde(default)]
38 pub local: LocalConfig,
39 #[serde(default)]
40 pub limits: Limits,
41 #[serde(default)]
42 pub audit: AuditConfig,
43 #[serde(default)]
44 pub guard: GuardConfig,
45 #[serde(default)]
46 pub approval: ApprovalConfig,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct RemoteConfig {
52 pub host: String,
56 pub workdir: String,
60 #[serde(default)]
64 pub os: Os,
65 #[serde(default = "default_shell")]
71 pub shell: Vec<String>,
72 #[serde(default = "default_connect_timeout")]
73 pub connect_timeout_secs: u64,
74 #[serde(default = "default_alive_interval")]
76 pub server_alive_interval_secs: u64,
77}
78
79pub fn default_shell() -> Vec<String> {
80 vec!["bash".into(), "-lc".into()]
81}
82
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum Os {
87 #[default]
88 Auto,
89 Posix,
91 Windows,
93}
94fn default_connect_timeout() -> u64 {
95 20
96}
97fn default_alive_interval() -> u64 {
98 30
99}
100
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct LocalConfig {
104 #[serde(default)]
107 pub allowed_dirs: Vec<PathBuf>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct Limits {
113 #[serde(default = "default_command_timeout")]
114 pub command_timeout_secs: u64,
115 #[serde(default = "default_max_command_timeout")]
116 pub max_command_timeout_secs: u64,
117 #[serde(default = "default_max_output_bytes")]
118 pub max_output_bytes: usize,
119 #[serde(default = "default_max_read_bytes")]
120 pub max_read_bytes: usize,
121 #[serde(default = "default_max_transfer_bytes")]
122 pub max_transfer_bytes: u64,
123 #[serde(default = "default_max_transfer_files")]
124 pub max_transfer_files: usize,
125}
126
127fn default_command_timeout() -> u64 {
128 120
129}
130fn default_max_command_timeout() -> u64 {
131 1800
132}
133fn default_max_output_bytes() -> usize {
134 100 * 1024
135}
136fn default_max_read_bytes() -> usize {
137 512 * 1024
138}
139fn default_max_transfer_bytes() -> u64 {
140 1024 * 1024 * 1024
141}
142fn default_max_transfer_files() -> usize {
143 5000
144}
145
146impl Default for Limits {
147 fn default() -> Self {
148 Self {
149 command_timeout_secs: default_command_timeout(),
150 max_command_timeout_secs: default_max_command_timeout(),
151 max_output_bytes: default_max_output_bytes(),
152 max_read_bytes: default_max_read_bytes(),
153 max_transfer_bytes: default_max_transfer_bytes(),
154 max_transfer_files: default_max_transfer_files(),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Default, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct AuditConfig {
162 pub log_dir: Option<PathBuf>,
166}
167
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct GuardConfig {
171 #[serde(default)]
174 pub deny_globs: Vec<String>,
175 #[serde(default)]
177 pub deny_content: Vec<String>,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
183#[serde(rename_all = "lowercase")]
184pub enum ActivationMode {
185 Always,
187 #[default]
192 Project,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum Source {
198 Explicit,
199 Env,
200 Project,
201 Global,
202}
203
204#[derive(Debug, Clone)]
207pub struct Loaded {
208 pub config: Config,
209 pub path: PathBuf,
210 pub source: Source,
211 pub notices: Vec<String>,
214}
215
216impl Loaded {
217 pub fn active(&self) -> bool {
218 match self.source {
219 Source::Explicit | Source::Env | Source::Project => true,
220 Source::Global => self.config.activation == ActivationMode::Always,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "lowercase")]
230pub enum ApprovalMode {
231 Ask,
234 Auto,
236 Strict,
238}
239
240impl ApprovalMode {
241 pub fn as_str(self) -> &'static str {
242 match self {
243 ApprovalMode::Ask => "ask",
244 ApprovalMode::Auto => "auto",
245 ApprovalMode::Strict => "strict",
246 }
247 }
248}
249
250pub const MUTATING_TOOLS: &[&str] = &[
252 "remote_shell",
253 "remote_spawn",
254 "remote_kill",
255 "remote_write",
256 "remote_edit",
257 "upload",
258 "download",
259];
260
261pub const READ_ONLY_TOOLS: &[&str] = &[
263 "remote_read",
264 "remote_ls",
265 "remote_glob",
266 "remote_grep",
267 "remote_jobs",
268 "local_ls",
269 "local_read",
270 "remote_info",
271];
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct ApprovalConfig {
276 #[serde(default = "default_approval")]
278 pub mode: ApprovalMode,
279 #[serde(default)]
281 pub tools: BTreeMap<String, ApprovalMode>,
282}
283
284fn default_approval() -> ApprovalMode {
285 ApprovalMode::Ask
286}
287
288impl Default for ApprovalConfig {
289 fn default() -> Self {
290 Self {
291 mode: default_approval(),
292 tools: BTreeMap::new(),
293 }
294 }
295}
296
297impl ApprovalConfig {
298 pub fn effective(&self) -> BTreeMap<&'static str, ApprovalMode> {
301 let mutating = match self.mode {
302 ApprovalMode::Auto => ApprovalMode::Auto,
303 ApprovalMode::Ask | ApprovalMode::Strict => ApprovalMode::Ask,
304 };
305 let read_only = match self.mode {
306 ApprovalMode::Strict => ApprovalMode::Ask,
307 ApprovalMode::Ask | ApprovalMode::Auto => ApprovalMode::Auto,
308 };
309 MUTATING_TOOLS
310 .iter()
311 .map(|t| (*t, mutating))
312 .chain(READ_ONLY_TOOLS.iter().map(|t| (*t, read_only)))
313 .map(|(t, m)| (t, self.tools.get(t).copied().unwrap_or(m)))
314 .collect()
315 }
316}
317
318impl Config {
319 pub fn example() -> &'static str {
320 EXAMPLE
321 }
322
323 pub fn load(explicit: Option<&Path>) -> Result<(Self, PathBuf)> {
326 let l = Self::load_in(None, explicit)?;
327 Ok((l.config, l.path))
328 }
329
330 pub fn load_in(cwd: Option<&Path>, explicit: Option<&Path>) -> Result<Loaded> {
333 let (path, source) = match explicit {
334 Some(p) if p.file_name() == Some(".farhand.toml".as_ref()) => {
337 (p.to_path_buf(), Source::Project)
338 }
339 Some(p) => (p.to_path_buf(), Source::Explicit),
340 None => Self::locate(cwd)?,
341 };
342 let text = std::fs::read_to_string(&path)
343 .map_err(|e| Error::Config(format!("cannot read {}: {e}", path.display())))?;
344 let base = match source {
348 Source::Global => None,
349 _ => path.parent().map(Path::to_path_buf),
350 };
351 let (config, notices) = if source == Source::Project {
352 let global = Self::global_path();
353 let mut notices = Vec::new();
354 let baseline = if global.symlink_metadata().is_ok() {
359 let c = std::fs::read_to_string(&global)
360 .map_err(Error::from)
361 .and_then(|t| Self::parse(&t))
362 .map_err(|e| {
363 Error::Config(format!(
364 "the global config {} does not load ({e}); fix or remove it",
365 global.display()
366 ))
367 })?;
368 Some(c)
369 } else {
370 None
371 };
372 let (config, more) = Self::parse_project(&text, base.as_deref(), baseline.as_ref())?;
373 notices.extend(more);
374 (config, notices)
375 } else {
376 (Self::parse_at(&text, base.as_deref())?, Vec::new())
377 };
378 Ok(Loaded {
379 config,
380 path,
381 source,
382 notices,
383 })
384 }
385
386 pub fn parse_project(
390 text: &str,
391 base: Option<&Path>,
392 baseline: Option<&Config>,
393 ) -> Result<(Self, Vec<String>)> {
394 let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
395 let mut notices = Vec::new();
396 let requested = std::mem::take(&mut cfg.local.allowed_dirs);
397 for d in requested {
398 if d.is_absolute() || d.to_string_lossy().starts_with('~') {
399 notices.push(format!(
400 "local.allowed_dirs entry `{}` ignored: a project .farhand.toml may only \
401 allow folders inside its own directory (`\".\"`, `\"assets\"`); put other \
402 folders in the global config",
403 d.display()
404 ));
405 } else {
406 cfg.local.allowed_dirs.push(d);
407 }
408 }
409 if let Some(d) = cfg.audit.log_dir.take() {
410 notices.push(format!(
411 "audit.log_dir `{}` ignored: only the global config may move the audit log",
412 d.display()
413 ));
414 }
415 cfg.validate(base)?;
416 let project = base
422 .map(|b| {
423 if b.as_os_str().is_empty() {
424 Path::new(".")
425 } else {
426 b
427 }
428 })
429 .and_then(|b| b.canonicalize().ok());
430 for d in std::mem::take(&mut cfg.local.allowed_dirs) {
431 match (d.canonicalize(), &project) {
432 (Ok(c), Some(p)) if c.starts_with(p) && c.is_dir() => {
433 cfg.local.allowed_dirs.push(c)
434 }
435 (Ok(_), _) => notices.push(format!(
436 "local.allowed_dirs entry `{}` ignored: it is not a folder inside the \
437 project directory once symlinks are resolved",
438 d.display()
439 )),
440 (Err(e), _) => notices.push(format!(
441 "local.allowed_dirs entry `{}` ignored: {e}",
442 d.display()
443 )),
444 }
445 }
446 cfg.audit.log_dir = baseline.and_then(|b| b.audit.log_dir.clone());
447
448 let floor = baseline.map(|b| b.approval.clone()).unwrap_or_default();
449 let floor = floor.effective();
450 let mut loosened = Vec::new();
451 let mut tools = BTreeMap::new();
452 for (tool, mode) in cfg.approval.effective() {
453 let mode = if mode == ApprovalMode::Auto && floor[tool] == ApprovalMode::Ask {
454 loosened.push(tool);
455 ApprovalMode::Ask
456 } else {
457 mode
458 };
459 tools.insert(tool.to_string(), mode);
460 }
461 if !loosened.is_empty() {
462 notices.push(format!(
463 "[approval] `auto` ignored for {}: a project .farhand.toml may make approval \
464 stricter, not looser; set it in the global config",
465 loosened.join(", ")
466 ));
467 cfg.approval.tools = tools;
470 }
471
472 if let Some(b) = baseline {
474 for g in &b.guard.deny_globs {
475 if !cfg.guard.deny_globs.contains(g) {
476 cfg.guard.deny_globs.push(g.clone());
477 }
478 }
479 for r in &b.guard.deny_content {
480 if !cfg.guard.deny_content.contains(r) {
481 cfg.guard.deny_content.push(r.clone());
482 }
483 }
484 }
485 Ok((cfg, notices))
486 }
487
488 pub fn parse(text: &str) -> Result<Self> {
491 Self::parse_at(text, None)
492 }
493
494 pub fn parse_at(text: &str, base: Option<&Path>) -> Result<Self> {
497 let mut cfg: Config = toml::from_str(text).map_err(|e| Error::Config(e.to_string()))?;
498 cfg.validate(base)?;
499 Ok(cfg)
500 }
501
502 fn locate(cwd: Option<&Path>) -> Result<(PathBuf, Source)> {
503 if let Some(p) = std::env::var_os("FARHAND_CONFIG") {
504 return Ok((PathBuf::from(p), Source::Env));
505 }
506 let start = match cwd {
507 Some(d) => std::path::absolute(d)?,
508 None => std::env::current_dir()?,
509 };
510 if let Some(local) = find_project_file(&start, dirs::home_dir().as_deref()) {
511 return Ok((local, Source::Project));
512 }
513 let p = Self::global_path();
514 if p.is_file() {
515 return Ok((p, Source::Global));
516 }
517 Err(Error::NoConfig)
518 }
519
520 pub fn global_path() -> PathBuf {
521 xdg_dir("XDG_CONFIG_HOME", ".config")
522 .join("farhand")
523 .join("config.toml")
524 }
525
526 fn validate(&mut self, base: Option<&Path>) -> Result<()> {
527 if self.remote.host.trim().is_empty() {
528 return Err(Error::Config("remote.host is empty".into()));
529 }
530 if self.remote.host.starts_with('-') {
531 return Err(Error::Config("remote.host may not start with '-'".into()));
532 }
533 let raw = self.remote.workdir.trim();
534 let w = raw.trim_end_matches(['/', '\\']);
535 let w = if w.is_empty() && raw.starts_with('/') {
536 "/".to_string()
537 } else if is_drive_root(raw) {
538 format!("{}\\", &raw[..2])
540 } else {
541 w.to_string()
542 };
543 let windows_abs = w.len() >= 3
544 && w.as_bytes()[0].is_ascii_alphabetic()
545 && w.as_bytes()[1] == b':'
546 && (w.as_bytes()[2] == b'\\' || w.as_bytes()[2] == b'/');
547 if !(w.starts_with('/')
548 || w == "~"
549 || w.starts_with("~/")
550 || w.starts_with("~\\")
551 || windows_abs)
552 {
553 return Err(Error::Config(
554 "remote.workdir must be an absolute path (`/srv/app`, `C:\\work`) or start with `~` \
555 (the remote home)"
556 .into(),
557 ));
558 }
559 self.remote.workdir = w;
560 if self.remote.shell.is_empty() {
561 return Err(Error::Config("remote.shell is empty".into()));
562 }
563 if self.limits.command_timeout_secs == 0 || self.limits.max_command_timeout_secs == 0 {
564 return Err(Error::Config("timeouts must be positive".into()));
565 }
566 let mut dirs = Vec::with_capacity(self.local.allowed_dirs.len());
567 let home = dirs::home_dir();
568 let home_canon = home.as_deref().and_then(|h| h.canonicalize().ok());
569 for d in &self.local.allowed_dirs {
570 let mut expanded = expand_home(d);
571 if expanded
574 .components()
575 .any(|c| matches!(c, std::path::Component::ParentDir))
576 {
577 return Err(Error::Config(format!(
578 "local.allowed_dirs entry `{}` may not contain `..`",
579 d.display()
580 )));
581 }
582 if !expanded.is_absolute() {
583 let Some(base) = base else {
586 return Err(Error::Config(format!(
587 "local.allowed_dirs entry `{}` is relative; only a project \
588 .farhand.toml may use relative entries (`\".\"` for its own folder)",
589 d.display()
590 )));
591 };
592 expanded = base.join(expanded);
593 }
594 let canon = expanded.canonicalize().ok();
598 let covers_home = |h: &Path| {
599 h.starts_with(&expanded) || canon.as_deref().is_some_and(|c| h.starts_with(c))
600 };
601 if expanded.parent().is_none()
602 || home.as_deref().is_some_and(covers_home)
603 || home_canon.as_deref().is_some_and(covers_home)
604 {
605 return Err(Error::Config(format!(
606 "local.allowed_dirs may not contain `/`, the home directory or a folder \
607 above it ({}); allow a specific folder instead",
608 d.display()
609 )));
610 }
611 dirs.push(expanded);
612 }
613 self.local.allowed_dirs = dirs;
614 if let Some(d) = &self.audit.log_dir {
615 self.audit.log_dir = Some(expand_home(d));
616 }
617 for (t, m) in &self.approval.tools {
618 if t == "remote_bash" {
619 return Err(Error::Config(
620 "approval.tools: `remote_bash` was renamed to `remote_shell` in 0.2.1".into(),
621 ));
622 }
623 if !MUTATING_TOOLS.contains(&t.as_str()) && !READ_ONLY_TOOLS.contains(&t.as_str()) {
624 return Err(Error::Config(format!(
625 "approval.tools: `{t}` is not a tool (one of {}, {})",
626 MUTATING_TOOLS.join(", "),
627 READ_ONLY_TOOLS.join(", ")
628 )));
629 }
630 if *m == ApprovalMode::Strict {
631 return Err(Error::Config(format!(
632 "approval.tools.{t}: use `ask` or `auto` (`strict` is a global mode)"
633 )));
634 }
635 }
636 Ok(())
637 }
638
639 pub fn audit_dir(&self) -> PathBuf {
640 self.audit.log_dir.clone().unwrap_or_else(|| {
641 xdg_dir("XDG_DATA_HOME", ".local/share")
642 .join("farhand")
643 .join("audit")
644 })
645 }
646}
647
648fn find_project_file(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
653 let here = start.join(".farhand.toml");
654 if here.is_file() {
655 return Some(here);
656 }
657 start
658 .ancestors()
659 .skip(1)
660 .take_while(|d| d.parent().is_some() && Some(*d) != home)
661 .map(|d| d.join(".farhand.toml"))
662 .find(|f| f.is_file())
663}
664
665fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
669 if let Some(v) = std::env::var_os(var) {
670 if !v.is_empty() {
671 return PathBuf::from(v);
672 }
673 }
674 dirs::home_dir()
675 .unwrap_or_else(|| PathBuf::from("."))
676 .join(fallback)
677}
678
679fn is_drive_root(s: &str) -> bool {
682 let b = s.as_bytes();
683 (b.len() == 2 || b.len() == 3)
684 && b[0].is_ascii_alphabetic()
685 && b[1] == b':'
686 && (b.len() == 2 || b[2] == b'\\' || b[2] == b'/')
687}
688
689pub fn expand_home(p: &Path) -> PathBuf {
690 let s = p.to_string_lossy();
691 if s == "~" {
692 return dirs::home_dir().unwrap_or_else(|| p.to_path_buf());
693 }
694 if let Some(rest) = s.strip_prefix("~/") {
695 if let Some(home) = dirs::home_dir() {
696 return home.join(rest);
697 }
698 }
699 p.to_path_buf()
700}
701
702const EXAMPLE: &str = r#"# FarHand configuration.
703# As .farhand.toml in a project root, it makes that project remote.
704# As ~/.config/farhand/config.toml it is optional: only needed for
705# activation = "always" (or as the target of --config / FARHAND_CONFIG).
706
707# "project" (default): only directories that carry a .farhand.toml are
708# remote; anywhere else the agent keeps its local tools and FarHand
709# stays out of the way.
710# "always": every agent session on this machine is remote, using the
711# [remote] section below.
712# Only meaningful in this global file; a project's .farhand.toml is always
713# active in its own directory.
714# activation = "project"
715
716[remote]
717# An alias from ~/.ssh/config or user@host. Keys, ports, jump hosts and
718# agent forwarding all come from your OpenSSH config.
719host = "devbox"
720# Directory on the remote where every command starts and relative paths
721# resolve. Absolute, or `~/...` for the remote user's home. Created on
722# first connection if it does not exist.
723workdir = "~/project"
724# os = "auto" # "posix" or "windows"; detected on connect, but set it so
725# # the model knows which shell it is writing for
726# shell = ["bash", "-lc"] # Windows: ["powershell"] (default) or ["pwsh"]
727# connect_timeout_secs = 20
728# server_alive_interval_secs = 30
729
730[local]
731# The only local directories the model may list, read from, upload from or
732# download into. Leave empty to close the local filesystem completely.
733# In a project .farhand.toml, "." means this folder: drop files here to
734# upload them, and downloads land here. A project file may only name this
735# folder or folders inside it; absolute and `~` entries belong in the
736# global config. Never allow your home directory or anything above it.
737allowed_dirs = ["."]
738
739[limits]
740# command_timeout_secs = 120
741# max_command_timeout_secs = 1800
742# max_output_bytes = 102400
743# max_read_bytes = 524288
744# max_transfer_bytes = 1073741824
745# max_transfer_files = 5000
746
747[audit]
748# Global config only; a project file cannot move the log.
749# log_dir = "~/.local/share/farhand/audit"
750
751[guard]
752# Extra globs that may never leave this machine (added to the built-in list).
753# deny_globs = ["*.internal"]
754# Extra regexes that mark content as secret.
755# deny_content = ["ACME-[0-9]{12}"]
756
757[approval]
758# "ask": the agent prompts you before remote_shell, remote_write,
759# remote_edit, remote_spawn, remote_kill, upload and download;
760# read-only tools run freely.
761# "auto": nothing prompts; the audit log is your record.
762# "strict": every tool prompts, reads and searches included.
763# A project .farhand.toml can make this stricter than the global config,
764# never looser: `auto` there only holds where the global config allows it.
765mode = "ask"
766# Per-tool overrides (`ask` or `auto`), any tool name:
767# tools = { remote_write = "auto", remote_edit = "auto", remote_read = "ask" }
768"#;
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
775 fn example_parses() {
776 let cfg = Config::parse_at(Config::example(), Some(Path::new("/proj"))).unwrap();
779 assert_eq!(cfg.remote.host, "devbox");
780 assert_eq!(cfg.remote.shell, vec!["bash", "-lc"]);
781 assert_eq!(cfg.local.allowed_dirs, vec![PathBuf::from("/proj")]);
782 assert!(Config::parse(Config::example()).is_err());
783 }
784
785 #[test]
786 fn project_file_is_found_above_the_working_directory() {
787 let home = tempfile::tempdir().unwrap();
788 let proj = home.path().join("proj");
789 let deep = proj.join(".claude/worktrees/w1/src");
790 std::fs::create_dir_all(&deep).unwrap();
791 let file = proj.join(".farhand.toml");
792 std::fs::write(&file, "").unwrap();
793 let h = Some(home.path());
794 assert_eq!(find_project_file(&deep, h), Some(file.clone()));
795 assert_eq!(find_project_file(&proj, h), Some(file));
796 let inner = deep.join(".farhand.toml");
798 std::fs::write(&inner, "").unwrap();
799 assert_eq!(find_project_file(&deep, h), Some(inner));
800 }
801
802 #[test]
803 fn project_file_search_stops_below_home() {
804 let home = tempfile::tempdir().unwrap();
805 let sub = home.path().join("a/b");
806 std::fs::create_dir_all(&sub).unwrap();
807 std::fs::write(home.path().join(".farhand.toml"), "").unwrap();
808 assert_eq!(find_project_file(&sub, Some(home.path())), None);
809 assert!(find_project_file(home.path(), Some(home.path())).is_some());
811 }
812
813 #[test]
814 fn rejects_home_and_root_as_allowed_dirs() {
815 for d in ["/", "~"] {
816 let err = Config::parse(&format!(
817 "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
818 ))
819 .unwrap_err();
820 assert!(err.to_string().contains("home directory"), "{d}: {err}");
821 }
822 assert!(
823 Config::parse("[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['~/out']")
824 .is_ok()
825 );
826 }
827
828 #[test]
829 fn relative_allowed_dirs_resolve_against_the_project_file() {
830 let text = "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['.', 'assets']";
831 let err = Config::parse(text).unwrap_err();
832 assert!(err.to_string().contains("relative"), "{err}");
833 let cfg = Config::parse_at(text, Some(Path::new("/proj"))).unwrap();
834 assert_eq!(
835 cfg.local.allowed_dirs,
836 vec![PathBuf::from("/proj"), PathBuf::from("/proj/assets")]
837 );
838 let err = Config::parse_at(
839 "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['../x']",
840 Some(Path::new("/proj")),
841 )
842 .unwrap_err();
843 assert!(err.to_string().contains(".."), "{err}");
844 }
845
846 #[test]
847 fn old_tool_name_points_at_the_new_one() {
848 let err = Config::parse(
849 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ remote_bash='ask' }",
850 )
851 .unwrap_err();
852 assert!(
853 err.to_string().contains("renamed to `remote_shell`"),
854 "{err}"
855 );
856 }
857
858 #[test]
859 fn rejects_relative_workdir() {
860 let err = Config::parse("[remote]\nhost='h'\nworkdir='rel'").unwrap_err();
861 assert!(err.to_string().contains("absolute"));
862 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='~/'").unwrap();
863 assert_eq!(cfg.remote.workdir, "~");
864 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/srv/app/'").unwrap();
865 assert_eq!(cfg.remote.workdir, "/srv/app");
866 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:\\work\\'").unwrap();
867 assert_eq!(cfg.remote.workdir, "C:\\work");
868 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='D:/'").unwrap();
869 assert_eq!(cfg.remote.workdir, "D:\\");
870 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='C:'").unwrap();
871 assert_eq!(cfg.remote.workdir, "C:\\");
872 assert!(Config::parse("[remote]\nhost='h'\nworkdir='work'").is_err());
873 let cfg = Config::parse("[remote]\nhost='h'\nworkdir='/'\nos='windows'").unwrap();
874 assert_eq!(cfg.remote.os, Os::Windows);
875 }
876
877 #[test]
878 fn approval_overrides_apply() {
879 let cfg = Config::parse(
880 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ remote_shell='ask' }",
881 )
882 .unwrap();
883 let e = cfg.approval.effective();
884 assert_eq!(e["remote_shell"], ApprovalMode::Ask);
885 assert_eq!(e["remote_write"], ApprovalMode::Auto);
886 assert_eq!(e["remote_read"], ApprovalMode::Auto);
887 assert_eq!(e.len(), MUTATING_TOOLS.len() + READ_ONLY_TOOLS.len());
888
889 let strict = Config::parse(
890 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='strict'\ntools={ remote_info='auto' }",
891 )
892 .unwrap();
893 let e = strict.approval.effective();
894 assert_eq!(e["remote_read"], ApprovalMode::Ask);
895 assert_eq!(e["remote_shell"], ApprovalMode::Ask);
896 assert_eq!(e["remote_info"], ApprovalMode::Auto);
897
898 let default = Config::parse("[remote]\nhost='h'\nworkdir='/'").unwrap();
899 assert_eq!(
900 default.approval.effective()["remote_read"],
901 ApprovalMode::Auto
902 );
903 assert!(Config::parse(
904 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ nope='auto' }"
905 )
906 .is_err());
907 assert!(Config::parse(
908 "[remote]\nhost='h'\nworkdir='/'\n[approval]\ntools={ upload='strict' }"
909 )
910 .is_err());
911 }
912
913 #[test]
914 fn activation_depends_on_source() {
915 let mk = |activation: &str, source: Source| Loaded {
916 config: Config::parse(&format!(
917 "activation='{activation}'\n[remote]\nhost='h'\nworkdir='/'"
918 ))
919 .unwrap(),
920 path: PathBuf::new(),
921 source,
922 notices: Vec::new(),
923 };
924 assert!(mk("always", Source::Global).active());
925 assert!(!mk("project", Source::Global).active());
926 assert!(mk("project", Source::Project).active());
927 assert!(mk("project", Source::Explicit).active());
928 assert_eq!(
929 Config::parse("[remote]\nhost='h'\nworkdir='/'")
930 .unwrap()
931 .activation,
932 ActivationMode::Project
933 );
934 }
935
936 #[test]
937 fn allowed_dirs_refuse_home_ancestors_and_dotdot() {
938 let home = dirs::home_dir().unwrap();
939 let parent = home.parent().unwrap().display().to_string();
940 for d in [parent.as_str(), "~/..", "~/../x", "/tmp/../etc"] {
941 let err = Config::parse(&format!(
942 "[remote]\nhost='h'\nworkdir='/w'\n[local]\nallowed_dirs=['{d}']"
943 ))
944 .unwrap_err();
945 assert!(err.to_string().contains("allowed_dirs"), "{d}: {err}");
946 }
947 }
948
949 fn project(text: &str, baseline: Option<&str>) -> (Config, Vec<String>, tempfile::TempDir) {
951 let d = tempfile::tempdir().unwrap();
952 let baseline = baseline.map(|b| Config::parse(b).unwrap());
953 let (cfg, notes) = Config::parse_project(text, Some(d.path()), baseline.as_ref()).unwrap();
954 (cfg, notes, d)
955 }
956
957 #[test]
958 fn project_file_cannot_loosen_approval() {
959 let (cfg, notes, _d) = project(
960 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'",
961 None,
962 );
963 let e = cfg.approval.effective();
964 assert_eq!(e["remote_shell"], ApprovalMode::Ask);
965 assert_eq!(e["upload"], ApprovalMode::Ask);
966 assert_eq!(e["remote_read"], ApprovalMode::Auto);
967 assert_eq!(notes.len(), 1);
968 assert!(notes[0].contains("remote_shell"), "{notes:?}");
969
970 let global = "[remote]\nhost='g'\nworkdir='/'\n[approval]\nmode='auto'";
972 let (cfg, notes, _d) = project(
973 "[remote]\nhost='h'\nworkdir='/'\n[approval]\nmode='auto'\ntools={ upload='ask' }",
974 Some(global),
975 );
976 assert!(notes.is_empty(), "{notes:?}");
977 assert_eq!(cfg.approval.effective()["remote_shell"], ApprovalMode::Auto);
978 assert_eq!(cfg.approval.effective()["upload"], ApprovalMode::Ask);
979
980 let strict = "[remote]\nhost='g'\nworkdir='/'\n[approval]\nmode='strict'";
982 let (cfg, notes, _d) = project("[remote]\nhost='h'\nworkdir='/'", Some(strict));
983 assert_eq!(cfg.approval.effective()["remote_read"], ApprovalMode::Ask);
984 assert!(notes[0].contains("remote_read"), "{notes:?}");
985 }
986
987 #[test]
988 fn project_file_keeps_to_its_own_folder_and_the_global_log() {
989 let (cfg, notes, d) = project(
990 "[remote]\nhost='h'\nworkdir='/'\n[local]\nallowed_dirs=['.', '/Users', '~/x']\n\
991 [audit]\nlog_dir='/dev'",
992 Some("[remote]\nhost='g'\nworkdir='/'\n[audit]\nlog_dir='/var/log/fh'\n[guard]\ndeny_globs=['*.corp']"),
993 );
994 assert_eq!(
995 cfg.local.allowed_dirs,
996 vec![d.path().canonicalize().unwrap()]
997 );
998 assert_eq!(cfg.audit.log_dir, Some(PathBuf::from("/var/log/fh")));
999 assert_eq!(cfg.guard.deny_globs, vec!["*.corp".to_string()]);
1000 assert_eq!(notes.len(), 3, "{notes:?}");
1001 let err = Config::parse_project(
1002 "[remote]\nhost='h'\nworkdir='/'\n[local]\nallowed_dirs=['../x']",
1003 Some(Path::new("/proj")),
1004 None,
1005 )
1006 .unwrap_err();
1007 assert!(err.to_string().contains(".."), "{err}");
1008 }
1009
1010 #[cfg(unix)]
1011 #[test]
1012 fn project_folders_may_not_leave_the_project_through_a_symlink() {
1013 let d = tempfile::tempdir().unwrap();
1014 let proj = d.path().join("proj");
1015 let outside = d.path().join("outside");
1016 std::fs::create_dir_all(proj.join("real/sub")).unwrap();
1017 std::fs::create_dir_all(&outside).unwrap();
1018 std::os::unix::fs::symlink(&outside, proj.join("assets")).unwrap();
1019 std::os::unix::fs::symlink(proj.join("real/sub"), proj.join("inner")).unwrap();
1020 let (cfg, notes) = Config::parse_project(
1021 "[remote]\nhost='h'\nworkdir='/'\n[local]\nallowed_dirs=['assets', 'inner', 'missing', 'real']",
1022 Some(&proj),
1023 None,
1024 )
1025 .unwrap();
1026 let real = proj.join("real").canonicalize().unwrap();
1027 assert_eq!(cfg.local.allowed_dirs, vec![real.join("sub"), real]);
1028 assert_eq!(notes.len(), 2, "{notes:?}");
1029 assert!(notes[0].contains("/assets`"), "{notes:?}");
1030 assert!(notes[1].contains("/missing`"), "{notes:?}");
1031 }
1032
1033 #[test]
1034 fn a_farhand_toml_passed_by_path_is_still_a_project_file() {
1035 let d = tempfile::tempdir().unwrap();
1036 let p = d.path().join(".farhand.toml");
1037 std::fs::write(
1038 &p,
1039 "[remote]\nhost='h'\nworkdir='/'\n[audit]\nlog_dir='/dev'",
1040 )
1041 .unwrap();
1042 let l = Config::load_in(None, Some(&p)).unwrap();
1043 assert_eq!(l.source, Source::Project);
1044 assert!(
1045 l.notices.iter().any(|n| n.contains("log_dir")),
1046 "{:?}",
1047 l.notices
1048 );
1049 let other = d.path().join("fh.toml");
1050 std::fs::copy(&p, &other).unwrap();
1051 let l = Config::load_in(None, Some(&other)).unwrap();
1052 assert_eq!(l.source, Source::Explicit);
1053 assert_eq!(l.config.audit.log_dir, Some(PathBuf::from("/dev")));
1054 }
1055
1056 #[test]
1057 fn rejects_unknown_fields() {
1058 assert!(Config::parse("[remote]\nhost='h'\nworkdir='/'\nbogus=1").is_err());
1059 }
1060}