Add WASI SSH CLI and in-process Git SSH transport

This commit is contained in:
2026-11-06 19:42:00 +00:00
parent 608d0c3fc1
commit 99a8e3a8be
29 changed files with 3546 additions and 30 deletions
+155
View File
@@ -0,0 +1,155 @@
//! Native localhost sshd fixture and host-only signing oracle for WASI tests.
use base64::{Engine, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
use signature::Signer;
use ssh_key::{Algorithm, LineEnding, PrivateKey};
use std::{
fs,
io::{BufRead, BufReader, Read, Write},
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
fn key(path: &Path) -> PrivateKey {
let key = PrivateKey::random(&mut rand_core::OsRng, Algorithm::Ed25519).unwrap();
key.write_openssh_file(path, LineEnding::LF).unwrap();
key
}
struct Daemon {
child: Child,
config: PathBuf,
binary: PathBuf,
host_key: PathBuf,
log: PathBuf,
port: u16,
}
impl Daemon {
fn start(binary: &Path, config: &Path, log: &Path, port: u16) -> Child {
let child = Command::new(binary)
.args(["-D", "-e", "-f"])
.arg(config)
.stdout(Stdio::null())
.stderr(
fs::OpenOptions::new()
.create(true)
.append(true)
.open(log)
.unwrap(),
)
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(
Instant::now() < deadline,
"sshd failed to start; inspect {}",
log.display()
);
std::thread::yield_now();
}
child
}
fn rotate(&mut self) {
self.child.kill().unwrap();
self.child.wait().unwrap();
key(&self.host_key);
self.child = Self::start(&self.binary, &self.config, &self.log, self.port);
}
}
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn main() {
let root = PathBuf::from(std::env::args().nth(1).expect("fixture root"));
let user = std::env::args().nth(2).expect("SSH user");
let host = root.join("host");
fs::create_dir_all(&host).unwrap();
fs::create_dir_all(root.join("guest/home")).unwrap();
let client = key(&host.join("client_key"));
fs::write(
host.join("authorized_keys"),
client.public_key().to_openssh().unwrap(),
)
.unwrap();
let host_key = host.join("server_key");
key(&host_key);
let socket = TcpListener::bind("127.0.0.1:0").unwrap();
let port = socket.local_addr().unwrap().port();
drop(socket);
let binary = std::env::split_paths(&std::env::var_os("PATH").unwrap())
.map(|p| p.join("sshd"))
.find(|p| p.is_file())
.unwrap()
.canonicalize()
.unwrap();
let config = host.join("sshd_config");
fs::write(&config, format!("Port {port}\nListenAddress 127.0.0.1\nHostKey {}\nPidFile {}\nAuthorizedKeysFile {}\nStrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers {user}\nLogLevel VERBOSE\n", host_key.display(), host.join("sshd.pid").display(), host.join("authorized_keys").display())).unwrap();
let log = host.join("sshd.log");
let child = Daemon::start(&binary, &config, &log, port);
let mut daemon = Daemon {
child,
config,
binary,
host_key,
log,
port,
};
let api = TcpListener::bind("127.0.0.1:0").unwrap();
fs::write(
root.join("ready.json"),
json!({"ssh":port,"api":api.local_addr().unwrap().port(),"user":user}).to_string(),
)
.unwrap();
for connection in api.incoming() {
let mut stream = connection.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut first = String::new();
reader.read_line(&mut first).unwrap();
let route = first.split_whitespace().nth(1).unwrap_or("");
let mut length = 0;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line == "\r\n" || line.is_empty() {
break;
}
if let Some((name, value)) = line.split_once(':') {
if name.eq_ignore_ascii_case("content-length") {
length = value.trim().parse().unwrap();
}
}
}
let mut bytes = vec![0; length];
reader.read_exact(&mut bytes).unwrap();
let request: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
let (status, body) = match route {
"/fixture/rotate" => {
daemon.rotate();
(200, json!({"ok":true}))
}
"/ssh/identity" | "/ssh/sign" if host.join("deny").exists() => (
403,
json!({"item":"test-key","request":"test-request","message":"approval required"}),
),
"/ssh/identity" => (
200,
json!({"item":"test-key","public_key":STANDARD.encode(client.public_key().to_bytes().unwrap()),"user":user}),
),
"/ssh/sign" if request["item"] == "test-key" && request["host"] == "127.0.0.1" => {
let bytes = STANDARD
.decode(request["payload"].as_str().unwrap())
.unwrap();
let signature: ssh_key::Signature = client.try_sign(&bytes).unwrap();
let signature: Vec<u8> = signature.try_into().unwrap();
(200, json!({"signature":STANDARD.encode(signature)}))
}
_ => (400, json!({"error":"invalid request"})),
};
let body = body.to_string();
write!(stream, "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
}
}