1use anyhow::{bail, ensure, Context, Result};
4use serde_json::{json, Value};
5use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
6
7pub const MAX_PACKET: usize = 64 * 1024;
8pub const MAX_PATH: usize = 4096;
9pub const MAX_TEXT: usize = 1024 * 1024;
10pub const MAX_ENTRIES: usize = 1024;
11pub const MAX_CHUNK: usize = 32768;
12pub const DEFAULT_READ: usize = 65536;
13
14pub fn validate_action(action: &Value) -> Result<()> {
15 let path = action["path"].as_str().context("SFTP needs a path")?;
16 ensure!(
17 !path.is_empty() && path.len() <= MAX_PATH && !path.contains('\0'),
18 "SFTP path must contain 1..4096 bytes without NUL"
19 );
20 if let Some(offset) = action.get("offset") {
21 offset
22 .as_u64()
23 .context("offset must be an unsigned integer")?;
24 }
25 if let Some(length) = action.get("length") {
26 ensure!(
27 length
28 .as_u64()
29 .is_some_and(|n| n > 0 && n <= MAX_TEXT as u64),
30 "length must be 1..1048576"
31 );
32 }
33 if let Some(follow) = action.get("follow_symlinks") {
34 ensure!(follow.is_boolean(), "follow_symlinks must be a boolean");
35 }
36 ensure!(
37 action["offset"]
38 .as_u64()
39 .unwrap_or(0)
40 .checked_add(action["length"].as_u64().unwrap_or(DEFAULT_READ as u64))
41 .is_some(),
42 "SFTP offset overflow"
43 );
44 Ok(())
45}
46
47struct Cursor<'a>(&'a [u8]);
48impl<'a> Cursor<'a> {
49 fn take(&mut self, n: usize) -> Result<&'a [u8]> {
50 ensure!(n <= self.0.len(), "Truncated SFTP field");
51 let (value, rest) = self.0.split_at(n);
52 self.0 = rest;
53 Ok(value)
54 }
55 fn u32(&mut self) -> Result<u32> {
56 Ok(u32::from_be_bytes(self.take(4)?.try_into()?))
57 }
58 fn u64(&mut self) -> Result<u64> {
59 Ok(u64::from_be_bytes(self.take(8)?.try_into()?))
60 }
61 fn string(&mut self, cap: usize) -> Result<&'a [u8]> {
62 let n = self.u32()? as usize;
63 ensure!(n <= cap, "SFTP string exceeds bound");
64 self.take(n)
65 }
66 fn text(&mut self) -> Result<String> {
67 Ok(std::str::from_utf8(self.string(MAX_PATH)?)?.into())
68 }
69 fn attrs(&mut self) -> Result<Value> {
70 let flags = self.u32()?;
71 ensure!(flags & !0x8000000f == 0, "Unknown SFTP attribute flags");
72 let mut a = serde_json::Map::new();
73 if flags & 1 != 0 {
74 a.insert("size".into(), json!(self.u64()?));
75 }
76 if flags & 2 != 0 {
77 a.insert("uid".into(), json!(self.u32()?));
78 a.insert("gid".into(), json!(self.u32()?));
79 }
80 if flags & 4 != 0 {
81 let mode = self.u32()?;
82 a.insert("permissions".into(), json!(mode));
83 a.insert("is_dir".into(), json!(mode & 0o170000 == 0o040000));
84 a.insert("is_symlink".into(), json!(mode & 0o170000 == 0o120000));
85 }
86 if flags & 8 != 0 {
87 a.insert("atime".into(), json!(self.u32()?));
88 a.insert("mtime".into(), json!(self.u32()?));
89 }
90 if flags & 0x80000000 != 0 {
91 let n = self.u32()? as usize;
92 ensure!(
93 n <= 64 && n <= self.0.len() / 8,
94 "SFTP extended attribute count exceeds bound"
95 );
96 for _ in 0..n {
98 self.string(MAX_PATH)?;
99 self.string(MAX_PATH)?;
100 }
101 }
102 Ok(Value::Object(a))
103 }
104 fn finish(self) -> Result<()> {
105 ensure!(self.0.is_empty(), "Trailing SFTP packet bytes");
106 Ok(())
107 }
108}
109
110#[derive(Debug)]
111pub enum Reply {
112 Status { code: u32, message: String },
113 Handle(Vec<u8>),
114 Data(Vec<u8>),
115 Names(Vec<Value>),
116 Attributes(Value),
117}
118pub fn decode_reply(bytes: &[u8], expected_id: u32) -> Result<Reply> {
120 ensure!(bytes.len() <= MAX_PACKET, "SFTP packet exceeds 64 KiB");
121 let mut c = Cursor(bytes);
122 let kind = c.take(1)?[0];
123 ensure!(c.u32()? == expected_id, "Mismatched SFTP response ID");
124 let reply = match kind {
125 101 => {
126 let code = c.u32()?;
127 ensure!(code <= 8, "Unknown SFTP status");
128 let message = c.text()?;
129 c.string(MAX_PATH)?;
130 Reply::Status { code, message }
131 }
132 102 => {
133 let handle = c.string(256)?;
134 ensure!(!handle.is_empty(), "Empty SFTP handle");
135 Reply::Handle(handle.to_vec())
136 }
137 103 => Reply::Data(c.string(MAX_CHUNK)?.to_vec()),
138 104 => {
139 let n = c.u32()? as usize;
140 ensure!(
141 n <= MAX_ENTRIES && n <= c.0.len() / 12,
142 "SFTP directory entry count exceeds bound"
143 );
144 let mut entries = Vec::with_capacity(n);
145 for _ in 0..n {
146 let name = c.text()?;
147 c.string(MAX_PATH)?; entries.push(json!({"name":name,"attributes":c.attrs()?}));
149 }
150 Reply::Names(entries)
151 }
152 105 => Reply::Attributes(c.attrs()?),
153 _ => bail!("Unexpected SFTP packet type"),
154 };
155 c.finish()?;
156 Ok(reply)
157}
158
159pub async fn read_packet<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
160 let length = reader.read_u32().await? as usize;
161 ensure!(
162 (1..=MAX_PACKET).contains(&length),
163 "SFTP packet length exceeds 64 KiB"
164 );
165 let mut bytes = vec![0; length];
166 reader.read_exact(&mut bytes).await?;
167 Ok(bytes)
168}
169fn string(bytes: &[u8]) -> Vec<u8> {
170 let mut out = (bytes.len() as u32).to_be_bytes().to_vec();
171 out.extend_from_slice(bytes);
172 out
173}
174struct RawSession<S> {
175 stream: S,
176 id: u32,
177 received: usize,
178}
179impl<S: AsyncRead + AsyncWrite + Unpin> RawSession<S> {
180 async fn receive(&mut self) -> Result<Vec<u8>> {
181 let bytes = read_packet(&mut self.stream).await?;
182 self.received += bytes.len();
183 ensure!(
184 self.received <= 2 * MAX_TEXT,
185 "SFTP operation packet budget exceeded"
186 );
187 Ok(bytes)
188 }
189 async fn send(&mut self, bytes: &[u8]) -> Result<()> {
190 ensure!(
191 bytes.len() <= MAX_PACKET,
192 "SFTP outgoing packet exceeds bound"
193 );
194 self.stream.write_u32(bytes.len() as u32).await?;
195 self.stream.write_all(bytes).await?;
196 self.stream.flush().await?;
197 Ok(())
198 }
199 async fn init(&mut self) -> Result<()> {
200 self.send(&[1, 0, 0, 0, 3]).await?;
201 let bytes = self.receive().await?;
202 let mut c = Cursor(&bytes);
203 ensure!(
204 c.take(1)?[0] == 2 && c.u32()? == 3,
205 "SFTP peer must negotiate version 3"
206 );
207 let mut extensions = 0;
208 while !c.0.is_empty() {
209 extensions += 1;
210 ensure!(extensions <= 64, "Too many SFTP extensions");
211 c.string(MAX_PATH)?;
212 c.string(MAX_PATH)?;
213 }
214 Ok(())
215 }
216 async fn request(&mut self, kind: u8, payload: &[u8]) -> Result<Reply> {
217 self.id = self
218 .id
219 .checked_add(1)
220 .context("SFTP request ID exhausted")?;
221 let mut out = vec![kind];
222 out.extend_from_slice(&self.id.to_be_bytes());
223 out.extend_from_slice(payload);
224 self.send(&out).await?;
225 decode_reply(&self.receive().await?, self.id)
226 }
227 async fn close(&mut self, handle: &[u8]) -> Result<()> {
228 match self.request(4, &string(handle)).await? {
229 Reply::Status { code: 0, .. } => Ok(()),
230 _ => bail!("SFTP handle close failed"),
231 }
232 }
233}
234fn status(reply: Reply) -> Result<Reply> {
235 match reply {
236 Reply::Status { code, message } => bail!("SFTP status {code}: {message}"),
237 reply => Ok(reply),
238 }
239}
240pub async fn exchange<S: AsyncRead + AsyncWrite + Unpin>(
243 stream: S,
244 action: &Value,
245) -> Result<Value> {
246 validate_action(action)?;
247 let mut s = RawSession {
248 stream,
249 id: 0,
250 received: 0,
251 };
252 s.init().await?;
253 let path = action["path"].as_str().context("Missing path")?;
254 match action["type"].as_str().unwrap_or("") {
255 "sftp_stat" => {
256 let kind = if action["follow_symlinks"].as_bool().unwrap_or(false) {
257 17
258 } else {
259 7
260 };
261 match status(s.request(kind, &string(path.as_bytes())).await?)? {
262 Reply::Attributes(attrs) => {
263 Ok(json!({"operation":"stat","path":path,"attributes":attrs}))
264 }
265 _ => bail!("SFTP stat expected attributes"),
266 }
267 }
268 "sftp_list_directory" => {
269 let handle = match status(s.request(11, &string(path.as_bytes())).await?)? {
270 Reply::Handle(h) => h,
271 _ => bail!("SFTP opendir expected handle"),
272 };
273 let mut entries = Vec::new();
274 let mut empty_batches = 0;
275 loop {
276 match s.request(12, &string(&handle)).await? {
277 Reply::Status { code: 1, .. } => break,
278 Reply::Names(batch) => {
279 if batch.is_empty() {
282 empty_batches += 1;
283 ensure!(empty_batches <= 16, "SFTP directory made no progress");
284 }
285 ensure!(
286 batch.len() <= MAX_ENTRIES.saturating_sub(entries.len()),
287 "SFTP directory exceeds 1024 entries"
288 );
289 entries.extend(batch);
290 }
291 reply => {
292 status(reply)?;
293 bail!("SFTP readdir expected names");
294 }
295 }
296 }
297 s.close(&handle).await?;
298 Ok(json!({"operation":"list_directory","path":path,"entries":entries}))
299 }
300 "sftp_read_file" => {
301 let mut payload = string(path.as_bytes());
302 payload.extend_from_slice(&1u32.to_be_bytes()); payload.extend_from_slice(&0u32.to_be_bytes()); let handle = match status(s.request(3, &payload).await?)? {
305 Reply::Handle(h) => h,
306 _ => bail!("SFTP open expected handle"),
307 };
308 let offset = action["offset"].as_u64().unwrap_or(0);
309 let length = action["length"].as_u64().unwrap_or(DEFAULT_READ as u64) as usize;
310 let mut data = Vec::new();
311 let mut eof = false;
312 while data.len() < length {
313 let count = MAX_CHUNK.min(length - data.len());
314 let mut payload = string(&handle);
315 payload.extend_from_slice(&(offset + data.len() as u64).to_be_bytes());
316 payload.extend_from_slice(&(count as u32).to_be_bytes());
317 match s.request(5, &payload).await? {
318 Reply::Status { code: 1, .. } => {
319 eof = true;
320 break;
321 }
322 Reply::Data(chunk) => {
323 ensure!(
324 !chunk.is_empty() && chunk.len() <= count,
325 "SFTP read returned an invalid data length"
326 );
327 data.extend(chunk);
328 }
329 reply => {
330 status(reply)?;
331 bail!("SFTP read expected data");
332 }
333 }
334 }
335 s.close(&handle).await?;
336 let bytes = data.len();
337 let text = String::from_utf8(data).context("SFTP file window is not UTF-8 text")?;
338 Ok(
339 json!({"operation":"read_file","path":path,"offset":offset,"bytes_read":bytes,"eof":eof,"text":text}),
340 )
341 }
342 _ => bail!("Unknown SFTP operation"),
343 }
344}