Add WASI SSH CLI and in-process Git SSH transport
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod branches;
|
||||
mod changes;
|
||||
mod checkout;
|
||||
mod http;
|
||||
mod ssh;
|
||||
mod merge;
|
||||
mod network;
|
||||
mod output;
|
||||
|
||||
+21
-14
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
Reference in New Issue
Block a user