Skip to main content

max / supernote-push

11.5 KB · 357 lines History Blame Raw
1 //! Wire format for the Supernote Browse & Access HTTP interface.
2 //!
3 //! Every assumption about the device's HTTP surface lives in this file.
4 //! When firmware drifts, only this module needs to change.
5 //!
6 //! Confirmed against a Supernote Nomad on 2026-07-19:
7 //! - Upload is POST multipart/form-data to the folder URL (not PUT).
8 //! - Field name is `file`, plus a custom `file-size` header with the byte
9 //! count of the file.
10 //! - Listing is not a JSON endpoint — the folder URL returns an HTML page
11 //! with a `const json = '...'` blob embedded in the page's script. We
12 //! extract and parse that.
13
14 use std::time::Duration;
15
16 use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
17
18 use crate::{
19 Error, Result,
20 client::{Entry, Reachability},
21 };
22
23 const PATH: &AsciiSet = &CONTROLS
24 .add(b' ')
25 .add(b'#')
26 .add(b'?')
27 .add(b'"')
28 .add(b'<')
29 .add(b'>');
30
31 pub(crate) fn probe(host: &str, port: u16, timeout: Duration) -> Reachability {
32 let url = format!("http://{host}:{port}/");
33 match agent(timeout).get(&url).call() {
34 // The agent reports 4xx/5xx as ordinary responses, so every status
35 // lands in this arm rather than being split across Ok and Err.
36 Ok(resp) => classify_probe_status(resp.status().as_u16()),
37 Err(_) => Reachability::Unreachable,
38 }
39 }
40
41 /// A passcode-protected device still answers, it just answers 401/403, so an
42 /// auth challenge counts as up.
43 fn classify_probe_status(status: u16) -> Reachability {
44 match status {
45 200..=299 | 401 | 403 => Reachability::Up,
46 _ => Reachability::WifiTransferOff,
47 }
48 }
49
50 pub(crate) fn upload(
51 host: &str,
52 port: u16,
53 passcode: Option<&str>,
54 dir: &str,
55 filename: &str,
56 bytes: &[u8],
57 timeout: Duration,
58 ) -> Result<()> {
59 let url = build_url(host, port, dir, None);
60 let (body, content_type) = encode_multipart(filename, "application/pdf", bytes);
61
62 let mut req = agent(timeout)
63 .post(&url)
64 .header("Content-Type", &content_type)
65 .header("file-size", bytes.len().to_string());
66 if let Some(pc) = passcode {
67 req = req.header("Authorization", basic(pc));
68 }
69 let mut resp = req.send(&body[..]).map_err(Error::from_http)?;
70 if let Some(e) = status_error(&mut resp) {
71 return Err(e);
72 }
73 Ok(())
74 }
75
76 pub(crate) fn list(
77 host: &str,
78 port: u16,
79 passcode: Option<&str>,
80 dir: &str,
81 timeout: Duration,
82 ) -> Result<Vec<Entry>> {
83 let url = build_url(host, port, dir, None);
84 let mut req = agent(timeout).get(&url);
85 if let Some(pc) = passcode {
86 req = req.header("Authorization", basic(pc));
87 }
88 let mut resp = req.call().map_err(Error::from_http)?;
89 if let Some(e) = status_error(&mut resp) {
90 return Err(e);
91 }
92 let html = resp.body_mut().read_to_string().map_err(Error::from_http)?;
93 let json = extract_embedded_json(&html).ok_or_else(|| {
94 Error::Http("could not find embedded webInfo JSON in listing page".to_string())
95 })?;
96 let page: PageInfo = serde_json::from_str(&json)
97 .map_err(|e| Error::Http(format!("failed to parse listing JSON: {e}")))?;
98 Ok(page
99 .file_list
100 .into_iter()
101 .map(RawEntry::into_entry)
102 .collect())
103 }
104
105 fn agent(timeout: Duration) -> ureq::Agent {
106 ureq::Agent::new_with_config(
107 ureq::Agent::config_builder()
108 .timeout_global(Some(timeout))
109 // ureq 3 would otherwise fail a 4xx/5xx with Error::StatusCode,
110 // which carries the code but not the response. The device puts a
111 // useful message in the body, so take every status as a normal
112 // response and inspect it in `status_error`.
113 .http_status_as_error(false)
114 .build(),
115 )
116 }
117
118 /// Returns the error for a non-2xx response, draining the body into it.
119 fn status_error(resp: &mut ureq::http::Response<ureq::Body>) -> Option<Error> {
120 let status = resp.status().as_u16();
121 if (200..300).contains(&status) {
122 return None;
123 }
124 Some(Error::Status {
125 status,
126 body: resp.body_mut().read_to_string().unwrap_or_default(),
127 })
128 }
129
130 fn build_url(host: &str, port: u16, dir: &str, filename: Option<&str>) -> String {
131 let dir = dir.trim_matches('/');
132 let mut url = format!("http://{host}:{port}");
133 if !dir.is_empty() {
134 for segment in dir.split('/') {
135 url.push('/');
136 url.push_str(&utf8_percent_encode(segment, PATH).to_string());
137 }
138 }
139 if let Some(name) = filename {
140 url.push('/');
141 url.push_str(&utf8_percent_encode(name, PATH).to_string());
142 }
143 url
144 }
145
146 fn encode_multipart(filename: &str, content_type: &str, bytes: &[u8]) -> (Vec<u8>, String) {
147 let boundary = format!(
148 "----supernote-push-{}",
149 std::time::SystemTime::now()
150 .duration_since(std::time::UNIX_EPOCH)
151 .map_or(0, |d| d.as_nanos())
152 );
153
154 let disposition = format!(
155 "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
156 escape_quoted(filename)
157 );
158
159 let mut body = Vec::with_capacity(bytes.len() + 256);
160 body.extend_from_slice(b"--");
161 body.extend_from_slice(boundary.as_bytes());
162 body.extend_from_slice(b"\r\n");
163 body.extend_from_slice(disposition.as_bytes());
164 body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes());
165 body.extend_from_slice(bytes);
166 body.extend_from_slice(b"\r\n--");
167 body.extend_from_slice(boundary.as_bytes());
168 body.extend_from_slice(b"--\r\n");
169
170 (body, format!("multipart/form-data; boundary={boundary}"))
171 }
172
173 fn escape_quoted(s: &str) -> String {
174 s.replace('\\', "\\\\").replace('"', "\\\"")
175 }
176
177 fn extract_embedded_json(html: &str) -> Option<String> {
178 let marker = "const json = '";
179 let start = html.find(marker)? + marker.len();
180 let tail = &html[start..];
181 // The JS embeds the JSON as a single-quoted string literal terminated by
182 // a newline. JSON itself uses double quotes, and any embedded apostrophe
183 // in a filename would be `\'`-escaped by the device before it reached
184 // the client, so `'\n` is the reliable terminator.
185 let end = tail.find("'\n").or_else(|| tail.find("'\r\n"))?;
186 Some(tail[..end].replace("\\'", "'"))
187 }
188
189 fn basic(passcode: &str) -> String {
190 format!("Basic {}", base64_stub::encode(format!(":{passcode}")))
191 }
192
193 mod base64_stub {
194 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
195
196 pub(super) fn encode(input: impl AsRef<[u8]>) -> String {
197 let input = input.as_ref();
198 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
199 for chunk in input.chunks(3) {
200 let b0 = chunk[0];
201 let b1 = chunk.get(1).copied().unwrap_or(0);
202 let b2 = chunk.get(2).copied().unwrap_or(0);
203 let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
204 out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
205 out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
206 out.push(if chunk.len() > 1 {
207 TABLE[((n >> 6) & 0x3f) as usize] as char
208 } else {
209 '='
210 });
211 out.push(if chunk.len() > 2 {
212 TABLE[(n & 0x3f) as usize] as char
213 } else {
214 '='
215 });
216 }
217 out
218 }
219
220 #[cfg(test)]
221 mod tests {
222 use super::encode;
223 #[test]
224 fn known_vectors() {
225 assert_eq!(encode(""), "");
226 assert_eq!(encode("f"), "Zg==");
227 assert_eq!(encode("fo"), "Zm8=");
228 assert_eq!(encode("foo"), "Zm9v");
229 assert_eq!(encode(":secret"), "OnNlY3JldA==");
230 }
231 }
232 }
233
234 #[derive(serde::Deserialize)]
235 struct PageInfo {
236 #[serde(rename = "fileList", default)]
237 file_list: Vec<RawEntry>,
238 }
239
240 #[derive(serde::Deserialize)]
241 struct RawEntry {
242 name: String,
243 #[serde(default, rename = "isDirectory")]
244 is_dir: bool,
245 #[serde(default)]
246 size: u64,
247 }
248
249 impl RawEntry {
250 fn into_entry(self) -> Entry {
251 Entry {
252 name: self.name,
253 is_dir: self.is_dir,
254 size: self.size,
255 }
256 }
257 }
258
259 #[cfg(test)]
260 mod tests {
261 use super::*;
262
263 #[test]
264 fn url_encodes_spaces_and_hashes() {
265 let url = build_url("10.0.0.5", 8089, "/Document/My Notes/", Some("a b#c.pdf"));
266 assert_eq!(
267 url,
268 "http://10.0.0.5:8089/Document/My%20Notes/a%20b%23c.pdf"
269 );
270 }
271
272 #[test]
273 fn url_folder_only() {
274 assert_eq!(
275 build_url("10.0.0.5", 8089, "Document/ripgrow", None),
276 "http://10.0.0.5:8089/Document/ripgrow"
277 );
278 }
279
280 #[test]
281 fn url_root() {
282 assert_eq!(
283 build_url("10.0.0.5", 8089, "/", None),
284 "http://10.0.0.5:8089"
285 );
286 }
287
288 #[test]
289 fn multipart_body_has_expected_shape() {
290 let (body, ctype) = encode_multipart("hi.pdf", "application/pdf", b"ABC");
291 assert!(ctype.starts_with("multipart/form-data; boundary=----supernote-push-"));
292 let s = String::from_utf8_lossy(&body);
293 assert!(s.contains(r#"Content-Disposition: form-data; name="file"; filename="hi.pdf""#));
294 assert!(s.contains("Content-Type: application/pdf"));
295 assert!(s.contains("ABC"));
296 assert!(s.trim_end().ends_with("--"));
297 }
298
299 #[test]
300 fn escapes_quotes_in_filename() {
301 let (body, _) = encode_multipart(r#"a"b.pdf"#, "application/pdf", b"");
302 let s = String::from_utf8_lossy(&body);
303 assert!(s.contains(r#"filename="a\"b.pdf""#));
304 }
305
306 #[test]
307 fn probe_treats_auth_challenges_as_reachable() {
308 assert_eq!(classify_probe_status(200), Reachability::Up);
309 assert_eq!(classify_probe_status(204), Reachability::Up);
310 assert_eq!(classify_probe_status(401), Reachability::Up);
311 assert_eq!(classify_probe_status(403), Reachability::Up);
312 // Anything else answering on the port is a device with Wi-Fi Transfer off.
313 assert_eq!(classify_probe_status(404), Reachability::WifiTransferOff);
314 assert_eq!(classify_probe_status(500), Reachability::WifiTransferOff);
315 }
316
317 #[test]
318 fn basic_header_is_colon_prefixed() {
319 assert_eq!(basic("secret"), "Basic OnNlY3JldA==");
320 }
321
322 #[test]
323 fn extracts_embedded_json_blob() {
324 let html = "<html>...\n const json = '{\"fileList\":[]}'\n const webInfo...</html>";
325 assert_eq!(
326 extract_embedded_json(html).as_deref(),
327 Some(r#"{"fileList":[]}"#)
328 );
329 }
330
331 #[test]
332 fn unescapes_apostrophes_in_extracted_json() {
333 let html = " const json = '{\"name\":\"Mike\\'s\"}'\n";
334 assert_eq!(
335 extract_embedded_json(html).as_deref(),
336 Some(r#"{"name":"Mike's"}"#)
337 );
338 }
339
340 #[test]
341 fn parses_real_nomad_listing_shape() {
342 let json = r#"{"deviceName":"Supernote Nomad","fileList":[{"date":"2026-07-19 07:39","extension":"","isDirectory":true,"name":"Document","size":0,"uri":"/Document"},{"date":"2026-07-19 07:58","extension":".pdf","isDirectory":false,"name":"guide.pdf","size":14817,"uri":"/Document/guide.pdf"}]}"#;
343 let page: PageInfo = serde_json::from_str(json).unwrap();
344 let entries: Vec<Entry> = page
345 .file_list
346 .into_iter()
347 .map(RawEntry::into_entry)
348 .collect();
349 assert_eq!(entries.len(), 2);
350 assert_eq!(entries[0].name, "Document");
351 assert!(entries[0].is_dir);
352 assert_eq!(entries[1].name, "guide.pdf");
353 assert!(!entries[1].is_dir);
354 assert_eq!(entries[1].size, 14817);
355 }
356 }
357