| 2 |
2 |
|
//!
|
| 3 |
3 |
|
//! Every assumption about the device's HTTP surface lives in this file.
|
| 4 |
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.
|
| 5 |
13 |
|
|
| 6 |
14 |
|
use std::time::Duration;
|
| 7 |
15 |
|
|
| 17 |
25 |
|
pub(crate) fn probe(host: &str, port: u16, timeout: Duration) -> Reachability {
|
| 18 |
26 |
|
let url = format!("http://{host}:{port}/");
|
| 19 |
27 |
|
match agent(timeout).get(&url).call() {
|
| 20 |
|
- |
Ok(_) => Reachability::Up,
|
|
28 |
+ |
Ok(resp) if resp.status() >= 200 && resp.status() < 300 => Reachability::Up,
|
|
29 |
+ |
Ok(_) => Reachability::WifiTransferOff,
|
| 21 |
30 |
|
Err(ureq::Error::Status(401 | 403, _)) => Reachability::Up,
|
| 22 |
31 |
|
Err(ureq::Error::Status(_, _)) => Reachability::WifiTransferOff,
|
| 23 |
32 |
|
Err(ureq::Error::Transport(_)) => Reachability::Unreachable,
|
| 24 |
33 |
|
}
|
| 25 |
34 |
|
}
|
| 26 |
35 |
|
|
| 27 |
|
- |
pub(crate) fn put(
|
|
36 |
+ |
pub(crate) fn upload(
|
| 28 |
37 |
|
host: &str,
|
| 29 |
38 |
|
port: u16,
|
| 30 |
39 |
|
passcode: Option<&str>,
|
| 33 |
42 |
|
bytes: &[u8],
|
| 34 |
43 |
|
timeout: Duration,
|
| 35 |
44 |
|
) -> Result<()> {
|
| 36 |
|
- |
let url = build_url(host, port, dir, Some(filename));
|
|
45 |
+ |
let url = build_url(host, port, dir, None);
|
|
46 |
+ |
let (body, content_type) = encode_multipart(filename, "application/pdf", bytes);
|
|
47 |
+ |
|
| 37 |
48 |
|
let mut req = agent(timeout)
|
| 38 |
|
- |
.put(&url)
|
| 39 |
|
- |
.set("Content-Type", "application/pdf");
|
|
49 |
+ |
.post(&url)
|
|
50 |
+ |
.set("Content-Type", &content_type)
|
|
51 |
+ |
.set("file-size", &bytes.len().to_string());
|
| 40 |
52 |
|
if let Some(pc) = passcode {
|
| 41 |
53 |
|
req = req.set("Authorization", &basic(pc));
|
| 42 |
54 |
|
}
|
| 43 |
|
- |
req.send_bytes(bytes).map_err(Error::from_http)?;
|
|
55 |
+ |
req.send_bytes(&body).map_err(Error::from_http)?;
|
| 44 |
56 |
|
Ok(())
|
| 45 |
57 |
|
}
|
| 46 |
58 |
|
|
| 52 |
64 |
|
timeout: Duration,
|
| 53 |
65 |
|
) -> Result<Vec<Entry>> {
|
| 54 |
66 |
|
let url = build_url(host, port, dir, None);
|
| 55 |
|
- |
let mut req = agent(timeout).get(&url).set("Accept", "application/json");
|
|
67 |
+ |
let mut req = agent(timeout).get(&url);
|
| 56 |
68 |
|
if let Some(pc) = passcode {
|
| 57 |
69 |
|
req = req.set("Authorization", &basic(pc));
|
| 58 |
70 |
|
}
|
| 59 |
|
- |
let resp = req.call().map_err(Error::from_http)?;
|
| 60 |
|
- |
let raw: Listing = resp.into_json().map_err(Error::from)?;
|
| 61 |
|
- |
Ok(raw.into_entries())
|
|
71 |
+ |
let html = req.call().map_err(Error::from_http)?.into_string()?;
|
|
72 |
+ |
let json = extract_embedded_json(&html).ok_or_else(|| {
|
|
73 |
+ |
Error::Http("could not find embedded webInfo JSON in listing page".to_string())
|
|
74 |
+ |
})?;
|
|
75 |
+ |
let page: PageInfo = serde_json::from_str(&json)
|
|
76 |
+ |
.map_err(|e| Error::Http(format!("failed to parse listing JSON: {e}")))?;
|
|
77 |
+ |
Ok(page.file_list.into_iter().map(RawEntry::into_entry).collect())
|
| 62 |
78 |
|
}
|
| 63 |
79 |
|
|
| 64 |
80 |
|
fn agent(timeout: Duration) -> ureq::Agent {
|
| 69 |
85 |
|
let dir = dir.trim_matches('/');
|
| 70 |
86 |
|
let mut url = format!("http://{host}:{port}");
|
| 71 |
87 |
|
if !dir.is_empty() {
|
| 72 |
|
- |
url.push('/');
|
| 73 |
|
- |
url.push_str(&utf8_percent_encode(dir, PATH).to_string());
|
|
88 |
+ |
for segment in dir.split('/') {
|
|
89 |
+ |
url.push('/');
|
|
90 |
+ |
url.push_str(&utf8_percent_encode(segment, PATH).to_string());
|
|
91 |
+ |
}
|
| 74 |
92 |
|
}
|
| 75 |
93 |
|
if let Some(name) = filename {
|
| 76 |
94 |
|
url.push('/');
|
| 79 |
97 |
|
url
|
| 80 |
98 |
|
}
|
| 81 |
99 |
|
|
|
100 |
+ |
fn encode_multipart(filename: &str, content_type: &str, bytes: &[u8]) -> (Vec<u8>, String) {
|
|
101 |
+ |
let boundary = format!(
|
|
102 |
+ |
"----supernote-push-{}",
|
|
103 |
+ |
std::time::SystemTime::now()
|
|
104 |
+ |
.duration_since(std::time::UNIX_EPOCH)
|
|
105 |
+ |
.map(|d| d.as_nanos())
|
|
106 |
+ |
.unwrap_or(0)
|
|
107 |
+ |
);
|
|
108 |
+ |
|
|
109 |
+ |
let disposition = format!(
|
|
110 |
+ |
"Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
|
|
111 |
+ |
escape_quoted(filename)
|
|
112 |
+ |
);
|
|
113 |
+ |
|
|
114 |
+ |
let mut body = Vec::with_capacity(bytes.len() + 256);
|
|
115 |
+ |
body.extend_from_slice(b"--");
|
|
116 |
+ |
body.extend_from_slice(boundary.as_bytes());
|
|
117 |
+ |
body.extend_from_slice(b"\r\n");
|
|
118 |
+ |
body.extend_from_slice(disposition.as_bytes());
|
|
119 |
+ |
body.extend_from_slice(format!("Content-Type: {content_type}\r\n\r\n").as_bytes());
|
|
120 |
+ |
body.extend_from_slice(bytes);
|
|
121 |
+ |
body.extend_from_slice(b"\r\n--");
|
|
122 |
+ |
body.extend_from_slice(boundary.as_bytes());
|
|
123 |
+ |
body.extend_from_slice(b"--\r\n");
|
|
124 |
+ |
|
|
125 |
+ |
(body, format!("multipart/form-data; boundary={boundary}"))
|
|
126 |
+ |
}
|
|
127 |
+ |
|
|
128 |
+ |
fn escape_quoted(s: &str) -> String {
|
|
129 |
+ |
s.replace('\\', "\\\\").replace('"', "\\\"")
|
|
130 |
+ |
}
|
|
131 |
+ |
|
|
132 |
+ |
fn extract_embedded_json(html: &str) -> Option<String> {
|
|
133 |
+ |
let marker = "const json = '";
|
|
134 |
+ |
let start = html.find(marker)? + marker.len();
|
|
135 |
+ |
let tail = &html[start..];
|
|
136 |
+ |
// The JS embeds the JSON as a single-quoted string literal terminated by
|
|
137 |
+ |
// a newline. JSON itself uses double quotes, and any embedded apostrophe
|
|
138 |
+ |
// in a filename would be `\'`-escaped by the device before it reached
|
|
139 |
+ |
// the client, so `'\n` is the reliable terminator.
|
|
140 |
+ |
let end = tail.find("'\n").or_else(|| tail.find("'\r\n"))?;
|
|
141 |
+ |
Some(tail[..end].replace("\\'", "'"))
|
|
142 |
+ |
}
|
|
143 |
+ |
|
| 82 |
144 |
|
fn basic(passcode: &str) -> String {
|
| 83 |
|
- |
use base64_stub::encode;
|
| 84 |
|
- |
format!("Basic {}", encode(format!(":{passcode}")))
|
|
145 |
+ |
format!("Basic {}", base64_stub::encode(format!(":{passcode}")))
|
| 85 |
146 |
|
}
|
| 86 |
147 |
|
|
| 87 |
|
- |
// Base64 without pulling in the `base64` crate — small enough to inline.
|
| 88 |
148 |
|
mod base64_stub {
|
| 89 |
|
- |
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
149 |
+ |
const TABLE: &[u8; 64] =
|
|
150 |
+ |
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
| 90 |
151 |
|
|
| 91 |
152 |
|
pub fn encode(input: impl AsRef<[u8]>) -> String {
|
| 92 |
153 |
|
let input = input.as_ref();
|
| 93 |
154 |
|
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
| 94 |
155 |
|
for chunk in input.chunks(3) {
|
| 95 |
|
- |
let (b0, b1, b2) = (chunk[0], chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0));
|
|
156 |
+ |
let b0 = chunk[0];
|
|
157 |
+ |
let b1 = chunk.get(1).copied().unwrap_or(0);
|
|
158 |
+ |
let b2 = chunk.get(2).copied().unwrap_or(0);
|
| 96 |
159 |
|
let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
|
| 97 |
160 |
|
out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
|
| 98 |
161 |
|
out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
|
| 99 |
|
- |
out.push(if chunk.len() > 1 { TABLE[((n >> 6) & 0x3f) as usize] as char } else { '=' });
|
| 100 |
|
- |
out.push(if chunk.len() > 2 { TABLE[(n & 0x3f) as usize] as char } else { '=' });
|
|
162 |
+ |
out.push(if chunk.len() > 1 {
|
|
163 |
+ |
TABLE[((n >> 6) & 0x3f) as usize] as char
|
|
164 |
+ |
} else {
|
|
165 |
+ |
'='
|
|
166 |
+ |
});
|
|
167 |
+ |
out.push(if chunk.len() > 2 {
|
|
168 |
+ |
TABLE[(n & 0x3f) as usize] as char
|
|
169 |
+ |
} else {
|
|
170 |
+ |
'='
|
|
171 |
+ |
});
|
| 101 |
172 |
|
}
|
| 102 |
173 |
|
out
|
| 103 |
174 |
|
}
|
| 116 |
187 |
|
}
|
| 117 |
188 |
|
}
|
| 118 |
189 |
|
|
| 119 |
|
- |
/// Deserialization shape for a directory listing.
|
| 120 |
|
- |
///
|
| 121 |
|
- |
/// The exact field names are unconfirmed against real firmware; the enum
|
| 122 |
|
- |
/// tolerates both a bare array and an object with a `files` key so a single
|
| 123 |
|
- |
/// `curl` session can pin the actual shape without breaking callers.
|
| 124 |
190 |
|
#[derive(serde::Deserialize)]
|
| 125 |
|
- |
#[serde(untagged)]
|
| 126 |
|
- |
enum Listing {
|
| 127 |
|
- |
Bare(Vec<RawEntry>),
|
| 128 |
|
- |
Wrapped { files: Vec<RawEntry> },
|
| 129 |
|
- |
}
|
| 130 |
|
- |
|
| 131 |
|
- |
impl Listing {
|
| 132 |
|
- |
fn into_entries(self) -> Vec<Entry> {
|
| 133 |
|
- |
let raws = match self {
|
| 134 |
|
- |
Self::Bare(v) => v,
|
| 135 |
|
- |
Self::Wrapped { files } => files,
|
| 136 |
|
- |
};
|
| 137 |
|
- |
raws.into_iter().map(RawEntry::into_entry).collect()
|
| 138 |
|
- |
}
|
|
191 |
+ |
struct PageInfo {
|
|
192 |
+ |
#[serde(rename = "fileList", default)]
|
|
193 |
+ |
file_list: Vec<RawEntry>,
|
| 139 |
194 |
|
}
|
| 140 |
195 |
|
|
| 141 |
196 |
|
#[derive(serde::Deserialize)]
|
| 142 |
197 |
|
struct RawEntry {
|
| 143 |
198 |
|
name: String,
|
| 144 |
|
- |
#[serde(default, alias = "isDirectory", alias = "directory")]
|
|
199 |
+ |
#[serde(default, rename = "isDirectory")]
|
| 145 |
200 |
|
is_dir: bool,
|
| 146 |
201 |
|
#[serde(default)]
|
| 147 |
202 |
|
size: u64,
|
| 168 |
223 |
|
}
|
| 169 |
224 |
|
|
| 170 |
225 |
|
#[test]
|
| 171 |
|
- |
fn url_without_filename() {
|
|
226 |
+ |
fn url_folder_only() {
|
| 172 |
227 |
|
assert_eq!(
|
| 173 |
228 |
|
build_url("10.0.0.5", 8089, "Document/ripgrow", None),
|
| 174 |
229 |
|
"http://10.0.0.5:8089/Document/ripgrow"
|
| 176 |
231 |
|
}
|
| 177 |
232 |
|
|
| 178 |
233 |
|
#[test]
|
| 179 |
|
- |
fn url_root_dir() {
|
|
234 |
+ |
fn url_root() {
|
| 180 |
235 |
|
assert_eq!(build_url("10.0.0.5", 8089, "/", None), "http://10.0.0.5:8089");
|
| 181 |
236 |
|
}
|
| 182 |
237 |
|
|
| 183 |
238 |
|
#[test]
|
|
239 |
+ |
fn multipart_body_has_expected_shape() {
|
|
240 |
+ |
let (body, ctype) = encode_multipart("hi.pdf", "application/pdf", b"ABC");
|
|
241 |
+ |
assert!(ctype.starts_with("multipart/form-data; boundary=----supernote-push-"));
|
|
242 |
+ |
let s = String::from_utf8_lossy(&body);
|
|
243 |
+ |
assert!(s.contains(r#"Content-Disposition: form-data; name="file"; filename="hi.pdf""#));
|
|
244 |
+ |
assert!(s.contains("Content-Type: application/pdf"));
|
|
245 |
+ |
assert!(s.contains("ABC"));
|
|
246 |
+ |
assert!(s.trim_end().ends_with("--"));
|
|
247 |
+ |
}
|
|
248 |
+ |
|
|
249 |
+ |
#[test]
|
|
250 |
+ |
fn escapes_quotes_in_filename() {
|
|
251 |
+ |
let (body, _) = encode_multipart(r#"a"b.pdf"#, "application/pdf", b"");
|
|
252 |
+ |
let s = String::from_utf8_lossy(&body);
|
|
253 |
+ |
assert!(s.contains(r#"filename="a\"b.pdf""#));
|
|
254 |
+ |
}
|
|
255 |
+ |
|
|
256 |
+ |
#[test]
|
| 184 |
257 |
|
fn basic_header_is_colon_prefixed() {
|
| 185 |
258 |
|
assert_eq!(basic("secret"), "Basic OnNlY3JldA==");
|
| 186 |
259 |
|
}
|
| 187 |
260 |
|
|
| 188 |
261 |
|
#[test]
|
| 189 |
|
- |
fn listing_parses_bare_array() {
|
| 190 |
|
- |
let json = r#"[{"name":"a.pdf","size":123}]"#;
|
| 191 |
|
- |
let l: Listing = serde_json::from_str(json).unwrap();
|
| 192 |
|
- |
let entries = l.into_entries();
|
| 193 |
|
- |
assert_eq!(entries.len(), 1);
|
| 194 |
|
- |
assert_eq!(entries[0].name, "a.pdf");
|
| 195 |
|
- |
assert_eq!(entries[0].size, 123);
|
| 196 |
|
- |
assert!(!entries[0].is_dir);
|
|
262 |
+ |
fn extracts_embedded_json_blob() {
|
|
263 |
+ |
let html = "<html>...\n const json = '{\"fileList\":[]}'\n const webInfo...</html>";
|
|
264 |
+ |
assert_eq!(extract_embedded_json(html).as_deref(), Some(r#"{"fileList":[]}"#));
|
|
265 |
+ |
}
|
|
266 |
+ |
|
|
267 |
+ |
#[test]
|
|
268 |
+ |
fn unescapes_apostrophes_in_extracted_json() {
|
|
269 |
+ |
let html = " const json = '{\"name\":\"Mike\\'s\"}'\n";
|
|
270 |
+ |
assert_eq!(
|
|
271 |
+ |
extract_embedded_json(html).as_deref(),
|
|
272 |
+ |
Some(r#"{"name":"Mike's"}"#)
|
|
273 |
+ |
);
|
| 197 |
274 |
|
}
|
| 198 |
275 |
|
|
| 199 |
276 |
|
#[test]
|
| 200 |
|
- |
fn listing_parses_wrapped_object() {
|
| 201 |
|
- |
let json = r#"{"files":[{"name":"Docs","isDirectory":true}]}"#;
|
| 202 |
|
- |
let l: Listing = serde_json::from_str(json).unwrap();
|
| 203 |
|
- |
let entries = l.into_entries();
|
|
277 |
+ |
fn parses_real_nomad_listing_shape() {
|
|
278 |
+ |
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"}]}"#;
|
|
279 |
+ |
let page: PageInfo = serde_json::from_str(json).unwrap();
|
|
280 |
+ |
let entries: Vec<Entry> = page.file_list.into_iter().map(RawEntry::into_entry).collect();
|
|
281 |
+ |
assert_eq!(entries.len(), 2);
|
|
282 |
+ |
assert_eq!(entries[0].name, "Document");
|
| 204 |
283 |
|
assert!(entries[0].is_dir);
|
|
284 |
+ |
assert_eq!(entries[1].name, "guide.pdf");
|
|
285 |
+ |
assert!(!entries[1].is_dir);
|
|
286 |
+ |
assert_eq!(entries[1].size, 14817);
|
| 205 |
287 |
|
}
|
| 206 |
288 |
|
}
|