Skip to main content

ssh_browser/sftp/
wire.rs

1//! SFTP v3 wire format.
2
3pub const INIT: u8 = 1;
4pub const VERSION: u8 = 2;
5pub const OPEN: u8 = 3;
6pub const CLOSE: u8 = 4;
7pub const READ: u8 = 5;
8pub const WRITE: u8 = 6;
9pub const LSTAT: u8 = 7;
10pub const OPENDIR: u8 = 11;
11pub const READDIR: u8 = 12;
12pub const MKDIR: u8 = 14;
13pub const REALPATH: u8 = 16;
14pub const STATUS: u8 = 101;
15pub const HANDLE: u8 = 102;
16pub const DATA: u8 = 103;
17pub const NAME: u8 = 104;
18pub const ATTRS: u8 = 105;
19
20pub const FXF_READ: u32 = 0x0000_0001;
21pub const FXF_WRITE: u32 = 0x0000_0002;
22/// In append mode the offset in each WRITE is ignored and the server places the data at
23/// the end. That is what makes a single-writer log safe without any locking.
24pub const FXF_APPEND: u32 = 0x0000_0004;
25pub const FXF_CREAT: u32 = 0x0000_0008;
26
27/// A read ending in SSH_FX_EOF is a normal end of file. Any other status is a
28/// real failure, and conflating the two turns a directory into an empty 200.
29pub const STATUS_EOF: u32 = 1;
30
31/// SSH_FX_OK, the only status a write may answer with.
32pub const STATUS_OK: u32 = 0;
33
34/// SSH_FX_NO_SUCH_FILE: the one refusal that means "there is nothing there" rather than
35/// "something went wrong". Everything else — a permission problem, a dead session, a server
36/// that simply failed — has to stay distinguishable from it, because the difference is the
37/// difference between an empty answer and an error.
38pub const STATUS_NO_SUCH_FILE: u32 = 2;
39
40const A_SIZE: u32 = 0x0000_0001;
41const A_UIDGID: u32 = 0x0000_0002;
42const A_PERM: u32 = 0x0000_0004;
43const A_TIME: u32 = 0x0000_0008;
44const A_EXT: u32 = 0x8000_0000;
45
46const S_IFMT: u32 = 0o170000;
47const S_IFDIR: u32 = 0o040000;
48const S_IFLNK: u32 = 0o120000;
49
50/// Big-endian encoder, chained by value so one request is one expression.
51#[derive(Default)]
52pub struct Enc(Vec<u8>);
53
54impl Enc {
55    pub fn new() -> Self {
56        Self(Vec::new())
57    }
58
59    pub fn u32(mut self, v: u32) -> Self {
60        self.0.extend_from_slice(&v.to_be_bytes());
61        self
62    }
63
64    pub fn u64(mut self, v: u64) -> Self {
65        self.0.extend_from_slice(&v.to_be_bytes());
66        self
67    }
68
69    pub fn str(mut self, v: &[u8]) -> Self {
70        self.0.extend_from_slice(&(v.len() as u32).to_be_bytes());
71        self.0.extend_from_slice(v);
72        self
73    }
74
75    pub fn done(self) -> Vec<u8> {
76        self.0
77    }
78}
79
80/// Bounds-checked reader. Every accessor returns None rather than panicking so a
81/// malformed reply from the remote cannot take the daemon down.
82pub struct Dec<'a> {
83    b: &'a [u8],
84    i: usize,
85}
86
87impl<'a> Dec<'a> {
88    pub fn new(b: &'a [u8]) -> Self {
89        Self { b, i: 0 }
90    }
91
92    pub fn u32(&mut self) -> Option<u32> {
93        let end = self.i.checked_add(4)?;
94        let v = u32::from_be_bytes(self.b.get(self.i..end)?.try_into().ok()?);
95        self.i = end;
96        Some(v)
97    }
98
99    pub fn u64(&mut self) -> Option<u64> {
100        let end = self.i.checked_add(8)?;
101        let v = u64::from_be_bytes(self.b.get(self.i..end)?.try_into().ok()?);
102        self.i = end;
103        Some(v)
104    }
105
106    pub fn str(&mut self) -> Option<&'a [u8]> {
107        let n = self.u32()? as usize;
108        let end = self.i.checked_add(n)?;
109        let v = self.b.get(self.i..end)?;
110        self.i = end;
111        Some(v)
112    }
113}
114
115/// The subset of SSH_FXP_ATTRS the origin layer needs.
116///
117/// `size` and `mtime` form the cache key, which is why a single READDIR can
118/// replace a per-file STAT and keep the round trips flat.
119///
120/// `uid` deliberately does not answer "does this log belong to the account it
121/// names": it is a number, an author is a name, and turning one into the other
122/// needs a passwd lookup there is no way to perform over the sftp subsystem.
123/// [`owner_of_longname`] is what answers that.
124#[derive(Debug, Clone, Copy, Default)]
125pub struct Attrs {
126    pub size: Option<u64>,
127    pub uid: Option<u32>,
128    pub gid: Option<u32>,
129    pub permissions: Option<u32>,
130    pub atime: Option<u32>,
131    pub mtime: Option<u32>,
132}
133
134impl Attrs {
135    pub fn decode(d: &mut Dec<'_>) -> Option<Self> {
136        let flags = d.u32()?;
137        let mut a = Self::default();
138        if flags & A_SIZE != 0 {
139            a.size = Some(d.u64()?);
140        }
141        if flags & A_UIDGID != 0 {
142            a.uid = Some(d.u32()?);
143            a.gid = Some(d.u32()?);
144        }
145        if flags & A_PERM != 0 {
146            a.permissions = Some(d.u32()?);
147        }
148        if flags & A_TIME != 0 {
149            a.atime = Some(d.u32()?);
150            a.mtime = Some(d.u32()?);
151        }
152        if flags & A_EXT != 0 {
153            for _ in 0..d.u32()? {
154                d.str()?;
155                d.str()?;
156            }
157        }
158        Some(a)
159    }
160
161    pub fn is_dir(&self) -> bool {
162        self.permissions.is_some_and(|p| p & S_IFMT == S_IFDIR)
163    }
164
165    pub fn is_symlink(&self) -> bool {
166        self.permissions.is_some_and(|p| p & S_IFMT == S_IFLNK)
167    }
168}
169
170/// The owner's account name out of an SFTP v3 `longname`, if one can be read with
171/// confidence.
172///
173/// Version 3 says only that the field looks like `ls -l` output, so this parses a
174/// convention rather than a grammar, and it is written to decline rather than to
175/// guess. That direction is the whole point. The one thing this feeds is the check
176/// that an annotation log belongs to the account its filename names, and a wrongly
177/// parsed owner would accuse a real person of writing somebody else's log — strictly
178/// worse than having no check at all. So the leading fields are all validated, and
179/// anything unfamiliar yields `None`, which the caller reports as "not checked"
180/// rather than as agreement.
181///
182/// Nothing past the size is looked at. The name at the end may contain spaces, and
183/// the date is the `ls -l` mixture of `Mon DD HH:MM` and `Mon DD  YYYY` depending on
184/// the file's age — neither is needed here, so neither is interpreted.
185pub fn owner_of_longname(longname: &str) -> Option<&str> {
186    let mut fields = longname.split_ascii_whitespace();
187    if !is_mode_string(fields.next()?) {
188        return None;
189    }
190    // Link count and size are parsed only to be discarded: they are what distinguishes
191    // a real listing line from a string that merely opens like one.
192    fields.next()?.parse::<u64>().ok()?;
193    let owner = fields.next()?;
194    let _group = fields.next()?;
195    fields.next()?.parse::<u64>().ok()?;
196    Some(owner)
197}
198
199/// Does this look like the ten-character mode column of a listing?
200fn is_mode_string(s: &str) -> bool {
201    // A trailing `+` or `.` marks an ACL or a security context on some systems, which
202    // says nothing about the owner either way.
203    let b = s
204        .strip_suffix('+')
205        .or_else(|| s.strip_suffix('.'))
206        .unwrap_or(s)
207        .as_bytes();
208    b.len() == 10
209        && matches!(b[0], b'-' | b'd' | b'l' | b'b' | b'c' | b'p' | b's')
210        && b[1..]
211            .iter()
212            .all(|c| matches!(c, b'r' | b'w' | b'x' | b's' | b't' | b'S' | b'T' | b'-'))
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn roundtrips_primitives() {
221        let bytes = Enc::new().u32(7).u64(1 << 40).str(b"hi").done();
222        let mut d = Dec::new(&bytes);
223        assert_eq!(d.u32(), Some(7));
224        assert_eq!(d.u64(), Some(1 << 40));
225        assert_eq!(d.str(), Some(&b"hi"[..]));
226        assert_eq!(d.u32(), None);
227    }
228
229    #[test]
230    fn truncated_input_returns_none_instead_of_panicking() {
231        let mut d = Dec::new(&[0, 0, 0, 9, 1, 2]);
232        assert_eq!(d.str(), None);
233    }
234
235    #[test]
236    fn decodes_attrs_and_classifies_a_directory() {
237        let bytes = Enc::new()
238            .u32(A_SIZE | A_UIDGID | A_PERM | A_TIME)
239            .u64(4096)
240            .u32(1000)
241            .u32(1000)
242            .u32(0o040755)
243            .u32(111)
244            .u32(222)
245            .done();
246        let a = Attrs::decode(&mut Dec::new(&bytes)).expect("attrs");
247        assert_eq!(a.size, Some(4096));
248        assert_eq!(a.uid, Some(1000));
249        assert_eq!(a.mtime, Some(222));
250        assert!(a.is_dir());
251        assert!(!a.is_symlink());
252    }
253
254    #[test]
255    fn skips_extended_attr_pairs() {
256        let bytes = Enc::new()
257            .u32(A_SIZE | A_EXT)
258            .u64(1)
259            .u32(1)
260            .str(b"k")
261            .str(b"v")
262            .done();
263        let a = Attrs::decode(&mut Dec::new(&bytes)).expect("attrs");
264        assert_eq!(a.size, Some(1));
265    }
266
267    /// The shape OpenSSH's sftp-server produces, which is the one that matters in
268    /// practice, plus the variations other servers are known to add.
269    #[test]
270    fn reads_the_owner_out_of_a_listing_line() {
271        for line in [
272            "-rw-r--r--    1 souta    devs         1234 Sep 12 01:00 souta.jsonl",
273            // An older file: the time column becomes a year, which is not looked at.
274            "-rw-r--r--    1 souta    devs         1234 Sep 12  2024 souta.jsonl",
275            "drwxrwsr-x    2 souta    devs         4096 Sep 12 01:00 ann",
276            // An ACL marker, and a setuid bit in the mode.
277            "-rwsr-xr-x+   1 souta    devs         1234 Sep 12 01:00 x",
278            // A filename with spaces in it, which is why nothing past the size is read.
279            "-rw-r--r--    1 souta    devs         1234 Sep 12 01:00 two words.jsonl",
280        ] {
281            assert_eq!(owner_of_longname(line), Some("souta"), "parsing {line:?}");
282        }
283    }
284
285    /// Declining is the load-bearing behaviour: a guessed owner would accuse somebody
286    /// of writing a log that is not theirs, which is worse than reporting no check.
287    #[test]
288    fn anything_unfamiliar_yields_no_owner() {
289        for line in [
290            "",
291            // What a listing carries when a server reports only the filename.
292            "souta.jsonl",
293            // Mode column the wrong length, or with characters that do not belong.
294            "-rw-r--r-   1 souta devs 1234 Sep 12 01:00 x",
295            "-rw-r--r--x 1 souta devs 1234 Sep 12 01:00 x",
296            "?rw-r--r--  1 souta devs 1234 Sep 12 01:00 x",
297            // Right shape up front, but the numeric columns are not numbers, so this is
298            // some other format that happens to start with ten plausible characters.
299            "-rw-r--r-- one souta devs 1234 Sep 12 01:00 x",
300            "-rw-r--r--   1 souta devs size Sep 12 01:00 x",
301            // Truncated before the owner can be established.
302            "-rw-r--r--   1 souta",
303        ] {
304            assert_eq!(owner_of_longname(line), None, "parsing {line:?}");
305        }
306    }
307
308    /// Ten bytes are not always ten characters. This is why the mode column is examined
309    /// as bytes: slicing the `str` would panic on a character boundary instead of
310    /// declining, and the remote chooses this string.
311    #[test]
312    fn a_ten_byte_mode_column_that_is_not_ten_characters_declines() {
313        let mode = "-\u{e9}-r--r--";
314        assert_eq!(mode.len(), 10, "ten bytes");
315        assert_eq!(mode.chars().count(), 9, "but nine characters");
316        assert_eq!(
317            owner_of_longname(&format!("{mode} 1 souta devs 1 Sep 12 01:00 x")),
318            None
319        );
320    }
321}