Skip to main content

agentmux_remote/
serve.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `agentmux-remote serve --stdio`: file operations for srv over this
5//! process's stdin and stdout (`fsproto`), one request at a time, until
6//! stdin closes (spec §6.1, §6.3). Paths are the host's own; `~` and a
7//! relative path are under the user's home. Nothing here opens a socket or
8//! reaches beyond what this user can already read and write.
9
10use std::fs;
11use std::io::{self, Read, Write};
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::UNIX_EPOCH;
15
16use crate::fsproto::{self, Entry, ErrKind, Kind, Reply, Request, Splitter, MAX_READ, MAX_WRITE};
17
18/// Serve requests from `input`, answering on `output`, until `input` ends.
19/// `home` is the user's home directory ([`home_dir`]).
20pub fn run(home: &Path, input: &mut impl Read, output: &mut impl Write) -> io::Result<()> {
21    run_with(Server::new(home.to_path_buf()), input, output)
22}
23
24/// [`run`] with a [`Server`] made by the caller (one that can ask the
25/// session daemon for a session's shell: [`Server::with_sessions`]).
26pub fn run_with(mut server: Server, input: &mut impl Read, output: &mut impl Write) -> io::Result<()> {
27    let mut splitter = Splitter::new();
28    let mut buf = vec![0u8; 64 * 1024];
29    loop {
30        let n = input.read(&mut buf)?;
31        if n == 0 {
32            return Ok(());
33        }
34        let bodies = splitter
35            .push(&buf[..n])
36            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
37        for body in bodies {
38            let reply = match Request::decode(&body) {
39                Ok((id, req)) => (id, server.handle(req)),
40                // A request that cannot be read: the stream is broken.
41                Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, e)),
42            };
43            output.write_all(&reply.1.encode(reply.0))?;
44            output.flush()?;
45        }
46    }
47}
48
49/// A durable session's shell PID, by session id (the daemon's).
50pub type SessionPid = Box<dyn Fn(&str) -> Option<u32> + Send>;
51
52/// One connection's server: [`handle`], plus what lasts between requests
53/// (Tower's CPU rates, `procs`).
54pub struct Server {
55    home: PathBuf,
56    procs: crate::procs::Sampler,
57    session_pid: Option<SessionPid>,
58}
59
60impl Server {
61    pub fn new(home: PathBuf) -> Self {
62        Self { home, procs: crate::procs::Sampler::new(), session_pid: None }
63    }
64
65    /// A server that finds a durable session's shell through `session_pid`
66    /// (`PaneProcs` with a session).
67    pub fn with_sessions(home: PathBuf, session_pid: SessionPid) -> Self {
68        Self { session_pid: Some(session_pid), ..Self::new(home) }
69    }
70
71    pub fn handle(&mut self, req: Request) -> Reply {
72        match req {
73            Request::Procs { top, filter } => Reply::Procs(self.procs.frame(top, &filter)),
74            Request::PaneProcs { block_id, session } => {
75                let pid = (!session.is_empty()).then(|| self.session_pid.as_ref().and_then(|f| f(&session))).flatten();
76                Reply::PaneProcs(self.procs.pane(&block_id, pid))
77            }
78            req => handle(&self.home, req),
79        }
80    }
81}
82
83pub fn home_dir() -> PathBuf {
84    let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
85    std::env::var_os(var)
86        .filter(|h| !h.is_empty())
87        .map(PathBuf::from)
88        .unwrap_or_else(|| PathBuf::from("/"))
89}
90
91/// A request's path on this host: `~` and `~/…` under the home directory,
92/// a relative path too, an absolute one as given.
93pub fn resolve(home: &Path, path: &str) -> PathBuf {
94    let p = if path == "~" {
95        home.to_path_buf()
96    } else if let Some(rest) = path.strip_prefix("~/") {
97        home.join(rest)
98    } else if Path::new(path).is_absolute() {
99        PathBuf::from(path)
100    } else {
101        home.join(path)
102    };
103    // Rebuilt from its parts: no trailing separator, which would make the OS
104    // follow a final symlink (`~/link/` is the link's target, not the link)
105    // and act on something other than what was named.
106    p.components().collect()
107}
108
109fn err(e: io::Error) -> Reply {
110    Reply::Err {
111        kind: ErrKind::from_io(&e),
112        message: e.to_string(),
113    }
114}
115
116fn invalid(message: &str) -> Reply {
117    Reply::Err {
118        kind: ErrKind::Invalid,
119        message: message.to_string(),
120    }
121}
122
123/// The reply to one request; `home` is the user's home directory. A `Procs`
124/// here has no previous round, so its frame has no CPU rates: the serve loop
125/// answers it through [`Server`] instead.
126pub fn handle(home: &Path, req: Request) -> Reply {
127    let result = match req {
128        Request::Procs { top, filter } => Ok(Reply::Procs(crate::procs::Sampler::new().frame(top, &filter))),
129        Request::PaneProcs { block_id, .. } => Ok(Reply::PaneProcs(crate::procs::Sampler::new().pane(&block_id, None))),
130        Request::Hello => Ok(Reply::Hello {
131            protocol: fsproto::PROTOCOL,
132            home: home.to_string_lossy().into_owned(),
133            os: std::env::consts::OS.to_string(),
134        }),
135        Request::Stat { path } => {
136            let p = resolve(home, &path);
137            entry_of(&p).map(Reply::Stat)
138        }
139        Request::List {
140            path,
141            offset,
142            limit,
143        } => list(&resolve(home, &path), offset, limit),
144        Request::Read { path, offset, len } => read(&resolve(home, &path), offset, len),
145        Request::Write { path, data } => {
146            if data.len() > MAX_WRITE {
147                return Reply::Err {
148                    kind: ErrKind::TooLarge,
149                    message: format!("over the {} MB a write may be", MAX_WRITE >> 20),
150                };
151            }
152            write_atomic(&resolve(home, &path), &data).map(|()| Reply::Done)
153        }
154        Request::Append { path, data } => {
155            if data.len() > MAX_WRITE {
156                return Reply::Err {
157                    kind: ErrKind::TooLarge,
158                    message: format!("over the {} MB a write may be", MAX_WRITE >> 20),
159                };
160            }
161            let p = resolve(home, &path);
162            // Only a file already there: never makes one, never follows a
163            // link somewhere else.
164            match fs::symlink_metadata(&p) {
165                Ok(m) if m.is_file() => fs::OpenOptions::new()
166                    .append(true)
167                    .open(&p)
168                    .and_then(|mut f| f.write_all(&data))
169                    .map(|()| Reply::Done),
170                Ok(_) => return invalid("only a regular file can be appended to"),
171                Err(e) => Err(e),
172            }
173        }
174        Request::Mkdir { path, parents } => {
175            let p = resolve(home, &path);
176            if parents {
177                fs::create_dir_all(&p)
178            } else {
179                fs::create_dir(&p)
180            }
181            .map(|()| Reply::Done)
182        }
183        Request::Rename { from, to } => {
184            let (from, to) = (resolve(home, &from), resolve(home, &to));
185            if let Err(e) = fs::symlink_metadata(&from) {
186                return err(e);
187            }
188            // Never over something already there: a rename that would
189            // replace a file says so instead. The same file under another
190            // spelling (a case-only rename on a case-insensitive disk) is
191            // not something else.
192            if fs::symlink_metadata(&to).is_ok() && !same_file(&from, &to) {
193                return Reply::Err {
194                    kind: ErrKind::AlreadyExists,
195                    message: format!("{} already exists", to.display()),
196                };
197            }
198            fs::rename(&from, &to).map(|()| Reply::Done)
199        }
200        Request::Replace { from, to } => {
201            let (from, to) = (resolve(home, &from), resolve(home, &to));
202            // Files only: `rename` over a file is atomic, and a folder is
203            // never replaced this way.
204            for p in [&from, &to] {
205                match fs::symlink_metadata(p) {
206                    Ok(m) if m.is_dir() => return invalid("only a file is replaced in one step"),
207                    Ok(_) => {}
208                    Err(e) => return err(e),
209                }
210            }
211            fs::rename(&from, &to).map(|()| Reply::Done)
212        }
213        Request::Sync { path } => {
214            let p = resolve(home, &path);
215            match fs::symlink_metadata(&p) {
216                Ok(m) if m.is_file() => fs::OpenOptions::new()
217                    // Read-only on Unix (fsync needs no write access, and a read-only file
218                    // must sync too); Windows flushes only a handle it can write.
219                    .read(cfg!(unix))
220                    .write(!cfg!(unix))
221                    .open(&p)
222                    .and_then(|f| f.sync_all())
223                    .map(|()| Reply::Done),
224                Ok(_) => return invalid("only a file is synced"),
225                Err(e) => Err(e),
226            }
227        }
228        Request::Realpath { path } => fs::canonicalize(resolve(home, &path))
229            .map(|p| Reply::Path(p.to_string_lossy().into_owned())),
230        Request::SetMeta {
231            path,
232            mode,
233            mtime_ms,
234        } => {
235            let p = resolve(home, &path);
236            match fs::symlink_metadata(&p) {
237                Ok(m) if m.is_file() => set_meta(&p, mode, mtime_ms).map(|()| Reply::Done),
238                Ok(_) => return invalid("only a file's metadata is set"),
239                Err(e) => Err(e),
240            }
241        }
242        Request::Delete { path, recursive } => {
243            let p = resolve(home, &path);
244            // `..` is never needed to name a thing to delete, and it is how a
245            // path that looks like it is under home reaches home or above.
246            if p.components().any(|c| c == std::path::Component::ParentDir) {
247                return invalid("refusing to delete a path with '..' in it");
248            }
249            // What it really is, its parents' links resolved (the thing
250            // itself is not followed: a link is removed, never its target).
251            let real = match (p.parent(), p.file_name()) {
252                (Some(parent), Some(name)) => match fs::canonicalize(parent) {
253                    Ok(dir) => dir.join(name),
254                    Err(e) => return err(e),
255                },
256                _ => return invalid("refusing to delete the root"),
257            };
258            let real_home = fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
259            if real.parent().is_none() || real == real_home || real_home.starts_with(&real) {
260                return invalid(
261                    "refusing to delete the root, the home directory or a folder holding it",
262                );
263            }
264            // Everything from here on uses `real`, the path the guard checked:
265            // never `p`, whose trailing slash (`~/link/`) would make the OS
266            // follow a link the guard saw as a link.
267            match fs::symlink_metadata(&real) {
268                // A link is removed, never followed (a directory link is a
269                // directory entry on Windows).
270                Ok(m) if m.file_type().is_symlink() => {
271                    fs::remove_file(&real).or_else(|_| fs::remove_dir(&real))
272                }
273                Ok(m) if m.is_dir() => {
274                    if recursive {
275                        fs::remove_dir_all(&real)
276                    } else {
277                        fs::remove_dir(&real)
278                    }
279                }
280                Ok(_) => fs::remove_file(&real),
281                Err(e) => Err(e),
282            }
283            .map(|()| Reply::Done)
284        }
285    };
286    result.unwrap_or_else(err)
287}
288
289/// A file's permission bits (`mode`, when not 0, on Unix) and modification
290/// time (`mtime_ms`, when not 0).
291fn set_meta(p: &Path, mode: u32, mtime_ms: i64) -> io::Result<()> {
292    if mtime_ms > 0 {
293        let t = UNIX_EPOCH + std::time::Duration::from_millis(mtime_ms as u64);
294        fs::OpenOptions::new()
295            .write(true)
296            .open(p)?
297            .set_modified(t)?;
298    }
299    #[cfg(unix)]
300    if mode != 0 {
301        use std::os::unix::fs::PermissionsExt;
302        fs::set_permissions(p, fs::Permissions::from_mode(mode & 0o7777))?;
303    }
304    #[cfg(not(unix))]
305    let _ = mode;
306    Ok(())
307}
308
309fn entry_of(p: &Path) -> io::Result<Entry> {
310    let link = fs::symlink_metadata(p)?;
311    let symlink = link.file_type().is_symlink();
312    // A symlink is what it points to; a dangling one is shown as itself.
313    let meta = if symlink {
314        fs::metadata(p).unwrap_or(link)
315    } else {
316        link
317    };
318    let kind = if meta.is_dir() {
319        Kind::Dir
320    } else if meta.is_file() {
321        Kind::File
322    } else {
323        Kind::Other
324    };
325    let mtime_ms = meta
326        .modified()
327        .ok()
328        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
329        .map(|d| d.as_millis() as i64)
330        .unwrap_or(0);
331    #[cfg(unix)]
332    let mode = {
333        use std::os::unix::fs::PermissionsExt;
334        // The permission bits only, not the file type in st_mode.
335        meta.permissions().mode() & 0o7777
336    };
337    #[cfg(not(unix))]
338    let mode = 0;
339    let link_target = if symlink {
340        fs::read_link(p)
341            .map(|t| t.to_string_lossy().into_owned())
342            .unwrap_or_default()
343    } else {
344        String::new()
345    };
346    Ok(Entry {
347        link_target,
348        name: p
349            .file_name()
350            .map(|n| n.to_string_lossy().into_owned())
351            .unwrap_or_else(|| p.to_string_lossy().into_owned()),
352        kind,
353        size: meta.len(),
354        mtime_ms,
355        mode,
356        symlink,
357    })
358}
359
360/// Whether `a` and `b` are one file (both exist): the same inode on Unix,
361/// the same final path on Windows, which gives each file one spelling.
362fn same_file(a: &Path, b: &Path) -> bool {
363    #[cfg(unix)]
364    {
365        use std::os::unix::fs::MetadataExt;
366        match (fs::symlink_metadata(a), fs::symlink_metadata(b)) {
367            (Ok(x), Ok(y)) => x.dev() == y.dev() && x.ino() == y.ino(),
368            _ => false,
369        }
370    }
371    #[cfg(not(unix))]
372    {
373        match (fs::canonicalize(a), fs::canonicalize(b)) {
374            (Ok(x), Ok(y)) => x == y,
375            _ => false,
376        }
377    }
378}
379
380fn list(dir: &Path, offset: u32, limit: u32) -> io::Result<Reply> {
381    let mut names: Vec<std::ffi::OsString> = fs::read_dir(dir)?
382        .filter_map(|e| e.ok().map(|e| e.file_name()))
383        .collect();
384    names.sort();
385    let total = names.len() as u32;
386    let entries = names
387        .into_iter()
388        .skip(offset as usize)
389        .take(limit.min(5000) as usize)
390        // An entry that vanished between the listing and its stat is left out.
391        .filter_map(|n| entry_of(&dir.join(n)).ok())
392        .collect();
393    Ok(Reply::List { entries, total })
394}
395
396fn read(p: &Path, offset: u64, len: u32) -> io::Result<Reply> {
397    use std::io::{Seek, SeekFrom};
398    // Never stuck opening a pipe: whatever this opened must be a regular
399    // file. A link is read through, as the editor opens a linked dotfile
400    // (a transfer skips links before it reads).
401    #[cfg(unix)]
402    let mut f = {
403        use std::os::unix::fs::OpenOptionsExt;
404        fs::OpenOptions::new()
405            .read(true)
406            .custom_flags(libc::O_NONBLOCK)
407            .open(p)?
408    };
409    #[cfg(not(unix))]
410    let mut f = fs::File::open(p)?;
411    let opened = f.metadata()?;
412    if !opened.is_dir() && !opened.is_file() {
413        return Ok(Reply::Err {
414            kind: ErrKind::Invalid,
415            message: format!("{} isn't a regular file", p.display()),
416        });
417    }
418    if opened.is_dir() {
419        return Ok(Reply::Err {
420            kind: ErrKind::IsADirectory,
421            message: format!("{} is a directory", p.display()),
422        });
423    }
424    f.seek(SeekFrom::Start(offset))?;
425    let want = len.min(MAX_READ) as usize;
426    let mut data = Vec::with_capacity(want);
427    Read::by_ref(&mut f)
428        .take(want as u64)
429        .read_to_end(&mut data)?;
430    // At the end if this range reached it.
431    let eof = data.len() < want || f.read(&mut [0u8; 1])? == 0;
432    Ok(Reply::Read { data, eof })
433}
434
435/// Replace `p` with `data`: write a temp file beside it, sync it, give it the
436/// old file's permissions, and rename it over. A reader never sees half a
437/// file, and a failure leaves the old one as it was. A symlink is written
438/// through to its target, so the link stays a link.
439fn write_atomic(p: &Path, data: &[u8]) -> io::Result<()> {
440    static N: AtomicU64 = AtomicU64::new(0);
441    let target = match fs::symlink_metadata(p) {
442        Ok(m) if m.file_type().is_symlink() => fs::canonicalize(p)?,
443        _ => p.to_path_buf(),
444    };
445    let dir = target
446        .parent()
447        .filter(|d| !d.as_os_str().is_empty())
448        .unwrap_or(Path::new("."));
449    let name = target
450        .file_name()
451        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "not a file path"))?;
452    let tmp = dir.join(format!(
453        ".{}.amx-{}-{}.tmp",
454        name.to_string_lossy(),
455        std::process::id(),
456        N.fetch_add(1, Ordering::Relaxed)
457    ));
458    let old = fs::metadata(&target).ok();
459    if old.as_ref().is_some_and(|m| m.is_dir()) {
460        return Err(io::Error::new(
461            io::ErrorKind::InvalidInput,
462            format!("{} is a directory", target.display()),
463        ));
464    }
465    let result = (|| {
466        let mut f = fs::OpenOptions::new()
467            .write(true)
468            .create_new(true)
469            .open(&tmp)?;
470        f.write_all(data)?;
471        f.sync_all()?;
472        if let Some(m) = &old {
473            fs::set_permissions(&tmp, m.permissions())?;
474        }
475        fs::rename(&tmp, &target)
476    })();
477    if result.is_err() {
478        let _ = fs::remove_file(&tmp);
479    }
480    result
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    /// Run requests through `run` as srv would, and return the replies.
488    fn exchange(home: &Path, requests: &[Request]) -> Vec<Reply> {
489        let mut input = Vec::new();
490        for (i, r) in requests.iter().enumerate() {
491            input.extend(r.encode(i as u32));
492        }
493        let mut output = Vec::new();
494        run(home, &mut input.as_slice(), &mut output).unwrap();
495        let mut s = Splitter::new();
496        s.push(&output)
497            .unwrap()
498            .iter()
499            .enumerate()
500            .map(|(i, b)| {
501                let (id, reply) = Reply::decode(b).unwrap();
502                assert_eq!(id, i as u32, "replies in order, each with its id");
503                reply
504            })
505            .collect()
506    }
507
508    #[test]
509    fn files_are_listed_read_written_renamed_and_deleted() {
510        let home = tempfile::tempdir().unwrap();
511        let h = home.path();
512        let replies = exchange(
513            h,
514            &[
515                Request::Hello,
516                Request::Mkdir {
517                    path: "~/d/e".into(),
518                    parents: true,
519                },
520                Request::Write {
521                    path: "d/a.txt".into(),
522                    data: b"hello world".to_vec(),
523                },
524                Request::Read {
525                    path: "~/d/a.txt".into(),
526                    offset: 6,
527                    len: 100,
528                },
529                Request::Read {
530                    path: "~/d/a.txt".into(),
531                    offset: 0,
532                    len: 5,
533                },
534                Request::List {
535                    path: "~/d".into(),
536                    offset: 0,
537                    limit: 10,
538                },
539                Request::List {
540                    path: "~/d".into(),
541                    offset: 1,
542                    limit: 10,
543                },
544                Request::Rename {
545                    from: "~/d/a.txt".into(),
546                    to: "~/d/b.txt".into(),
547                },
548                Request::Stat {
549                    path: "~/d/a.txt".into(),
550                },
551                Request::Delete {
552                    path: "~/d".into(),
553                    recursive: false,
554                },
555                Request::Delete {
556                    path: "~/d".into(),
557                    recursive: true,
558                },
559                Request::Stat { path: "~/d".into() },
560            ],
561        );
562        match &replies[0] {
563            Reply::Hello {
564                protocol, home: hh, ..
565            } => {
566                assert_eq!(*protocol, fsproto::PROTOCOL);
567                assert_eq!(Path::new(hh), h);
568            }
569            r => panic!("{r:?}"),
570        }
571        assert_eq!(replies[1], Reply::Done);
572        assert_eq!(replies[2], Reply::Done);
573        assert_eq!(
574            replies[3],
575            Reply::Read {
576                data: b"world".to_vec(),
577                eof: true
578            }
579        );
580        assert_eq!(
581            replies[4],
582            Reply::Read {
583                data: b"hello".to_vec(),
584                eof: false
585            }
586        );
587        match &replies[5] {
588            Reply::List { entries, total } => {
589                assert_eq!(*total, 2);
590                let got: Vec<(&str, Kind, u64)> = entries
591                    .iter()
592                    .map(|e| (e.name.as_str(), e.kind, e.size))
593                    .collect();
594                assert_eq!(
595                    got,
596                    vec![("a.txt", Kind::File, 11), ("e", Kind::Dir, got[1].2)]
597                );
598                assert!(entries[0].mtime_ms > 0);
599            }
600            r => panic!("{r:?}"),
601        }
602        match &replies[6] {
603            Reply::List { entries, total } => {
604                assert_eq!(
605                    (*total, entries.len(), entries[0].name.as_str()),
606                    (2, 1, "e")
607                )
608            }
609            r => panic!("{r:?}"),
610        }
611        assert_eq!(replies[7], Reply::Done);
612        assert!(matches!(
613            replies[8],
614            Reply::Err {
615                kind: ErrKind::NotFound,
616                ..
617            }
618        ));
619        // Not empty, and not recursive: refused, kept.
620        assert!(matches!(replies[9], Reply::Err { .. }), "{:?}", replies[9]);
621        assert_eq!(replies[10], Reply::Done);
622        assert!(matches!(
623            replies[11],
624            Reply::Err {
625                kind: ErrKind::NotFound,
626                ..
627            }
628        ));
629    }
630
631    #[test]
632    fn a_write_replaces_whole_keeps_the_mode_and_follows_a_link() {
633        let home = tempfile::tempdir().unwrap();
634        let h = home.path();
635        let f = h.join("f.sh");
636        fs::write(&f, b"old").unwrap();
637        #[cfg(unix)]
638        {
639            use std::os::unix::fs::PermissionsExt;
640            fs::set_permissions(&f, fs::Permissions::from_mode(0o750)).unwrap();
641            std::os::unix::fs::symlink(&f, h.join("link")).unwrap();
642        }
643        let replies = exchange(
644            h,
645            &[
646                Request::Write {
647                    path: "~/f.sh".into(),
648                    data: b"new".to_vec(),
649                },
650                #[cfg(unix)]
651                Request::Write {
652                    path: "~/link".into(),
653                    data: b"via link".to_vec(),
654                },
655            ],
656        );
657        assert!(replies.iter().all(|r| *r == Reply::Done), "{replies:?}");
658        #[cfg(unix)]
659        {
660            use std::os::unix::fs::PermissionsExt;
661            assert_eq!(fs::read(&f).unwrap(), b"via link");
662            assert_eq!(
663                fs::metadata(&f).unwrap().permissions().mode() & 0o777,
664                0o750
665            );
666            assert!(fs::symlink_metadata(h.join("link"))
667                .unwrap()
668                .file_type()
669                .is_symlink());
670        }
671        #[cfg(not(unix))]
672        assert_eq!(fs::read(&f).unwrap(), b"new");
673        // No temp file left behind.
674        let left: Vec<_> = fs::read_dir(h)
675            .unwrap()
676            .filter_map(|e| e.ok())
677            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
678            .collect();
679        assert!(left.is_empty());
680    }
681
682    #[test]
683    fn a_case_only_rename_is_not_refused_as_a_clash() {
684        let home = tempfile::tempdir().unwrap();
685        let h = home.path();
686        fs::write(h.join("a.txt"), b"a").unwrap();
687        let case_insensitive = h.join("A.TXT").exists();
688        let r = exchange(
689            h,
690            &[Request::Rename {
691                from: "a.txt".into(),
692                to: "A.txt".into(),
693            }],
694        );
695        assert_eq!(
696            r[0],
697            Reply::Done,
698            "case-insensitive disk: {case_insensitive}"
699        );
700        let names: Vec<String> = fs::read_dir(h)
701            .unwrap()
702            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
703            .collect();
704        assert_eq!(names, vec!["A.txt"]);
705    }
706
707    #[test]
708    fn a_path_never_ends_in_a_separator() {
709        let h = Path::new("/home/u");
710        assert_eq!(resolve(h, "~/link/"), Path::new("/home/u/link"));
711        assert_eq!(resolve(h, "/srv/data//"), Path::new("/srv/data"));
712        assert_eq!(resolve(h, "rel/"), Path::new("/home/u/rel"));
713        assert_eq!(resolve(h, "~"), h);
714    }
715
716    #[test]
717    fn append_adds_to_an_existing_file_only() {
718        let home = tempfile::tempdir().unwrap();
719        let h = home.path();
720        let r = exchange(
721            h,
722            &[
723                Request::Write {
724                    path: "~/up".into(),
725                    data: b"ab".to_vec(),
726                },
727                Request::Append {
728                    path: "~/up".into(),
729                    data: b"cd".to_vec(),
730                },
731                Request::Append {
732                    path: "~/missing".into(),
733                    data: b"x".to_vec(),
734                },
735                Request::Append {
736                    path: "~".into(),
737                    data: b"x".to_vec(),
738                },
739            ],
740        );
741        assert_eq!((&r[0], &r[1]), (&Reply::Done, &Reply::Done));
742        assert!(
743            matches!(
744                r[2],
745                Reply::Err {
746                    kind: ErrKind::NotFound,
747                    ..
748                }
749            ),
750            "{:?}",
751            r[2]
752        );
753        assert!(
754            matches!(
755                r[3],
756                Reply::Err {
757                    kind: ErrKind::Invalid,
758                    ..
759                }
760            ),
761            "{:?}",
762            r[3]
763        );
764        assert_eq!(fs::read(h.join("up")).unwrap(), b"abcd");
765        assert!(!h.join("missing").exists());
766    }
767
768    #[test]
769    fn replace_realpath_and_set_meta() {
770        let home = tempfile::tempdir().unwrap();
771        let h = home.path();
772        fs::write(h.join("new"), b"new").unwrap();
773        fs::write(h.join("old"), b"old").unwrap();
774        fs::create_dir(h.join("d")).unwrap();
775        let r = exchange(
776            h,
777            &[
778                Request::Replace {
779                    from: "~/new".into(),
780                    to: "~/old".into(),
781                },
782                Request::Replace {
783                    from: "~/old".into(),
784                    to: "~/d".into(),
785                },
786                Request::Realpath {
787                    path: "~/d/../old".into(),
788                },
789                Request::SetMeta {
790                    path: "~/old".into(),
791                    mode: 0o751,
792                    mtime_ms: 1_600_000_000_000,
793                },
794                Request::SetMeta {
795                    path: "~/d".into(),
796                    mode: 0o700,
797                    mtime_ms: 0,
798                },
799            ],
800        );
801        assert_eq!(r[0], Reply::Done);
802        assert_eq!(fs::read(h.join("old")).unwrap(), b"new");
803        assert!(!h.join("new").exists());
804        assert!(
805            matches!(
806                r[1],
807                Reply::Err {
808                    kind: ErrKind::Invalid,
809                    ..
810                }
811            ),
812            "{:?}",
813            r[1]
814        );
815        match &r[2] {
816            Reply::Path(p) => assert_eq!(Path::new(p), fs::canonicalize(h.join("old")).unwrap()),
817            other => panic!("{other:?}"),
818        }
819        assert_eq!(r[3], Reply::Done);
820        let e = entry_of(&h.join("old")).unwrap();
821        assert_eq!(e.mtime_ms, 1_600_000_000_000);
822        #[cfg(unix)]
823        assert_eq!(e.mode, 0o751);
824        assert!(matches!(
825            r[4],
826            Reply::Err {
827                kind: ErrKind::Invalid,
828                ..
829            }
830        ));
831    }
832
833    /// A pipe is refused at once (never a read that waits for a writer); a
834    /// link is read through.
835    #[cfg(unix)]
836    #[test]
837    fn a_read_takes_only_a_regular_file() {
838        let home = tempfile::tempdir().unwrap();
839        let h = home.path();
840        assert!(std::process::Command::new("mkfifo")
841            .arg(h.join("pipe"))
842            .status()
843            .unwrap()
844            .success());
845        fs::write(h.join("real"), b"r").unwrap();
846        std::os::unix::fs::symlink(h.join("real"), h.join("link")).unwrap();
847        let r = exchange(
848            h,
849            &[
850                Request::Read {
851                    path: "~/pipe".into(),
852                    offset: 0,
853                    len: 10,
854                },
855                Request::Read {
856                    path: "~/link".into(),
857                    offset: 0,
858                    len: 10,
859                },
860                Request::Sync {
861                    path: "~/real".into(),
862                },
863            ],
864        );
865        assert!(
866            matches!(
867                r[0],
868                Reply::Err {
869                    kind: ErrKind::Invalid,
870                    ..
871                }
872            ),
873            "{:?}",
874            r[0]
875        );
876        assert_eq!(
877            r[1],
878            Reply::Read {
879                data: b"r".to_vec(),
880                eof: true
881            },
882            "a link is read through"
883        );
884        assert_eq!(r[2], Reply::Done);
885        // A read-only file syncs too.
886        use std::os::unix::fs::PermissionsExt;
887        fs::set_permissions(h.join("real"), fs::Permissions::from_mode(0o444)).unwrap();
888        let r = exchange(
889            h,
890            &[Request::Sync {
891                path: "~/real".into(),
892            }],
893        );
894        assert_eq!(r[0], Reply::Done);
895    }
896
897    #[test]
898    fn modes_are_permission_bits_only() {
899        let home = tempfile::tempdir().unwrap();
900        fs::write(home.path().join("f"), b"").unwrap();
901        let e = entry_of(&home.path().join("f")).unwrap();
902        assert_eq!(e.mode & !0o7777, 0, "{:o}", e.mode);
903    }
904
905    #[test]
906    fn it_refuses_what_would_destroy_more_than_asked() {
907        let home = tempfile::tempdir().unwrap();
908        let h = home.path();
909        fs::write(h.join("a"), b"a").unwrap();
910        fs::write(h.join("b"), b"b").unwrap();
911        fs::create_dir(h.join("x")).unwrap();
912        #[cfg(unix)]
913        {
914            std::os::unix::fs::symlink(h, h.join("to-home")).unwrap();
915            std::os::unix::fs::symlink(h, h.join("to-home2")).unwrap();
916        }
917        let replies = exchange(
918            h,
919            &[
920                Request::Rename {
921                    from: "a".into(),
922                    to: "b".into(),
923                },
924                Request::Delete {
925                    path: "~".into(),
926                    recursive: true,
927                },
928                Request::Delete {
929                    path: "/".into(),
930                    recursive: true,
931                },
932                Request::Read {
933                    path: "~".into(),
934                    offset: 0,
935                    len: 10,
936                },
937                // Home, or above it, spelled another way.
938                Request::Delete {
939                    path: "~/x/..".into(),
940                    recursive: true,
941                },
942                Request::Delete {
943                    path: "~/..".into(),
944                    recursive: true,
945                },
946                Request::Delete {
947                    path: h.join("x/..").to_string_lossy().into_owned(),
948                    recursive: true,
949                },
950                Request::Rename {
951                    from: "missing".into(),
952                    to: "b".into(),
953                },
954            ],
955        );
956        assert!(matches!(
957            replies[0],
958            Reply::Err {
959                kind: ErrKind::AlreadyExists,
960                ..
961            }
962        ));
963        for r in [
964            &replies[1],
965            &replies[2],
966            &replies[4],
967            &replies[5],
968            &replies[6],
969        ] {
970            assert!(
971                matches!(
972                    r,
973                    Reply::Err {
974                        kind: ErrKind::Invalid,
975                        ..
976                    }
977                ),
978                "{r:?}"
979            );
980        }
981        assert!(matches!(replies[3], Reply::Err { .. }));
982        assert!(matches!(
983            replies[7],
984            Reply::Err {
985                kind: ErrKind::NotFound,
986                ..
987            }
988        ));
989        assert_eq!(fs::read(h.join("b")).unwrap(), b"b");
990        assert!(h.join("x").exists());
991
992        // Through a link to home: the link goes, home stays.
993        #[cfg(unix)]
994        {
995            let r = exchange(
996                h,
997                &[Request::Delete {
998                    path: "~/to-home".into(),
999                    recursive: true,
1000                }],
1001            );
1002            assert_eq!(r[0], Reply::Done);
1003            assert!(h.join("a").exists() && !h.join("to-home").exists());
1004            // A trailing slash would make the OS follow the link: still only
1005            // the link goes.
1006            let r = exchange(
1007                h,
1008                &[Request::Delete {
1009                    path: "~/to-home2/".into(),
1010                    recursive: true,
1011                }],
1012            );
1013            assert_eq!(r[0], Reply::Done);
1014            assert!(h.join("a").exists() && h.join("x").exists() && !h.join("to-home2").exists());
1015        }
1016    }
1017}