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