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