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