Skip to main content

ssh_browser/sftp/
wire.rs

1//! SFTP v3 wire format.
2
3/// A request this daemon can send.
4///
5/// The README says nothing is written to your remote, and this is the sentence that makes it
6/// true: `Verb` wraps a `u8` that nothing outside this module can construct, and there are six
7/// of them. `SSH_FXP_WRITE`, `SETSTAT`, `REMOVE`, `MKDIR`, `RMDIR`, `RENAME` and `SYMLINK` are
8/// not missing by policy -- there is no value of this type that means them, so the code that
9/// would send one does not compile.
10///
11/// A seventh means adding a `pub const` here, which is a line in a diff somebody reads. That
12/// is the point: the claim stops being a thing to remember and becomes a thing to notice.
13#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14pub struct Verb(u8);
15
16impl Verb {
17    /// The byte that goes on the wire.
18    ///
19    /// One way only. There is no `from_u8`, because a byte that arrived from somewhere is a
20    /// reply type or somebody's input, and neither is a request this may send.
21    pub const fn code(self) -> u8 {
22        self.0
23    }
24}
25
26pub const OPEN: Verb = Verb(3);
27pub const CLOSE: Verb = Verb(4);
28pub const READ: Verb = Verb(5);
29pub const OPENDIR: Verb = Verb(11);
30pub const READDIR: Verb = Verb(12);
31pub const REALPATH: Verb = Verb(16);
32
33/// The version handshake, which is not a filesystem request and so is not a `Verb`.
34pub const INIT: u8 = 1;
35pub const VERSION: u8 = 2;
36
37/// Reply types. These arrive; they are never sent, which is why they stay bytes.
38pub const STATUS: u8 = 101;
39pub const HANDLE: u8 = 102;
40pub const DATA: u8 = 103;
41pub const NAME: u8 = 104;
42pub const ATTRS: u8 = 105;
43
44/// The only open mode this daemon has. There is no write path.
45pub const FXF_READ: u32 = 0x0000_0001;
46
47/// A read ending in SSH_FX_EOF is a normal end of file. Any other status is a
48/// real failure, and conflating the two turns a directory into an empty 200.
49pub const STATUS_EOF: u32 = 1;
50
51/// SSH_FX_OK, the only status a write may answer with.
52pub const STATUS_OK: u32 = 0;
53
54/// SSH_FX_NO_SUCH_FILE: the one refusal that means "there is nothing there" rather than
55/// "something went wrong". Everything else — a permission problem, a dead session, a server
56/// that simply failed — has to stay distinguishable from it, because the difference is the
57/// difference between an empty answer and an error.
58pub const STATUS_NO_SUCH_FILE: u32 = 2;
59
60const A_SIZE: u32 = 0x0000_0001;
61const A_UIDGID: u32 = 0x0000_0002;
62const A_PERM: u32 = 0x0000_0004;
63const A_TIME: u32 = 0x0000_0008;
64const A_EXT: u32 = 0x8000_0000;
65
66const S_IFMT: u32 = 0o170000;
67const S_IFDIR: u32 = 0o040000;
68const S_IFLNK: u32 = 0o120000;
69
70/// Big-endian encoder, chained by value so one request is one expression.
71#[derive(Default)]
72pub struct Enc(Vec<u8>);
73
74impl Enc {
75    pub fn new() -> Self {
76        Self(Vec::new())
77    }
78
79    pub fn u32(mut self, v: u32) -> Self {
80        self.0.extend_from_slice(&v.to_be_bytes());
81        self
82    }
83
84    pub fn u64(mut self, v: u64) -> Self {
85        self.0.extend_from_slice(&v.to_be_bytes());
86        self
87    }
88
89    pub fn str(mut self, v: &[u8]) -> Self {
90        self.0.extend_from_slice(&(v.len() as u32).to_be_bytes());
91        self.0.extend_from_slice(v);
92        self
93    }
94
95    pub fn done(self) -> Vec<u8> {
96        self.0
97    }
98}
99
100/// Bounds-checked reader. Every accessor returns None rather than panicking so a
101/// malformed reply from the remote cannot take the daemon down.
102pub struct Dec<'a> {
103    b: &'a [u8],
104    i: usize,
105}
106
107impl<'a> Dec<'a> {
108    pub fn new(b: &'a [u8]) -> Self {
109        Self { b, i: 0 }
110    }
111
112    pub fn u32(&mut self) -> Option<u32> {
113        let end = self.i.checked_add(4)?;
114        let v = u32::from_be_bytes(self.b.get(self.i..end)?.try_into().ok()?);
115        self.i = end;
116        Some(v)
117    }
118
119    pub fn u64(&mut self) -> Option<u64> {
120        let end = self.i.checked_add(8)?;
121        let v = u64::from_be_bytes(self.b.get(self.i..end)?.try_into().ok()?);
122        self.i = end;
123        Some(v)
124    }
125
126    pub fn str(&mut self) -> Option<&'a [u8]> {
127        let n = self.u32()? as usize;
128        let end = self.i.checked_add(n)?;
129        let v = self.b.get(self.i..end)?;
130        self.i = end;
131        Some(v)
132    }
133}
134
135/// The subset of SSH_FXP_ATTRS the origin layer needs.
136///
137/// `size` and `mtime` form the cache key, which is why a single READDIR can
138/// replace a per-file STAT and keep the round trips flat.
139#[derive(Debug, Clone, Copy, Default)]
140pub struct Attrs {
141    pub size: Option<u64>,
142    pub uid: Option<u32>,
143    pub gid: Option<u32>,
144    pub permissions: Option<u32>,
145    pub atime: Option<u32>,
146    pub mtime: Option<u32>,
147}
148
149impl Attrs {
150    pub fn decode(d: &mut Dec<'_>) -> Option<Self> {
151        let flags = d.u32()?;
152        let mut a = Self::default();
153        if flags & A_SIZE != 0 {
154            a.size = Some(d.u64()?);
155        }
156        if flags & A_UIDGID != 0 {
157            a.uid = Some(d.u32()?);
158            a.gid = Some(d.u32()?);
159        }
160        if flags & A_PERM != 0 {
161            a.permissions = Some(d.u32()?);
162        }
163        if flags & A_TIME != 0 {
164            a.atime = Some(d.u32()?);
165            a.mtime = Some(d.u32()?);
166        }
167        if flags & A_EXT != 0 {
168            for _ in 0..d.u32()? {
169                d.str()?;
170                d.str()?;
171            }
172        }
173        Some(a)
174    }
175
176    pub fn is_dir(&self) -> bool {
177        self.permissions.is_some_and(|p| p & S_IFMT == S_IFDIR)
178    }
179
180    pub fn is_symlink(&self) -> bool {
181        self.permissions.is_some_and(|p| p & S_IFMT == S_IFLNK)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    /// The README says nothing is written to your remote. This is the list that claim is about.
190    ///
191    /// It fails two ways, and both are the point. Adding a `Verb` and forgetting this list
192    /// fails it; deleting one that is in use fails to compile. So the set cannot grow quietly,
193    /// which is the only way a read-only client stops being one.
194    #[test]
195    fn the_verbs_are_the_six_read_ones() {
196        let mut codes = [OPEN, CLOSE, READ, OPENDIR, READDIR, REALPATH].map(Verb::code);
197        codes.sort_unstable();
198        assert_eq!(codes, [3, 4, 5, 11, 12, 16]);
199
200        // SSH_FXP_WRITE, SETSTAT, FSETSTAT, REMOVE, MKDIR, RMDIR, RENAME, SYMLINK. Named here
201        // so that the absence is written down: a reader should not have to know SFTP v3 by
202        // heart to see that the dangerous half of it is not in the list above.
203        for writes in [6u8, 9, 10, 13, 14, 15, 18, 20] {
204            assert!(!codes.contains(&writes), "{writes} is a write");
205        }
206    }
207
208    /// The only open mode there is. `FXF_WRITE`, `FXF_APPEND`, `FXF_CREAT` and `FXF_TRUNC` are
209    /// not defined anywhere in this crate, so `OPEN` has nothing else it could ask for.
210    #[test]
211    fn the_only_open_flag_is_read() {
212        assert_eq!(FXF_READ, 1);
213    }
214
215    #[test]
216    fn roundtrips_primitives() {
217        let bytes = Enc::new().u32(7).u64(1 << 40).str(b"hi").done();
218        let mut d = Dec::new(&bytes);
219        assert_eq!(d.u32(), Some(7));
220        assert_eq!(d.u64(), Some(1 << 40));
221        assert_eq!(d.str(), Some(&b"hi"[..]));
222        assert_eq!(d.u32(), None);
223    }
224
225    #[test]
226    fn truncated_input_returns_none_instead_of_panicking() {
227        let mut d = Dec::new(&[0, 0, 0, 9, 1, 2]);
228        assert_eq!(d.str(), None);
229    }
230
231    #[test]
232    fn decodes_attrs_and_classifies_a_directory() {
233        let bytes = Enc::new()
234            .u32(A_SIZE | A_UIDGID | A_PERM | A_TIME)
235            .u64(4096)
236            .u32(1000)
237            .u32(1000)
238            .u32(0o040755)
239            .u32(111)
240            .u32(222)
241            .done();
242        let a = Attrs::decode(&mut Dec::new(&bytes)).expect("attrs");
243        assert_eq!(a.size, Some(4096));
244        assert_eq!(a.uid, Some(1000));
245        assert_eq!(a.mtime, Some(222));
246        assert!(a.is_dir());
247        assert!(!a.is_symlink());
248    }
249
250    #[test]
251    fn skips_extended_attr_pairs() {
252        let bytes = Enc::new()
253            .u32(A_SIZE | A_EXT)
254            .u64(1)
255            .u32(1)
256            .str(b"k")
257            .str(b"v")
258            .done();
259        let a = Attrs::decode(&mut Dec::new(&bytes)).expect("attrs");
260        assert_eq!(a.size, Some(1));
261    }
262}