Skip to main content

ssh_browser/fs/
sftp.rs

1//! Concurrent SFTP access over a single stream.
2//!
3//! A Mutex around the stream would serialise every HTTP handler, turning a page's
4//! N parallel subresource fetches back into N round trips — the exact failure this
5//! exists to avoid. Instead two tasks own the stream and replies are demultiplexed
6//! by request id, so any number of callers share one connection and their requests
7//! coalesce into one flush.
8
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12use std::time::Duration;
13
14use anyhow::{Context, Result, anyhow, ensure};
15use tokio::io::{AsyncRead, AsyncWrite};
16use tokio::sync::{mpsc, oneshot};
17
18use super::{Entry, RangeReq, Refused, RemoteFs};
19use crate::sftp::transport::{self, SshChild};
20use crate::sftp::wire::{
21    Attrs, CLOSE, DATA, Dec, Enc, FXF_READ, HANDLE, NAME, OPEN, OPENDIR, READ, READDIR, REALPATH,
22    STATUS, STATUS_EOF,
23};
24use crate::sftp::{Reply, Rx, Sftp, Tx};
25
26const QUEUE_DEPTH: usize = 1024;
27const MAX_BATCH: usize = 256;
28const READ_CHUNK: u32 = 32 * 1024;
29
30/// How long the writer waits for sibling callers before committing to a flush.
31/// Against a 16 ms RTT this costs roughly 1%, and it is what collapses N
32/// concurrent handler calls into one round trip even when they did not arrive
33/// together through `read_batch`.
34const COALESCE: Duration = Duration::from_micros(200);
35
36struct Job {
37    kind: u8,
38    /// Request body without the leading id; the writer owns id allocation.
39    body: Vec<u8>,
40    reply: oneshot::Sender<Reply>,
41}
42
43type Pending = Arc<Mutex<HashMap<u32, oneshot::Sender<Reply>>>>;
44
45pub struct SftpFs {
46    jobs: mpsc::Sender<Job>,
47    round_trips: Arc<AtomicU64>,
48    /// Dropping this kills ssh, which closes both pipes and fails pending callers.
49    _child: Option<SshChild>,
50}
51
52impl SftpFs {
53    pub async fn connect(host: &str) -> Result<Self> {
54        let (child, w, r) = transport::open(host)?;
55        let sftp = Sftp::handshake(w, r).await?;
56        Ok(Self::drive(sftp, Some(child)))
57    }
58
59    /// Drive a session over arbitrary streams. Exists so the round-trip invariant
60    /// can be asserted against an in-memory server, with no ssh anywhere.
61    pub async fn over<W, R>(w: W, r: R) -> Result<Self>
62    where
63        W: AsyncWrite + Unpin + Send + 'static,
64        R: AsyncRead + Unpin + Send + 'static,
65    {
66        let sftp = Sftp::handshake(w, r).await?;
67        Ok(Self::drive(sftp, None))
68    }
69
70    fn drive<W, R>(sftp: Sftp<W, R>, child: Option<SshChild>) -> Self
71    where
72        W: AsyncWrite + Unpin + Send + 'static,
73        R: AsyncRead + Unpin + Send + 'static,
74    {
75        let (tx, rx) = sftp.into_halves();
76        let (jobs, job_rx) = mpsc::channel(QUEUE_DEPTH);
77        let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
78        let round_trips = Arc::new(AtomicU64::new(0));
79
80        tokio::spawn(writer(
81            tx,
82            job_rx,
83            Arc::clone(&pending),
84            Arc::clone(&round_trips),
85        ));
86        tokio::spawn(reader(rx, pending));
87
88        Self {
89            jobs,
90            round_trips,
91            _child: child,
92        }
93    }
94
95    /// Hand a request to the writer without awaiting its reply.
96    async fn issue(&self, kind: u8, body: Vec<u8>) -> Result<oneshot::Receiver<Reply>> {
97        let (reply, rx) = oneshot::channel();
98        self.jobs
99            .send(Job { kind, body, reply })
100            .await
101            .map_err(|_| anyhow!("sftp session is gone"))?;
102        Ok(rx)
103    }
104}
105
106async fn await_reply(rx: oneshot::Receiver<Reply>) -> Result<Reply> {
107    rx.await
108        .map_err(|_| anyhow!("sftp session closed before replying"))
109}
110
111/// Decode one SSH_FXP_NAME page.
112fn decode_names(payload: &[u8]) -> Result<Vec<Entry>> {
113    let mut d = Dec::new(payload);
114    let count = d.u32().context("readdir count")?;
115    // A count is a length prefix from the far end, so it is not trusted enough to
116    // size an allocation with.
117    ensure!(count <= 1 << 16, "implausible readdir count {count}");
118    let mut out = Vec::with_capacity(count as usize);
119    for _ in 0..count {
120        let name = String::from_utf8_lossy(d.str().context("filename")?).into_owned();
121        // Read and discarded: the field is on the wire whether or not anything wants it,
122        // and a decoder that skipped it would read the attrs from the wrong offset.
123        d.str().context("longname")?;
124        let attrs = Attrs::decode(&mut d).context("attrs")?;
125        out.push(Entry { name, attrs });
126    }
127    Ok(out)
128}
129
130/// The handle from an OPEN or OPENDIR reply, or an error carrying why the remote said no.
131///
132/// The status code is decoded rather than dropped. It is the only thing separating "there is
133/// no such path" — the ordinary answer for a link a page got wrong — from a permission problem
134/// or a session that has gone away. A caller handed one undifferentiated error has to guess,
135/// and the guess that looks safe turns every remote failure into an empty page.
136fn handle_from(r: &Reply, what: &str) -> Result<Vec<u8>> {
137    if r.kind != HANDLE {
138        let why = match Dec::new(r.payload()).u32() {
139            Some(status) => anyhow::Error::new(Refused { status }),
140            // A reply that is neither a handle nor a readable status. Still an error, just
141            // one the remote did not explain.
142            None => anyhow!("unreadable reply (type {})", r.kind),
143        };
144        return Err(why.context(format!("{what} refused")));
145    }
146    Ok(Dec::new(r.payload()).str().context("handle")?.to_vec())
147}
148
149impl RemoteFs for SftpFs {
150    async fn read_batch(&self, paths: &[String]) -> Vec<Result<Vec<u8>>> {
151        // Every open is issued before any reply is awaited. That ordering is the
152        // whole mechanism; awaiting inside this loop would cost paths.len() round
153        // trips instead of one.
154        let mut opens = Vec::with_capacity(paths.len());
155        for p in paths {
156            opens.push(
157                self.issue(
158                    OPEN,
159                    Enc::new().str(p.as_bytes()).u32(FXF_READ).u32(0).done(),
160                )
161                .await,
162            );
163        }
164
165        let mut handles: Vec<Option<Vec<u8>>> = Vec::with_capacity(paths.len());
166        let mut out: Vec<Result<Vec<u8>>> = Vec::with_capacity(paths.len());
167        for (rx, path) in opens.into_iter().zip(paths) {
168            let opened = match rx {
169                Ok(rx) => await_reply(rx).await.and_then(|r| handle_from(&r, path)),
170                Err(e) => Err(e),
171            };
172            match opened {
173                Ok(h) => {
174                    handles.push(Some(h));
175                    out.push(Ok(Vec::new()));
176                }
177                Err(e) => {
178                    handles.push(None);
179                    out.push(Err(e));
180                }
181            }
182        }
183
184        // Chunk index k for every still-live file goes out together, so this loop
185        // costs one round trip per chunk index rather than one per file.
186        let mut live: Vec<usize> = (0..paths.len()).filter(|&i| handles[i].is_some()).collect();
187        while !live.is_empty() {
188            let mut rxs = Vec::with_capacity(live.len());
189            for &i in &live {
190                let handle = handles[i].as_ref().expect("live implies a handle");
191                let offset = out[i].as_ref().map_or(0, Vec::len) as u64;
192                rxs.push(
193                    self.issue(
194                        READ,
195                        Enc::new().str(handle).u64(offset).u32(READ_CHUNK).done(),
196                    )
197                    .await,
198                );
199            }
200
201            let mut still_live = Vec::new();
202            for (&i, rx) in live.iter().zip(rxs) {
203                let chunk = match rx {
204                    Ok(rx) => await_reply(rx).await,
205                    Err(e) => Err(e),
206                };
207                match chunk {
208                    Ok(r) if r.kind == DATA => {
209                        let data = Dec::new(r.payload()).str().unwrap_or(&[]).to_vec();
210                        let full = data.len() as u32 == READ_CHUNK;
211                        if let Ok(buf) = &mut out[i] {
212                            buf.extend_from_slice(&data);
213                        }
214                        if full {
215                            still_live.push(i);
216                        }
217                    }
218                    // A STATUS is EOF only when it says so. Treating every
219                    // STATUS as end-of-file hands back an empty success for a
220                    // directory, whose open succeeds and whose read fails --
221                    // exactly the silent success invariant 4 forbids.
222                    Ok(r) if r.kind == STATUS => {
223                        let code = Dec::new(r.payload()).u32().unwrap_or(u32::MAX);
224                        if code != STATUS_EOF {
225                            out[i] = Err(anyhow!("read failed with sftp status {code}"));
226                        }
227                    }
228                    Ok(r) => out[i] = Err(anyhow!("read gave reply type {}", r.kind)),
229                    Err(e) => out[i] = Err(e),
230                }
231            }
232            live = still_live;
233        }
234
235        for handle in handles.iter().flatten() {
236            let _ = self.issue(CLOSE, Enc::new().str(handle).done()).await;
237        }
238        out
239    }
240
241    async fn read_ranges(&self, reqs: &[RangeReq]) -> Vec<Result<Vec<u8>>> {
242        let mut opens = Vec::with_capacity(reqs.len());
243        for r in reqs {
244            opens.push(
245                self.issue(
246                    OPEN,
247                    Enc::new()
248                        .str(r.path.as_bytes())
249                        .u32(FXF_READ)
250                        .u32(0)
251                        .done(),
252                )
253                .await,
254            );
255        }
256
257        let mut handles: Vec<Option<Vec<u8>>> = Vec::with_capacity(reqs.len());
258        let mut out: Vec<Result<Vec<u8>>> = Vec::with_capacity(reqs.len());
259        for (rx, r) in opens.into_iter().zip(reqs) {
260            let opened = match rx {
261                Ok(rx) => await_reply(rx)
262                    .await
263                    .and_then(|reply| handle_from(&reply, &r.path)),
264                Err(e) => Err(e),
265            };
266            match opened {
267                Ok(h) => {
268                    handles.push(Some(h));
269                    out.push(Ok(Vec::new()));
270                }
271                Err(e) => {
272                    handles.push(None);
273                    out.push(Err(e));
274                }
275            }
276        }
277
278        // Chunk every range up front and issue the whole set at once. A one-megabyte
279        // range is thirty-two reads; sending them one at a time would cost
280        // thirty-two round trips and put the invariant back where it started.
281        struct Piece {
282            req: usize,
283            offset: u64,
284            len: u32,
285        }
286        let mut pieces = Vec::new();
287        for (i, r) in reqs.iter().enumerate() {
288            if handles[i].is_none() {
289                continue;
290            }
291            let mut at = r.offset;
292            let end = r.offset.saturating_add(r.len);
293            while at < end {
294                let len =
295                    u32::try_from((end - at).min(u64::from(READ_CHUNK))).unwrap_or(READ_CHUNK);
296                pieces.push(Piece {
297                    req: i,
298                    offset: at,
299                    len,
300                });
301                at += u64::from(len);
302            }
303        }
304
305        let mut rxs = Vec::with_capacity(pieces.len());
306        for p in &pieces {
307            let handle = handles[p.req].as_ref().expect("pieces skip failed opens");
308            rxs.push(
309                self.issue(READ, Enc::new().str(handle).u64(p.offset).u32(p.len).done())
310                    .await,
311            );
312        }
313
314        // Replies are reassembled in issue order, which is offset order within each
315        // request, so a short read at end of file simply ends that request's data.
316        for (p, rx) in pieces.iter().zip(rxs) {
317            let reply = match rx {
318                Ok(rx) => await_reply(rx).await,
319                Err(e) => Err(e),
320            };
321            match reply {
322                Ok(r) if r.kind == DATA => {
323                    let data = Dec::new(r.payload()).str().unwrap_or(&[]).to_vec();
324                    if let Ok(buf) = &mut out[p.req] {
325                        buf.extend_from_slice(&data);
326                    }
327                }
328                // EOF inside a requested range is not a failure: the file is simply
329                // shorter than the client asked for, and the caller sees that in the
330                // length of what comes back.
331                Ok(r) if r.kind == STATUS => {
332                    let code = Dec::new(r.payload()).u32().unwrap_or(u32::MAX);
333                    if code != STATUS_EOF {
334                        out[p.req] = Err(anyhow!("read failed with sftp status {code}"));
335                    }
336                }
337                Ok(r) => out[p.req] = Err(anyhow!("read gave reply type {}", r.kind)),
338                Err(e) => out[p.req] = Err(e),
339            }
340        }
341
342        for handle in handles.iter().flatten() {
343            let _ = self.issue(CLOSE, Enc::new().str(handle).done()).await;
344        }
345        out
346    }
347
348    async fn home(&self) -> Result<String> {
349        // REALPATH of "." rather than of "~". A tilde is shell syntax, and the sftp
350        // subsystem is not a shell: OpenSSH's own client expands it in the client, so a
351        // server handed a literal "~" answers about a directory of that name. "." is the
352        // session's starting directory, which is the home of the account ssh authenticated
353        // as — the thing actually being asked for.
354        let rx = self.issue(REALPATH, Enc::new().str(b".").done()).await?;
355        let reply = await_reply(rx).await?;
356        ensure!(
357            reply.kind == NAME,
358            "realpath answered {} rather than a name",
359            reply.kind
360        );
361        let mut d = Dec::new(reply.payload());
362        // v3 sends this as a one-entry listing. Servers agree on the count being 1, but
363        // the field is read rather than assumed, because skipping it would read the
364        // length prefix as a filename on any server that disagreed.
365        let count = d.u32().context("realpath count")?;
366        ensure!(count >= 1, "realpath answered with no name");
367        let path = String::from_utf8(d.str().context("realpath name")?.to_vec())
368            .context("home directory path is not utf-8")?;
369        // Refused rather than patched up. Everything downstream joins onto this and the
370        // guards all assume an absolute base, so a relative answer would produce paths
371        // that look fine and address nothing.
372        ensure!(
373            path.starts_with('/'),
374            "realpath answered {path:?}, which is not an absolute path"
375        );
376        Ok(path)
377    }
378
379    async fn list_dirs(&self, paths: &[String]) -> Vec<Result<Vec<Entry>>> {
380        // Every opendir goes out before any reply is awaited, for the same reason
381        // read_batch does it. A symlink check walks a whole path, and one round
382        // trip per component would put that walk back inside the per-request
383        // budget the origin layer cannot afford.
384        let mut opens = Vec::with_capacity(paths.len());
385        for p in paths {
386            opens.push(
387                self.issue(OPENDIR, Enc::new().str(p.as_bytes()).done())
388                    .await,
389            );
390        }
391
392        let mut handles: Vec<Option<Vec<u8>>> = Vec::with_capacity(paths.len());
393        let mut out: Vec<Result<Vec<Entry>>> = Vec::with_capacity(paths.len());
394        for (rx, path) in opens.into_iter().zip(paths) {
395            let opened = match rx {
396                Ok(rx) => await_reply(rx).await.and_then(|r| handle_from(&r, path)),
397                Err(e) => Err(e),
398            };
399            match opened {
400                Ok(h) => {
401                    handles.push(Some(h));
402                    out.push(Ok(Vec::new()));
403                }
404                Err(e) => {
405                    handles.push(None);
406                    out.push(Err(e));
407                }
408            }
409        }
410
411        // A readdir returns one page at a time, so page k for every still-open
412        // directory is issued together: one round trip per page index rather than
413        // one per directory.
414        let mut live: Vec<usize> = (0..paths.len()).filter(|&i| handles[i].is_some()).collect();
415        while !live.is_empty() {
416            let mut rxs = Vec::with_capacity(live.len());
417            for &i in &live {
418                let handle = handles[i].as_ref().expect("live implies a handle");
419                rxs.push(self.issue(READDIR, Enc::new().str(handle).done()).await);
420            }
421
422            let mut still_live = Vec::new();
423            for (&i, rx) in live.iter().zip(rxs) {
424                let page = match rx {
425                    Ok(rx) => await_reply(rx).await,
426                    Err(e) => Err(e),
427                };
428                match page {
429                    Ok(r) if r.kind == NAME => match decode_names(r.payload()) {
430                        Ok(entries) => {
431                            if let Ok(acc) = &mut out[i] {
432                                acc.extend(entries);
433                            }
434                            still_live.push(i);
435                        }
436                        Err(e) => out[i] = Err(e),
437                    },
438                    // A STATUS ends the listing only when it says EOF. Accepting
439                    // any status as the end returns a short listing as a success,
440                    // which is the same silent success read_batch had: a directory
441                    // we were refused would read as an empty directory.
442                    Ok(r) if r.kind == STATUS => {
443                        let code = Dec::new(r.payload()).u32().unwrap_or(u32::MAX);
444                        if code != STATUS_EOF {
445                            out[i] = Err(anyhow!("readdir failed with sftp status {code}"));
446                        }
447                    }
448                    Ok(r) => out[i] = Err(anyhow!("readdir gave reply type {}", r.kind)),
449                    Err(e) => out[i] = Err(e),
450                }
451            }
452            live = still_live;
453        }
454
455        for handle in handles.iter().flatten() {
456            let _ = self.issue(CLOSE, Enc::new().str(handle).done()).await;
457        }
458        out
459    }
460
461    fn round_trips(&self) -> u64 {
462        self.round_trips.load(Ordering::Relaxed)
463    }
464}
465
466async fn writer<W: AsyncWrite + Unpin>(
467    mut tx: Tx<W>,
468    mut jobs: mpsc::Receiver<Job>,
469    pending: Pending,
470    round_trips: Arc<AtomicU64>,
471) {
472    let mut batch: Vec<Job> = Vec::with_capacity(MAX_BATCH);
473    loop {
474        if jobs.recv_many(&mut batch, MAX_BATCH).await == 0 {
475            return;
476        }
477        if batch.len() < MAX_BATCH {
478            tokio::time::sleep(COALESCE).await;
479            while batch.len() < MAX_BATCH {
480                match jobs.try_recv() {
481                    Ok(job) => batch.push(job),
482                    Err(_) => break,
483                }
484            }
485        }
486
487        for job in batch.drain(..) {
488            let id = tx.alloc_id();
489            let mut payload = Vec::with_capacity(4 + job.body.len());
490            payload.extend_from_slice(&id.to_be_bytes());
491            payload.extend_from_slice(&job.body);
492            // Registered before the write, because the reply can land the instant
493            // we flush.
494            pending
495                .lock()
496                .expect("pending map poisoned")
497                .insert(id, job.reply);
498            if tx.queue(job.kind, &payload).await.is_err() {
499                return;
500            }
501        }
502
503        if tx.flush().await.is_err() {
504            return;
505        }
506        round_trips.fetch_add(1, Ordering::Relaxed);
507    }
508}
509
510async fn reader<R: AsyncRead + Unpin>(mut rx: Rx<R>, pending: Pending) {
511    while let Ok(reply) = rx.recv().await {
512        let waiter = pending
513            .lock()
514            .expect("pending map poisoned")
515            .remove(&reply.id);
516        if let Some(waiter) = waiter {
517            let _ = waiter.send(reply);
518        }
519    }
520    // The stream is finished. Dropping the senders makes every awaiting caller
521    // fail, rather than hang forever on a reply that can no longer arrive.
522    pending.lock().expect("pending map poisoned").clear();
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::sftp::wire::{INIT, VERSION};
529    use crate::sftp::{read_frame, write_frame};
530    use tokio::io::AsyncWriteExt;
531
532    /// Just enough sftp server to answer the calls `read_batch` makes. Every file
533    /// has the same body.
534    async fn fake_server<R, W>(mut r: R, mut w: W, body: Vec<u8>, fail_read: bool)
535    where
536        R: AsyncRead + Unpin,
537        W: AsyncWrite + Unpin,
538    {
539        let (kind, _) = read_frame(&mut r).await.expect("init frame");
540        assert_eq!(kind, INIT);
541        write_frame(&mut w, VERSION, &Enc::new().u32(3).done())
542            .await
543            .expect("version");
544        w.flush().await.expect("flush version");
545
546        while let Ok((kind, payload)) = read_frame(&mut r).await {
547            let mut d = Dec::new(&payload);
548            let id = d.u32().expect("request id");
549            let (out_kind, out) = match kind {
550                OPEN => (HANDLE, Enc::new().u32(id).str(b"h").done()),
551                READ => {
552                    d.str().expect("handle");
553                    let offset = d.u64().expect("offset") as usize;
554                    if fail_read {
555                        // SSH_FX_FAILURE, which is what reading a directory gives.
556                        (
557                            STATUS,
558                            Enc::new()
559                                .u32(id)
560                                .u32(4)
561                                .str(b"is a directory")
562                                .str(b"")
563                                .done(),
564                        )
565                    } else if offset >= body.len() {
566                        (
567                            STATUS,
568                            Enc::new().u32(id).u32(1).str(b"eof").str(b"").done(),
569                        )
570                    } else {
571                        (DATA, Enc::new().u32(id).str(&body[offset..]).done())
572                    }
573                }
574                CLOSE => (STATUS, Enc::new().u32(id).u32(0).str(b"ok").str(b"").done()),
575                other => panic!("fake server got unexpected request type {other}"),
576            };
577            write_frame(&mut w, out_kind, &out).await.expect("reply");
578            w.flush().await.expect("flush reply");
579        }
580    }
581
582    /// Invariant 1, with no network involved: forty files must not cost forty round
583    /// trips. This is the test that fails if anyone ever "simplifies" read_batch
584    /// into a loop that awaits each open.
585    #[tokio::test]
586    async fn forty_reads_cost_a_constant_number_of_round_trips() {
587        let (client, server) = tokio::io::duplex(1 << 20);
588        let (cr, cw) = tokio::io::split(client);
589        let (sr, sw) = tokio::io::split(server);
590        tokio::spawn(fake_server(sr, sw, b"hello".to_vec(), false));
591
592        let fs = SftpFs::over(cw, cr).await.expect("handshake");
593        let paths: Vec<String> = (0..40).map(|i| format!("/f{i}")).collect();
594        let out = tokio::time::timeout(Duration::from_secs(10), fs.read_batch(&paths))
595            .await
596            .expect("read_batch should not hang");
597
598        assert_eq!(out.len(), 40);
599        for r in &out {
600            assert_eq!(r.as_ref().expect("read succeeded").as_slice(), b"hello");
601        }
602
603        // One flush for the opens, one for the reads, one for the closes. The
604        // number that must not move is that it does not scale with 40.
605        let trips = fs.round_trips();
606        assert!(trips <= 6, "forty files cost {trips} round trips");
607    }
608
609    /// Regression: found on a real host, not in a test. A directory's open
610    /// succeeds and its read fails, and treating that STATUS as EOF returned an
611    /// empty 200 instead of letting the caller fall through to a listing.
612    #[tokio::test]
613    async fn a_failed_read_is_not_reported_as_an_empty_success() {
614        let (client, server) = tokio::io::duplex(1 << 16);
615        let (cr, cw) = tokio::io::split(client);
616        let (sr, sw) = tokio::io::split(server);
617        tokio::spawn(fake_server(sr, sw, b"unused".to_vec(), true));
618
619        let fs = SftpFs::over(cw, cr).await.expect("handshake");
620        let out = tokio::time::timeout(
621            Duration::from_secs(10),
622            fs.read_batch(&["/a-directory".to_string()]),
623        )
624        .await
625        .expect("read_batch should not hang");
626
627        assert!(
628            out[0].is_err(),
629            "a read that failed must not look like an empty file"
630        );
631    }
632
633    /// Invariant 4: a session that dies must surface as an error. Hanging forever
634    /// on a reply that can never arrive is the worst failure available.
635    #[tokio::test]
636    async fn a_dead_session_fails_callers_instead_of_hanging() {
637        let (client, server) = tokio::io::duplex(1 << 16);
638        let (cr, cw) = tokio::io::split(client);
639        let (mut sr, mut sw) = tokio::io::split(server);
640        tokio::spawn(async move {
641            read_frame(&mut sr).await.expect("init frame");
642            write_frame(&mut sw, VERSION, &Enc::new().u32(3).done())
643                .await
644                .expect("version");
645            sw.flush().await.expect("flush version");
646            // Then vanish, mid-conversation.
647        });
648
649        let fs = SftpFs::over(cw, cr).await.expect("handshake");
650        let out = tokio::time::timeout(Duration::from_secs(10), fs.read_batch(&["/a".to_string()]))
651            .await
652            .expect("a dead session must not hang the caller");
653
654        assert!(out[0].is_err(), "a closed session must surface as an error");
655    }
656
657    /// The one question about the remote that no local computation can answer.
658    ///
659    /// The path is deliberately not a plausible home. A test asserting `/home/<name>`
660    /// would pass against an implementation that built the string locally instead of
661    /// asking, which is the exact mistake this method exists to avoid.
662    #[tokio::test]
663    async fn the_home_directory_is_whatever_the_remote_says_it_is() {
664        let fs = crate::testing::FakeRemote::new()
665            .home("/export/scratch/u42")
666            .spawn()
667            .await;
668        assert_eq!(fs.home().await.expect("a home"), "/export/scratch/u42");
669    }
670
671    /// Nothing downstream can work from a guess here: every path the origin serves is
672    /// joined onto this, so a wrong answer is a whole alias pointing at the wrong tree.
673    #[tokio::test]
674    async fn a_remote_that_will_not_say_where_home_is_fails_rather_than_guessing() {
675        let fs = crate::testing::FakeRemote::new().spawn().await;
676        assert!(
677            fs.home().await.is_err(),
678            "a refusal must not turn into a default path"
679        );
680    }
681}