rust: merge UI feature modules; fix Ctrl+key on Linux/Windows (egui command_only shadowing); clippy clean; rustfmt config

This commit is contained in:
Andy Dixon
2026-10-11 17:40:48 +01:00
parent 327ca1caa9
commit 2bb12c3a58
49 changed files with 1140 additions and 659 deletions
+20 -63
View File
@@ -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");
+18 -54
View File
@@ -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);
+1 -3
View File
@@ -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())
+3 -12
View File
@@ -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).
+3 -11
View File
@@ -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:?}");
}
+10 -9
View File
@@ -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)
+10 -13
View File
@@ -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)
}
+40 -24
View File
@@ -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)
})
+1 -2
View File
@@ -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);
+29 -31
View File
@@ -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());
}
+1 -1
View File
@@ -2,9 +2,9 @@
//! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary
//! Link are the same format.
use crate::Result;
use crate::models::Profile;
use crate::secrets::{derive_key, random_bytes};
use crate::Result;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use serde::{Deserialize, Serialize};
+4 -12
View File
@@ -409,10 +409,7 @@ mod tests {
fn hex_colours() {
assert_eq!(
format_text("\x04ff8800orange\x04 default"),
vec![
span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }),
span(" default", Style::default())
]
vec![span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }), span(" default", Style::default())]
);
let s = format_text("\x0400ff00,0000FFx");
assert_eq!(s[0].style, Style { fg_rgb: Some([0, 255, 0]), bg_rgb: Some([0, 0, 255]), ..Style::default() });
@@ -444,10 +441,8 @@ mod tests {
#[test]
fn event_lines() {
let message = format_event(
&Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() },
"12:34:56",
);
let message =
format_event(&Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() }, "12:34:56");
assert_eq!(message[0], span("[12:34:56]", Style { dim: true, ..Style::fg(GREY) }));
assert!(message.iter().any(|s| s.text == "world" && s.style.bold));
let nick = message.iter().find(|s| s.text == "alice").unwrap();
@@ -458,10 +453,7 @@ mod tests {
let action = format_event(&Event { kind: "action".into(), from: "bob".into(), text: "waves".into(), ..Default::default() }, "01:02:03");
assert!(action.iter().any(|s| s.text.ends_with("waves") && s.style.italic && s.style.fg == Some(MAGENTA)));
let kick = format_event(
&Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() },
"t",
);
let kick = format_event(&Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() }, "t");
assert_eq!(kick.iter().map(|s| s.text.as_str()).collect::<String>(), "[t] <-- eve was kicked by op (spam)");
let system = format_event(&Event { kind: "system".into(), text: "hi".into(), ..Default::default() }, "t");
assert_eq!(system.last().unwrap(), &span("hi", Style::fg(CYAN)));
+5 -20
View File
@@ -5,25 +5,11 @@ use crate::Result;
use std::io::Read;
use std::path::{Path, PathBuf};
const PRIVATE_KEY_MARKERS: [&str; 6] = [
"BEGIN OPENSSH PRIVATE KEY",
"BEGIN RSA PRIVATE KEY",
"BEGIN DSA PRIVATE KEY",
"BEGIN EC PRIVATE KEY",
"BEGIN ED25519 PRIVATE KEY",
"BEGIN PRIVATE KEY",
];
const PRIVATE_KEY_MARKERS: [&str; 6] =
["BEGIN OPENSSH PRIVATE KEY", "BEGIN RSA PRIVATE KEY", "BEGIN DSA PRIVATE KEY", "BEGIN EC PRIVATE KEY", "BEGIN ED25519 PRIVATE KEY", "BEGIN PRIVATE KEY"];
const IGNORED: [&str; 8] = [
"authorized_keys",
"authorized_keys2",
"config",
"known_hosts",
"known_hosts.old",
"ssh_config",
"ssh_known_hosts",
"ssh_known_hosts2",
];
const IGNORED: [&str; 8] =
["authorized_keys", "authorized_keys2", "config", "known_hosts", "known_hosts.old", "ssh_config", "ssh_known_hosts", "ssh_known_hosts2"];
/// Whether the first 512 bytes look like a private key. Unreadable or empty paths are `false`
/// (this is also Go's `FileIsPrivateKey`).
@@ -33,8 +19,7 @@ pub fn is_likely_private_key(path: &Path) -> bool {
if f.take(512).read_to_end(&mut head).is_err() || head.is_empty() {
return false;
}
head.starts_with(b"openssh-key-v1")
|| PRIVATE_KEY_MARKERS.iter().any(|m| head.windows(m.len()).any(|w| w == m.as_bytes()))
head.starts_with(b"openssh-key-v1") || PRIVATE_KEY_MARKERS.iter().any(|m| head.windows(m.len()).any(|w| w == m.as_bytes()))
}
/// Candidate private keys in `~/.ssh`, sorted. A missing home or `.ssh` gives an empty list.
+1 -2
View File
@@ -52,8 +52,7 @@ pub enum Protocol {
}
impl Protocol {
pub const SAVED: [Protocol; 7] =
[Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial];
pub const SAVED: [Protocol; 7] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial];
pub fn id(self) -> &'static str {
match self {
Protocol::Ssh => "ssh",
+1 -4
View File
@@ -340,10 +340,7 @@ impl Store {
}
}
let id = filename.trim_end_matches(".cast").to_string();
let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0)
.ok_or("recording: invalid timestamp")?
.with_timezone(&chrono::Local)
.fixed_offset();
let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0).ok_or("recording: invalid timestamp")?.with_timezone(&chrono::Local).fixed_offset();
Ok(RecordingMeta {
session_id: id.clone(), // recording ID matches session ID by default
id,
+3 -12
View File
@@ -43,9 +43,7 @@ pub fn open(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < 12 {
return Err("ciphertext too short".into());
}
Aes256Gcm::new(key.into())
.decrypt(Nonce::from_slice(&data[..12]), &data[12..])
.map_err(|_| "decryption failed".into())
Aes256Gcm::new(key.into()).decrypt(Nonce::from_slice(&data[..12]), &data[12..]).map_err(|_| "decryption failed".into())
}
#[derive(Serialize, Deserialize)]
@@ -93,10 +91,7 @@ impl Vault {
return Err("secrets: vault already initialised".into());
}
let salt = random_bytes::<32>();
let u = Unlocked {
key: derive_key(password, &salt)?,
vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() },
};
let u = Unlocked { key: derive_key(password, &salt)?, vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() } };
std::fs::create_dir_all(&self.dir)?;
self.save(&u)?;
*self.state.lock().unwrap() = Some(u);
@@ -185,11 +180,7 @@ fn off_runtime<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
fn entry(service: &str, key: &str) -> keyring::Result<keyring::Entry> {
// go-keyring's Windows target is "service:user"; keyring's default differs.
if cfg!(windows) {
keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key)
} else {
keyring::Entry::new(service, key)
}
if cfg!(windows) { keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key) } else { keyring::Entry::new(service, key) }
}
/// The OS keychain. Every call can fail (no secret service, locked keyring); callers fall back
+11 -2
View File
@@ -229,7 +229,12 @@ impl Service {
}
}
}
self.store.add_audit("export", "", "", &format!("Exported {} profile(s) {}", ps.len(), if include_secrets { "with secrets" } else { "without secrets" }));
self.store.add_audit(
"export",
"",
"",
&format!("Exported {} profile(s) {}", ps.len(), if include_secrets { "with secrets" } else { "without secrets" }),
);
Ok(serde_json::to_string_pretty(&ps)?)
}
@@ -238,7 +243,11 @@ impl Service {
return Err(format!("import data is too large (max {MAX_IMPORT_JSON_BYTES} bytes)").into());
}
let ps: Vec<Option<Profile>> = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;
let ps = ps.into_iter().enumerate().map(|(i, p)| p.ok_or_else(|| format!("profile import entry {i} is null"))).collect::<std::result::Result<Vec<_>, _>>()?;
let ps = ps
.into_iter()
.enumerate()
.map(|(i, p)| p.ok_or_else(|| format!("profile import entry {i} is null")))
.collect::<std::result::Result<Vec<_>, _>>()?;
self.import(ps, strategy)
}
+71 -17
View File
@@ -2,14 +2,14 @@
//! internal/sessions — same lifecycle, reconnect backoff, login scripts, recording hooks and
//! credential resolution — reporting to the UI through `Event`s.
use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet};
use crate::Result;
use crate::clients::local::LocalShell;
use crate::clients::ssh::{HostKeyCheck, HostKeyInfo, SecretFn, SshClient};
use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet};
use crate::models::*;
use crate::recording::{self, Recorder};
use crate::secrets::Secrets;
use crate::storage::Store;
use crate::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -214,7 +214,9 @@ impl Manager {
let id2 = id.clone();
std::thread::spawn(move || {
let r = me.connect_protocol(&id2, epoch, &profile, prompt);
me.finish_connect(&id2, epoch, r, |me| me.store.add_audit("connect", &profile.id, &id2, &format!("Connected via {} to {}", profile.protocol.id(), profile.host)));
me.finish_connect(&id2, epoch, r, |me| {
me.store.add_audit("connect", &profile.id, &id2, &format!("Connected via {} to {}", profile.protocol.id(), profile.host))
});
});
id
}
@@ -266,9 +268,8 @@ impl Manager {
Protocol::Raw => {
let cb = self.terminal_callbacks(id, epoch);
let (me, sid) = (self.clone(), id.to_string());
let on_data: raw::RawDataFn = Arc::new(move |d, dir, ts| {
me.emit(Event::RawData { session: sid.clone(), data: d.to_vec(), direction: dir.into(), timestamp: ts })
});
let on_data: raw::RawDataFn =
Arc::new(move |d, dir, ts| me.emit(Event::RawData { session: sid.clone(), data: d.to_vec(), direction: dir.into(), timestamp: ts }));
let c = Arc::new(raw::RawClient::new(p, on_data, cb.on_error, cb.on_close));
c.connect()?;
Ok(Client::Raw(c))
@@ -465,7 +466,20 @@ impl Manager {
/// An SSH connection used only to tunnel another protocol (IRC, VNC).
#[allow(clippy::too_many_arguments)]
fn tunnel(self: &Arc<Self>, id: &str, profile_id: &str, host: &str, port: i64, user: &str, method: &str, key: &str, agent: bool, mode: SecretMode, kind: &str, prompt: Prompt) -> Result<Arc<SshClient>> {
fn tunnel(
self: &Arc<Self>,
id: &str,
profile_id: &str,
host: &str,
port: i64,
user: &str,
method: &str,
key: &str,
agent: bool,
mode: SecretMode,
kind: &str,
prompt: Prompt,
) -> Result<Arc<SshClient>> {
let mut tp = Profile::new(Protocol::Ssh);
tp.host = host.into();
tp.port = if port == 0 { 22 } else { port };
@@ -493,10 +507,7 @@ impl Manager {
Err(e) => {
let msg = e.to_string().to_lowercase();
let auth = ["token validation failed", "authentication failed", "http 401", "http 403"].iter().any(|k| msg.contains(k));
let retry = auth
.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok())
.flatten()
.filter(|t| !t.is_empty());
let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty());
let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) };
if gh.secret_mode == SecretMode::Saved {
self.secrets.set(&format!("profile:{}:github-token", p.id), &t);
@@ -562,7 +573,19 @@ impl Manager {
rooms = crate::storage::dedup_rooms(&[&rooms, &last]);
}
let tunnel = if ic.ssh_tunnel {
Some(self.tunnel(id, &p.id, &ic.ssh_host, ic.ssh_port, &ic.ssh_username, &ic.ssh_auth_method, &ic.ssh_private_key, ic.ssh_agent_auth, ic.ssh_secret_mode, "irc", prompt)?)
Some(self.tunnel(
id,
&p.id,
&ic.ssh_host,
ic.ssh_port,
&ic.ssh_username,
&ic.ssh_auth_method,
&ic.ssh_private_key,
ic.ssh_agent_auth,
ic.ssh_secret_mode,
"irc",
prompt,
)?)
} else {
None
};
@@ -629,14 +652,35 @@ impl Manager {
let host = if vc.vnc_host.is_empty() { p.host.clone() } else { vc.vnc_host.clone() };
let port = if vc.vnc_port == 0 { 5900 } else { vc.vnc_port } as u16;
let tunnel = if vc.ssh_tunnel {
Some(self.tunnel(id, &p.id, &vc.ssh_host, vc.ssh_port, &vc.ssh_username, &vc.ssh_auth_method, &vc.ssh_private_key, vc.ssh_agent_auth, vc.ssh_secret_mode, "vnc", prompt.clone())?)
Some(self.tunnel(
id,
&p.id,
&vc.ssh_host,
vc.ssh_port,
&vc.ssh_username,
&vc.ssh_auth_method,
&vc.ssh_private_key,
vc.ssh_agent_auth,
vc.ssh_secret_mode,
"vnc",
prompt.clone(),
)?)
} else {
None
};
let password = self.secret_fn(&p.id, "vnc-password", vc.secret_mode, prompt);
let cb = self.terminal_callbacks(id, epoch);
let (me, sid) = (self.clone(), id.to_string());
let c = connect_vnc_client(&host, port, &vc, p.connect_timeout, password, tunnel.clone(), cb, Arc::new(move || me.emit(Event::VncFrame { session: sid.clone() })));
let c = connect_vnc_client(
&host,
port,
&vc,
p.connect_timeout,
password,
tunnel.clone(),
cb,
Arc::new(move || me.emit(Event::VncFrame { session: sid.clone() })),
);
match c {
Ok(c) => {
self.emit(Event::VncReady { session: id.into() });
@@ -811,7 +855,12 @@ impl Manager {
return;
};
let delay = (1u64 << (attempt - 1).min(5)).min(30);
self.emit(Event::State { session: id.into(), state: SessionState::Reconnecting, error: String::new(), retry: Some(Retry { attempt, max, delay_secs: delay }) });
self.emit(Event::State {
session: id.into(),
state: SessionState::Reconnecting,
error: String::new(),
retry: Some(Retry { attempt, max, delay_secs: delay }),
});
let (me, id) = (self.clone(), id.to_string());
std::thread::spawn(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(delay);
@@ -903,7 +952,10 @@ impl Manager {
"wait" => {
let timeout = Duration::from_secs(if step.timeout <= 0 { 10 } else { step.timeout as u64 });
if !wait_for_output(&login, &step.value, timeout) {
me.emit(Event::Debug { session: id.clone(), message: format!("login script step {} timed out waiting for {:?}", i + 1, step.value) });
me.emit(Event::Debug {
session: id.clone(),
message: format!("login script step {} timed out waiting for {:?}", i + 1, step.value),
});
break;
}
}
@@ -1105,7 +1157,9 @@ impl Manager {
if exists {
return err(format!("sessions: session {id:?} is already being recorded"));
}
let r = Arc::new(Recorder::new(self.recordings.dir(), id, &title, cols as i64, rows as i64).map_err(|e| format!("sessions: failed to start recording: {e}"))?);
let r = Arc::new(
Recorder::new(self.recordings.dir(), id, &title, cols as i64, rows as i64).map_err(|e| format!("sessions: failed to start recording: {e}"))?,
);
self.with(id, |s| s.recorder = Some(r))?;
self.emit(Event::RecordingStarted { session: id.into() });
Ok(())
+2 -7
View File
@@ -29,11 +29,7 @@ pub fn parse_file(path: &Path) -> Result<Vec<HostBlock>> {
return Err(format!("sshconfig: {p} is a directory").into());
}
if meta.len() > MAX_CONFIG_FILE_BYTES {
return Err(format!(
"sshconfig: config file is too large: {} bytes exceeds {MAX_CONFIG_FILE_BYTES} bytes",
meta.len()
)
.into());
return Err(format!("sshconfig: config file is too large: {} bytes exceeds {MAX_CONFIG_FILE_BYTES} bytes", meta.len()).into());
}
let raw = std::fs::read(path).map_err(|e| format!("sshconfig: failed to open {p}: {e}"))?;
parse(&String::from_utf8_lossy(&raw))
@@ -138,8 +134,7 @@ fn parse_proxy_jump(value: &str) -> Option<JumpHost> {
if value.is_empty() || value == "none" {
return None;
}
let mut jh =
JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() };
let mut jh = JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() };
if let Some((user, rest)) = value.split_once('@') {
jh.username = user.into();
value = rest;
+8 -25
View File
@@ -1,8 +1,8 @@
//! SQLite persistence: profiles, known hosts, preferences, audit log, snippets and IRC channel
//! state. The schema and migration files are the Go app's, so existing databases open unchanged.
use crate::models::*;
use crate::Result;
use crate::models::*;
use chrono::{DateTime, SecondsFormat};
use rusqlite::{Connection, OptionalExtension, Row, params};
use std::path::Path;
@@ -228,10 +228,7 @@ impl Store {
}
pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> {
let n = self.db.lock().unwrap().execute(
"UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?",
params![fav as i64, fmt_time(&now()), id],
)?;
let n = self.db.lock().unwrap().execute("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", params![fav as i64, fmt_time(&now()), id])?;
if n == 0 {
return Err(format!("storage: profile {id:?} not found").into());
}
@@ -240,10 +237,7 @@ impl Store {
pub fn update_last_connected(&self, id: &str) -> Result<()> {
let now = fmt_time(&now());
self.db
.lock()
.unwrap()
.execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
self.db.lock().unwrap().execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
Ok(())
}
@@ -337,9 +331,7 @@ impl Store {
pub fn list_audit(&self, limit: i64) -> Result<Vec<AuditEntry>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(
"SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?",
)?;
let mut st = db.prepare("SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?")?;
let rows = st
.query_map([limit], |r| {
Ok(AuditEntry {
@@ -357,8 +349,7 @@ impl Store {
pub fn irc_last_rooms(&self, profile_id: &str) -> Result<Vec<String>> {
let db = self.db.lock().unwrap();
let mut st =
db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
let mut st = db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
let rows = st.query_map([profile_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows)
}
@@ -384,14 +375,8 @@ impl Store {
}
pub fn preferences(&self) -> AppPreferences {
let v: Option<String> = self
.db
.lock()
.unwrap()
.query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0))
.optional()
.ok()
.flatten();
let v: Option<String> =
self.db.lock().unwrap().query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0)).optional().ok().flatten();
let mut p = v.and_then(|v| serde_json::from_str::<AppPreferences>(&v).ok()).unwrap_or_default();
p.apply_defaults();
p
@@ -407,9 +392,7 @@ impl Store {
pub fn list_snippets(&self) -> Result<Vec<Snippet>> {
let db = self.db.lock().unwrap();
let mut st = db.prepare(
"SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC",
)?;
let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?;
let rows = st.query_map([], scan_snippet)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
+1 -5
View File
@@ -16,11 +16,7 @@ pub fn local_shell_env(env: Vec<String>) -> Vec<String> {
/// Variables to request for an SSH session. A locale set by the profile is kept as is; otherwise
/// a UTF-8 `LC_CTYPE` is added. Keys are trimmed and empty keys dropped. Iteration order is sorted.
pub fn ssh_env_vars(profile_env: &BTreeMap<String, String>) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, String> = profile_env
.iter()
.map(|(k, v)| (k.trim().to_string(), v.clone()))
.filter(|(k, _)| !k.is_empty())
.collect();
let mut out: BTreeMap<String, String> = profile_env.iter().map(|(k, v)| (k.trim().to_string(), v.clone())).filter(|(k, _)| !k.is_empty()).collect();
if ![LC_ALL, LC_CTYPE, LANG].iter().any(|k| out.contains_key(*k)) {
out.insert(LC_CTYPE.into(), default_utf8_locale(std::env::consts::OS).into());
}
+5 -21
View File
@@ -70,17 +70,8 @@ pub fn expand_update_url(template: &str) -> String {
/// GET with the updater headers; the body is read (bounded) only on 200.
fn fetch(url: &str, accept: &str) -> Result<(u16, Vec<u8>)> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(REQUEST_TIMEOUT))
.http_status_as_error(false)
.build()
.into();
let resp = agent
.get(url)
.header("Accept", accept)
.header("User-Agent", USER_AGENT)
.call()
.map_err(|e| format!("updater: request failed: {e}"))?;
let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(REQUEST_TIMEOUT)).http_status_as_error(false).build().into();
let resp = agent.get(url).header("Accept", accept).header("User-Agent", USER_AGENT).call().map_err(|e| format!("updater: request failed: {e}"))?;
let status = resp.status().as_u16();
if status != 200 {
return Ok((status, vec![]));
@@ -130,8 +121,7 @@ pub fn check_github(current_version: &str) -> Result<UpdateInfo> {
if status != 200 {
return Err(format!("updater: GitHub API returned {status}").into());
}
let release: Release =
serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?;
let release: Release = serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?;
let latest = normalise_version(&release.tag_name);
if release.draft || release.prerelease {
return Ok(UpdateInfo { current_version: current, latest_version: latest, ..Default::default() });
@@ -189,10 +179,7 @@ fn is_newer(latest: &str, current: &str) -> bool {
/// Go's `fmt.Sscanf(s, "%d")`: optional sign then digits; anything else is 0.
fn leading_int(s: Option<&&str>) -> i64 {
let s = s.map_or("", |s| s.trim_start());
let end = s
.char_indices()
.find(|&(i, c)| !(c.is_ascii_digit() || (i == 0 && (c == '+' || c == '-'))))
.map_or(s.len(), |(i, _)| i);
let end = s.char_indices().find(|&(i, c)| !(c.is_ascii_digit() || (i == 0 && (c == '+' || c == '-')))).map_or(s.len(), |(i, _)| i);
s[..end].parse().unwrap_or(0)
}
@@ -228,10 +215,7 @@ mod tests {
}
req.extend(&buf[..n]);
}
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let head = format!("HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
let _ = s.write_all(head.as_bytes());
let _ = s.write_all(&body);
});
+3 -10
View File
@@ -380,8 +380,7 @@ pub fn validate_serial_config(cfg: &SerialConfig) -> Result<(), ValidationErrors
fn is_valid_nick(nick: &str) -> bool {
let b = nick.as_bytes();
let Some((&first, rest)) = b.split_first() else { return false };
((b'A'..=b'}').contains(&first) || first == b'?')
&& rest.iter().all(|c| (b'A'..=b'}').contains(c) || c.is_ascii_digit() || *c == b'-')
((b'A'..=b'}').contains(&first) || first == b'?') && rest.iter().all(|c| (b'A'..=b'}').contains(c) || c.is_ascii_digit() || *c == b'-')
}
/// girc's IsValidChannel.
@@ -526,14 +525,8 @@ mod tests {
assert!(validate_vnc_config("", &named).is_ok());
assert!(validate_vnc_config("desktop.local", &base).is_ok());
assert!(validate_vnc_config("", &base).is_err());
let tunnel = VncConfig {
ssh_tunnel: true,
ssh_host: "jump.local".into(),
ssh_port: 22,
ssh_username: "admin".into(),
ssh_auth_method: "token".into(),
..base
};
let tunnel =
VncConfig { ssh_tunnel: true, ssh_host: "jump.local".into(), ssh_port: 22, ssh_username: "admin".into(), ssh_auth_method: "token".into(), ..base };
assert!(validate_vnc_config("desktop.local", &tunnel).is_err());
}