rust: port recording, sshconfig, terminalenv, validation, updater, keyfiles

This commit is contained in:
Andy Dixon
2026-10-11 16:17:03 +01:00
parent 38dff53485
commit fd54c988b6
9 changed files with 2072 additions and 2 deletions
+145
View File
@@ -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<Vec<PathBuf>> {
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<Vec<PathBuf>> {
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<String> {
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<String> {
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 <final>.txt").unwrap(), "report <final>.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();
}
}