1use agentmux_remote::fsproto::{Entry, ErrKind, Kind};
15
16use crate::backend::remote::files::{self, RemoteError, RemoteFiles};
17use crate::backend::remote::sessions::AskIn;
18use crate::backend::rpc_types::{
19 FsCreateKind, FsEntry, FsError, FsErrorKind, FsListResult, FsOpResult,
20};
21
22const SYSTEM_DIRS: &[&str] = &[
24 "/usr",
25 "/bin",
26 "/sbin",
27 "/etc",
28 "/var",
29 "/boot",
30 "/lib",
31 "/lib32",
32 "/lib64",
33 "/proc",
34 "/sys",
35 "/dev",
36 "/mnt",
37 "/run",
38 "/opt",
39 "/System",
40 "/Library",
41 "/Applications",
42 "/private",
43 "/cores",
44];
45
46#[derive(Debug, Clone, Copy)]
49pub struct Remote<'a> {
50 pub connection: &'a str,
51 pub block_id: Option<&'a str>,
52 pub auth_key: &'a str,
53}
54
55pub fn ssh_connection(connection: Option<&str>) -> Option<&str> {
58 let c = connection?.trim();
59 matches!(
60 crate::backend::remote::ConnTarget::parse(c),
61 Ok(crate::backend::remote::ConnTarget::Ssh(_))
62 )
63 .then_some(c)
64}
65
66fn home_of(f: &RemoteFiles) -> String {
70 resolve("/", &f.home)
71}
72
73async fn open(r: Remote<'_>) -> Result<std::sync::Arc<RemoteFiles>, String> {
74 let ask = r.block_id.filter(|b| !b.is_empty()).map(|block_id| AskIn {
75 block_id,
76 auth_key: r.auth_key,
77 });
78 files::connect(r.connection, ask)
79 .await
80 .map_err(|e| e.message)
81}
82
83pub fn resolve(home: &str, path: &str) -> String {
87 let path = path.trim();
88 let full = if path.is_empty() || path == "~" {
89 home.to_string()
90 } else if let Some(rest) = path.strip_prefix("~/") {
91 format!("{home}/{rest}")
92 } else if path.starts_with('/') {
93 path.to_string()
94 } else {
95 format!("{home}/{path}")
96 };
97 let mut parts: Vec<&str> = Vec::new();
98 for part in full.split('/') {
99 match part {
100 "" | "." => {}
101 ".." => {
102 parts.pop();
103 }
104 p => parts.push(p),
105 }
106 }
107 format!("/{}", parts.join("/"))
108}
109
110fn parent(path: &str) -> Option<&str> {
111 if path == "/" {
112 return None;
113 }
114 Some(match path.rfind('/') {
115 Some(0) => "/",
116 Some(i) => &path[..i],
117 None => "/",
118 })
119}
120
121fn join(dir: &str, name: &str) -> String {
122 if dir.ends_with('/') {
123 format!("{dir}{name}")
124 } else {
125 format!("{dir}/{name}")
126 }
127}
128
129pub fn protected(home: &str, path: &str) -> bool {
134 if home != "/" && path != home && path.starts_with(&format!("{}/", home.trim_end_matches('/')))
137 {
138 return false;
139 }
140 path == "/"
141 || path == home
142 || SYSTEM_DIRS
143 .iter()
144 .any(|d| path == *d || path.starts_with(&format!("{d}/")))
145}
146
147fn refusal(conn: &str, path: &str) -> String {
148 format!("AgentMux doesn't change {path} on {conn}: it's a system folder or your home folder.")
149}
150
151fn entry(e: Entry) -> FsEntry {
152 FsEntry {
153 hidden: e.name.starts_with('.'),
154 readonly: e.mode != 0 && e.mode & 0o222 == 0,
156 is_dir: e.kind == Kind::Dir,
157 is_symlink: e.symlink,
158 size: (e.kind != Kind::Dir).then_some(e.size),
159 mtime: (e.mtime_ms > 0).then_some(e.mtime_ms as u64),
160 link_target: (!e.link_target.is_empty()).then_some(e.link_target),
161 error: None,
162 name: e.name,
163 }
164}
165
166fn list_error(e: &RemoteError) -> FsError {
167 FsError {
168 kind: match e.kind {
169 Some(ErrKind::NotFound) => FsErrorKind::NotFound,
170 Some(ErrKind::PermissionDenied) => FsErrorKind::PermissionDenied,
171 Some(ErrKind::NotADirectory) => FsErrorKind::NotADirectory,
172 _ => FsErrorKind::Other,
173 },
174 message: e.message.clone(),
175 }
176}
177
178pub async fn list(r: Remote<'_>, path: &str) -> Result<FsListResult, String> {
181 let f = open(r).await?;
182 let home = home_of(&f);
183 let dir = resolve(&home, path);
184 Ok(match f.list(&dir).await {
185 Ok(entries) => FsListResult {
186 path: dir,
187 entries: entries.into_iter().map(entry).collect(),
188 cursor: None,
189 error: None,
190 },
191 Err(e) => FsListResult {
192 path: dir,
193 entries: Vec::new(),
194 cursor: None,
195 error: Some(list_error(&e)),
196 },
197 })
198}
199
200fn valid_name(name: &str) -> Result<(), String> {
201 if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
202 return Err("That name can't be used.".to_string());
203 }
204 Ok(())
205}
206
207fn friendly(e: RemoteError, what: &str, name: &str) -> String {
208 match e.kind {
209 Some(ErrKind::AlreadyExists) => format!("\"{name}\" already exists here."),
210 Some(ErrKind::NotFound) => "It isn't there anymore.".to_string(),
211 Some(ErrKind::PermissionDenied) => format!("You don't have permission to {what} that."),
212 _ => format!("Couldn't {what} it: {}", e.message),
213 }
214}
215
216pub async fn rename(r: Remote<'_>, path: &str, new_name: &str) -> Result<String, String> {
218 valid_name(new_name)?;
219 let f = open(r).await?;
220 let home = home_of(&f);
221 let src = resolve(&home, path);
222 let dir = parent(&src).ok_or_else(|| refusal(r.connection, &src))?;
223 let dest = join(dir, new_name);
224 for p in [&src, &dest] {
225 if protected(&home, p) {
226 return Err(refusal(r.connection, p));
227 }
228 }
229 if src == dest {
230 return Ok(dest);
231 }
232 let result = f
233 .rename(&src, &dest)
234 .await
235 .map_err(|e| friendly(e, "rename", new_name));
236 tracing::info!(op = "fs.rename", connection = %r.connection, from = %src, to = %dest,
237 outcome = if result.is_ok() { "ok" } else { "error" }, "remote fs mutation");
238 result.map(|()| dest)
239}
240
241pub async fn create(
243 r: Remote<'_>,
244 parent_dir: &str,
245 name: &str,
246 kind: FsCreateKind,
247) -> Result<String, String> {
248 valid_name(name)?;
249 let f = open(r).await?;
250 let home = home_of(&f);
251 let dir = resolve(&home, parent_dir);
252 let target = join(&dir, name);
253 if protected(&home, &target) {
254 return Err(refusal(r.connection, &target));
255 }
256 match f.stat(&target).await {
257 Ok(_) => return Err(format!("\"{name}\" already exists here.")),
258 Err(e) if e.is_not_found() => {}
259 Err(e) => return Err(friendly(e, "create", name)),
260 }
261 let result = match kind {
262 FsCreateKind::Dir => f.mkdir(&target, false).await,
263 FsCreateKind::File => f.write(&target, Vec::new()).await,
264 }
265 .map_err(|e| friendly(e, "create", name));
266 tracing::info!(op = "fs.create", connection = %r.connection, path = %target,
267 outcome = if result.is_ok() { "ok" } else { "error" }, "remote fs mutation");
268 result.map(|()| target)
269}
270
271pub async fn delete(r: Remote<'_>, paths: &[String]) -> Vec<FsOpResult> {
274 let f = match open(r).await {
275 Ok(f) => f,
276 Err(e) => {
277 return paths
278 .iter()
279 .map(|p| FsOpResult {
280 path: p.clone(),
281 ok: false,
282 error: Some(e.clone()),
283 })
284 .collect()
285 }
286 };
287 let home = home_of(&f);
288 let mut out = Vec::with_capacity(paths.len());
289 for p in paths {
290 let target = resolve(&home, p);
291 let result = if protected(&home, &target) {
292 Err(refusal(r.connection, &target))
293 } else {
294 f.delete(&target, true)
295 .await
296 .map_err(|e| friendly(e, "delete", &target))
297 };
298 tracing::info!(op = "fs.delete", connection = %r.connection, path = %target,
299 outcome = if result.is_ok() { "ok" } else { "error" }, "remote fs mutation");
300 out.push(FsOpResult {
301 path: p.clone(),
302 ok: result.is_ok(),
303 error: result.err(),
304 });
305 }
306 out
307}
308
309pub const EDITOR_MAX: u64 = 10_000_000;
311
312pub async fn read_file(r: Remote<'_>, path: &str) -> Result<(Vec<u8>, bool), String> {
315 let f = open(r).await?;
316 let home = home_of(&f);
317 let target = resolve(&home, path);
318 let entry = f.stat(&target).await.map_err(|e| e.message)?;
319 if entry.kind == Kind::Dir {
320 return Err(format!("{target} is a folder"));
321 }
322 if entry.size > EDITOR_MAX {
323 return Err("File too large (>10MB)".to_string());
324 }
325 let bytes = f.read(&target, EDITOR_MAX).await.map_err(|e| e.message)?;
326 let read_only = entry.mode != 0 && entry.mode & 0o222 == 0;
327 Ok((bytes, read_only))
328}
329
330pub fn editor_may_write(home: &str, path: &str) -> bool {
333 home != "/"
334 && path.starts_with(&format!("{}/", home.trim_end_matches('/')))
335 && !protected(home, path)
336}
337
338pub async fn write_file(r: Remote<'_>, path: &str, bytes: Vec<u8>) -> Result<(), String> {
340 let f = open(r).await?;
341 let home = home_of(&f);
342 let target = resolve(&home, path);
343 if !editor_may_write(&home, &target) {
344 return Err(format!(
345 "writeeditorfile: {target} on {} is outside your home folder there",
346 r.connection
347 ));
348 }
349 let size = bytes.len();
350 let result = f
351 .write(&target, bytes)
352 .await
353 .map_err(|e| format!("writeeditorfile: {}", e.message));
354 tracing::info!(connection = %r.connection, path = %target, bytes = size,
355 outcome = if result.is_ok() { "ok" } else { "error" }, "remote editor file saved");
356 result
357}
358
359pub fn no_trash(connection: &str) -> String {
362 format!("{connection} has no Trash: delete permanently instead.")
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn paths_resolve_under_home_and_stay_tidy() {
371 let h = "/home/u";
372 assert_eq!(resolve(h, "~"), "/home/u");
373 assert_eq!(resolve(h, ""), "/home/u");
374 assert_eq!(resolve(h, "~/a/./b/"), "/home/u/a/b");
375 assert_eq!(resolve(h, "src"), "/home/u/src");
376 assert_eq!(resolve(h, "/etc/../srv//x"), "/srv/x");
377 assert_eq!(resolve(h, "/../.."), "/");
378 assert_eq!(parent("/home/u/a"), Some("/home/u"));
379 assert_eq!(parent("/a"), Some("/"));
380 assert_eq!(parent("/"), None);
381 assert_eq!(join("/", "a"), "/a");
382 assert_eq!(join("/home/u", "a"), "/home/u/a");
383 }
384
385 #[test]
386 fn system_folders_root_and_home_are_never_changed() {
387 let h = "/home/u";
388 for p in [
389 "/",
390 "/home/u",
391 "/etc",
392 "/etc/hosts",
393 "/usr/bin/x",
394 "/System/Library",
395 "/opt",
396 ] {
397 assert!(protected(h, p), "{p}");
398 }
399 for p in [
400 "/home/u/a",
401 "/srv/data",
402 "/tmp/x",
403 "/home/other/a",
404 "/etcetera",
405 ] {
406 assert!(!protected(h, p), "{p}");
407 }
408 let h = "/var/home/u";
411 assert!(!protected(h, "/var/home/u/proj"));
412 assert!(protected(h, "/var/home/u"));
413 assert!(protected(h, "/var/log"));
414 assert!(protected(h, "/var/home/uv"));
415 assert!(protected("/", "/etc"));
417 assert!(protected("/", "/"));
418 assert!(!protected("/", "/srv/data"));
419 }
420
421 #[test]
422 fn the_editor_writes_only_inside_home_on_a_host() {
423 assert!(editor_may_write("/home/u", "/home/u/src/main.rs"));
424 assert!(editor_may_write("/var/home/u", "/var/home/u/a"));
425 assert!(!editor_may_write("/home/u", "/home/u"));
426 assert!(!editor_may_write("/home/u", "/etc/hosts"));
427 assert!(!editor_may_write("/home/u", "/home/other/a"));
428 assert!(!editor_may_write("/", "/srv/a"));
429 }
430
431 #[test]
432 fn helper_entries_read_like_local_ones() {
433 let e = entry(Entry {
434 name: ".bashrc".into(),
435 kind: Kind::File,
436 size: 10,
437 mtime_ms: 1_700_000_000_000,
438 mode: 0o444,
439 symlink: true,
440 link_target: "dotfiles/bashrc".into(),
441 });
442 assert!(e.hidden && e.readonly && e.is_symlink && !e.is_dir);
443 assert_eq!((e.size, e.mtime), (Some(10), Some(1_700_000_000_000)));
444 assert_eq!(e.link_target.as_deref(), Some("dotfiles/bashrc"));
445 let d = entry(Entry {
446 name: "src".into(),
447 kind: Kind::Dir,
448 size: 4096,
449 mtime_ms: 0,
450 mode: 0,
451 symlink: false,
452 link_target: String::new(),
453 });
454 assert!(d.is_dir && !d.readonly && !d.hidden);
455 assert_eq!((d.size, d.mtime, d.link_target), (None, None, None));
456 }
457
458 #[cfg(target_os = "linux")]
463 #[tokio::test]
464 async fn hangar_lists_creates_renames_and_deletes_on_a_host() {
465 let home = tempfile::tempdir().unwrap();
466 let h = home.path().to_string_lossy().into_owned();
467 let conn = "fsremote-test-host";
468 files::connect_in_process(conn, home.path().to_path_buf()).await;
469 let r = Remote {
470 connection: conn,
471 block_id: None,
472 auth_key: "",
473 };
474
475 let dir = create(r, "~", "proj", FsCreateKind::Dir).await.unwrap();
476 assert_eq!(dir, format!("{h}/proj"));
477 let file = create(r, "~/proj", "a.txt", FsCreateKind::File)
478 .await
479 .unwrap();
480 assert!(create(r, "~/proj", "a.txt", FsCreateKind::File)
481 .await
482 .unwrap_err()
483 .contains("already exists"));
484 assert!(create(r, "~/proj", "../x", FsCreateKind::File)
485 .await
486 .is_err());
487
488 let listed = list(r, "proj/").await.unwrap();
489 assert_eq!(listed.path, format!("{h}/proj"));
490 assert_eq!(listed.entries.len(), 1);
491 assert_eq!(listed.entries[0].name, "a.txt");
492 assert_eq!(listed.entries[0].size, Some(0));
493
494 let moved = rename(r, &file, "b.txt").await.unwrap();
495 assert_eq!(moved, format!("{h}/proj/b.txt"));
496 let missing = list(r, "~/nope").await.unwrap();
497 assert_eq!(missing.error.unwrap().kind, FsErrorKind::NotFound);
498
499 assert!(rename(r, "~", "elsewhere")
501 .await
502 .unwrap_err()
503 .contains("doesn't change"));
504 let out = delete(r, &["~".into(), "/etc".into(), "~/proj".into()]).await;
505 assert_eq!(
506 out.iter().map(|o| o.ok).collect::<Vec<_>>(),
507 vec![false, false, true]
508 );
509 assert!(!home.path().join("proj").exists());
510 assert!(home.path().exists());
511
512 write_file(r, "~/doc.txt", b"hello".to_vec()).await.unwrap();
514 let (bytes, read_only) = read_file(r, "~/doc.txt").await.unwrap();
515 assert_eq!((bytes, read_only), (b"hello".to_vec(), false));
516 assert!(write_file(r, "/etc/x", b"x".to_vec())
517 .await
518 .unwrap_err()
519 .contains("outside your home"));
520 assert!(read_file(r, "~").await.unwrap_err().contains("folder"));
521 }
522
523 #[test]
524 fn only_ssh_connections_go_remote() {
525 assert_eq!(ssh_connection(Some("user@box")), Some("user@box"));
526 assert_eq!(ssh_connection(Some(" box:2222 ")), Some("box:2222"));
527 assert_eq!(ssh_connection(Some("local")), None);
528 assert_eq!(ssh_connection(Some("wsl://Ubuntu")), None);
529 assert_eq!(ssh_connection(Some("")), None);
530 assert_eq!(ssh_connection(None), None);
531 }
532}