diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cb8cc7d..817de8b 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" @@ -218,6 +224,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" @@ -387,6 +399,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 +561,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 +738,22 @@ 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 = "iana-time-zone" version = "0.1.65" @@ -837,6 +885,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,13 +919,14 @@ version = "0.1.0" dependencies = [ "aes-gcm", "argon2", - "base64", + "base64 0.22.1", "chrono", "keyring", "rand", "rusqlite", "serde", "serde_json", + "ureq", "uuid", ] @@ -995,6 +1054,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 +1181,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 +1222,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 +1410,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 +1435,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]] @@ -1475,6 +1595,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 +1710,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 +2065,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..b4afc9e 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -13,4 +13,5 @@ argon2 = "0.5" aes-gcm = "0.10" rand = "0.8" base64 = "0.22" +ureq = "3" keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "tokio", "crypto-rust"] } diff --git a/rust/core/src/keyfiles.rs b/rust/core/src/keyfiles.rs new file mode 100644 index 0000000..fbe191f --- /dev/null +++ b/rust/core/src/keyfiles.rs @@ -0,0 +1,145 @@ +//! SSH key file helpers that need no SSH crypto: spotting private keys, listing `~/.ssh`, and +//! sanitising key and upload file names. + +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 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`). +pub fn is_likely_private_key(path: &Path) -> bool { + let mut head = Vec::with_capacity(512); + let Ok(f) = std::fs::File::open(path) else { return false }; + 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())) +} + +/// Candidate private keys in `~/.ssh`, sorted. A missing home or `.ssh` gives an empty list. +pub fn list_ssh_keys() -> Result> { + match std::env::home_dir() { + Some(home) => list_ssh_keys_in(&home.join(".ssh")), + None => Ok(vec![]), + } +} + +pub fn list_ssh_keys_in(dir: &Path) -> Result> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(e.into()), + }; + let mut keys = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if IGNORED.contains(&name.as_str()) || name.ends_with(".pub") || name.ends_with(".old") { + continue; + } + let path = dir.join(&name); + if is_likely_private_key(&path) { + keys.push(path); + } + } + keys.sort(); + Ok(keys) +} + +/// Trimmed name of ASCII letters, digits, `-` and `_`. +pub fn validate_ssh_key_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("key name is required".into()); + } + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err("key name contains invalid characters (use alphanumeric, hyphens, underscores)".into()); + } + Ok(name.into()) +} + +/// The base name of an uploaded file (either slash style), rejecting `.`, `..` and NULs. +pub fn safe_sftp_upload_name(file_name: &str) -> Result { + let name = file_name.trim().replace('\\', "/"); + if name.is_empty() { + return Err("invalid file name".into()); + } + // Go's path.Base: trailing slashes dropped; all slashes gives "/". + let trimmed = name.trim_end_matches('/'); + let base = if trimmed.is_empty() { "/" } else { trimmed.rsplit('/').next().unwrap_or(trimmed) }; + if matches!(base, "" | "." | ".." | "/") || base.contains('\0') { + return Err("invalid file name".into()); + } + Ok(base.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upload_names() { + assert_eq!(safe_sftp_upload_name("../report .txt").unwrap(), "report .txt"); + assert_eq!(safe_sftp_upload_name(r"C:\Users\me\a.txt").unwrap(), "a.txt"); + assert_eq!(safe_sftp_upload_name("dir/b.txt/").unwrap(), "b.txt"); + for bad in ["..", "folder/..", r"folder\..", "", " ", "///", ".", "a\0b"] { + assert!(safe_sftp_upload_name(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn key_names() { + assert_eq!(validate_ssh_key_name(" id_ed25519-work ").unwrap(), "id_ed25519-work"); + for bad in ["", " ", "a/b", "../x", "a b", "kéy"] { + assert!(validate_ssh_key_name(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn lists_private_keys() { + let dir = std::env::temp_dir().join(format!("nexus-keys-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("subdir")).unwrap(); + let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n"; + for (name, body) in [ + ("id_ed25519", key), + ("id_rsa", "-----BEGIN RSA PRIVATE KEY-----\n"), + ("id_ed25519.pub", key), + ("config", key), + ("key.old", key), + ("notes.txt", "hello"), + ("empty", ""), + ] { + std::fs::write(dir.join(name), body).unwrap(); + } + let keys = list_ssh_keys_in(&dir).unwrap(); + assert_eq!(keys, vec![dir.join("id_ed25519"), dir.join("id_rsa")]); + assert!(!is_likely_private_key(Path::new(""))); + assert!(list_ssh_keys_in(&dir.join("missing")).unwrap().is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index 5eaec28..59c0dd7 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -4,9 +4,15 @@ pub mod clients; pub mod config; pub mod exportimport; +pub mod keyfiles; pub mod models; +pub mod recording; pub mod secrets; +pub mod sshconfig; pub mod storage; +pub mod terminalenv; +pub mod updater; +pub mod validation; pub type Error = Box; pub type Result = std::result::Result; diff --git a/rust/core/src/recording.rs b/rust/core/src/recording.rs new file mode 100644 index 0000000..29cc485 --- /dev/null +++ b/rust/core/src/recording.rs @@ -0,0 +1,477 @@ +//! Session recording in asciicast v2 (https://docs.asciinema.org/manual/asciicast/v2/): a JSON +//! header line, then one `[elapsed_secs, type, data]` array per line. Same files as the Go app. + +use crate::Result; +use crate::models::Time; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Instant; + +pub const EVENT_OUTPUT: &str = "o"; +pub const EVENT_INPUT: &str = "i"; +/// Bounds a single active recording file. +pub const MAX_RECORDING_BYTES: u64 = 250 * 1024 * 1024; +/// Bounds the file size loaded for playback. +pub const MAX_PLAYBACK_BYTES: u64 = 100 * 1024 * 1024; +/// Bounds one asciicast line during playback parsing. +pub const MAX_PLAYBACK_LINE_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Header { + pub version: i64, + pub width: i64, + pub height: i64, + pub timestamp: i64, + #[serde(skip_serializing_if = "String::is_empty")] + pub title: String, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, +} + +/// One recorded I/O event; `kind` is `"o"` or `"i"` (other types are kept as read). +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + pub elapsed: f64, + pub kind: String, + pub data: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordingMeta { + pub id: String, + pub session_id: String, + pub title: String, + pub width: i64, + pub height: i64, + /// seconds + pub duration: f64, + /// file size in bytes + pub size: u64, + pub created_at: Time, + pub file_path: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StorageInfo { + pub count: usize, + pub total_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub oldest_created_at: Option