| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 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 |
|
| 35 |
|
| 36 |
Ok(resp) => classify_probe_status(resp.status().as_u16()), |
| 37 |
Err(_) => Reachability::Unreachable, |
| 38 |
} |
| 39 |
} |
| 40 |
|
| 41 |
|
| 42 |
|
| 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 |
|
| 110 |
|
| 111 |
|
| 112 |
|
| 113 |
.http_status_as_error(false) |
| 114 |
.build(), |
| 115 |
) |
| 116 |
} |
| 117 |
|
| 118 |
|
| 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 |
|
| 182 |
|
| 183 |
|
| 184 |
|
| 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 |
|
| 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 |
|