ssh_browser/fs/mod.rs
1//! Filesystem access shaped so that latency optimisation survives a backend swap.
2//!
3//! The operations are batch-first on purpose. A per-path interface — `stat(p)`,
4//! `read(p)` — forces O(N) round trips no matter which backend implements it, so
5//! the abstraction would defeat the invariant it sits beneath. A single read is
6//! the n=1 case of `read_batch`, not the other way round.
7
8use anyhow::{Result, anyhow};
9
10use crate::sftp::wire::{Attrs, STATUS_NO_SUCH_FILE};
11
12pub mod sftp;
13
14/// A refusal from the remote, carrying the reason it gave.
15///
16/// The reason has to survive the trip. Without it every failed listing looks alike, and a
17/// caller that wants to treat "there is no such directory" as an ordinary empty answer ends
18/// up treating a dead session and a permission problem that way too — which is how a remote
19/// that has stopped answering comes to render as a directory with nothing in it.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Refused {
22 pub status: u32,
23}
24
25impl std::fmt::Display for Refused {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 let name = match self.status {
28 1 => "end of file",
29 2 => "no such file",
30 3 => "permission denied",
31 4 => "failure",
32 5 => "bad message",
33 6 => "no connection",
34 7 => "connection lost",
35 8 => "operation unsupported",
36 _ => "unrecognised status",
37 };
38 write!(f, "the remote refused: {name} ({})", self.status)
39 }
40}
41
42impl std::error::Error for Refused {}
43
44/// Did this failure mean "there is nothing there", as opposed to anything else at all?
45///
46/// Anything that cannot be established as absence is not treated as absence. Guessing the
47/// other way turns every transport problem into an empty answer, which is the failure this
48/// project has already shipped once.
49pub fn is_absent(e: &anyhow::Error) -> bool {
50 e.chain()
51 .filter_map(|c| c.downcast_ref::<Refused>())
52 .any(|r| r.status == STATUS_NO_SUCH_FILE)
53}
54
55#[derive(Debug, Clone)]
56pub struct Entry {
57 pub name: String,
58 pub attrs: Attrs,
59}
60
61/// One byte range of one file.
62#[derive(Debug, Clone)]
63pub struct RangeReq {
64 pub path: String,
65 pub offset: u64,
66 /// Bytes wanted. Fewer may come back at end of file, which is not an error.
67 pub len: u64,
68}
69
70#[allow(async_fn_in_trait)]
71pub trait RemoteFs {
72 /// Read whole files. Implementations must issue every request before awaiting
73 /// any reply; doing otherwise silently reintroduces O(N) round trips.
74 async fn read_batch(&self, paths: &[String]) -> Vec<Result<Vec<u8>>>;
75
76 /// Read byte ranges. Chunking is the implementation's business; what matters
77 /// here is that the whole set is issued together, so a one-megabyte range costs
78 /// one round trip rather than the thirty-two its chunks would suggest.
79 async fn read_ranges(&self, reqs: &[RangeReq]) -> Vec<Result<Vec<u8>>>;
80
81 /// List several directories at once. One listing carries every entry's attrs,
82 /// which is what removes per-file stat from the page path; batching the
83 /// listings is what holds a symlink check over a deep path at one round trip
84 /// rather than one per path component.
85 async fn list_dirs(&self, paths: &[String]) -> Vec<Result<Vec<Entry>>>;
86
87 /// The n=1 case, defined in terms of the batch so that no implementation can
88 /// quietly make the single listing the cheap path and the batch a loop.
89 async fn list_dir(&self, path: &str) -> Result<Vec<Entry>> {
90 let one = [path.to_string()];
91 self.list_dirs(&one)
92 .await
93 .into_iter()
94 .next()
95 .unwrap_or_else(|| Err(anyhow!("list_dirs returned no result for {path}")))
96 }
97
98 /// The absolute path a fresh session starts in — the account's home directory.
99 ///
100 /// The one question about the remote that cannot be answered from a path, and the
101 /// reason an alias can be written without a base at all. `~` is shell syntax and
102 /// the transport never runs a shell, so expanding it locally would produce this
103 /// machine's home rather than the remote one.
104 ///
105 /// Asked once per alias at startup, never on a page path, so it costs no round trip
106 /// that a reader waits for.
107 async fn home(&self) -> Result<String>;
108
109 /// Flushes issued so far. One flush is one remote round trip, so this is the
110 /// invariant made observable.
111 ///
112 /// Reported by `GET /_control/hosts` per open alias, not only asserted in tests. A
113 /// claim about round trips that can only be checked against a fake remote is a claim
114 /// about the fake.
115 fn round_trips(&self) -> u64;
116}