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
@@ -15,3 +15,4 @@ gix-pack = { version = "0.77", default-features = false, features = ["sha1", "ge
gix-zlib = "0.4"
url = "2"
wasi = "0.14"
sausage-ssh = { path = "../ssh-client" }
+12 -3
View File
@@ -1,9 +1,18 @@
# Git for WASI 0.2
This crate adapts the v2 Git plugin's local operations and HTTP transport.
This crate adapts the v2 Git plugin's local operations, HTTP transport, and SSH transport.
It uses gix 0.90 and the patches in [`vendor/PATCHES.md`](vendor/PATCHES.md).
It does not use the plugin SDK, RPC dispatch, the vault, or SSH.
The host supplies credentials through standard `wasi:http`.
It does not use the plugin SDK, RPC dispatch, the vault, or native child processes.
The host supplies HTTP credentials and SSH signatures through standard `wasi:http`.
SSH clone, fetch, and push use the shared [`ssh-client`](../ssh-client/README.md) library in-process.
The remote command runs `git-upload-pack` or `git-receive-pack` over standard WASI sockets.
Remote URLs accept `ssh://user@host[:port]/path` and `user@host:path`.
SCP-style URLs use port 22.
The user can come from the URL or the host identity.
The host key must already match `$HOME/.ssh/known_hosts`.
Use the `ssh` CLI with `-o StrictHostKeyChecking=accept-new` to record a new key.
See [`../ssh/README.md`](../ssh/README.md) for the native sshd fixture and `ssh-test.nu` commands.
## Build
+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())))
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env nu
# Compare SSH clone, fetch, and push contents and refs with native Git.
use std/assert
def wasi [component: path, root: path, args: list] {
let ready = (open ($root | path join ready.json))
let result = (^wasmtime run -S inherit-network -S allow-ip-name-lookup -S http -S cli-exit-with-code=y --dir $'($root)/guest::/' --env HOME=/home --env $'SAUSAGE_HOST_API=http://127.0.0.1:($ready.api)' --env GIT_AUTHOR_NAME=Agent --env GIT_AUTHOR_EMAIL=agent@example.test --env GIT_COMMITTER_NAME=Agent --env GIT_COMMITTER_EMAIL=agent@example.test $component ...$args | complete)
if $result.exit_code != 0 {error make {msg: ($result | to json)}}
$result
}
def native [args: list] {
let result = (^git ...$args | complete)
if $result.exit_code != 0 {error make {msg: ($result | to json)}}
$result
}
def main [component: path, ssh_component: path, root: path] {
let component = ($component | path expand)
let ssh_component = ($ssh_component | path expand)
let root = ($root | path expand)
let ready = (open ($root | path join ready.json))
let wit = (^wasm-tools component wit $component | complete)
assert equal $wit.exit_code 0
let imports = ($wit.stdout | lines | str trim | where {|line| $line | str starts-with 'import '})
assert (not ($imports | is-empty))
assert ($imports | all {|line| $line | str starts-with 'import wasi:'})
print 'Git component imports: PASS (WASI only)'
print ($imports | str join (char newline))
wasi $ssh_component $root [-o StrictHostKeyChecking=accept-new -p $ready.ssh $'($ready.user)@127.0.0.1' true] | ignore
let seed = ($root | path join host/seed)
let bare = ($root | path join host/bare.git)
let clone = ($root | path join guest/clone)
let comparison = ($root | path join host/native-clone)
native [init -b main $seed] | ignore
native [-C $seed config user.name Agent] | ignore
native [-C $seed config user.email agent@example.test] | ignore
$"base\n" | save ($seed | path join file.txt)
native [-C $seed add .] | ignore
native [-C $seed commit --no-gpg-sign -m base] | ignore
native [clone --bare $seed $bare] | ignore
let url = $'ssh://($ready.user)@127.0.0.1:($ready.ssh)($bare)'
let ssh_command = $"ssh -F /dev/null -o IdentityAgent=none -o BatchMode=yes -i '($root)/host/client_key' -p ($ready.ssh) -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile='($root)/host/native_known_hosts'"
with-env {GIT_SSH_COMMAND: $ssh_command} {
native [clone $url $comparison] | ignore
wasi $component $root [clone $url /clone] | ignore
assert equal (open --raw ($clone | path join file.txt)) (open --raw ($comparison | path join file.txt))
assert equal (native [-C $clone show-ref] | get stdout) (native [-C $comparison show-ref] | get stdout)
print 'SSH clone: contents and refs match native Git: PASS'
$"fetch\n" | save --force ($seed | path join file.txt)
native [-C $seed commit --no-gpg-sign -am fetch] | ignore
native [-C $seed push $bare main] | ignore
native [-C $comparison fetch] | ignore
wasi $component $root [-C /clone fetch] | ignore
assert equal (native [-C $clone show-ref] | get stdout) (native [-C $comparison show-ref] | get stdout)
assert equal (native [-C $clone show origin/main:file.txt] | get stdout) $"fetch\n"
print 'SSH fetch: contents and refs match native Git: PASS'
wasi $component $root [-C /clone config user.name Agent] | ignore
wasi $component $root [-C /clone config user.email agent@example.test] | ignore
$"pushed\n" | save ($clone | path join pushed.txt)
wasi $component $root [-C /clone add pushed.txt] | ignore
wasi $component $root [-C /clone commit -m pushed] | ignore
wasi $component $root [-C /clone push origin HEAD:wasi] | ignore
native [-C $comparison fetch] | ignore
assert equal (native [-C $bare rev-parse wasi] | get stdout) (native [-C $clone rev-parse HEAD] | get stdout)
assert equal (native [-C $comparison show origin/wasi:pushed.txt] | get stdout) $"pushed\n"
print 'SSH push: remote contents and refs match native Git: PASS'
let scp = $'($ready.user)@127.0.0.1:($bare)'
wasi $component $root [-C /clone remote add scp $scp] | ignore
assert equal (wasi $component $root [-C /clone remote get-url scp] | get stdout | str trim) $scp
print 'SCP-style remote URL: PASS'
}
print $'Fixture: ($root)'
}