Skip to main content

agent_float_term/
config.rs

1//! User configuration and private application directories.
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use std::env;
7use std::fs::{self, DirBuilder, OpenOptions};
8use std::io::Read;
9use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
10use std::path::{Component, Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct HarnessMapping {
15    pub harness: String,
16    pub path: PathBuf,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21pub struct Config {
22    #[serde(rename = "shortcut")]
23    pub key: String,
24    pub width: u8,
25    pub height: u8,
26    pub shell: Option<PathBuf>,
27    pub harness_paths: Vec<HarnessMapping>,
28}
29
30impl Default for Config {
31    fn default() -> Self {
32        Self {
33            key: "F7".into(),
34            width: 80,
35            height: 80,
36            shell: None,
37            harness_paths: Vec::new(),
38        }
39    }
40}
41
42impl Config {
43    pub fn validate(&self) -> Result<()> {
44        let mut key = self.key.as_str();
45        let mut modifiers = HashSet::new();
46        while key.starts_with("C-") || key.starts_with("M-") || key.starts_with("S-") {
47            if !modifiers.insert(&key[..2]) {
48                bail!("duplicate shortcut modifier");
49            }
50            key = &key[2..];
51        }
52        let function_key = key.strip_prefix('F').is_some_and(|n| {
53            n.parse::<u8>()
54                .is_ok_and(|n| (1..=12).contains(&n) && key == format!("F{n}"))
55        });
56        if !(function_key
57            || (key.len() == 1 && (key.as_bytes()[0].is_ascii_alphanumeric() || key == "@"))
58            || matches!(
59                key,
60                "Space"
61                    | "Enter"
62                    | "Escape"
63                    | "BSpace"
64                    | "Tab"
65                    | "BTab"
66                    | "Up"
67                    | "Down"
68                    | "Left"
69                    | "Right"
70                    | "Home"
71                    | "End"
72                    | "PPage"
73                    | "NPage"
74                    | "DC"
75                    | "IC"
76            ))
77        {
78            bail!("shortcut must be a safe tmux key (for example F7, C-a, or M-Space)");
79        }
80        if !(10..=100).contains(&self.width) || !(10..=100).contains(&self.height) {
81            bail!("width and height must each be between 10 and 100");
82        }
83        if let Some(shell) = &self.shell {
84            executable(shell).context("invalid shell")?;
85        }
86        let mut mappings = HashSet::new();
87        for mapping in &self.harness_paths {
88            if !matches!(mapping.harness.as_str(), "claude" | "codex" | "opencode") {
89                bail!(
90                    "unsupported harness {:?}; expected claude, codex, or opencode",
91                    mapping.harness
92                );
93            }
94            executable(&mapping.path).context("invalid harness mapping")?;
95            if !mappings.insert(fs::canonicalize(&mapping.path)?) {
96                bail!(
97                    "duplicate or conflicting harness mapping for {}",
98                    mapping.path.display()
99                );
100            }
101        }
102        Ok(())
103    }
104}
105
106fn executable(path: &Path) -> Result<()> {
107    checked_path(path)?;
108    let metadata = fs::metadata(path).with_context(|| format!("inspect {}", path.display()))?;
109    if !metadata.is_file() || metadata.mode() & 0o111 == 0 {
110        bail!("{} must be an executable regular file", path.display());
111    }
112    // access checks the current user's actual permissions, not just an arbitrary execute bit.
113    let cpath = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
114    // SAFETY: cpath is a valid NUL-terminated string and access does not retain its pointer.
115    if unsafe { libc::access(cpath.as_ptr(), libc::X_OK) } != 0 {
116        return Err(std::io::Error::last_os_error()).context("executable is not accessible");
117    }
118    Ok(())
119}
120
121/// Discover paths without creating anything. Every member is a directory.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct Paths {
125    pub config: PathBuf,
126    pub data: PathBuf,
127    pub state: PathBuf,
128    pub bin: PathBuf,
129}
130
131impl Paths {
132    pub fn discover() -> Result<Self> {
133        let home = env::var_os("HOME").context("HOME is not set")?;
134        Self::from_environment(PathBuf::from(home), |name| env::var_os(name))
135    }
136
137    fn from_environment(
138        home: PathBuf,
139        lookup: impl Fn(&str) -> Option<std::ffi::OsString>,
140    ) -> Result<Self> {
141        checked_path(&home).context("invalid HOME")?;
142        let xdg = |name: &str, fallback: &str| -> Result<PathBuf> {
143            let root = lookup(name)
144                .filter(|value| !value.is_empty())
145                .map(PathBuf::from)
146                .unwrap_or_else(|| home.join(fallback));
147            checked_path(&root).with_context(|| format!("invalid {name}"))?;
148            Ok(root.join("agent-float-term"))
149        };
150        Ok(Self {
151            config: xdg("XDG_CONFIG_HOME", ".config")?,
152            data: xdg("XDG_DATA_HOME", ".local/share")?,
153            state: xdg("XDG_STATE_HOME", ".local/state")?,
154            bin: home.join(".local/bin"),
155        })
156    }
157}
158
159pub fn load() -> Result<Config> {
160    load_file(&Paths::discover()?.config.join("config.json"))
161}
162
163fn load_file(path: &Path) -> Result<Config> {
164    if let Some(parent) = path.parent() {
165        match fs::symlink_metadata(parent) {
166            Ok(metadata)
167                if !metadata.is_dir()
168                    || metadata.uid() != uid()
169                    || metadata.mode() & 0o022 != 0 =>
170            {
171                bail!("configuration directory must be user-owned, not writable by others or a symlink");
172            }
173            Ok(_) => (),
174            Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
175            Err(error) => return Err(error).context("inspect configuration directory"),
176        }
177    }
178    let file = match OpenOptions::new()
179        .read(true)
180        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
181        .open(path)
182    {
183        Ok(file) => file,
184        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
185            let legacy = path.with_file_name("config.toml");
186            match fs::symlink_metadata(&legacy) {
187                Ok(_) => bail!(
188                    "{} is no longer supported and was left unchanged; create {} as a JSON object, \
189                     rename 'key' to 'shortcut', and convert any shell/harness_paths settings; \
190                     use {{}} for defaults. TOML is not loaded",
191                    legacy.display(),
192                    path.display()
193                ),
194                Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
195                Err(error) => {
196                    return Err(error).with_context(|| {
197                        format!("inspect legacy configuration {}", legacy.display())
198                    });
199                }
200            }
201            return Ok(Config::default());
202        }
203        Err(error) => return Err(error).with_context(|| format!("open {}", path.display())),
204    };
205    let metadata = file.metadata()?;
206    if !metadata.is_file() || metadata.uid() != uid() || metadata.mode() & 0o022 != 0 {
207        bail!("configuration must be a user-owned regular file, not writable by others");
208    }
209    let mut text = String::new();
210    file.take(1024 * 1024 + 1).read_to_string(&mut text)?;
211    if text.len() > 1024 * 1024 {
212        bail!("configuration exceeds 1 MiB");
213    }
214    // Serde's struct deserializer also accepts positional arrays; config is object-only.
215    if !text.trim_start().starts_with('{') {
216        bail!(
217            "invalid config.json at {}; expected a JSON object",
218            path.display()
219        );
220    }
221    let config: Config = serde_json::from_str(&text).with_context(|| {
222        format!(
223            "invalid config.json at {}; expected a JSON object",
224            path.display()
225        )
226    })?;
227    config.validate()?;
228    Ok(config)
229}
230
231pub(crate) fn uid() -> u32 {
232    // SAFETY: geteuid has no preconditions.
233    unsafe { libc::geteuid() }
234}
235
236pub(crate) fn checked_path(path: &Path) -> Result<()> {
237    let text = path.to_str().context("paths must be valid UTF-8")?;
238    if !path.is_absolute()
239        || text.chars().any(char::is_control)
240        || path
241            .components()
242            .any(|c| matches!(c, Component::ParentDir | Component::CurDir))
243    {
244        bail!("path must be absolute, without control characters or '..': {path:?}");
245    }
246    Ok(())
247}
248
249/// Create a private product directory; never chmod HOME or an existing XDG parent.
250/// Existing product directories with unsafe ownership/permissions are rejected.
251pub fn private_dir(path: &Path) -> Result<()> {
252    checked_path(path)?;
253    match fs::symlink_metadata(path) {
254        Ok(metadata) => {
255            if !metadata.is_dir() || metadata.uid() != uid() || metadata.mode() & 0o077 != 0 {
256                bail!(
257                    "{} must be a private, user-owned directory (mode 0700), not a symlink",
258                    path.display()
259                );
260            }
261        }
262        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
263            ensure_user_dir(path.parent().context("directory has no parent")?)?;
264            match DirBuilder::new().mode(0o700).create(path) {
265                Ok(()) => (),
266                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (),
267                Err(error) => return Err(error).context("create private directory"),
268            }
269            private_dir(path)?;
270            fs::File::open(path.parent().context("directory has no parent")?)?.sync_all()?;
271        }
272        Err(error) => return Err(error).context("inspect private directory"),
273    }
274    Ok(())
275}
276
277pub(crate) fn ensure_user_dir(path: &Path) -> Result<()> {
278    checked_path(path)?;
279    match fs::symlink_metadata(path) {
280        Ok(metadata) => {
281            if !metadata.is_dir() || metadata.uid() != uid() || metadata.mode() & 0o022 != 0 {
282                bail!(
283                    "{} must be a user-owned directory, not writable by others or a symlink",
284                    path.display()
285                );
286            }
287        }
288        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
289            ensure_user_dir(path.parent().context("directory has no parent")?)?;
290            match DirBuilder::new().mode(0o700).create(path) {
291                Ok(()) => (),
292                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (),
293                Err(error) => return Err(error).context("create user directory"),
294            }
295            ensure_user_dir(path)?;
296            fs::File::open(path.parent().context("directory has no parent")?)?.sync_all()?;
297        }
298        Err(error) => return Err(error).context("inspect user directory"),
299    }
300    Ok(())
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use std::os::unix::fs::{symlink, PermissionsExt};
307
308    #[test]
309    fn defaults_unknown_fields_and_validation() {
310        let config: Config = serde_json::from_str("{}").unwrap();
311        assert_eq!(config.key, "F7");
312        assert_eq!((config.width, config.height), (80, 80));
313        config.validate().unwrap();
314        for text in [
315            r#"{"widht":80}"#,
316            r#"{"key":"F7"}"#,
317            r#"{"shortcut":"F7","shortcut":"F8"}"#,
318            r#"{"width":"80"}"#,
319            r#"{"width":80.5}"#,
320            r#"{"height":-1}"#,
321            r#"{"height":256}"#,
322            r#"{"shortcut":"F7",}"#,
323            "",
324            "width = 80",
325        ] {
326            assert!(serde_json::from_str::<Config>(text).is_err(), "{text:?}");
327        }
328        let config: Config =
329            serde_json::from_str(r#"{"shortcut":"C-a","width":10,"height":100}"#).unwrap();
330        assert_eq!(config.key, "C-a");
331        config.validate().unwrap();
332        let json = serde_json::to_value(&config).unwrap();
333        assert_eq!(json["shortcut"], "C-a");
334        assert!(json.get("key").is_none());
335        let partial: Config = serde_json::from_str(r#"{"height":10}"#).unwrap();
336        assert_eq!((partial.width, partial.height), (80, 10));
337        assert_eq!(partial.key, "F7");
338        for dimension in [0, 9, 101, 255] {
339            for (width, height) in [(dimension, 80), (80, dimension)] {
340                assert!(Config {
341                    width,
342                    height,
343                    ..Config::default()
344                }
345                .validate()
346                .is_err());
347            }
348        }
349        for dimension in [10, 100] {
350            Config {
351                width: dimension,
352                height: dimension,
353                ..Config::default()
354            }
355            .validate()
356            .unwrap();
357        }
358        for key in [
359            "F7; run-shell bad",
360            "#{pane_id}",
361            "'",
362            "\n",
363            "-T",
364            "F0",
365            "F025",
366            "C-C-a",
367        ] {
368            assert!(
369                Config {
370                    key: key.into(),
371                    ..Config::default()
372                }
373                .validate()
374                .is_err(),
375                "{key:?}"
376            );
377        }
378        for key in ["F7", "C-a", "M-Space", "C-M-Left"] {
379            Config {
380                key: key.into(),
381                ..Config::default()
382            }
383            .validate()
384            .unwrap();
385        }
386        assert!(Config {
387            width: 9,
388            ..Config::default()
389        }
390        .validate()
391        .is_err());
392        assert!(Config {
393            height: 101,
394            ..Config::default()
395        }
396        .validate()
397        .is_err());
398        assert!(Config {
399            shell: Some("relative".into()),
400            ..Config::default()
401        }
402        .validate()
403        .is_err());
404    }
405
406    #[test]
407    fn executable_mappings_and_config_files() {
408        let temp = tempfile::tempdir().unwrap();
409        let executable = temp.path().join("tool");
410        fs::write(&executable, "#!/bin/sh\n").unwrap();
411        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
412        let mapping = HarnessMapping {
413            harness: "claude".into(),
414            path: executable.clone(),
415        };
416        let config = Config {
417            shell: Some(executable.clone()),
418            harness_paths: vec![mapping.clone()],
419            ..Config::default()
420        };
421        let path = temp.path().join("config.json");
422        fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap();
423        let loaded = load_file(&path).unwrap();
424        assert_eq!(loaded.shell, config.shell);
425        assert_eq!(loaded.harness_paths[0].path, executable);
426        assert_eq!(loaded.harness_paths[0].harness, "claude");
427        fs::remove_file(&path).unwrap();
428        assert!(Config {
429            harness_paths: vec![HarnessMapping {
430                harness: "unknown".into(),
431                path: executable.clone()
432            }],
433            ..Config::default()
434        }
435        .validate()
436        .is_err());
437        assert!(Config {
438            harness_paths: vec![mapping.clone(), mapping],
439            ..Config::default()
440        }
441        .validate()
442        .is_err());
443        fs::set_permissions(&executable, fs::Permissions::from_mode(0o600)).unwrap();
444        assert!(Config {
445            shell: Some(executable),
446            ..Config::default()
447        }
448        .validate()
449        .is_err());
450        assert_eq!(load_file(&path).unwrap().key, "F7");
451        fs::write(&path, r#"{"height":101}"#).unwrap();
452        assert!(load_file(&path).is_err());
453        fs::remove_file(&path).unwrap();
454        symlink("missing", &path).unwrap();
455        assert!(load_file(&path).is_err());
456    }
457
458    #[test]
459    fn unsafe_config_permissions_and_symlink_directory_are_refused() {
460        let temp = tempfile::tempdir().unwrap();
461        let directory = temp.path().join("product");
462        private_dir(&directory).unwrap();
463        let path = directory.join("config.json");
464        fs::write(&path, r#"{"shortcut":"F7"}"#).unwrap();
465        fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).unwrap();
466        assert!(load_file(&path).is_err());
467        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
468        load_file(&path).unwrap();
469        let alias = temp.path().join("alias");
470        symlink(&directory, &alias).unwrap();
471        assert!(load_file(&alias.join("config.json")).is_err());
472        assert_eq!(fs::read(&path).unwrap(), br#"{"shortcut":"F7"}"#);
473        fs::set_permissions(&directory, fs::Permissions::from_mode(0o777)).unwrap();
474        assert!(load_file(&path).is_err());
475    }
476
477    #[test]
478    fn json_only_preserves_legacy_files_and_reports_migration() {
479        let temp = tempfile::tempdir().unwrap();
480        let path = temp.path().join("config.json");
481        let legacy = temp.path().join("config.toml");
482        let old_text = "key = 'F8'\nwidth = 70\n";
483        fs::write(&legacy, old_text).unwrap();
484        let error = load_file(&path).unwrap_err().to_string();
485        assert!(error.contains("config.toml"));
486        assert!(error.contains(&format!("create {}", path.display())));
487        assert!(error.contains("'key' to 'shortcut'"));
488        assert!(!path.exists());
489        fs::write(&path, r#"{"shortcut":"F9"}"#).unwrap();
490        assert_eq!(load_file(&path).unwrap().key, "F9");
491        fs::write(&path, old_text).unwrap();
492        assert!(load_file(&path)
493            .unwrap_err()
494            .to_string()
495            .contains("invalid config.json"));
496        assert_eq!(fs::read_to_string(&legacy).unwrap(), old_text);
497        assert_eq!(fs::read_to_string(&path).unwrap(), old_text);
498        fs::remove_file(&path).unwrap();
499        fs::remove_file(&legacy).unwrap();
500        symlink("missing", &legacy).unwrap();
501        assert!(load_file(&path)
502            .unwrap_err()
503            .to_string()
504            .contains("TOML is not loaded"));
505        assert_eq!(fs::read_link(&legacy).unwrap(), Path::new("missing"));
506    }
507
508    #[test]
509    fn oversized_and_nonregular_json_are_refused() {
510        let temp = tempfile::tempdir().unwrap();
511        let path = temp.path().join("config.json");
512        for text in ["[]", r#"["F7",80,80,null,[]]"#, "null", "true", ""] {
513            fs::write(&path, text).unwrap();
514            assert!(load_file(&path)
515                .unwrap_err()
516                .to_string()
517                .contains("expected a JSON object"));
518        }
519        fs::write(&path, " ".repeat(1024 * 1024 + 1)).unwrap();
520        assert!(load_file(&path)
521            .unwrap_err()
522            .to_string()
523            .contains("exceeds 1 MiB"));
524        fs::remove_file(&path).unwrap();
525        fs::create_dir(&path).unwrap();
526        assert!(load_file(&path).is_err());
527    }
528
529    #[test]
530    fn private_directories_do_not_change_parent_permissions() {
531        let temp = tempfile::tempdir().unwrap();
532        fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)).unwrap();
533        let product = temp.path().join("product");
534        private_dir(&product).unwrap();
535        private_dir(&product).unwrap();
536        assert_eq!(fs::metadata(temp.path()).unwrap().mode() & 0o777, 0o755);
537        assert_eq!(fs::metadata(&product).unwrap().mode() & 0o777, 0o700);
538        let link = temp.path().join("link");
539        symlink(&product, &link).unwrap();
540        assert!(private_dir(&link).is_err());
541        fs::set_permissions(&product, fs::Permissions::from_mode(0o755)).unwrap();
542        assert!(private_dir(&product).is_err());
543    }
544
545    #[test]
546    fn xdg_paths_and_rejected_relative_roots() {
547        let temp = tempfile::tempdir().unwrap();
548        let home = temp.path().join("home");
549        let defaults = Paths::from_environment(home.clone(), |_| None).unwrap();
550        assert_eq!(defaults.config, home.join(".config/agent-float-term"));
551        assert_eq!(defaults.data, home.join(".local/share/agent-float-term"));
552        assert_eq!(defaults.state, home.join(".local/state/agent-float-term"));
553        assert_eq!(defaults.bin, home.join(".local/bin"));
554        let overridden = Paths::from_environment(home.clone(), |name| {
555            Some(temp.path().join(name).into_os_string())
556        })
557        .unwrap();
558        assert_eq!(
559            overridden.config,
560            temp.path().join("XDG_CONFIG_HOME/agent-float-term")
561        );
562        assert_eq!(
563            overridden.data,
564            temp.path().join("XDG_DATA_HOME/agent-float-term")
565        );
566        assert_eq!(
567            overridden.state,
568            temp.path().join("XDG_STATE_HOME/agent-float-term")
569        );
570        assert_eq!(overridden.bin, defaults.bin);
571        assert_eq!(
572            Paths::from_environment(home.clone(), |_| Some("".into())).unwrap(),
573            defaults
574        );
575        assert!(Paths::from_environment(home, |_| Some("relative".into())).is_err());
576        assert!(Paths::from_environment("relative-home".into(), |_| None).is_err());
577        assert!(!temp.path().join("home").exists());
578    }
579}