1use 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 let cpath = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
114 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#[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 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 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
249pub 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}