rust: merge UI feature modules; fix Ctrl+key on Linux/Windows (egui command_only shadowing); clippy clean; rustfmt config
This commit is contained in:
@@ -70,11 +70,7 @@ struct ApiPerson {
|
||||
impl Client {
|
||||
/// `base_url` is the API root, e.g. `https://api.github.com` or `https://ghe.example.com/api/v3`.
|
||||
pub fn new(base_url: &str, token: &str) -> Client {
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.into();
|
||||
let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into();
|
||||
Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent }
|
||||
}
|
||||
|
||||
@@ -89,15 +85,12 @@ impl Client {
|
||||
|
||||
/// Confirms the token works and returns the authenticated login.
|
||||
pub fn validate_token(&self) -> crate::Result<String> {
|
||||
let mut resp = self
|
||||
.get(&format!("{}/user", self.base_url))
|
||||
.map_err(|e| format!("contacting GitHub API: {e}"))?;
|
||||
let mut resp = self.get(&format!("{}/user", self.base_url)).map_err(|e| format!("contacting GitHub API: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status != 200 {
|
||||
return Err(format!("token validation failed (HTTP {status})").into());
|
||||
}
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading user response: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading user response: {e}"))?;
|
||||
#[derive(Deserialize)]
|
||||
struct User {
|
||||
#[serde(default)]
|
||||
@@ -114,35 +107,21 @@ impl Client {
|
||||
|
||||
/// Like `fetch_repos`, reporting commit-enrichment progress as `(done, total)`; it is called
|
||||
/// from worker threads.
|
||||
pub fn fetch_repos_with_progress(
|
||||
&self,
|
||||
progress: impl Fn(usize, usize) + Sync,
|
||||
) -> crate::Result<Vec<GitHubRepo>> {
|
||||
pub fn fetch_repos_with_progress(&self, progress: impl Fn(usize, usize) + Sync) -> crate::Result<Vec<GitHubRepo>> {
|
||||
let mut all: Vec<ApiRepo> = Vec::new();
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc",
|
||||
self.base_url
|
||||
);
|
||||
let url = format!("{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc", self.base_url);
|
||||
let mut resp = self.get(&url).map_err(|e| format!("fetching repos page {page}: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading response body: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading response body: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status == 401 || status == 403 {
|
||||
return Err(
|
||||
format!("authentication failed (HTTP {status}) — check your access token").into()
|
||||
);
|
||||
return Err(format!("authentication failed (HTTP {status}) — check your access token").into());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
)
|
||||
.into());
|
||||
return Err(format!("GitHub API returned HTTP {status}: {}", String::from_utf8_lossy(&body)).into());
|
||||
}
|
||||
let batch: Vec<ApiRepo> =
|
||||
serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
let batch: Vec<ApiRepo> = serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -161,7 +140,7 @@ impl Client {
|
||||
let updated = parse_time(r.updated_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
parsed.push((r, pushed, updated));
|
||||
}
|
||||
parsed.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
parsed.sort_by_key(|p| std::cmp::Reverse(p.1));
|
||||
|
||||
let mut result: Vec<GitHubRepo> = parsed
|
||||
.into_iter()
|
||||
@@ -234,14 +213,9 @@ impl Client {
|
||||
return Err(format!("authentication failed (HTTP {status})").into());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
read_error_body(resp.body_mut().as_reader())
|
||||
)
|
||||
.into());
|
||||
return Err(format!("GitHub API returned HTTP {status}: {}", read_error_body(resp.body_mut().as_reader())).into());
|
||||
}
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading commit response: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading commit response: {e}"))?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
@@ -284,25 +258,14 @@ impl Client {
|
||||
return Err(format!("authentication failed (HTTP {status}) — check your access token").into());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
read_error_body(resp.body_mut().as_reader())
|
||||
)
|
||||
.into());
|
||||
return Err(format!("GitHub API returned HTTP {status}: {}", read_error_body(resp.body_mut().as_reader())).into());
|
||||
}
|
||||
let length = resp
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.trim().parse::<u64>().ok());
|
||||
let length = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
|
||||
if let Some(length) = length.filter(|&l| l > MAX_ARCHIVE_BYTES) {
|
||||
return Err(
|
||||
format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into()
|
||||
);
|
||||
return Err(format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into());
|
||||
}
|
||||
let mut file = std::fs::File::create(dest_path).map_err(|e| format!("creating file: {e}"))?;
|
||||
copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES)
|
||||
.map_err(|e| format!("writing archive: {e}"))?;
|
||||
copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES).map_err(|e| format!("writing archive: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -446,9 +409,7 @@ mod tests {
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for req in server.incoming_requests() {
|
||||
let header = |name: &'static str| {
|
||||
req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string())
|
||||
};
|
||||
let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string());
|
||||
log.lock().unwrap().push((req.url().to_string(), header("Authorization"), header("User-Agent")));
|
||||
let (status, body) = handler(req.url());
|
||||
let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status));
|
||||
@@ -475,7 +436,7 @@ mod tests {
|
||||
serde_json::json!({
|
||||
"full_name": format!("me/r{n}"), "name": format!("r{n}"), "description": null,
|
||||
"html_url": format!("https://example/me/r{n}"), "default_branch": if n == 0 { "" } else { "main" },
|
||||
"private": n % 2 == 0, "fork": false, "archived": false, "language": null,
|
||||
"private": n.is_multiple_of(2), "fork": false, "archived": false, "language": null,
|
||||
"stargazers_count": n, "pushed_at": pushed, "updated_at": "2024-01-01T00:00:00Z",
|
||||
"owner": {"login": "me"}
|
||||
})
|
||||
@@ -485,9 +446,7 @@ mod tests {
|
||||
fn fetch_repos_paginates_sorts_and_enriches() {
|
||||
let (base, seen) = serve(|url| {
|
||||
if url.starts_with("/user/repos") && url.contains("page=1&") {
|
||||
let repos: Vec<_> = (0..100)
|
||||
.map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28)))
|
||||
.collect();
|
||||
let repos: Vec<_> = (0..100).map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28))).collect();
|
||||
(200, serde_json::to_string(&repos).unwrap())
|
||||
} else if url.starts_with("/user/repos") {
|
||||
(200, serde_json::to_string(&[repo_json(100, "2025-06-01T12:00:00Z")]).unwrap())
|
||||
@@ -500,9 +459,7 @@ mod tests {
|
||||
}
|
||||
});
|
||||
let calls = Mutex::new(Vec::new());
|
||||
let repos = Client::new(&base, "t")
|
||||
.fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total)))
|
||||
.unwrap();
|
||||
let repos = Client::new(&base, "t").fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total))).unwrap();
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[0].full_name, "me/r100");
|
||||
assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z");
|
||||
|
||||
@@ -478,14 +478,14 @@ impl Client {
|
||||
st.connected = false;
|
||||
r
|
||||
};
|
||||
if let Err(e) = result {
|
||||
if !was_closing {
|
||||
let msg = e.to_string();
|
||||
self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() });
|
||||
(self.cfg.on_error)(msg.clone());
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(msg));
|
||||
}
|
||||
if let Err(e) = result
|
||||
&& !was_closing
|
||||
{
|
||||
let msg = e.to_string();
|
||||
self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() });
|
||||
(self.cfg.on_error)(msg.clone());
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(msg));
|
||||
}
|
||||
}
|
||||
if was_connected {
|
||||
@@ -498,8 +498,7 @@ impl Client {
|
||||
Some(connector) => connector()?,
|
||||
None => {
|
||||
let addr = format!("{}:{}", self.cfg.server, self.cfg.port);
|
||||
let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout)
|
||||
.map_err(|e| format!("dial tcp {addr}: {e}"))?;
|
||||
let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout).map_err(|e| format!("dial tcp {addr}: {e}"))?;
|
||||
tcp.set_read_timeout(Some(READ_POLL))?;
|
||||
tcp.set_write_timeout(Some(self.cfg.connect_timeout))?;
|
||||
let _ = tcp.set_nodelay(true);
|
||||
@@ -571,10 +570,7 @@ impl Client {
|
||||
if ping_sent && self.st().last_pong.elapsed() > PING_DELAY + PING_TIMEOUT {
|
||||
return Err("timed out waiting for a requested PING response".into());
|
||||
}
|
||||
let id = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let id = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
|
||||
write(&mut stream, &encode("PING", &[&id.to_string()]))?;
|
||||
last_ping = Instant::now();
|
||||
ping_sent = true;
|
||||
@@ -637,11 +633,7 @@ impl Client {
|
||||
}
|
||||
"322" => {
|
||||
if m.params.len() >= 4 {
|
||||
let room = RoomInfo {
|
||||
name: m.param(1).into(),
|
||||
users: m.param(2).parse().unwrap_or(0),
|
||||
topic: m.param(3).into(),
|
||||
};
|
||||
let room = RoomInfo { name: m.param(1).into(), users: m.param(2).parse().unwrap_or(0), topic: m.param(3).into() };
|
||||
let mut st = self.st();
|
||||
if st.list_rooms.len() < MAX_ROOM_LIST_ENTRIES {
|
||||
st.list_rooms.push(room);
|
||||
@@ -668,11 +660,7 @@ impl Client {
|
||||
st.ready.take()
|
||||
};
|
||||
let host = if self.cfg.server.contains(':') { format!("[{}]", self.cfg.server) } else { self.cfg.server.clone() };
|
||||
self.emit(Event {
|
||||
kind: "system".into(),
|
||||
text: format!("Connected to {host}:{}", self.cfg.port),
|
||||
..Default::default()
|
||||
});
|
||||
self.emit(Event { kind: "system".into(), text: format!("Connected to {host}:{}", self.cfg.port), ..Default::default() });
|
||||
self.identify_with_nickserv();
|
||||
if !self.cfg.auto_join_rooms.is_empty() {
|
||||
let _ = self.send("JOIN", &[&self.cfg.auto_join_rooms.join(",")]);
|
||||
@@ -747,11 +735,7 @@ impl Client {
|
||||
let from = m.source.clone();
|
||||
if !from.is_empty() {
|
||||
let mut st = self.st();
|
||||
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel {
|
||||
name: room.clone(),
|
||||
topic: String::new(),
|
||||
users: Vec::new(),
|
||||
});
|
||||
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { name: room.clone(), topic: String::new(), users: Vec::new() });
|
||||
add_user(ch, &from);
|
||||
}
|
||||
if self.is_me(&from) {
|
||||
@@ -856,14 +840,7 @@ impl Client {
|
||||
if let Some(ch) = self.st().channels.get_mut(&fold(room)) {
|
||||
ch.topic = topic.to_string();
|
||||
}
|
||||
self.emit(Event {
|
||||
kind: "topic".into(),
|
||||
target: room.into(),
|
||||
from: m.source.clone(),
|
||||
text: topic.into(),
|
||||
topic: topic.into(),
|
||||
..Default::default()
|
||||
});
|
||||
self.emit(Event { kind: "topic".into(), target: room.into(), from: m.source.clone(), text: topic.into(), topic: topic.into(), ..Default::default() });
|
||||
self.emit_rooms();
|
||||
}
|
||||
|
||||
@@ -1060,10 +1037,7 @@ mod tests {
|
||||
fn remember_room_membership_tracks_confirmed_channels() {
|
||||
let updates = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = updates.clone();
|
||||
let client = Client::new(Config {
|
||||
on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))),
|
||||
..Default::default()
|
||||
});
|
||||
let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))), ..Default::default() });
|
||||
client.remember_room_membership("#One", true);
|
||||
client.remember_room_membership("#two", true);
|
||||
client.remember_room_membership("#one", true);
|
||||
@@ -1095,11 +1069,7 @@ mod tests {
|
||||
fn event_serialises_like_go() {
|
||||
let e = Event { kind: "users".into(), target: "#a".into(), timestamp: 5, ..Default::default() };
|
||||
assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"users","target":"#a","timestamp":5}"##);
|
||||
let e = Event {
|
||||
kind: "rooms".into(),
|
||||
rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }],
|
||||
..Default::default()
|
||||
};
|
||||
let e = Event { kind: "rooms".into(), rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }], ..Default::default() };
|
||||
assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"rooms","timestamp":0,"rooms":[{"name":"#a"}]}"##);
|
||||
}
|
||||
|
||||
@@ -1236,10 +1206,7 @@ mod tests {
|
||||
srv.expect("NAMES #a");
|
||||
client.handle_input("#a", "/mode #a +i").unwrap();
|
||||
srv.expect("MODE #a +i");
|
||||
assert_eq!(
|
||||
client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(),
|
||||
"select a room or use /msg <nick> <message>"
|
||||
);
|
||||
assert_eq!(client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(), "select a room or use /msg <nick> <message>");
|
||||
assert_eq!(client.handle_input("#a", "/msg bob").unwrap_err().to_string(), "/msg requires a target and message");
|
||||
client.handle_input("", "/help").unwrap();
|
||||
assert!(wait_for(&rx, |e| e.kind == "system").text.starts_with("Commands: /join"));
|
||||
@@ -1269,10 +1236,7 @@ mod tests {
|
||||
fn remote_close_reports_error_and_close() {
|
||||
let errors = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = errors.clone();
|
||||
let (client, listener, rx, closes) = harness(Config {
|
||||
on_error: Arc::new(move |e| log.lock().unwrap().push(e)),
|
||||
..Default::default()
|
||||
});
|
||||
let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().unwrap().push(e)), ..Default::default() });
|
||||
let c = client.clone();
|
||||
let connecting = std::thread::spawn(move || c.connect());
|
||||
let mut srv = accept(&listener);
|
||||
|
||||
@@ -9,9 +9,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
pub fn default_shell() -> String {
|
||||
if cfg!(windows) {
|
||||
let ps = std::env::var_os("SystemRoot")
|
||||
.map(|r| PathBuf::from(r).join(r"System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.filter(|p| p.exists());
|
||||
let ps = std::env::var_os("SystemRoot").map(|r| PathBuf::from(r).join(r"System32\WindowsPowerShell\v1.0\powershell.exe")).filter(|p| p.exists());
|
||||
return ps.map_or_else(|| "cmd.exe".into(), |p| p.to_string_lossy().into());
|
||||
}
|
||||
std::env::var("SHELL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "/bin/sh".into())
|
||||
|
||||
@@ -17,9 +17,7 @@ use std::sync::{Arc, OnceLock};
|
||||
/// The shared tokio runtime for async protocol stacks (russh). Blocking clients use threads.
|
||||
pub fn rt() -> &'static tokio::runtime::Runtime {
|
||||
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
|
||||
RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime")
|
||||
})
|
||||
RT.get_or_init(|| tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime"))
|
||||
}
|
||||
|
||||
pub type DataFn = Arc<dyn Fn(&[u8]) + Send + Sync>;
|
||||
@@ -81,11 +79,7 @@ impl TcpConn {
|
||||
TcpConn { stream: std::sync::Mutex::new(None), on_error, on_close }
|
||||
}
|
||||
|
||||
pub(crate) fn connect(
|
||||
self: &Arc<Self>,
|
||||
stream: std::net::TcpStream,
|
||||
mut on_read: impl FnMut(&[u8]) + Send + 'static,
|
||||
) -> crate::Result<()> {
|
||||
pub(crate) fn connect(self: &Arc<Self>, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> {
|
||||
let mut reader = stream.try_clone()?;
|
||||
{
|
||||
let mut guard = self.stream.lock().unwrap();
|
||||
@@ -148,10 +142,7 @@ pub(crate) fn is_latin1(encoding: &str) -> bool {
|
||||
/// UTF-8 → ISO 8859-1. Like Go's charmap encoder, input that isn't valid UTF-8 or holds a
|
||||
/// character above U+00FF is returned unchanged.
|
||||
pub(crate) fn utf8_to_latin1(data: &[u8]) -> Vec<u8> {
|
||||
std::str::from_utf8(data)
|
||||
.ok()
|
||||
.and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect())
|
||||
.unwrap_or_else(|| data.to_vec())
|
||||
std::str::from_utf8(data).ok().and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect()).unwrap_or_else(|| data.to_vec())
|
||||
}
|
||||
|
||||
/// ISO 8859-1 → UTF-8 (every byte maps to the code point of the same value).
|
||||
|
||||
@@ -262,10 +262,7 @@ pub fn parse_escaped(s: &str) -> crate::Result<Vec<u8>> {
|
||||
/// "latin1" / "latin-1", otherwise UTF-8 with invalid bytes replaced by U+FFFD.
|
||||
pub fn to_display_encoding(data: &[u8], encoding: &str) -> String {
|
||||
match encoding.trim().to_ascii_lowercase().as_str() {
|
||||
"ascii" => data
|
||||
.iter()
|
||||
.map(|&b| if (0x20..=0x7e).contains(&b) || matches!(b, b'\n' | b'\r' | b'\t') { b as char } else { '.' })
|
||||
.collect(),
|
||||
"ascii" => data.iter().map(|&b| if (0x20..=0x7e).contains(&b) || matches!(b, b'\n' | b'\r' | b'\t') { b as char } else { '.' }).collect(),
|
||||
e if super::is_latin1(e) => super::latin1_to_utf8(data),
|
||||
_ => String::from_utf8_lossy(data).into_owned(),
|
||||
}
|
||||
@@ -285,13 +282,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_hex_string_cases() {
|
||||
let ok: [(&str, &[u8]); 5] = [
|
||||
("48656c6c6f", b"Hello"),
|
||||
("48 65 6c 6c 6f", b"Hello"),
|
||||
("0x48 0x65 0x6c 0x6c 0x6f", b"Hello"),
|
||||
("0000", &[0, 0]),
|
||||
("ff ff", &[0xff, 0xff]),
|
||||
];
|
||||
let ok: [(&str, &[u8]); 5] =
|
||||
[("48656c6c6f", b"Hello"), ("48 65 6c 6c 6f", b"Hello"), ("0x48 0x65 0x6c 0x6c 0x6f", b"Hello"), ("0000", &[0, 0]), ("ff ff", &[0xff, 0xff])];
|
||||
for (input, want) in ok {
|
||||
assert_eq!(parse_hex_string(input).unwrap(), want, "{input:?}");
|
||||
}
|
||||
|
||||
@@ -38,12 +38,7 @@ pub fn list_ports() -> Vec<String> {
|
||||
impl SerialClient {
|
||||
pub fn new(profile: &Profile, cb: Callbacks) -> SerialClient {
|
||||
SerialClient {
|
||||
inner: Arc::new(Inner {
|
||||
path: profile.host.trim().to_string(),
|
||||
cfg: profile.serial.clone().unwrap_or_default(),
|
||||
cb,
|
||||
port: Mutex::new(None),
|
||||
}),
|
||||
inner: Arc::new(Inner { path: profile.host.trim().to_string(), cfg: profile.serial.clone().unwrap_or_default(), cb, port: Mutex::new(None) }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,9 +287,15 @@ mod tests {
|
||||
let (etx, erx) = mpsc::channel();
|
||||
let (ctx, crx) = mpsc::channel();
|
||||
let cb = Callbacks {
|
||||
on_data: Arc::new(move |d| { let _ = dtx.send(d.to_vec()); }),
|
||||
on_error: Arc::new(move |e| { let _ = etx.send(e); }),
|
||||
on_close: Arc::new(move || { let _ = ctx.send(()); }),
|
||||
on_data: Arc::new(move |d| {
|
||||
let _ = dtx.send(d.to_vec());
|
||||
}),
|
||||
on_error: Arc::new(move |e| {
|
||||
let _ = etx.send(e);
|
||||
}),
|
||||
on_close: Arc::new(move || {
|
||||
let _ = ctx.send(());
|
||||
}),
|
||||
};
|
||||
let profile = Profile { host: pty.path.clone(), serial: Some(cfg), ..Profile::new(models::Protocol::Serial) };
|
||||
(SerialClient::new(&profile, cb), drx, erx, crx)
|
||||
|
||||
@@ -31,19 +31,18 @@ pub struct Progress {
|
||||
}
|
||||
|
||||
fn mode_string(m: &russh_sftp::protocol::FileAttributes) -> String {
|
||||
let kind = if m.is_dir() { 'd' } else if m.is_symlink() { 'L' } else { '-' };
|
||||
let kind = if m.is_dir() {
|
||||
'd'
|
||||
} else if m.is_symlink() {
|
||||
'L'
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
format!("{kind}{}", m.permissions())
|
||||
}
|
||||
|
||||
fn entry(name: String, path: String, m: &russh_sftp::protocol::FileAttributes) -> FileEntry {
|
||||
FileEntry {
|
||||
name,
|
||||
path,
|
||||
size: m.size.unwrap_or(0),
|
||||
is_dir: m.is_dir(),
|
||||
mode: mode_string(m),
|
||||
modified_at: m.mtime.map_or(0, |t| t as i64 * 1000),
|
||||
}
|
||||
FileEntry { name, path, size: m.size.unwrap_or(0), is_dir: m.is_dir(), mode: mode_string(m), modified_at: m.mtime.map_or(0, |t| t as i64 * 1000) }
|
||||
}
|
||||
|
||||
/// `path.Join` for remote (always '/') paths.
|
||||
@@ -60,10 +59,8 @@ pub fn join(dir: &str, name: &str) -> String {
|
||||
/// Directories first, then case-insensitive name order.
|
||||
pub fn list(s: &Arc<SftpSession>, path: &str) -> Result<Vec<FileEntry>> {
|
||||
let rd = rt().block_on(s.read_dir(path)).map_err(|e| format!("sftpclient: failed to list {path}: {e}"))?;
|
||||
let mut out: Vec<FileEntry> = rd
|
||||
.filter(|e| e.file_name() != "." && e.file_name() != "..")
|
||||
.map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata()))
|
||||
.collect();
|
||||
let mut out: Vec<FileEntry> =
|
||||
rd.filter(|e| e.file_name() != "." && e.file_name() != "..").map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata())).collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! port forwards, agent forwarding and SFTP. A port of internal/sshclient + sftpclient.
|
||||
|
||||
use super::{Callbacks, rt};
|
||||
use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig};
|
||||
use crate::Result;
|
||||
use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig};
|
||||
use base64::Engine;
|
||||
use russh::client::{self, Handle, Msg};
|
||||
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey};
|
||||
@@ -160,12 +160,9 @@ async fn connect_agent() -> Result<Agent> {
|
||||
|
||||
fn is_totp_prompt(q: &str) -> bool {
|
||||
let q = q.to_lowercase();
|
||||
[
|
||||
"verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator",
|
||||
"security code", "6-digit", "6 digit",
|
||||
]
|
||||
.iter()
|
||||
.any(|k| q.contains(k))
|
||||
["verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator", "security code", "6-digit", "6 digit"]
|
||||
.iter()
|
||||
.any(|k| q.contains(k))
|
||||
}
|
||||
|
||||
/// RFC 6238 code (SHA-1, 6 digits, 30 s) for a base32 secret, normalised as pquerna/otp does.
|
||||
@@ -448,7 +445,9 @@ impl SshClient {
|
||||
let host = host.to_string();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||
let addr = listener.local_addr()?;
|
||||
let ch = rt().block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0)).map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?;
|
||||
let ch = rt()
|
||||
.block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0))
|
||||
.map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?;
|
||||
let client = std::net::TcpStream::connect(addr)?;
|
||||
let (server, _) = listener.accept()?;
|
||||
server.set_nonblocking(true)?;
|
||||
@@ -488,7 +487,11 @@ impl SshClient {
|
||||
let methods = if sc.auth_methods.is_empty() { SshConfig::defaults().auth_methods } else { sc.auth_methods.clone() };
|
||||
let key = if methods.iter().any(|m| m == "publickey") && !sc.private_key_path.is_empty() {
|
||||
let (path, pp) = (sc.private_key_path.clone(), passphrase.clone());
|
||||
Some(blocking(move || load_private_key(&path, pp.as_ref())).await.map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?)
|
||||
Some(
|
||||
blocking(move || load_private_key(&path, pp.as_ref()))
|
||||
.await
|
||||
.map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -498,17 +501,16 @@ impl SshClient {
|
||||
self.handshake(tcp, &p.host, port, &check).await?
|
||||
} else {
|
||||
let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?;
|
||||
let ch = prev.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
|
||||
let ch = prev
|
||||
.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
|
||||
self.hops.lock().unwrap().extend(h);
|
||||
self.hops.lock().unwrap().push(prev);
|
||||
self.handshake(ch.into_stream(), &p.host, port, &check).await?
|
||||
};
|
||||
|
||||
authenticate(
|
||||
&mut handle,
|
||||
Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() },
|
||||
)
|
||||
.await?;
|
||||
authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }).await?;
|
||||
let handle = Arc::new(handle);
|
||||
if !shell {
|
||||
*self.handle.lock().unwrap() = Some(handle);
|
||||
@@ -565,7 +567,13 @@ impl SshClient {
|
||||
}
|
||||
|
||||
/// Authenticates through each jump host in turn; returns the last hop and the ones before it.
|
||||
async fn via_jumps(&self, sc: &SshConfig, check: &HostKeyCheck, passphrase: Option<SecretFn>, timeout: Duration) -> Result<(Arc<Handle<Handler>>, Vec<Arc<Handle<Handler>>>)> {
|
||||
async fn via_jumps(
|
||||
&self,
|
||||
sc: &SshConfig,
|
||||
check: &HostKeyCheck,
|
||||
passphrase: Option<SecretFn>,
|
||||
timeout: Duration,
|
||||
) -> Result<(Arc<Handle<Handler>>, Vec<Arc<Handle<Handler>>>)> {
|
||||
let mut chain: Vec<Arc<Handle<Handler>>> = vec![];
|
||||
for (i, j) in sc.jump_hosts.iter().enumerate() {
|
||||
let JumpHost { host, port, username, private_key_path, .. } = j;
|
||||
@@ -585,13 +593,23 @@ impl SshClient {
|
||||
self.handshake(tcp, host, jport, check).await?
|
||||
}
|
||||
Some(prev) => {
|
||||
let ch = prev.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?;
|
||||
let ch = prev
|
||||
.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?;
|
||||
self.handshake(ch.into_stream(), host, jport, check).await?
|
||||
}
|
||||
};
|
||||
authenticate(
|
||||
&mut h,
|
||||
Auth { user: username, methods: vec!["publickey".into()], agent: key.is_none() || sc.agent_auth, key, password: None, totp_secret: String::new() },
|
||||
Auth {
|
||||
user: username,
|
||||
methods: vec!["publickey".into()],
|
||||
agent: key.is_none() || sc.agent_auth,
|
||||
key,
|
||||
password: None,
|
||||
totp_secret: String::new(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("failed to establish SSH through jump host {i}: {e}"))?;
|
||||
@@ -678,9 +696,8 @@ impl SshClient {
|
||||
ForwardDirection::Local => {
|
||||
let bind = if rule.bind_host.is_empty() { "127.0.0.1".to_string() } else { rule.bind_host.clone() };
|
||||
let addr = format!("{bind}:{}", rule.bind_port);
|
||||
let listener = rt()
|
||||
.block_on(tokio::net::TcpListener::bind(addr.clone()))
|
||||
.map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?;
|
||||
let listener =
|
||||
rt().block_on(tokio::net::TcpListener::bind(addr.clone())).map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?;
|
||||
let (dhost, dport) = (rule.dest_host.clone(), rule.dest_port as u32);
|
||||
let t2 = tasks.clone();
|
||||
let task = rt().spawn(async move {
|
||||
@@ -755,9 +772,8 @@ impl SshClient {
|
||||
let h = self.handle()?;
|
||||
let ch = h.channel_open_session().await?;
|
||||
ch.request_subsystem(true, "sftp").await?;
|
||||
let s = Arc::new(
|
||||
russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?,
|
||||
);
|
||||
let s =
|
||||
Arc::new(russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?);
|
||||
*slot = Some(s.clone());
|
||||
Ok(s)
|
||||
})
|
||||
|
||||
@@ -367,8 +367,7 @@ mod tests {
|
||||
let (mut srv, _) = listener.accept().unwrap();
|
||||
srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
|
||||
srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99])
|
||||
.unwrap();
|
||||
srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99]).unwrap();
|
||||
let mut want = vec![IAC, WILL, OPT_TERM_TYPE, IAC, WILL, OPT_NAWS, IAC, SB, OPT_NAWS, 0, 80, 0, 24, IAC, SE];
|
||||
want.extend_from_slice(&[IAC, DO, OPT_ECHO, IAC, DO, OPT_SGA, IAC, WONT, 99]);
|
||||
assert_eq!(read_n(&mut srv, want.len()), want);
|
||||
|
||||
@@ -76,7 +76,7 @@ pub struct Framebuffer {
|
||||
impl Framebuffer {
|
||||
fn new(width: u16, height: u16) -> Framebuffer {
|
||||
let mut rgba = vec![0u8; width as usize * height as usize * 4];
|
||||
rgba.chunks_exact_mut(4).for_each(|p| p[3] = 255);
|
||||
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255);
|
||||
Framebuffer { width, height, rgba, dirty: true }
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ impl Framebuffer {
|
||||
let (w, stride) = (w as usize, self.width as usize);
|
||||
for row in 0..h as usize {
|
||||
let dst = ((y as usize + row) * stride + x as usize) * 4;
|
||||
for (d, s) in self.rgba[dst..dst + w * 4].chunks_exact_mut(4).zip(px[row * w * bpp..].chunks_exact(bpp)) {
|
||||
for (d, s) in self.rgba[dst..dst + w * 4].as_chunks_mut::<4>().0.iter_mut().zip(px[row * w * bpp..].chunks_exact(bpp)) {
|
||||
d[..3].copy_from_slice(&s[..3]);
|
||||
d[3] = 255;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ impl Framebuffer {
|
||||
let stride = self.width as usize;
|
||||
for row in 0..h as usize {
|
||||
let dst = ((y as usize + row) * stride + x as usize) * 4;
|
||||
for d in self.rgba[dst..dst + w as usize * 4].chunks_exact_mut(4) {
|
||||
for d in self.rgba[dst..dst + w as usize * 4].as_chunks_mut::<4>().0 {
|
||||
d[..3].copy_from_slice(&rgb[..3]);
|
||||
d[3] = 255;
|
||||
}
|
||||
@@ -241,13 +241,13 @@ impl Client {
|
||||
st.connected = false;
|
||||
r
|
||||
};
|
||||
if let Err(e) = result {
|
||||
if !was_closing {
|
||||
let msg = e.to_string();
|
||||
(self.cfg.on_error)(msg.clone());
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(msg));
|
||||
}
|
||||
if let Err(e) = result
|
||||
&& !was_closing
|
||||
{
|
||||
let msg = e.to_string();
|
||||
(self.cfg.on_error)(msg.clone());
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(msg));
|
||||
}
|
||||
}
|
||||
if was_connected {
|
||||
@@ -334,7 +334,13 @@ impl Client {
|
||||
None => return Err(format!("not a VNC server (got {:?})", ver.trim_end()).into()),
|
||||
};
|
||||
// 3.889 is Apple's 3.8; anything between 3.3 and 3.7 is treated as 3.3, as RFB specifies.
|
||||
let minor = if minor >= 8 { 8 } else if minor == 7 { 7 } else { 3 };
|
||||
let minor = if minor >= 8 {
|
||||
8
|
||||
} else if minor == 7 {
|
||||
7
|
||||
} else {
|
||||
3
|
||||
};
|
||||
r.get_mut().send(format!("RFB 003.00{minor}\n").as_bytes())?;
|
||||
|
||||
let security = if minor == 3 {
|
||||
@@ -368,15 +374,13 @@ impl Client {
|
||||
}
|
||||
t => return Err(unsupported_security(&[t as u8]).into()),
|
||||
}
|
||||
if minor == 8 || security == 2 {
|
||||
if read_u32(r)? != 0 {
|
||||
let reason = if minor == 8 { read_string(r)? } else { String::new() };
|
||||
return Err(if reason.is_empty() {
|
||||
"VNC authentication failed (wrong password?)".into()
|
||||
} else {
|
||||
format!("VNC authentication failed: {reason}").into()
|
||||
});
|
||||
}
|
||||
if (minor == 8 || security == 2) && read_u32(r)? != 0 {
|
||||
let reason = if minor == 8 { read_string(r)? } else { String::new() };
|
||||
return Err(if reason.is_empty() {
|
||||
"VNC authentication failed (wrong password?)".into()
|
||||
} else {
|
||||
format!("VNC authentication failed: {reason}").into()
|
||||
});
|
||||
}
|
||||
|
||||
r.get_mut().send(&[self.cfg.shared as u8])?;
|
||||
@@ -414,11 +418,11 @@ pub fn vnc_auth_response(password: &str, challenge: &[u8; 16]) -> [u8; 16] {
|
||||
}
|
||||
let cipher = des::Des::new(&key.into());
|
||||
let mut out = *challenge;
|
||||
for chunk in out.chunks_exact_mut(8) {
|
||||
let mut block: [u8; 8] = chunk.try_into().unwrap();
|
||||
for chunk in out.as_chunks_mut::<8>().0 {
|
||||
let mut block: [u8; 8] = *chunk;
|
||||
let b = (&mut block).into();
|
||||
cipher.encrypt_block(b);
|
||||
chunk.copy_from_slice(&block);
|
||||
*chunk = block;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -502,11 +506,7 @@ struct Decoders {
|
||||
|
||||
impl Default for Decoders {
|
||||
fn default() -> Decoders {
|
||||
Decoders {
|
||||
zrle: Decompress::new(true),
|
||||
tight: std::array::from_fn(|_| Decompress::new(true)),
|
||||
buf: Vec::new(),
|
||||
}
|
||||
Decoders { zrle: Decompress::new(true), tight: std::array::from_fn(|_| Decompress::new(true)), buf: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,9 +726,7 @@ fn tight(r: &mut impl Read, fb: &mut Framebuffer, dec: &mut Decoders, x: u16, y:
|
||||
9 => {
|
||||
let len = compact_len(r)?;
|
||||
let jpeg = read_n(r, len)?;
|
||||
let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg)
|
||||
.map_err(|e| format!("Tight JPEG: {e}"))?
|
||||
.to_rgb8();
|
||||
let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).map_err(|e| format!("Tight JPEG: {e}"))?.to_rgb8();
|
||||
if img.width() as usize != wu || img.height() as usize != hu {
|
||||
return Err("Tight JPEG size doesn't match its rectangle".into());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user