diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0f23771..768a4dd 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -224,7 +224,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.13.2", "home", "libc", @@ -542,6 +542,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" @@ -2233,6 +2239,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "hybrid-array" version = "0.4.15" @@ -2756,7 +2778,7 @@ version = "0.1.0" dependencies = [ "aes-gcm 0.10.3", "argon2 0.5.3", - "base64", + "base64 0.22.1", "chrono", "hmac 0.13.0", "keyring", @@ -2770,6 +2792,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "tokio", + "ureq", "uuid", ] @@ -4141,6 +4164,41 @@ dependencies = [ "rustix 1.1.5", ] +[[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" @@ -5010,6 +5068,41 @@ 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" @@ -5311,6 +5404,15 @@ dependencies = [ "wasm-bindgen", ] +[[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 = "weezl" version = "0.1.12" diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index 6a5574e..689e533 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -13,6 +13,7 @@ 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"] } russh = { version = "0.64.1", default-features = false, features = ["flate2", "ring", "rsa"] } russh-sftp = "3.0.1" 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