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
+1
View File
@@ -4,6 +4,7 @@ mod branches;
mod changes;
mod checkout;
mod http;
mod ssh;
mod merge;
mod network;
mod output;
+21 -14
View File
@@ -1,9 +1,10 @@
//! Anonymous HTTP and HTTPS clone/fetch through standard wasi:http.
//! Clone and fetch over standard WASI HTTP or in-process SSH exec channels.
use crate::{
Result,
http::{WasiHttp, clean_url},
http::WasiHttp,
remotes,
repo::{self, error},
ssh::clean_url,
};
use gix::bstr::ByteSlice;
use std::{collections::BTreeSet, sync::atomic::AtomicBool};
@@ -82,18 +83,24 @@ fn perform(
));
}
}
let transport: Box<dyn gix_transport::client::blocking_io::Transport> = {
let transport_url = gix::url::parse(url.as_bytes().as_bstr())
.map_err(|_| error("fetch", "invalid HTTPS URL"))?;
Box::new(
gix_transport::client::blocking_io::http::Transport::new_http(
WasiHttp::default(),
transport_url,
gix_transport::Protocol::V2,
false,
),
)
};
let transport: Box<dyn gix_transport::client::blocking_io::Transport> =
if crate::ssh::is_ssh(&url) {
Box::new(crate::ssh::connection(
&url,
gix_transport::Service::UploadPack,
)?)
} else {
let transport_url = gix::url::parse(url.as_bytes().as_bstr())
.map_err(|_| error("fetch", "invalid HTTPS URL"))?;
Box::new(
gix_transport::client::blocking_io::http::Transport::new_http(
WasiHttp::default(),
transport_url,
gix_transport::Protocol::V2,
false,
),
)
};
let mut options = gix::remote::ref_map::Options::default();
options.extra_refspecs.push(
gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch)
+58 -1
View File
@@ -1,4 +1,4 @@
//! Single-ref receive-pack client over standard wasi:http.
//! Single-ref receive-pack over standard WASI HTTP or in-process SSH exec channels.
use crate::Result;
use crate::{
@@ -587,6 +587,9 @@ pub fn push(p: PushParams) -> Result<PushResult> {
.to_bstring()
.to_str_lossy()
.into_owned();
if crate::ssh::is_ssh(&url) {
return ssh_push(&repo, &url, remote, &plan, p.set_upstream.unwrap_or(false));
}
let base = clean_url(&url)
.map_err(|e| error("push URL", e))?
.to_string()
@@ -670,3 +673,57 @@ pub fn push(p: PushParams) -> Result<PushResult> {
})();
result
}
fn ssh_push(
repo: &gix::Repository,
url: &str,
remote: &str,
plan: &Plan,
upstream: bool,
) -> Result<PushResult> {
let (mut read, mut write) =
crate::ssh::connection(url, gix_transport::Service::ReceivePack)?.into_inner();
let advertised = advertise_refs(&mut read)?;
let old = advertised.refs.get(&plan.destination).copied();
if let (Some(old), Some(new)) = (old, plan.new) {
if old != new && !plan.force && !is_ancestor(repo, old, new)? {
return Err(error("push", "non-fast-forward; fetch first or use force"));
}
}
let up_to_date = old == plan.new;
let mut objects = 0;
if !up_to_date {
if plan.new.is_none() && !advertised.caps.contains("delete-refs") {
return Err(error("push", "remote does not allow deleting refs"));
}
let ids = match plan.new {
Some(new) => objects_to_send(repo, new, advertised.refs.values().copied())?,
None => BTreeSet::new(),
};
objects = ids.len();
let sideband = advertised.caps.contains("side-band-64k");
let mut caps = vec!["report-status"];
if sideband {
caps.push("side-band-64k");
}
commands(old, plan, &caps, &mut write)?;
if plan.new.is_some() {
pack(repo, &ids, &mut write)?;
}
write.flush().map_err(|e| error("push SSH flush", e))?;
report(&mut read, &plan.destination, sideband)?;
} else {
write
.write_all(b"0000")
.and_then(|_| write.flush())
.map_err(|e| error("push SSH finish", e))?;
}
update_local(repo, remote, plan, upstream)?;
Ok(PushResult {
remote: remote.into(),
reference: plan.destination.clone(),
old: old.map(|id| id.to_string()),
new: plan.new.map(|id| id.to_string()),
up_to_date,
objects,
})
}
+2 -2
View File
@@ -1,6 +1,6 @@
//! Non-destructive local Git config edits. No plugin-owned persistent format.
use crate::Result;
use crate::{http::clean_url, repo::error};
use crate::{repo::error, ssh::clean_url};
use gix::bstr::ByteSlice;
/// Remote identity.
@@ -81,7 +81,7 @@ pub fn list(repo: &gix::Repository) -> Result<Vec<RemoteInfo>> {
// Do not return legacy embedded credentials from repositories imported outside this plugin.
let url = url
.map(|s| {
clean_url(&s).map(|u| u.to_string()).map_err(|_| {
clean_url(&s).map_err(|_| {
error(
"remote list",
"remote URL must be HTTPS without credentials or SSH without password",
+63
View File
@@ -0,0 +1,63 @@
//! Git exec channels over the shared SSH library, with host-mediated signing.
use crate::{Result, repo::error};
use gix::bstr::ByteSlice;
use std::io;
fn parse(input: &str) -> io::Result<gix::Url> {
let url = gix::url::parse(input.as_bytes().as_bstr())
.map_err(|_| io::Error::other("invalid SSH URL"))?;
if url.scheme != gix::url::Scheme::Ssh
|| url.host.as_deref().is_none_or(str::is_empty)
|| url.password.is_some()
|| url.path.is_empty()
|| url.path.iter().any(|b| matches!(b, 0 | b'\n' | b'\r'))
{
return Err(io::Error::other(
"use ssh://[user@]host[:port]/path or [user@]host:path without a password",
));
}
Ok(url)
}
pub fn clean_url(input: &str) -> io::Result<String> {
if input.starts_with("http://") || input.starts_with("https://") {
crate::http::clean_url(input).map(|url| url.to_string())
} else {
parse(input)?;
Ok(input.into())
}
}
pub fn is_ssh(input: &str) -> bool {
!input.starts_with("http://") && !input.starts_with("https://")
}
pub type Connection =
gix_transport::client::git::blocking_io::Connection<sausage_ssh::Reader, sausage_ssh::Writer>;
pub fn connection(input: &str, service: gix_transport::Service) -> Result<Connection> {
let url = parse(input).map_err(|e| error("SSH URL", e))?;
let host = url.host.as_deref().unwrap();
let mut session =
sausage_ssh::identity::connect(host, url.port.unwrap_or(22), url.user.as_deref(), false)
.map_err(|e| error("SSH", e))?;
let mut command = service.as_str().as_bytes().to_vec();
command.extend_from_slice(b" '");
for byte in url.path.iter() {
if *byte == b'\'' {
command.extend_from_slice(b"'\\''");
} else {
command.push(*byte);
}
}
command.push(b'\'');
session
.open_command(&command)
.map_err(|e| error("SSH exec", e))?;
let (read, write) = session.split();
Ok(Connection::new(
read,
write,
gix_transport::Protocol::V1,
url.path,
None::<(&str, Option<u16>)>,
gix_transport::client::git::ConnectMode::Process,
false,
)
.custom_url(Some(input.into())))
}