//! Wire format for the Supernote Browse & Access HTTP interface. //! //! Every assumption about the device's HTTP surface lives in this file. //! When firmware drifts, only this module needs to change. //! //! Confirmed against a Supernote Nomad on 2026-07-19: //! - Upload is POST multipart/form-data to the folder URL (not PUT). //! - Field name is `file`, plus a custom `file-size` header with the byte //! count of the file. //! - Listing is not a JSON endpoint — the folder URL returns an HTML page //! with a `const json = '...'` blob embedded in the page's script. We //! extract and parse that. use std::time::Duration; use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; use crate::{ Error, Result, client::{Entry, Reachability}, }; const PATH: &AsciiSet = &CONTROLS .add(b' ') .add(b'#') .add(b'?') .add(b'"') .add(b'<') .add(b'>'); pub(crate) fn probe(host: &str, port: u16, timeout: Duration) -> Reachability { let url = format!("http://{host}:{port}/"); match agent(timeout).get(&url).call() { // The agent reports 4xx/5xx as ordinary responses, so every status // lands in this arm rather than being split across Ok and Err. Ok(resp) => classify_probe_status(resp.status().as_u16()), Err(_) => Reachability::Unreachable, } } /// A passcode-protected device still answers, it just answers 401/403, so an /// auth challenge counts as up. fn classify_probe_status(status: u16) -> Reachability { match status { 200..=299 | 401 | 403 => Reachability::Up, _ => Reachability::WifiTransferOff, } } pub(crate) fn upload( host: &str, port: u16, passcode: Option<&str>, dir: &str, filename: &str, bytes: &[u8], timeout: Duration, ) -> Result<()> { let url = build_url(host, port, dir, None); let (body, content_type) = encode_multipart(filename, "application/pdf", bytes); let mut req = agent(timeout) .post(&url) .header("Content-Type", &content_type) .header("file-size", bytes.len().to_string()); if let Some(pc) = passcode { req = req.header("Authorization", basic(pc)); } let mut resp = req.send(&body[..]).map_err(Error::from_http)?; if let Some(e) = status_error(&mut resp) { return Err(e); } Ok(()) } pub(crate) fn list( host: &str, port: u16, passcode: Option<&str>, dir: &str, timeout: Duration, ) -> Result> { let url = build_url(host, port, dir, None); let mut req = agent(timeout).get(&url); if let Some(pc) = passcode { req = req.header("Authorization", basic(pc)); } let mut resp = req.call().map_err(Error::from_http)?; if let Some(e) = status_error(&mut resp) { return Err(e); } let html = resp.body_mut().read_to_string().map_err(Error::from_http)?; let json = extract_embedded_json(&html).ok_or_else(|| { Error::Http("could not find embedded webInfo JSON in listing page".to_string()) })?; let page: PageInfo = serde_json::from_str(&json) .map_err(|e| Error::Http(format!("failed to parse listing JSON: {e}")))?; Ok(page .file_list .into_iter() .map(RawEntry::into_entry) .collect()) } fn agent(timeout: Duration) -> ureq::Agent { ureq::Agent::new_with_config( ureq::Agent::config_builder() .timeout_global(Some(timeout)) // ureq 3 would otherwise fail a 4xx/5xx with Error::StatusCode, // which carries the code but not the response. The device puts a // useful message in the body, so take every status as a normal // response and inspect it in `status_error`. .http_status_as_error(false) .build(), ) } /// Returns the error for a non-2xx response, draining the body into it. fn status_error(resp: &mut ureq::http::Response) -> Option { let status = resp.status().as_u16(); if (200..300).contains(&status) { return None; } Some(Error::Status { status, body: resp.body_mut().read_to_string().unwrap_or_default(), }) } fn build_url(host: &str, port: u16, dir: &str, filename: Option<&str>) -> String { let dir = dir.trim_matches('/'); let mut url = format!("http://{host}:{port}"); if !dir.is_empty() { for segment in dir.split('/') { url.push('/'); url.push_str(&utf8_percent_encode(segment, PATH).to_string()); } } if let Some(name) = filename { url.push('/'); url.push_str(&utf8_percent_encode(name, PATH).to_string()); } url } fn encode_multipart(filename: &str, content_type: &str, bytes: &[u8]) -> (Vec, String) { let boundary = format!( "----supernote-push-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_nanos()) ); let disposition = format!( "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n", escape_quoted(filename) ); let mut body = Vec::with_capacity(bytes.len() + 256); body.extend_from_slice(b"--"); body.extend_from_slice(boundary.as_bytes()); body.extend_from_slice(b"\r\n"); body.extend_from_slice(disposition.as_bytes()); body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes()); body.extend_from_slice(bytes); body.extend_from_slice(b"\r\n--"); body.extend_from_slice(boundary.as_bytes()); body.extend_from_slice(b"--\r\n"); (body, format!("multipart/form-data; boundary={boundary}")) } fn escape_quoted(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } fn extract_embedded_json(html: &str) -> Option { let marker = "const json = '"; let start = html.find(marker)? + marker.len(); let tail = &html[start..]; // The JS embeds the JSON as a single-quoted string literal terminated by // a newline. JSON itself uses double quotes, and any embedded apostrophe // in a filename would be `\'`-escaped by the device before it reached // the client, so `'\n` is the reliable terminator. let end = tail.find("'\n").or_else(|| tail.find("'\r\n"))?; Some(tail[..end].replace("\\'", "'")) } fn basic(passcode: &str) -> String { format!("Basic {}", base64_stub::encode(format!(":{passcode}"))) } mod base64_stub { const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; pub(super) fn encode(input: impl AsRef<[u8]>) -> String { let input = input.as_ref(); let mut out = String::with_capacity(input.len().div_ceil(3) * 4); for chunk in input.chunks(3) { let b0 = chunk[0]; let b1 = chunk.get(1).copied().unwrap_or(0); let b2 = chunk.get(2).copied().unwrap_or(0); let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2); out.push(TABLE[((n >> 18) & 0x3f) as usize] as char); out.push(TABLE[((n >> 12) & 0x3f) as usize] as char); out.push(if chunk.len() > 1 { TABLE[((n >> 6) & 0x3f) as usize] as char } else { '=' }); out.push(if chunk.len() > 2 { TABLE[(n & 0x3f) as usize] as char } else { '=' }); } out } #[cfg(test)] mod tests { use super::encode; #[test] fn known_vectors() { assert_eq!(encode(""), ""); assert_eq!(encode("f"), "Zg=="); assert_eq!(encode("fo"), "Zm8="); assert_eq!(encode("foo"), "Zm9v"); assert_eq!(encode(":secret"), "OnNlY3JldA=="); } } } #[derive(serde::Deserialize)] struct PageInfo { #[serde(rename = "fileList", default)] file_list: Vec, } #[derive(serde::Deserialize)] struct RawEntry { name: String, #[serde(default, rename = "isDirectory")] is_dir: bool, #[serde(default)] size: u64, } impl RawEntry { fn into_entry(self) -> Entry { Entry { name: self.name, is_dir: self.is_dir, size: self.size, } } } #[cfg(test)] mod tests { use super::*; #[test] fn url_encodes_spaces_and_hashes() { let url = build_url("10.0.0.5", 8089, "/Document/My Notes/", Some("a b#c.pdf")); assert_eq!( url, "http://10.0.0.5:8089/Document/My%20Notes/a%20b%23c.pdf" ); } #[test] fn url_folder_only() { assert_eq!( build_url("10.0.0.5", 8089, "Document/ripgrow", None), "http://10.0.0.5:8089/Document/ripgrow" ); } #[test] fn url_root() { assert_eq!( build_url("10.0.0.5", 8089, "/", None), "http://10.0.0.5:8089" ); } #[test] fn multipart_body_has_expected_shape() { let (body, ctype) = encode_multipart("hi.pdf", "application/pdf", b"ABC"); assert!(ctype.starts_with("multipart/form-data; boundary=----supernote-push-")); let s = String::from_utf8_lossy(&body); assert!(s.contains(r#"Content-Disposition: form-data; name="file"; filename="hi.pdf""#)); assert!(s.contains("Content-Type: application/pdf")); assert!(s.contains("ABC")); assert!(s.trim_end().ends_with("--")); } #[test] fn escapes_quotes_in_filename() { let (body, _) = encode_multipart(r#"a"b.pdf"#, "application/pdf", b""); let s = String::from_utf8_lossy(&body); assert!(s.contains(r#"filename="a\"b.pdf""#)); } #[test] fn probe_treats_auth_challenges_as_reachable() { assert_eq!(classify_probe_status(200), Reachability::Up); assert_eq!(classify_probe_status(204), Reachability::Up); assert_eq!(classify_probe_status(401), Reachability::Up); assert_eq!(classify_probe_status(403), Reachability::Up); // Anything else answering on the port is a device with Wi-Fi Transfer off. assert_eq!(classify_probe_status(404), Reachability::WifiTransferOff); assert_eq!(classify_probe_status(500), Reachability::WifiTransferOff); } #[test] fn basic_header_is_colon_prefixed() { assert_eq!(basic("secret"), "Basic OnNlY3JldA=="); } #[test] fn extracts_embedded_json_blob() { let html = "...\n const json = '{\"fileList\":[]}'\n const webInfo..."; assert_eq!( extract_embedded_json(html).as_deref(), Some(r#"{"fileList":[]}"#) ); } #[test] fn unescapes_apostrophes_in_extracted_json() { let html = " const json = '{\"name\":\"Mike\\'s\"}'\n"; assert_eq!( extract_embedded_json(html).as_deref(), Some(r#"{"name":"Mike's"}"#) ); } #[test] fn parses_real_nomad_listing_shape() { 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"}]}"#; let page: PageInfo = serde_json::from_str(json).unwrap(); let entries: Vec = page .file_list .into_iter() .map(RawEntry::into_entry) .collect(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].name, "Document"); assert!(entries[0].is_dir); assert_eq!(entries[1].name, "guide.pdf"); assert!(!entries[1].is_dir); assert_eq!(entries[1].size, 14817); } }