From d20d16e8c83800ffc08f2b174b44adbf95bf1b55 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 11 Oct 2026 16:21:08 +0100 Subject: [PATCH] rust: port GitHub and IRC clients, IRC formatting --- rust/Cargo.lock | 213 ++++- rust/core/Cargo.toml | 6 + rust/core/src/clients/github.rs | 555 +++++++++++++ rust/core/src/clients/irc.rs | 1337 +++++++++++++++++++++++++++++++ rust/core/src/clients/mod.rs | 3 + rust/core/src/ircformat.rs | 469 +++++++++++ rust/core/src/lib.rs | 1 + 7 files changed, 2582 insertions(+), 2 deletions(-) create mode 100644 rust/core/src/clients/github.rs create mode 100644 rust/core/src/clients/irc.rs create mode 100644 rust/core/src/ircformat.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cb8cc7d..14527ee 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -58,6 +64,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -218,6 +230,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -333,6 +351,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.4.4" @@ -387,6 +411,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -540,6 +573,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -706,6 +750,28 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -837,6 +903,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -861,14 +937,18 @@ version = "0.1.0" dependencies = [ "aes-gcm", "argon2", - "base64", + "base64 0.22.1", "chrono", "keyring", "rand", "rusqlite", + "rustls", "serde", "serde_json", + "tiny_http", + "ureq", "uuid", + "webpki-roots", ] [[package]] @@ -995,6 +1075,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1116,6 +1202,20 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -1143,6 +1243,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1296,6 +1431,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1315,7 +1456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1365,6 +1506,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tokio" version = "1.53.2" @@ -1475,6 +1628,47 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "uuid" version = "1.28.0" @@ -1549,6 +1743,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1895,6 +2098,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index a3c8903..eae0693 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -14,3 +14,9 @@ aes-gcm = "0.10" rand = "0.8" base64 = "0.22" keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "tokio", "crypto-rust"] } +ureq = "3" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +webpki-roots = "1" + +[dev-dependencies] +tiny_http = "0.12" diff --git a/rust/core/src/clients/github.rs b/rust/core/src/clients/github.rs new file mode 100644 index 0000000..462e7a5 --- /dev/null +++ b/rust/core/src/clients/github.rs @@ -0,0 +1,555 @@ +//! Lightweight GitHub REST client for repository metadata and archive downloads. Works with +//! github.com and GitHub Enterprise via a configurable API base URL. + +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use chrono::{DateTime, FixedOffset, SecondsFormat}; +use serde::Deserialize; + +use crate::models::GitHubRepo; + +const MAX_JSON_BODY_BYTES: u64 = 4 << 20; +const MAX_ERROR_BODY_BYTES: u64 = 64 << 10; +const MAX_ARCHIVE_BYTES: u64 = 2 << 30; +const USER_AGENT: &str = "NexusTerminal/1.0"; +const COMMIT_LOOKUP_WORKERS: usize = 5; + +pub struct Client { + base_url: String, + token: String, + agent: ureq::Agent, +} + +#[derive(Deserialize)] +struct ApiRepo { + #[serde(default)] + full_name: Option, + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + html_url: Option, + #[serde(default)] + default_branch: Option, + #[serde(default)] + private: Option, + #[serde(default)] + fork: Option, + #[serde(default)] + archived: Option, + #[serde(default)] + language: Option, + #[serde(default)] + stargazers_count: Option, + #[serde(default)] + pushed_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + owner: Option, +} + +#[derive(Deserialize)] +struct ApiOwner { + #[serde(default)] + login: Option, +} + +#[derive(Deserialize, Default)] +struct ApiPerson { + #[serde(default)] + name: Option, + #[serde(default)] + date: Option, +} + +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(); + Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent } + } + + fn get(&self, url: &str) -> Result, ureq::Error> { + self.agent + .get(url) + .header("Authorization", &format!("Bearer {}", self.token)) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", USER_AGENT) + .call() + } + + /// Confirms the token works and returns the authenticated login. + pub fn validate_token(&self) -> crate::Result { + 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}"))?; + #[derive(Deserialize)] + struct User { + #[serde(default)] + login: Option, + } + let user: User = serde_json::from_slice(&body).map_err(|e| format!("decoding user: {e}"))?; + Ok(user.login.unwrap_or_default()) + } + + /// All repositories visible to the token, newest push first, enriched with latest-commit info. + pub fn fetch_repos(&self) -> crate::Result> { + self.fetch_repos_with_progress(|_, _| {}) + } + + /// 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> { + let mut all: Vec = 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 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 status = resp.status().as_u16(); + if status == 401 || status == 403 { + 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()); + } + let batch: Vec = + serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?; + if batch.is_empty() { + break; + } + let short = batch.len() < 100; + all.extend(batch); + // Safety cap — 5000 repos should be more than enough. + if all.len() >= 5000 || short { + break; + } + page += 1; + } + + let mut parsed = Vec::with_capacity(all.len()); + for r in all { + let pushed = parse_time(r.pushed_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?; + 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)); + + let mut result: Vec = parsed + .into_iter() + .map(|(r, pushed, updated)| GitHubRepo { + full_name: r.full_name.unwrap_or_default(), + name: r.name.unwrap_or_default(), + owner: r.owner.and_then(|o| o.login).unwrap_or_default(), + description: r.description.unwrap_or_default(), + html_url: r.html_url.unwrap_or_default(), + default_branch: r.default_branch.unwrap_or_default(), + private: r.private.unwrap_or_default(), + fork: r.fork.unwrap_or_default(), + archived: r.archived.unwrap_or_default(), + language: r.language.unwrap_or_default(), + stars: r.stargazers_count.unwrap_or_default(), + pushed_at: format_time(pushed), + updated_at: format_time(updated), + ..Default::default() + }) + .collect(); + + let total = result.len(); + progress(0, total); + + // Enrich with latest commit metadata (best-effort), five lookups at a time. + let next = AtomicUsize::new(0); + let done = AtomicUsize::new(0); + let lookups: Vec<(usize, String, String)> = result + .iter() + .enumerate() + .map(|(i, r)| { + let reference = if r.default_branch.is_empty() { "HEAD" } else { &r.default_branch }; + (i, r.full_name.clone(), reference.to_string()) + }) + .collect(); + let found: Vec<(usize, String, String)> = std::thread::scope(|s| { + let workers: Vec<_> = (0..COMMIT_LOOKUP_WORKERS.min(total)) + .map(|_| { + s.spawn(|| { + let mut out = Vec::new(); + loop { + let i = next.fetch_add(1, Ordering::SeqCst); + let Some((idx, name, reference)) = lookups.get(i) else { break }; + if let Ok((who, when)) = self.fetch_latest_commit(name, reference) { + out.push((*idx, who, when)); + } + progress(done.fetch_add(1, Ordering::SeqCst) + 1, total); + } + out + }) + }) + .collect(); + workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect() + }); + for (idx, who, when) in found { + result[idx].latest_committer = who; + result[idx].latest_commit_at = when; + } + Ok(result) + } + + /// Committer name and RFC 3339 commit time for a repo and ref (`HEAD` when empty). + pub fn fetch_latest_commit(&self, full_name: &str, reference: &str) -> crate::Result<(String, String)> { + let reference = if reference.is_empty() { "HEAD" } else { reference }; + let (owner, repo) = split_repo_full_name(full_name)?; + let url = self.endpoint_url(&["repos", &owner, &repo, "commits", reference])?; + let mut resp = self.get(&url).map_err(|e| format!("fetching commit: {e}"))?; + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + 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()); + } + 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 { + #[serde(default)] + commit: Commit, + } + #[derive(Deserialize, Default)] + struct Commit { + #[serde(default)] + committer: Option, + #[serde(default)] + author: Option, + } + let payload: Payload = serde_json::from_slice(&body).map_err(|e| format!("decoding commit: {e}"))?; + let committer = payload.commit.committer.unwrap_or_default(); + let author = payload.commit.author.unwrap_or_default(); + let (mut name, mut date) = (committer.name.unwrap_or_default(), committer.date); + if name.is_empty() { + name = author.name.unwrap_or_default(); + date = author.date; + } + let date = parse_time(date.as_deref()).map_err(|e| format!("decoding commit: {e}"))?; + match date { + Some(d) if !name.is_empty() => Ok((name, format_time(Some(d)))), + _ => Err("commit metadata missing".into()), + } + } + + /// Downloads the repository zipball (default branch when `reference` is empty) to `dest_path`. + pub fn download_repo_archive(&self, full_name: &str, reference: &str, dest_path: &Path) -> crate::Result<()> { + let (owner, repo) = split_repo_full_name(full_name)?; + let mut segments = vec!["repos", &owner, &repo, "zipball"]; + if !reference.is_empty() { + segments.push(reference); + } + let url = self.endpoint_url(&segments)?; + let mut resp = self.get(&url).map_err(|e| format!("downloading archive: {e}"))?; + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + 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()); + } + let length = resp + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().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() + ); + } + 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}"))?; + Ok(()) + } + + /// Base URL plus path-escaped segments. + fn endpoint_url(&self, segments: &[&str]) -> crate::Result { + let mut url = self.base_url.clone(); + for segment in segments { + if segment.is_empty() { + return Err("empty GitHub API path segment".into()); + } + url.push('/'); + url.push_str(&path_escape(segment)); + } + Ok(url) + } +} + +/// Go's `url.PathEscape`. +fn path_escape(segment: &str) -> String { + let mut out = String::new(); + for b in segment.bytes() { + if b.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +fn parse_time(value: Option<&str>) -> Result>, String> { + match value { + None => Ok(None), + Some(v) => { + let t = DateTime::parse_from_rfc3339(v).map_err(|e| format!("parsing time {v:?}: {e}"))?; + // Go's zero time means "unset". + Ok(Some(t).filter(|t| t.timestamp() != -62135596800)) + } + } +} + +/// Go's `time.Time.Format(time.RFC3339)`, with the zero time for a missing value. +fn format_time(value: Option>) -> String { + match value { + Some(t) => t.to_rfc3339_opts(SecondsFormat::Secs, true), + None => "0001-01-01T00:00:00Z".to_string(), + } +} + +fn read_limited(r: impl Read, max_bytes: u64) -> crate::Result> { + let mut body = Vec::new(); + r.take(max_bytes + 1).read_to_end(&mut body)?; + if body.len() as u64 > max_bytes { + return Err(format!("response body exceeds {max_bytes} bytes").into()); + } + Ok(body) +} + +fn read_error_body(r: impl Read) -> String { + match read_limited(r, MAX_ERROR_BODY_BYTES) { + Ok(body) => String::from_utf8_lossy(&body).trim().to_string(), + Err(e) => format!(""), + } +} + +fn split_repo_full_name(full_name: &str) -> crate::Result<(String, String)> { + let parts: Vec<&str> = full_name.trim().split('/').collect(); + let [owner, repo] = parts.as_slice() else { + return Err("repository name must be in owner/repo format".into()); + }; + let (owner, repo) = (owner.trim(), repo.trim()); + if owner.is_empty() || repo.is_empty() { + return Err("repository name must be in owner/repo format".into()); + } + Ok((owner.to_string(), repo.to_string())) +} + +/// Copies at most `max_bytes`; returns the number copied, or an error once the source exceeds it. +fn copy_limited_archive(dst: &mut impl Write, src: impl Read, max_bytes: u64) -> crate::Result { + let n = std::io::copy(&mut src.take(max_bytes + 1), dst)?; + if n > max_bytes { + return Err(format!("archive exceeds {max_bytes} bytes").into()); + } + Ok(n) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[test] + fn read_limited_rejects_oversized_response() { + let err = read_limited(&b"abcd"[..], 3).unwrap_err(); + assert!(err.to_string().contains("response body exceeds"), "{err}"); + } + + #[test] + fn read_limited_allows_response_at_limit() { + assert_eq!(read_limited(&b"abc"[..], 3).unwrap(), b"abc"); + } + + #[test] + fn endpoint_url_escapes_repo_path_segments() { + let client = Client::new("https://ghe.example.com/api/v3", ""); + let got = client.endpoint_url(&["repos", "owner", "repo", "commits", "feature/a b"]).unwrap(); + assert_eq!(got, "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b"); + assert!(client.endpoint_url(&["repos", ""]).is_err()); + } + + #[test] + fn split_repo_full_name_rejects_extra_segments() { + assert!(split_repo_full_name("owner/repo/../../user").is_err()); + assert!(split_repo_full_name("owner/").is_err()); + assert_eq!(split_repo_full_name(" a / b ").unwrap(), ("a".into(), "b".into())); + } + + #[test] + fn copy_limited_archive_rejects_oversized_stream() { + let mut dst = Vec::new(); + let err = copy_limited_archive(&mut dst, &b"abcd"[..], 3).unwrap_err(); + assert!(err.to_string().contains("archive exceeds"), "{err}"); + assert_eq!(dst.len(), 4); + } + + #[test] + fn copy_limited_archive_allows_stream_at_limit() { + let mut dst = Vec::new(); + assert_eq!(copy_limited_archive(&mut dst, &b"abc"[..], 3).unwrap(), 3); + assert_eq!(dst, b"abc"); + } + + type Seen = Arc, Option)>>>; + + /// A local HTTP server answering with `handler(url) -> (status, body)`; records + /// (url, authorization, user-agent) for every request. + fn serve(handler: impl Fn(&str) -> (u16, String) + Send + 'static) -> (String, Seen) { + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let base = format!("http://{}", server.server_addr().to_ip().unwrap()); + let seen: Seen = Arc::default(); + 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()) + }; + 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)); + } + }); + (base, seen) + } + + #[test] + fn validate_token_returns_login_and_reports_status() { + let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into())); + assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat"); + let (url, auth, ua) = seen.lock().unwrap()[0].clone(); + assert_eq!(url, "/user"); + assert_eq!(auth.as_deref(), Some("Bearer tok")); + assert_eq!(ua.as_deref(), Some(USER_AGENT)); + + let (base, _) = serve(|_| (401, "{}".into())); + let err = Client::new(&base, "bad").validate_token().unwrap_err(); + assert_eq!(err.to_string(), "token validation failed (HTTP 401)"); + } + + fn repo_json(n: usize, pushed: &str) -> serde_json::Value { + 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, + "stargazers_count": n, "pushed_at": pushed, "updated_at": "2024-01-01T00:00:00Z", + "owner": {"login": "me"} + }) + } + + #[test] + 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(); + (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()) + } else if url == "/repos/me/r100/commits/main" { + (200, r#"{"commit":{"committer":{"name":"","date":"0001-01-01T00:00:00Z"},"author":{"name":"Ann","date":"2025-06-01T11:00:00Z"}}}"#.into()) + } else if url.starts_with("/repos/me/r0/commits/HEAD") { + (200, r#"{"commit":{"committer":{"name":"Bob","date":"2024-01-05T10:00:00+00:00"}}}"#.into()) + } else { + (404, "nope".into()) + } + }); + 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(); + assert_eq!(repos.len(), 101); + assert_eq!(repos[0].full_name, "me/r100"); + assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z"); + assert_eq!(repos[0].latest_committer, "Ann"); + assert_eq!(repos[0].latest_commit_at, "2025-06-01T11:00:00Z"); + assert!(repos.windows(2).all(|w| w[0].pushed_at >= w[1].pushed_at)); + let r0 = repos.iter().find(|r| r.name == "r0").unwrap(); + assert_eq!((r0.latest_committer.as_str(), r0.latest_commit_at.as_str()), ("Bob", "2024-01-05T10:00:00Z")); + assert_eq!(r0.owner, "me"); + assert!(r0.private && r0.description.is_empty() && r0.language.is_empty()); + assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99); + + let mut calls = calls.into_inner().unwrap(); + assert_eq!(calls[0], (0, 101)); + calls.sort(); + assert_eq!(calls.last(), Some(&(101, 101))); + assert_eq!(calls.len(), 102); + assert_eq!(seen.lock().unwrap().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2); + } + + #[test] + fn fetch_repos_reports_auth_and_api_errors() { + let (base, _) = serve(|_| (403, "{}".into())); + let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string(); + assert_eq!(err, "authentication failed (HTTP 403) — check your access token"); + + let (base, _) = serve(|_| (500, "boom".into())); + let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string(); + assert_eq!(err, "GitHub API returned HTTP 500: boom"); + } + + #[test] + fn download_repo_archive_writes_file() { + let (base, seen) = serve(|url| match url { + "/repos/me/r/zipball/v1%2F2" => (200, "ZIPDATA".into()), + _ => (404, " missing \n".into()), + }); + let dir = std::env::temp_dir().join(format!("nexus-gh-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("a.zip"); + let client = Client::new(&base, "t"); + client.download_repo_archive("me/r", "v1/2", &dest).unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA"); + let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string(); + assert_eq!(err, "GitHub API returned HTTP 404: missing"); + assert_eq!(seen.lock().unwrap()[1].0, "/repos/me/r/zipball"); + assert!(client.download_repo_archive("bad", "", &dest).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/rust/core/src/clients/irc.rs b/rust/core/src/clients/irc.rs new file mode 100644 index 0000000..3fdf209 --- /dev/null +++ b/rust/core/src/clients/irc.rs @@ -0,0 +1,1337 @@ +//! IRC client: registration, PING/PONG, channel/user tracking, CTCP ACTION, NickServ identify, +//! auto-join and the composer's /commands, reporting through typed `Event`s. +//! +//! One I/O thread owns the transport (TCP, or a caller-supplied stream such as an SSH tunnel, +//! optionally wrapped in TLS). It alternates between short reads and draining the outgoing +//! queue, so the transport never has to be split. + +use std::collections::HashMap; +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use super::{CloseFn, ErrorFn}; + +pub const SERVER_TARGET: &str = "server"; +const MAX_ROOM_LIST_ENTRIES: usize = 2000; +const QUIT_MESSAGE: &str = "Nexus Terminal disconnected"; +const READ_POLL: Duration = Duration::from_millis(50); +const PING_DELAY: Duration = Duration::from_secs(20); +const PING_TIMEOUT: Duration = Duration::from_secs(60); +const PING_GRACE: Duration = Duration::from_secs(30); +const MAX_LINE_BYTES: usize = 1 << 20; + +/// A bidirectional byte stream. Reads must not block indefinitely: they should return +/// `WouldBlock` or `TimedOut` after a short interval (tens of milliseconds) so the client's +/// I/O thread can interleave writes. +pub trait ReadWrite: Read + Write + Send {} +impl ReadWrite for T {} + +pub type Connector = Arc crate::Result> + Send + Sync>; +pub type EventFn = Arc; +pub type RoomsChangedFn = Arc) + Send + Sync>; + +#[derive(Clone)] +pub struct Config { + pub server: String, + pub port: u16, + pub nick: String, + pub user: String, + pub real_name: String, + pub use_tls: bool, + pub server_pass: String, + pub auto_join_rooms: Vec, + + pub nickserv_identify: bool, + pub nickserv_account: String, + pub nickserv_password: String, + + /// Provides the transport instead of a direct TCP connect (e.g. an SSH tunnel). TLS, when + /// enabled, is layered on top of it. + pub connector: Option, + + pub connect_timeout: Duration, + pub on_event: EventFn, + pub on_error: ErrorFn, + pub on_close: CloseFn, + pub on_rooms_changed: RoomsChangedFn, +} + +impl Default for Config { + fn default() -> Config { + Config { + server: String::new(), + port: 6667, + nick: String::new(), + user: String::new(), + real_name: String::new(), + use_tls: false, + server_pass: String::new(), + auto_join_rooms: Vec::new(), + nickserv_identify: false, + nickserv_account: String::new(), + nickserv_password: String::new(), + connector: None, + connect_timeout: Duration::ZERO, + on_event: Arc::new(|_| {}), + on_error: Arc::new(|_| {}), + on_close: Arc::new(|| {}), + on_rooms_changed: Arc::new(|_| {}), + } + } +} + +/// A typed IRC event, serialised for the UI exactly as Go's `ircclient.Event`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Event { + #[serde(rename = "type")] + pub kind: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub target: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub from: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub text: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub command: String, + pub timestamp: i64, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub rooms: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub users: Vec, + #[serde(skip_serializing_if = "String::is_empty")] + pub topic: String, +} + +/// A joined or listed IRC room. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RoomInfo { + pub name: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub topic: String, + #[serde(skip_serializing_if = "is_zero")] + pub users: i64, +} + +fn is_zero(n: &i64) -> bool { + *n == 0 +} + +enum Out { + Line(String), + Quit(String), +} + +struct Channel { + name: String, + topic: String, + users: Vec, +} + +struct State { + nick: String, + connected: bool, + closing: bool, + list_rooms: Vec, + room_names: Vec, + /// Keyed by RFC 1459 casefolded name. + channels: HashMap, + ready: Option>>, + last_pong: Instant, +} + +pub struct Client { + cfg: Config, + state: Mutex, + out: Mutex>>, +} + +/// A parsed server line. +struct Message { + source: String, + command: String, + params: Vec, +} + +impl Message { + fn last(&self) -> &str { + self.params.last().map(String::as_str).unwrap_or("") + } + fn param(&self, i: usize) -> &str { + self.params.get(i).map(String::as_str).unwrap_or("") + } +} + +impl Client { + pub fn new(mut cfg: Config) -> Arc { + if cfg.connect_timeout.is_zero() { + cfg.connect_timeout = Duration::from_secs(30); + } + if cfg.user.is_empty() { + cfg.user = cfg.nick.clone(); + } + if cfg.real_name.is_empty() { + cfg.real_name = cfg.nick.clone(); + } + let nick = cfg.nick.clone(); + Arc::new(Client { + cfg, + state: Mutex::new(State { + nick, + connected: false, + closing: false, + list_rooms: Vec::new(), + room_names: Vec::new(), + channels: HashMap::new(), + ready: None, + last_pong: Instant::now(), + }), + out: Mutex::new(None), + }) + } + + fn st(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out. + pub fn connect(self: &Arc) -> crate::Result<()> { + let (ready_tx, ready_rx) = mpsc::channel(); + let (out_tx, out_rx) = mpsc::channel(); + self.st().ready = Some(ready_tx); + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = Some(out_tx); + + let client = self.clone(); + std::thread::spawn(move || client.io_thread(out_rx)); + + let timeout = self.cfg.connect_timeout; + match ready_rx.recv_timeout(timeout) { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e.into()), + Err(_) => { + self.close(); + Err(format!("IRC connection timed out after {timeout:?}").into()) + } + } + } + + /// Sends QUIT and disconnects. Idempotent; `on_close` fires from the I/O thread. + pub fn close(&self) { + { + let mut st = self.st(); + if st.closing { + return; + } + st.closing = true; + } + self.queue(Out::Quit(QUIT_MESSAGE.to_string())); + } + + fn queue(&self, out: Out) -> bool { + let guard = self.out.lock().unwrap_or_else(|e| e.into_inner()); + guard.as_ref().is_some_and(|tx| tx.send(out).is_ok()) + } + + /// Sends one IRC line (CR/LF are stripped). + fn send_raw(&self, line: &str) -> crate::Result<()> { + let line: String = line.chars().filter(|c| *c != '\r' && *c != '\n').collect(); + if self.queue(Out::Line(line)) { Ok(()) } else { Err("not connected".into()) } + } + + fn send(&self, command: &str, params: &[&str]) -> crate::Result<()> { + self.send_raw(&encode(command, params)) + } + + /// PRIVMSG/NOTICE, split so each line fits the 512-byte limit. + fn send_text(&self, command: &str, target: &str, text: &str) -> crate::Result<()> { + // ponytail: fixed prefix allowance (nick!user@host) like girc's default; no ISUPPORT tuning. + let max = 510usize.saturating_sub(115 + command.len() + target.len() + 3).max(64); + let mut rest = text; + while !rest.is_empty() { + let mut cut = rest.len().min(max); + while !rest.is_char_boundary(cut) { + cut -= 1; + } + self.send(command, &[target, &rest[..cut]])?; + rest = &rest[cut..]; + } + Ok(()) + } + + fn nick(&self) -> String { + self.st().nick.clone() + } + + /// Parses a composer line: plain text goes to `target`, `/commands` are interpreted. + pub fn handle_input(&self, target: &str, input: &str) -> crate::Result<()> { + let text = input.trim(); + if text.is_empty() { + return Ok(()); + } + let target = if target.is_empty() { SERVER_TARGET } else { target }; + + if !text.starts_with('/') || text == "/" { + if target == SERVER_TARGET { + return Err("select a room or use /msg ".into()); + } + self.send_text("PRIVMSG", target, text)?; + self.emit_local_message("message", target, text); + return Ok(()); + } + + let command_line = text[1..].trim(); + let Some(first) = command_line.split_whitespace().next() else { return Ok(()) }; + let cmd = first.to_lowercase(); + let args = command_line[first.len()..].trim(); + + match cmd.as_str() { + "me" | "action" => { + if target == SERVER_TARGET { + return Err("/me requires an active room or private tab".into()); + } + if args.is_empty() { + return Err("/me requires an action".into()); + } + self.send("PRIVMSG", &[target, &format!("\x01ACTION {args}\x01")])?; + self.emit_local_message("action", target, args); + } + "msg" | "query" => { + let (recipient, body) = split_first_arg(args); + if recipient.is_empty() || body.is_empty() { + return Err("/msg requires a target and message".into()); + } + self.send_text("PRIVMSG", recipient, body)?; + self.emit_local_message("message", recipient, body); + } + "notice" => { + let (recipient, body) = split_first_arg(args); + if recipient.is_empty() || body.is_empty() { + return Err("/notice requires a target and message".into()); + } + self.send_text("NOTICE", recipient, body)?; + self.emit_local_message("notice", recipient, body); + } + "join" | "j" => { + let (room, key) = split_first_arg(args); + if room.is_empty() { + return Err("/join requires a room".into()); + } + if key.is_empty() { + self.send("JOIN", &[room])?; + } else { + self.send("JOIN", &[room, key])?; + } + } + "part" | "leave" => { + let (room, msg) = split_first_arg(args); + let room = if room.is_empty() { target } else { room }; + if room == SERVER_TARGET { + return Err("/part requires a room".into()); + } + if msg.is_empty() { + self.send("PART", &[room])?; + } else { + self.send("PART", &[room, msg])?; + } + } + "quit" => { + let msg = if args.is_empty() { QUIT_MESSAGE } else { args }; + self.queue(Out::Quit(msg.to_string())); + } + "nick" => { + let Some(nick) = args.split_whitespace().next() else { + return Err("/nick requires a nickname".into()); + }; + self.send("NICK", &[nick])?; + } + "topic" => { + let (room, topic) = split_first_arg(args); + let room = if room.is_empty() { target } else { room }; + if room == SERVER_TARGET { + return Err("/topic requires a room".into()); + } + if topic.is_empty() { + self.send("TOPIC", &[room])?; + } else { + self.send("TOPIC", &[room, topic])?; + } + } + "list" => { + if args.is_empty() { + self.send("LIST", &[])?; + } else { + self.send("LIST", &[&args.split_whitespace().collect::>().join(",")])?; + } + } + "names" => { + let room = if args.is_empty() && target != SERVER_TARGET { target } else { args }; + if room.is_empty() { + return self.send_raw("NAMES"); + } + return self.send_raw(&format!("NAMES {room}")); + } + "whois" => { + if args.is_empty() { + return Err("/whois requires a nickname".into()); + } + for nick in args.split_whitespace() { + self.send("WHOIS", &[nick])?; + } + } + "who" => { + let args = if args.is_empty() { + if target == SERVER_TARGET { + return Err("/who requires a target".into()); + } + target + } else { + args + }; + for who in args.split_whitespace() { + self.send("WHO", &[who, "%tcuhnr,2"])?; + } + } + "raw" | "quote" => { + if args.is_empty() { + return Err("/raw requires an IRC command".into()); + } + return self.send_raw(args); + } + "help" => self.emit(Event { + kind: "system".into(), + target: target.into(), + text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit".into(), + ..Default::default() + }), + _ => { + let mut raw = first.to_uppercase(); + if !args.is_empty() { + raw.push(' '); + raw.push_str(args); + } + return self.send_raw(&raw); + } + } + Ok(()) + } + + pub fn join(&self, room: &str) -> crate::Result<()> { + let room = room.trim(); + if room.is_empty() { + return Err("room is required".into()); + } + self.send("JOIN", &[room]) + } + + pub fn part(&self, room: &str) -> crate::Result<()> { + let room = room.trim(); + if room.is_empty() { + return Err("room is required".into()); + } + self.send("PART", &[room]) + } + + /// Requests the server's room list; results arrive as a `room-list` event. + pub fn list_rooms(&self) -> crate::Result<()> { + self.st().list_rooms.clear(); + self.send("LIST", &[]) + } + + /// Currently joined rooms, sorted case-insensitively. + pub fn rooms(&self) -> Vec { + let st = self.st(); + let mut rooms: Vec = st + .room_names + .iter() + .map(|name| match st.channels.get(&fold(name)) { + Some(ch) => RoomInfo { name: ch.name.clone(), topic: ch.topic.clone(), users: ch.users.len() as i64 }, + None => RoomInfo { name: name.clone(), ..Default::default() }, + }) + .collect(); + rooms.sort_by_key(|r| r.name.to_lowercase()); + rooms + } + + /// Known users in a room, sorted ignoring mode prefixes. + pub fn users(&self, room: &str) -> Vec { + let st = self.st(); + let Some(ch) = st.channels.get(&fold(room)) else { return Vec::new() }; + let mut users = ch.users.clone(); + users.sort_by_key(|u| u.trim_start_matches(['~', '&', '@', '%', '+']).to_lowercase()); + users + } + + // ---- I/O thread ---- + + fn io_thread(self: Arc, out_rx: Receiver) { + let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx)); + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; + let (was_closing, was_connected, ready) = { + let mut st = self.st(); + let r = (st.closing, st.connected, st.ready.take()); + 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 was_connected { + (self.cfg.on_close)(); + } + } + + fn open_transport(&self) -> crate::Result> { + let stream: Box = match &self.cfg.connector { + 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}"))?; + tcp.set_read_timeout(Some(READ_POLL))?; + tcp.set_write_timeout(Some(self.cfg.connect_timeout))?; + let _ = tcp.set_nodelay(true); + Box::new(tcp) + } + }; + if !self.cfg.use_tls { + return Ok(stream); + } + self.tls_wrap(stream).map_err(|e| format!("IRC TLS handshake failed: {e}").into()) + } + + fn tls_wrap(&self, sock: Box) -> crate::Result> { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + let name = rustls::pki_types::ServerName::try_from(self.cfg.server.clone())?; + let conn = rustls::ClientConnection::new(Arc::new(config), name)?; + let mut tls = rustls::StreamOwned::new(conn, sock); + let deadline = Instant::now() + self.cfg.connect_timeout; + while tls.conn.is_handshaking() { + match tls.conn.complete_io(&mut tls.sock) { + Ok(_) => {} + Err(e) if is_poll_timeout(&e) && Instant::now() < deadline => {} + Err(e) => return Err(e.into()), + } + } + Ok(Box::new(tls)) + } + + /// Registers, then pumps reads, the outgoing queue and keepalive pings until the connection + /// ends. `Ok` means a deliberate QUIT. + fn run(&self, mut stream: Box, out_rx: &Receiver) -> crate::Result<()> { + if self.st().closing { + return Ok(()); + } + let write = |stream: &mut Box, line: &str| -> crate::Result<()> { + stream.write_all(line.as_bytes())?; + stream.write_all(b"\r\n")?; + Ok(stream.flush()?) + }; + if !self.cfg.server_pass.is_empty() { + write(&mut stream, &encode("PASS", &[&self.cfg.server_pass]))?; + } + write(&mut stream, &encode("NICK", &[&self.cfg.nick]))?; + write(&mut stream, &encode("USER", &[&self.cfg.user, "*", "*", &self.cfg.real_name]))?; + + let started = Instant::now(); + let mut last_ping = Instant::now(); + let mut ping_sent = false; + self.st().last_pong = Instant::now(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + while let Ok(out) = out_rx.try_recv() { + match out { + Out::Line(line) => write(&mut stream, &line)?, + Out::Quit(msg) => { + let _ = write(&mut stream, &encode("QUIT", &[&msg])); + return Ok(()); + } + } + } + + if started.elapsed() >= PING_GRACE && last_ping.elapsed() >= PING_DELAY { + 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(); + write(&mut stream, &encode("PING", &[&id.to_string()]))?; + last_ping = Instant::now(); + ping_sent = true; + } + + match stream.read(&mut chunk) { + Ok(0) => return Err("connection closed by server".into()), + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if is_poll_timeout(&e) || e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(e.into()), + } + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let raw: Vec = buf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&raw); + if let Some(msg) = parse_line(line.trim_end_matches(['\r', '\n'])) { + self.handle_message(&msg); + } + } + if buf.len() > MAX_LINE_BYTES { + return Err("server line too long".into()); + } + } + } + + // ---- server message handling (girc's state tracking, then Link's handlers) ---- + + fn is_me(&self, nick: &str) -> bool { + fold(nick) == fold(&self.st().nick) + } + + fn handle_message(&self, m: &Message) { + match m.command.as_str() { + "001" => self.handle_welcome(m), + "433" | "436" | "437" => { + let mut st = self.st(); + if !st.connected { + st.nick.push('_'); + let nick = st.nick.clone(); + drop(st); + let _ = self.send("NICK", &[&nick]); + } + } + "PING" => { + let _ = self.send("PONG", &[m.last()]); + } + "PONG" => self.st().last_pong = Instant::now(), + "PRIVMSG" => self.handle_privmsg(m), + "NOTICE" => self.handle_notice(m), + "JOIN" => self.handle_join(m), + "PART" => self.handle_part(m), + "KICK" => self.handle_kick(m), + "QUIT" => self.handle_quit(m), + "NICK" => self.handle_nick(m), + "TOPIC" | "332" => self.handle_topic(m), + "353" => self.handle_names(m), + "366" => { + if m.params.len() >= 2 { + self.emit_users(m.param(1)); + } + } + "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 mut st = self.st(); + if st.list_rooms.len() < MAX_ROOM_LIST_ENTRIES { + st.list_rooms.push(room); + } + } + } + "323" => { + let mut rooms = std::mem::take(&mut self.st().list_rooms); + rooms.sort_by(|a, b| b.users.cmp(&a.users).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + self.emit(Event { kind: "room-list".into(), rooms, ..Default::default() }); + } + "ERROR" => self.emit(Event { kind: "error".into(), text: m.last().into(), ..Default::default() }), + _ => {} + } + } + + fn handle_welcome(&self, m: &Message) { + let ready = { + let mut st = self.st(); + if !m.params.is_empty() { + st.nick = m.param(0).to_string(); + } + st.connected = true; + 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.identify_with_nickserv(); + if !self.cfg.auto_join_rooms.is_empty() { + let _ = self.send("JOIN", &[&self.cfg.auto_join_rooms.join(",")]); + } + self.emit_rooms(); + if let Some(ready) = ready { + let _ = ready.send(Ok(())); + } + } + + fn identify_with_nickserv(&self) { + if !self.cfg.nickserv_identify || self.cfg.nickserv_password.is_empty() { + return; + } + let body = if self.cfg.nickserv_account.is_empty() { + format!("IDENTIFY {}", self.cfg.nickserv_password) + } else { + format!("IDENTIFY {} {}", self.cfg.nickserv_account, self.cfg.nickserv_password) + }; + let _ = self.send("PRIVMSG", &["NickServ", &body]); + self.emit(Event { kind: "system".into(), text: "Sent NickServ identify request".into(), ..Default::default() }); + } + + fn handle_privmsg(&self, m: &Message) { + if m.params.len() < 2 || m.source.is_empty() { + return; + } + let mut target = m.param(0).to_string(); + if self.is_me(&target) { + target = m.source.clone(); + } + let mut kind = "message"; + let mut text = m.last().to_string(); + if let Some((ctcp, args)) = decode_ctcp(m) { + match ctcp.as_str() { + "ACTION" => { + kind = "action"; + text = args; + } + "VERSION" => { + let _ = self.send("NOTICE", &[&m.source, "\x01VERSION Nexus Terminal\x01"]); + } + "PING" => { + let body = if args.is_empty() { "\x01PING\x01".to_string() } else { format!("\x01PING {args}\x01") }; + let _ = self.send("NOTICE", &[&m.source, &body]); + } + _ => {} + } + } + self.emit(Event { kind: kind.into(), target, from: m.source.clone(), text, ..Default::default() }); + } + + fn handle_notice(&self, m: &Message) { + let mut target = SERVER_TARGET.to_string(); + if !m.params.is_empty() { + target = m.param(0).to_string(); + if target == "*" { + target = SERVER_TARGET.into(); + } + if self.is_me(&target) && !m.source.is_empty() { + target = m.source.clone(); + } + } + self.emit(Event { kind: "notice".into(), target, from: m.source.clone(), text: m.last().into(), ..Default::default() }); + } + + fn handle_join(&self, m: &Message) { + let room = m.param(0).to_string(); + if room.is_empty() { + return; + } + 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(), + }); + add_user(ch, &from); + } + if self.is_me(&from) { + self.remember_room_membership(&room, true); + } + self.emit(Event { kind: "join".into(), target: room.clone(), from: from.clone(), text: format!("{from} joined {room}"), ..Default::default() }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_part(&self, m: &Message) { + let room = m.param(0).to_string(); + if room.is_empty() { + return; + } + let from = m.source.clone(); + let me = self.is_me(&from); + if !from.is_empty() { + let mut st = self.st(); + if me { + st.channels.remove(&fold(&room)); + } else if let Some(ch) = st.channels.get_mut(&fold(&room)) { + remove_user(ch, &from); + } + } + let message = if m.params.len() > 1 { m.last().trim().to_string() } else { String::new() }; + if me { + self.remember_room_membership(&room, false); + } + self.emit(Event { kind: "part".into(), target: room.clone(), from, text: message, ..Default::default() }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_kick(&self, m: &Message) { + if m.params.len() < 2 { + return; + } + let (room, kicked) = (m.param(0).to_string(), m.param(1).to_string()); + let me = self.is_me(&kicked); + { + let mut st = self.st(); + if me { + st.channels.remove(&fold(&room)); + } else if let Some(ch) = st.channels.get_mut(&fold(&room)) { + remove_user(ch, &kicked); + } + } + if me { + self.remember_room_membership(&room, false); + } + self.emit(Event { + kind: "kick".into(), + target: room.clone(), + from: m.source.clone(), + command: kicked, + text: m.last().trim().into(), + ..Default::default() + }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_quit(&self, m: &Message) { + if !m.source.is_empty() && !self.is_me(&m.source) { + for ch in self.st().channels.values_mut() { + remove_user(ch, &m.source); + } + } + self.emit(Event { kind: "quit".into(), from: m.source.clone(), text: m.last().trim().into(), ..Default::default() }); + self.emit_users_for_all(); + } + + fn handle_nick(&self, m: &Message) { + let new_nick = m.last().to_string(); + if !m.source.is_empty() && !m.params.is_empty() { + let mut st = self.st(); + if fold(&m.source) == fold(&st.nick) { + st.nick = new_nick.clone(); + } + for ch in st.channels.values_mut() { + if remove_user(ch, &m.source) { + add_user(ch, &new_nick); + } + } + } + self.emit(Event { kind: "nick".into(), from: m.source.clone(), text: new_nick, ..Default::default() }); + self.emit_users_for_all(); + } + + fn handle_topic(&self, m: &Message) { + let (room, topic) = if m.command == "332" && m.params.len() >= 3 { + (m.param(1), m.param(2)) + } else if m.params.len() >= 2 { + (m.param(0), m.param(1)) + } else { + return; + }; + if room.is_empty() { + return; + } + 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_rooms(); + } + + fn handle_names(&self, m: &Message) { + if m.params.len() < 4 { + return; + } + { + let mut st = self.st(); + if let Some(ch) = st.channels.get_mut(&fold(m.param(2))) { + for part in m.last().split(' ') { + let nick = part.trim_start_matches(['~', '&', '@', '%', '+']); + let nick = if nick.contains('@') { source_name(nick) } else { nick }; + if is_valid_nick(nick) { + add_user(ch, nick); + } + } + } + } + self.emit(Event { + kind: "users".into(), + target: m.param(2).into(), + users: m.param(3).split_whitespace().map(String::from).collect(), + ..Default::default() + }); + } + + // ---- emitting ---- + + fn remember_room_membership(&self, room: &str, joined: bool) { + let room = room.trim(); + if room.is_empty() { + return; + } + let names = { + let mut st = self.st(); + let position = st.room_names.iter().position(|r| r.eq_ignore_ascii_case(room)); + match (joined, position) { + (true, None) => st.room_names.push(room.to_string()), + (false, Some(i)) => { + st.room_names.remove(i); + } + _ => return, + } + st.room_names.clone() + }; + (self.cfg.on_rooms_changed)(names); + } + + fn emit_rooms(&self) { + self.emit(Event { kind: "rooms".into(), rooms: self.rooms(), ..Default::default() }); + } + + fn emit_users(&self, room: &str) { + if !room.is_empty() { + self.emit(Event { kind: "users".into(), target: room.into(), users: self.users(room), ..Default::default() }); + } + } + + fn emit_users_for_all(&self) { + for room in self.rooms() { + self.emit_users(&room.name); + } + } + + fn emit_local_message(&self, kind: &str, target: &str, text: &str) { + self.emit(Event { kind: kind.into(), target: target.into(), from: self.nick(), text: text.into(), ..Default::default() }); + } + + fn emit(&self, mut event: Event) { + if event.timestamp == 0 { + event.timestamp = chrono::Utc::now().timestamp_millis(); + } + if event.target.is_empty() { + event.target = SERVER_TARGET.into(); + } + (self.cfg.on_event)(event); + } +} + +fn dial(host: &str, port: u16, timeout: Duration) -> std::io::Result { + let mut last = std::io::Error::new(ErrorKind::NotFound, "no addresses found"); + for addr in (host, port).to_socket_addrs()? { + match TcpStream::connect_timeout(&addr, timeout) { + Ok(s) => return Ok(s), + Err(e) => last = e, + } + } + Err(last) +} + +fn is_poll_timeout(e: &std::io::Error) -> bool { + matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) +} + +/// girc's `ToRFC1459`: ASCII A-Z and `[\]^` fold to a-z and `{|}~`. +fn fold(s: &str) -> String { + s.chars().map(|c| if ('A'..='^').contains(&c) { (c as u8 + 32) as char } else { c }).collect() +} + +fn add_user(ch: &mut Channel, nick: &str) { + if !ch.users.iter().any(|u| fold(u) == fold(nick)) { + ch.users.push(nick.to_string()); + } +} + +fn remove_user(ch: &mut Channel, nick: &str) -> bool { + let before = ch.users.len(); + ch.users.retain(|u| fold(u) != fold(nick)); + ch.users.len() != before +} + +/// girc's `IsValidNick`. +fn is_valid_nick(nick: &str) -> bool { + let b = nick.as_bytes(); + let Some(&first) = b.first() else { return false }; + if !(b'A'..=b'}').contains(&first) && first != b'?' { + return false; + } + b[1..].iter().all(|&c| (b'A'..=b'}').contains(&c) || c.is_ascii_digit() || c == b'-') +} + +/// The nick part of `nick!user@host`. +fn source_name(prefix: &str) -> &str { + prefix.split(['!', '@']).next().unwrap_or("") +} + +/// Encodes a command line the way girc does: the last parameter gets a `:` when it is empty, +/// contains a space or starts with `:`; CR/LF are stripped. +fn encode(command: &str, params: &[&str]) -> String { + let mut line = command.to_string(); + for (i, p) in params.iter().enumerate() { + line.push(' '); + if i == params.len() - 1 && (p.is_empty() || p.contains(' ') || p.starts_with(':')) { + line.push(':'); + } + line.push_str(p); + } + line.retain(|c| c != '\r' && c != '\n'); + line +} + +fn parse_line(line: &str) -> Option { + let mut rest = line.trim_start(); + if rest.starts_with('@') { + rest = rest.split_once(' ')?.1.trim_start(); + } + let mut source = String::new(); + if let Some(prefixed) = rest.strip_prefix(':') { + let (prefix, tail) = prefixed.split_once(' ')?; + source = source_name(prefix).to_string(); + rest = tail.trim_start(); + } + let (head, trailing) = match rest.split_once(" :") { + Some((h, t)) => (h, Some(t)), + None => (rest, None), + }; + let mut words = head.split(' ').filter(|w| !w.is_empty()); + let command = words.next()?.to_uppercase(); + let mut params: Vec = words.map(String::from).collect(); + params.extend(trailing.map(String::from)); + Some(Message { source, command, params }) +} + +/// A CTCP request in a PRIVMSG: `(TAG, args)`, as girc's `DecodeCTCP`. +fn decode_ctcp(m: &Message) -> Option<(String, String)> { + if m.params.len() != 2 { + return None; + } + let text = m.param(1).strip_prefix('\x01')?.strip_suffix('\x01')?; + let (tag, args) = text.split_once(' ').unwrap_or((text, "")); + if tag.is_empty() || !tag.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) { + return None; + } + Some((tag.to_string(), args.to_string())) +} + +/// The first whitespace-separated word, and the trimmed remainder. +fn split_first_arg(input: &str) -> (&str, &str) { + let input = input.trim_start(); + match input.split_whitespace().next() { + Some(first) => (first, input[first.len()..].trim()), + None => ("", ""), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + + #[test] + 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() + }); + client.remember_room_membership("#One", true); + client.remember_room_membership("#two", true); + client.remember_room_membership("#one", true); + client.remember_room_membership("#missing", false); + client.remember_room_membership("#ONE", false); + assert_eq!(updates.lock().unwrap().join("|"), "#One|#One,#two|#two"); + assert_eq!(client.st().room_names, vec!["#two"]); + } + + #[test] + fn parses_and_encodes_lines() { + let m = parse_line("@time=x :nick!u@h PRIVMSG #chan :hello there").unwrap(); + assert_eq!((m.source.as_str(), m.command.as_str()), ("nick", "PRIVMSG")); + assert_eq!(m.params, vec!["#chan", "hello there"]); + let m = parse_line("PING :abc").unwrap(); + assert_eq!((m.source.as_str(), m.last()), ("", "abc")); + let m = parse_line(":srv 353 me = #c :@op +v plain").unwrap(); + assert_eq!(m.params.len(), 4); + assert_eq!(encode("PRIVMSG", &["#c", "hi"]), "PRIVMSG #c hi"); + assert_eq!(encode("PRIVMSG", &["#c", "hi there"]), "PRIVMSG #c :hi there"); + assert_eq!(encode("TOPIC", &["#c", ""]), "TOPIC #c :"); + assert_eq!(encode("QUIT", &[":x\r\nJOIN #evil"]), "QUIT ::xJOIN #evil"); + assert_eq!(split_first_arg(" #room key here "), ("#room", "key here")); + assert_eq!(split_first_arg(" "), ("", "")); + assert_eq!(fold("Nick[A]^"), "nick{a}~"); + } + + #[test] + 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() + }; + assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"rooms","timestamp":0,"rooms":[{"name":"#a"}]}"##); + } + + struct Fake { + reader: BufReader, + writer: TcpStream, + } + + impl Fake { + fn line(&mut self) -> String { + let mut s = String::new(); + self.reader.read_line(&mut s).unwrap(); + s.trim_end().to_string() + } + fn expect(&mut self, want: &str) { + for _ in 0..20 { + if self.line() == want { + return; + } + } + panic!("never received {want:?}"); + } + fn send(&mut self, line: &str) { + self.writer.write_all(format!("{line}\r\n").as_bytes()).unwrap(); + } + } + + fn harness(cfg: Config) -> (Arc, TcpListener, Receiver, Arc>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let (tx, rx) = mpsc::channel(); + let tx = Mutex::new(tx); + let closes = Arc::new(Mutex::new(0)); + let c = closes.clone(); + let client = Client::new(Config { + server: "127.0.0.1".into(), + port: listener.local_addr().unwrap().port(), + nick: "me".into(), + connect_timeout: Duration::from_secs(5), + on_event: Arc::new(move |e| { + let _ = tx.lock().unwrap().send(e); + }), + on_close: Arc::new(move || *c.lock().unwrap() += 1), + ..cfg + }); + (client, listener, rx, closes) + } + + fn accept(listener: &TcpListener) -> Fake { + let (s, _) = listener.accept().unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + Fake { reader: BufReader::new(s.try_clone().unwrap()), writer: s } + } + + fn wait_for(rx: &Receiver, pred: impl Fn(&Event) -> bool) -> Event { + loop { + let e = rx.recv_timeout(Duration::from_secs(5)).expect("event"); + if pred(&e) { + return e; + } + } + } + + #[test] + fn registers_tracks_rooms_and_handles_input() { + let (client, listener, rx, closes) = harness(Config { + server_pass: "pw".into(), + auto_join_rooms: vec!["#a".into(), "#b".into()], + nickserv_identify: true, + nickserv_account: "acct".into(), + nickserv_password: "secret".into(), + ..Default::default() + }); + let c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + assert_eq!(srv.line(), "PASS pw"); + assert_eq!(srv.line(), "NICK me"); + assert_eq!(srv.line(), "USER me * * me"); + srv.send(":srv 433 * me :Nickname is already in use"); + assert_eq!(srv.line(), "NICK me_"); + srv.send(":srv 001 me_ :Welcome"); + connecting.join().unwrap().unwrap(); + let e = wait_for(&rx, |e| e.kind == "system"); + assert_eq!(e.text, format!("Connected to 127.0.0.1:{}", client.cfg.port)); + assert_eq!(e.target, SERVER_TARGET); + assert_eq!(srv.line(), "PRIVMSG NickServ :IDENTIFY acct secret"); + assert_eq!(srv.line(), "JOIN #a,#b"); + assert_eq!(wait_for(&rx, |e| e.kind == "system").text, "Sent NickServ identify request"); + + srv.send("PING :xyz"); + srv.expect("PONG xyz"); + + srv.send(":me_!u@h JOIN #a"); + let e = wait_for(&rx, |e| e.kind == "join"); + assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "me_", "me_ joined #a")); + srv.send(":srv 332 me_ #a :the topic"); + assert_eq!(wait_for(&rx, |e| e.kind == "topic").topic, "the topic"); + srv.send(":srv 353 me_ = #a :@me_ +Bob carol"); + assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["@me_", "+Bob", "carol"]); + srv.send(":srv 366 me_ #a :End of NAMES"); + assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["Bob", "carol", "me_"]); + assert_eq!(client.rooms(), vec![RoomInfo { name: "#a".into(), topic: "the topic".into(), users: 3 }]); + + srv.send(":Bob!u@h PRIVMSG #a :hi all"); + let e = wait_for(&rx, |e| e.kind == "message"); + assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "Bob", "hi all")); + srv.send(":Bob!u@h PRIVMSG ME_ :\x01ACTION waves\x01"); + let e = wait_for(&rx, |e| e.kind == "action"); + assert_eq!((e.target.as_str(), e.text.as_str()), ("Bob", "waves")); + srv.send(":Bob!u@h NICK :Robert"); + assert_eq!(wait_for(&rx, |e| e.kind == "nick").text, "Robert"); + assert_eq!(client.users("#A"), vec!["carol", "me_", "Robert"]); + srv.send(":carol!u@h QUIT :bye"); + assert_eq!(wait_for(&rx, |e| e.kind == "quit").text, "bye"); + assert_eq!(client.users("#a"), vec!["me_", "Robert"]); + srv.send(":Robert!u@h KICK #a me_ :out"); + let e = wait_for(&rx, |e| e.kind == "kick"); + assert_eq!((e.from.as_str(), e.command.as_str(), e.text.as_str()), ("Robert", "me_", "out")); + assert!(client.rooms().is_empty()); + + client.handle_input("#a", "hello there").unwrap(); + srv.expect("PRIVMSG #a :hello there"); + let e = wait_for(&rx, |e| e.kind == "message"); + assert_eq!((e.from.as_str(), e.text.as_str()), ("me_", "hello there")); + client.handle_input("#a", "/me dances").unwrap(); + srv.expect("PRIVMSG #a :\x01ACTION dances\x01"); + client.handle_input("#a", "/join #c key").unwrap(); + srv.expect("JOIN #c key"); + client.handle_input("#a", "/part").unwrap(); + srv.expect("PART #a"); + client.handle_input("#a", "/topic").unwrap(); + srv.expect("TOPIC #a"); + client.handle_input("#a", "/names").unwrap(); + 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 " + ); + 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")); + + client.list_rooms().unwrap(); + srv.expect("LIST"); + srv.send(":srv 322 me_ #small 2 :s"); + srv.send(":srv 322 me_ #Big 9 :b"); + srv.send(":srv 322 me_ #also 2 :a"); + srv.send(":srv 323 me_ :End"); + let names: Vec<_> = wait_for(&rx, |e| e.kind == "room-list").rooms.into_iter().map(|r| r.name).collect(); + assert_eq!(names, vec!["#Big", "#also", "#small"]); + + client.close(); + client.close(); + srv.expect("QUIT :Nexus Terminal disconnected"); + let deadline = Instant::now() + Duration::from_secs(5); + while *closes.lock().unwrap() == 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(*closes.lock().unwrap(), 1); + assert!(client.handle_input("#a", "late").is_err()); + } + + #[test] + 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 c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + srv.expect("USER me * * me"); + srv.send(":srv 001 me :Welcome"); + connecting.join().unwrap().unwrap(); + srv.send("ERROR :Closing link"); + assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "Closing link"); + drop(srv); + assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server"); + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(*errors.lock().unwrap(), vec!["connection closed by server"]); + assert_eq!(*closes.lock().unwrap(), 1); + } + + #[test] + fn connect_times_out_without_welcome() { + let (client, listener, _rx, closes) = harness(Config::default()); + let client = Client::new(Config { connect_timeout: Duration::from_millis(300), ..client.cfg.clone() }); + let err = client.connect().unwrap_err().to_string(); + assert_eq!(err, "IRC connection timed out after 300ms"); + drop(listener); + assert_eq!(*closes.lock().unwrap(), 0); + } + + #[test] + fn connector_supplies_the_transport() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let used = Arc::new(Mutex::new(false)); + let flag = used.clone(); + let client = Client::new(Config { + server: "unresolvable.invalid".into(), + nick: "me".into(), + connect_timeout: Duration::from_secs(5), + connector: Some(Arc::new(move || { + *flag.lock().unwrap() = true; + let s = TcpStream::connect(addr)?; + s.set_read_timeout(Some(Duration::from_millis(20)))?; + Ok(Box::new(s) as Box) + })), + ..Default::default() + }); + let c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + srv.expect("USER me * * me"); + srv.send(":srv 001 me :Welcome"); + connecting.join().unwrap().unwrap(); + assert!(*used.lock().unwrap()); + client.handle_input("", "/quit gone").unwrap(); + srv.expect("QUIT gone"); + } + + #[test] + fn dial_failure_is_reported() { + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let client = Client::new(Config { server: "127.0.0.1".into(), port, nick: "me".into(), ..Default::default() }); + let err = client.connect().unwrap_err().to_string(); + assert!(err.starts_with(&format!("dial tcp 127.0.0.1:{port}:")), "{err}"); + } +} diff --git a/rust/core/src/clients/mod.rs b/rust/core/src/clients/mod.rs index d811535..069865f 100644 --- a/rust/core/src/clients/mod.rs +++ b/rust/core/src/clients/mod.rs @@ -6,6 +6,9 @@ //! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not //! fire `on_close` re-entrantly while holding locks. +pub mod github; +pub mod irc; + use std::sync::Arc; pub type DataFn = Arc; diff --git a/rust/core/src/ircformat.rs b/rust/core/src/ircformat.rs new file mode 100644 index 0000000..115888e --- /dev/null +++ b/rust/core/src/ircformat.rs @@ -0,0 +1,469 @@ +//! mIRC formatting (^B ^] ^^ ^_ ^V ^Q ^O ^C fg,bg ^D hex) to styled spans, the nick colour hash +//! and the IRC event line layout. Ported from the Wails frontend's `irc.js`. +//! +//! Colours are xterm-256 palette indices: IRC colour codes are mapped through `IRC_COLOURS`. + +use crate::clients::irc::Event; + +/// IRC colours 00-98 mapped to the xterm 256-colour palette (modern IRC formatting spec). +/// Colour 99, and anything out of range, means the terminal default. +pub const IRC_COLOURS: [u8; 99] = [ + 15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7, // + 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, // + 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, // + 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, // + 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, // + 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, // + 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, // + 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231, +]; + +/// Palette indices used for nicks. +pub const NICK_COLOURS: [u8; 12] = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220]; + +const GREY: u8 = 244; +const RED: u8 = 203; +const GREEN: u8 = 83; +const YELLOW: u8 = 221; +const CYAN: u8 = 81; +const MAGENTA: u8 = 177; + +/// Text style. `fg`/`bg` are xterm-256 indices; `fg_rgb`/`bg_rgb` come from ^D hex colours. +/// At most one of `fg`/`fg_rgb` (and of `bg`/`bg_rgb`) is set. `None` is the default colour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub fg_rgb: Option<[u8; 3]>, + pub bg_rgb: Option<[u8; 3]>, + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub reverse: bool, + pub strike: bool, + pub monospace: bool, +} + +impl Style { + fn fg(colour: u8) -> Style { + Style { fg: Some(colour), ..Style::default() } + } + fn set_fg(&mut self, colour: u8) { + self.fg = Some(colour); + self.fg_rgb = None; + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Span { + pub text: String, + pub style: Style, +} + +/// Spans under construction; `style` applies to the next text pushed. +#[derive(Default)] +struct Line { + spans: Vec, + style: Style, +} + +impl Line { + fn push(&mut self, text: &str) { + if text.is_empty() { + return; + } + match self.spans.last_mut() { + Some(last) if last.style == self.style => last.text.push_str(text), + _ => self.spans.push(Span { text: text.to_string(), style: self.style }), + } + } + + /// `text` in `style`, then back to the default style. + fn styled(&mut self, style: Style, text: &str) { + self.style = style; + self.push(text); + self.style = Style::default(); + } + + /// A bold, nick-coloured name layered on the current style, then reset. + fn nick(&mut self, nick: &str) { + let mut style = self.style; + style.bold = true; + style.set_fg(nick_colour(nick)); + self.styled(style, nick); + } + + /// Applies mIRC formatting codes in `value` on top of the current style. + fn mirc(&mut self, value: &str) { + let input: Vec = strip_terminal_escapes(value).chars().collect(); + // Toggle states start off regardless of the inherited style, as in irc.js. + let (mut bold, mut italic, mut strike, mut underline, mut reverse, mut mono) = (false, false, false, false, false, false); + let mut i = 0; + while i < input.len() { + let c = input[i]; + i += 1; + match c { + '\x02' => { + bold = !bold; + self.style.bold = bold; + if !bold { + self.style.dim = false; // SGR 22 clears both + } + } + '\x1d' => { + italic = !italic; + self.style.italic = italic; + } + '\x1e' => { + strike = !strike; + self.style.strike = strike; + } + '\x1f' => { + underline = !underline; + self.style.underline = underline; + } + '\x16' => { + reverse = !reverse; + self.style.reverse = reverse; + } + '\x11' => { + mono = !mono; + self.style.monospace = mono; + } + '\x0f' => { + self.style = Style::default(); + (bold, italic, strike, underline, reverse, mono) = (false, false, false, false, false, false); + } + '\x03' | '\x04' => { + let hex = c == '\x04'; + let read = |at: usize| if hex { read_hex(&input, at) } else { read_decimal(&input, at) }; + let Some((fg, len)) = read(i) else { + self.reset_colours(); + continue; + }; + i += len; + self.apply_colour(fg, false); + if input.get(i) != Some(&',') { + continue; + } + match read(i + 1) { + Some((bg, len)) => { + i += len + 1; + self.apply_colour(bg, true); + } + None => { + self.push(","); + i += 1; + } + } + } + c if (c as u32) < 0x20 || c as u32 == 0x7f => {} + c => self.push(c.encode_utf8(&mut [0; 4])), + } + } + } + + fn reset_colours(&mut self) { + (self.style.fg, self.style.bg, self.style.fg_rgb, self.style.bg_rgb) = (None, None, None, None); + } + + fn apply_colour(&mut self, colour: Colour, background: bool) { + let s = &mut self.style; + let (index, rgb) = if background { (&mut s.bg, &mut s.bg_rgb) } else { (&mut s.fg, &mut s.fg_rgb) }; + match colour { + Colour::Indexed(code) => { + *index = IRC_COLOURS.get(code as usize).copied(); + *rgb = None; + } + Colour::Rgb(value) => { + *rgb = Some(value); + *index = None; + } + } + } +} + +enum Colour { + Indexed(u8), + Rgb([u8; 3]), +} + +fn read_decimal(input: &[char], at: usize) -> Option<(Colour, usize)> { + let digits: String = input.iter().skip(at).take(2).take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + Some((Colour::Indexed(digits.parse().ok()?), digits.len())) +} + +fn read_hex(input: &[char], at: usize) -> Option<(Colour, usize)> { + let hex: String = input.get(at..at + 6)?.iter().collect(); + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + Some((Colour::Rgb([byte(0)?, byte(2)?, byte(4)?]), 6)) +} + +/// Removes untrusted terminal sequences (OSC, CSI, other ESC pairs) and C1 controls, applying the +/// same four passes as irc.js. +fn strip_terminal_escapes(value: &str) -> String { + // OSC: ESC ] ... (BEL | ESC \), greedy like the JS regex. + let mut s = String::new(); + let mut rest = value; + while let Some(pos) = rest.find("\x1b]") { + let body = &rest[pos + 2..]; + let run_end = body.find('\x07'); + let end = match run_end { + Some(bel) => Some(bel + 1), + None => body.rfind("\x1b\\").map(|st| st + 2), + }; + match end { + Some(end) => { + s.push_str(&rest[..pos]); + rest = &body[end..]; + } + None => { + s.push_str(&rest[..pos + 2]); + rest = body; + } + } + } + s.push_str(rest); + + // CSI: ESC [ params intermediates final. + let chars: Vec = s.chars().collect(); + let mut csi = String::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '\x1b' && chars.get(i + 1) == Some(&'[') { + let mut j = i + 2; + while chars.get(j).is_some_and(|c| ('0'..='?').contains(c)) { + j += 1; + } + while chars.get(j).is_some_and(|c| (' '..='/').contains(c)) { + j += 1; + } + if chars.get(j).is_some_and(|c| ('@'..='~').contains(c)) { + i = j + 1; + continue; + } + } + csi.push(chars[i]); + i += 1; + } + + // ESC plus any character but a line terminator. + let chars: Vec = csi.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '\x1b' && chars.get(i + 1).is_some_and(|c| !matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')) { + i += 2; + continue; + } + out.push(chars[i]); + i += 1; + } + out.retain(|c| !('\u{80}'..='\u{9f}').contains(&c)); + out +} + +/// mIRC-formatted text as styled spans, starting from the default style. +pub fn format_text(value: &str) -> Vec { + let mut line = Line::default(); + line.mirc(value); + line.spans +} + +/// The text with all formatting removed. +pub fn plain_text(value: &str) -> String { + format_text(value).into_iter().map(|s| s.text).collect() +} + +/// The palette index for a nick (Java-style string hash over code points). +pub fn nick_colour(nick: &str) -> u8 { + let hash = nick.chars().fold(0u32, |h, c| h.wrapping_mul(31).wrapping_add(c as u32)); + NICK_COLOURS[hash as usize % NICK_COLOURS.len()] +} + +/// One chat line for an event: `[time] body`, laid out per event type as the Wails viewer did. +pub fn format_event(event: &Event, time: &str) -> Vec { + let from = plain_text(&event.from); + let subject = plain_text(&event.command); + let mut l = Line::default(); + l.styled(Style { dim: true, ..Style::fg(GREY) }, &format!("[{time}]")); + l.push(" "); + + let nick_or = |l: &mut Line, fallback: &str| { + if from.is_empty() { l.push(fallback) } else { l.nick(&from) } + }; + let suffix = |l: &mut Line| { + if !event.text.is_empty() { + l.push(" ("); + l.mirc(&event.text); + l.push(")"); + } + }; + let magenta_italic = Style { italic: true, ..Style::fg(MAGENTA) }; + + match event.kind.as_str() { + "message" => { + l.styled(Style::fg(GREY), "<"); + nick_or(&mut l, ""); + l.styled(Style::fg(GREY), ">"); + l.push(" "); + l.mirc(&event.text); + } + "action" => { + l.style = magenta_italic; + l.push("* "); + nick_or(&mut l, ""); + l.style = magenta_italic; + l.push(" "); + l.mirc(&event.text); + } + "notice" => { + l.style = Style::fg(YELLOW); + l.push("-"); + nick_or(&mut l, "notice"); + l.styled(Style::fg(YELLOW), "-"); + l.push(" "); + l.mirc(&event.text); + } + "join" => { + l.styled(Style::fg(GREEN), "-->"); + l.push(" "); + nick_or(&mut l, "user"); + l.push(" joined"); + } + "part" | "quit" => { + l.styled(Style::fg(RED), "<--"); + l.push(" "); + nick_or(&mut l, "user"); + l.push(if event.kind == "part" { " left" } else { " quit" }); + suffix(&mut l); + } + "kick" => { + l.styled(Style::fg(RED), "<--"); + l.push(" "); + l.styled(Style { bold: true, ..Style::default() }, if subject.is_empty() { "user" } else { &subject }); + l.push(" was kicked by "); + nick_or(&mut l, "server"); + suffix(&mut l); + } + "nick" => { + nick_or(&mut l, "user"); + l.push(" is now known as "); + l.styled(Style { bold: true, ..Style::default() }, &plain_text(&event.text)); + } + "topic" | "error" => { + let (colour, label) = if event.kind == "topic" { (CYAN, "topic:") } else { (RED, "error:") }; + l.styled(Style { bold: true, ..Style::fg(colour) }, label); + l.push(" "); + l.mirc(&event.text); + } + _ => { + l.style = Style::fg(CYAN); + l.mirc(&event.text); + } + } + l.spans +} + +#[cfg(test)] +mod tests { + use super::*; + + fn span(text: &str, style: Style) -> Span { + Span { text: text.into(), style } + } + + #[test] + fn toggles() { + let b = Style { bold: true, ..Style::default() }; + let i = Style { italic: true, ..Style::default() }; + let u = Style { underline: true, ..Style::default() }; + let d = Style::default(); + assert_eq!( + format_text("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"), + vec![span("plain ", d), span("bold", b), span(" ", d), span("italic", i), span(" ", d), span("underlined", u)] + ); + let all = format_text("\x16\x1e\x11x\x0fy"); + assert_eq!(all[0].style, Style { reverse: true, strike: true, monospace: true, ..Style::default() }); + assert_eq!(all[1], span("y", d)); + } + + #[test] + fn indexed_colours() { + assert_eq!(format_text("\x0304red\x03 default"), vec![span("red", Style::fg(9)), span(" default", Style::default())]); + assert_eq!( + format_text("\x0352extended \x0303,01matrix"), + vec![span("extended ", Style::fg(196)), span("matrix", Style { bg: Some(0), ..Style::fg(2) })] + ); + assert_eq!(format_text("\x0399default"), vec![span("default", Style::default())]); + } + + #[test] + 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()) + ] + ); + 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() }); + } + + #[test] + fn colour_without_background_keeps_comma() { + assert_eq!(format_text("\x0304,comma"), vec![span(",comma", Style::fg(9))]); + } + + #[test] + fn plain_text_strips_formatting() { + assert_eq!(plain_text("\x02bold\x02 \x0304red\x03"), "bold red"); + } + + #[test] + fn untrusted_terminal_sequences_are_removed() { + assert_eq!(plain_text("before\x1b[31munsafe\x1b]0;title\x07after"), "beforeunsafeafter"); + assert_eq!(plain_text("a\x1b]8;;x\x1b\\b\u{9b}c\x1bZd\x01"), "abcd"); + } + + #[test] + fn nick_colour_matches_js_hash() { + // Hashes computed with the irc.js function under node. + assert_eq!(nick_colour(""), 33); + assert_eq!(nick_colour("bob"), NICK_COLOURS[97717 % 12]); + assert_eq!(nick_colour("alice"), NICK_COLOURS[92903040 % 12]); + } + + #[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", + ); + 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(); + assert_eq!(nick.style, Style { bold: true, ..Style::fg(nick_colour("alice")) }); + let text: String = message.iter().map(|s| s.text.as_str()).collect(); + assert_eq!(text, "[12:34:56] hello world"); + + 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", + ); + assert_eq!(kick.iter().map(|s| s.text.as_str()).collect::(), "[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))); + } +} diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index 5eaec28..46fb6d2 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -4,6 +4,7 @@ pub mod clients; pub mod config; pub mod exportimport; +pub mod ircformat; pub mod models; pub mod secrets; pub mod storage;