radicle-git init

Port over all git relevant parts of the radicle-link repository[0].
The packages that are kept are:
* git-ext
* git-ref-format
* git-trailers
* link-git
* macros
* std-ext
* test -- only the portions that test the above

The development and testing scripts are also added for local and CI
testing.

[0]: https://github.com/radicle-dev/radicle-link/
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
This commit is contained in:
Fintan Halpenny
2022-09-08 12:07:51 +01:00
parent c43420956c
commit 70b571b67b
112 changed files with 10341 additions and 7 deletions
+290
View File
@@ -0,0 +1,290 @@
// Copyright © 2021 The Radicle Link Contributors
//
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
// Linking Exception. For full terms see the included LICENSE file.
use std::{
future::Future,
io,
mem,
pin::Pin,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
task::{Context, Poll},
};
use bstr::{BString, ByteSlice as _};
use futures_lite::{
future,
io::{AsyncBufRead, AsyncRead, AsyncWrite},
};
use git_features::progress::{self, Progress};
use git_protocol::{
fetch::{response, Action, Arguments, Delegate, DelegateBlocking, LsRefsAction, Response},
transport::client,
};
use once_cell::sync::Lazy;
use pin_project::{pin_project, pinned_drop};
use versions::Version;
pub use git_hash::ObjectId;
pub use git_protocol::fetch::Ref;
use super::{packwriter::PackWriter, remote_git_version, transport};
// Work around `git-upload-pack` not handling namespaces properly,
//
// cf. https://lore.kernel.org/git/CD2XNXHACAXS.13J6JTWZPO1JA@schmidt/
// Fixed in `git.git` 1ab13eb, which should land in 2.34
//
// Based on testing with git 2.25.1 in Ubuntu 20.04, this workaround is
// not needed. Hence the checked version is lowered to 2.25.0.
fn must_namespace_want_ref(caps: &client::Capabilities) -> bool {
static FIXED_AFTER: Lazy<Version> = Lazy::new(|| Version::new("2.25.0").unwrap());
remote_git_version(caps)
.map(|version| version <= *FIXED_AFTER)
.unwrap_or(false)
}
#[derive(Debug)]
pub struct Options {
/// The remote (logical) repository to fetch from.
///
/// Normally, this is the path to a repo on the remote side (eg.
/// `/git.git`). `radicle-link` serves only a single namespaced repo, so
/// this value should be the name of a namespace.
pub repo: BString,
/// [Extra Parameters][extra] to send with the initial transport header.
///
/// [extra]: https://git.kernel.org/pub/scm/git/git.git/tree/Documentation/technical/pack-protocol.txt#n52
pub extra_params: Vec<(String, Option<String>)>,
/// [`ObjectId`]s to send as `want` lines.
pub wants: Vec<ObjectId>,
/// [`ObjectId`]s to send as `have` lines.
pub haves: Vec<ObjectId>,
/// Known refs to ask the server to include in the packfile.
pub want_refs: Vec<BString>,
}
/// Result of a succesful [`fetch`].
#[derive(Debug)]
pub struct Outputs<T> {
/// The `wanted-refs` as acknowledged by the server.
pub wanted_refs: Vec<Ref>,
/// If a packfile was received successfully, some info about it.
pub pack: Option<T>,
}
impl<T> Default for Outputs<T> {
fn default() -> Self {
Self {
wanted_refs: Vec::new(),
pack: None,
}
}
}
/// [`Delegate`] driving the fetch end of the [pack protocol].
///
/// [pack protocol]: https://git.kernel.org/pub/scm/git/git.git/tree/Documentation/technical/pack-protocol.txt
pub struct Fetch<P, O> {
opt: Options,
pack_writer: P,
out: Outputs<O>,
need_namespaced_want_ref: bool,
}
impl<P, O> Fetch<P, O> {
pub fn new(opt: Options, pack_writer: P) -> Self {
Self {
opt,
pack_writer,
out: Outputs::default(),
need_namespaced_want_ref: false,
}
}
pub fn outputs(&self) -> &Outputs<O> {
&self.out
}
pub fn take_outputs(&mut self) -> Outputs<O> {
mem::take(&mut self.out)
}
}
impl<P: PackWriter> DelegateBlocking for Fetch<P, P::Output> {
fn handshake_extra_parameters(&self) -> Vec<(String, Option<String>)> {
self.opt.extra_params.clone()
}
fn prepare_ls_refs(
&mut self,
_: &client::Capabilities,
_: &mut Vec<BString>,
_: &mut Vec<(&str, Option<&str>)>,
) -> io::Result<LsRefsAction> {
Ok(LsRefsAction::Skip)
}
fn prepare_fetch(
&mut self,
_: git_protocol::transport::Protocol,
caps: &client::Capabilities,
_: &mut Vec<(&str, Option<&str>)>,
_: &[Ref],
) -> io::Result<Action> {
if !self.opt.want_refs.is_empty() && !remote_supports_ref_in_want(caps) {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"`want-ref`s given, but server does not support `ref-in-want`",
));
}
if self.opt.wants.is_empty() && self.opt.want_refs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"`fetch` is empty",
));
}
self.need_namespaced_want_ref = must_namespace_want_ref(caps);
Ok(Action::Continue)
}
fn negotiate(
&mut self,
_: &[Ref],
args: &mut Arguments,
_: Option<&Response>,
) -> io::Result<Action> {
for oid in &self.opt.wants {
args.want(oid);
}
for oid in &self.opt.haves {
args.have(oid)
}
for name in &self.opt.want_refs {
if self.need_namespaced_want_ref {
let want_ref = format!("refs/namespaces/{}/{}", self.opt.repo, name);
args.want_ref(BString::from(want_ref).as_bstr());
} else {
args.want_ref(name.as_bstr());
}
}
// send done, as we don't bother with further negotiation
Ok(Action::Cancel)
}
}
#[async_trait(?Send)]
impl<P: PackWriter> Delegate for Fetch<P, P::Output> {
async fn receive_pack(
&mut self,
pack: impl AsyncBufRead + Unpin + 'async_trait,
prog: impl Progress,
_: &[Ref],
resp: &Response,
) -> io::Result<()> {
// Strip any namespaces leaked by the other end due to workarounds
let namespace = format!("refs/namespaces/{}/", self.opt.repo);
self.out.wanted_refs.extend(resp.wanted_refs().iter().map(
|response::WantedRef { id, path }| {
Ref::Direct {
path: path
.strip_prefix(namespace.as_bytes())
.map(BString::from)
.unwrap_or_else(|| path.clone()),
object: *id,
}
},
));
let out = self.pack_writer.write_pack(pack, prog)?;
self.out.pack = Some(out);
Ok(())
}
}
/// Future created by the [`fetch`] function.
///
/// Ensures that a running inner [`PackWriter`] is cancelled when the
/// [`Fetching`] future is dropped without also dropping the [`AsyncRead`] data
/// source.
#[pin_project(PinnedDrop)]
struct Fetching<T> {
stop: Arc<AtomicBool>,
#[pin]
task: T,
}
#[pinned_drop]
impl<T> PinnedDrop for Fetching<T> {
fn drop(self: Pin<&mut Self>) {
self.stop.store(true, Ordering::Release)
}
}
impl<T> Future for Fetching<T>
where
T: Future,
{
type Output = T::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
self.project().task.poll(cx)
}
}
pub fn fetch<B, P, R, W>(
opt: Options,
build_pack_writer: B,
recv: R,
send: W,
) -> impl Future<Output = io::Result<Outputs<P::Output>>>
where
B: FnOnce(Arc<AtomicBool>) -> P,
P: PackWriter + Send + 'static,
P::Output: Send + 'static,
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let stop = Arc::new(AtomicBool::new(false));
let task = blocking::unblock({
let mut conn = transport::Stateless::new(opt.repo.clone(), recv, send);
let pack_writer = build_pack_writer(Arc::clone(&stop));
move || {
let mut delegate = Fetch::new(opt, pack_writer);
future::block_on(git_protocol::fetch(
&mut conn,
&mut delegate,
|_| unreachable!("credentials helper requested"),
progress::Discard,
git_protocol::FetchConnection::AllowReuse,
))
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(delegate.out)
}
});
Fetching { stop, task }
}
fn remote_supports_ref_in_want(caps: &client::Capabilities) -> bool {
caps.capability("fetch")
.and_then(|cap| cap.supports("ref-in-want"))
.unwrap_or(false)
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright © 2021 The Radicle Link Contributors
//
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
// Linking Exception. For full terms see the included LICENSE file.
use std::io;
use bstr::{BString, ByteVec as _};
use futures_lite::io::{AsyncBufRead, AsyncRead, AsyncWrite};
use git_features::progress::{self, Progress};
use git_protocol::{
fetch::{Action, Arguments, Delegate, DelegateBlocking, LsRefsAction, Response},
transport::client,
};
use once_cell::sync::Lazy;
use versions::Version;
pub use git_protocol::fetch::Ref;
use super::{remote_git_version, transport};
// Work around `git-upload-pack` not handling namespaces properly
//
// cf. https://lore.kernel.org/git/pMV5dJabxOBTD8kJBaPuWK0aS6OJhRQ7YFGwfhPCeSJEbPDrIFBza36nXBCgUCeUJWGmpjPI1rlOGvZJEh71Ruz4SqljndUwOCoBUDRHRDU=@eagain.st/
//
// Based on testing with git 2.25.1 in Ubuntu 20.04, this workaround is
// not needed. Hence the checked version is lowered to 2.25.0.
fn must_namespace(caps: &client::Capabilities) -> bool {
static MIN_GIT_VERSION_NAMESPACES: Lazy<Version> =
Lazy::new(|| Version::new("2.25.0").unwrap());
remote_git_version(caps)
.map(|version| version < *MIN_GIT_VERSION_NAMESPACES)
.unwrap_or(false)
}
#[derive(Debug)]
pub struct Options {
/// The remote (logical) repository to fetch from.
///
/// Normally, this is the path to a repo on the remote side (eg.
/// `/git.git`). `radicle-link` serves only a single namespaced repo, so
/// this value should be the name of a namespace.
pub repo: BString,
/// [Extra Parameters][extra] to send with the initial transport header.
///
/// [extra]: https://git.kernel.org/pub/scm/git/git.git/tree/Documentation/technical/pack-protocol.txt#n52
pub extra_params: Vec<(String, Option<String>)>,
/// Prefixes of refs to ask the server to advertise via `ls-refs`.
///
/// If the [`Vec`] is empty, the server is asked to return all refs it knows
/// about. Otherwise, the server is asked to only return refs matching
/// the given prefixes.
pub ref_prefixes: Vec<BString>,
}
/// [`Delegate`] for running a stateless `ls-refs` command.
pub struct LsRefs {
opt: Options,
out: Vec<Ref>,
}
impl LsRefs {
pub fn new(opt: Options) -> Self {
Self {
opt,
out: Vec::new(),
}
}
}
impl DelegateBlocking for LsRefs {
fn handshake_extra_parameters(&self) -> Vec<(String, Option<String>)> {
self.opt.extra_params.clone()
}
fn prepare_ls_refs(
&mut self,
caps: &client::Capabilities,
args: &mut Vec<BString>,
_: &mut Vec<(&str, Option<&str>)>,
) -> io::Result<LsRefsAction> {
let must_namespace = must_namespace(caps);
for prefix in &self.opt.ref_prefixes {
let mut arg = BString::from("ref-prefix ");
if must_namespace {
arg.push_str("refs/namespaces/");
arg.push_str(&self.opt.repo);
arg.push_char('/');
}
arg.push_str(prefix);
args.push(arg)
}
Ok(LsRefsAction::Continue)
}
fn prepare_fetch(
&mut self,
_: git_protocol::transport::Protocol,
_: &client::Capabilities,
_: &mut Vec<(&str, Option<&str>)>,
refs: &[Ref],
) -> io::Result<Action> {
self.out.extend_from_slice(refs);
Ok(Action::Cancel)
}
fn negotiate(
&mut self,
_: &[Ref],
_: &mut Arguments,
_: Option<&Response>,
) -> io::Result<Action> {
unreachable!("`negotiate` called even though no `fetch` command was sent")
}
}
#[async_trait(?Send)]
impl Delegate for LsRefs {
async fn receive_pack(
&mut self,
_: impl AsyncBufRead + Unpin + 'async_trait,
_: impl Progress,
_: &[Ref],
_: &Response,
) -> io::Result<()> {
unreachable!("`receive_pack` called even though no `fetch` command was sent")
}
}
pub async fn ls_refs<R, W>(opt: Options, recv: R, send: W) -> io::Result<Vec<Ref>>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut conn = transport::Stateless::new(opt.repo.clone(), recv, send);
let mut delegate = LsRefs::new(opt);
git_protocol::fetch(
&mut conn,
&mut delegate,
|_| unreachable!("credentials helper requested"),
progress::Discard,
git_protocol::FetchConnection::AllowReuse,
)
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(delegate.out)
}
+271
View File
@@ -0,0 +1,271 @@
// Copyright © 2021 The Radicle Link Contributors
//
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
// Linking Exception. For full terms see the included LICENSE file.
use std::{
io,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use futures_lite::io::{AsyncBufRead, BlockOn};
use git_features::progress::Progress;
use git_hash::ObjectId;
use git_odb::{self as odb, pack};
use super::take::TryTake;
#[cfg(feature = "git2")]
pub use libgit::Libgit;
/// What to do with the `packfile` response.
///
/// _This is mostly the same as [`git_protocol::fetch::Delegate`], but without
/// incurring the [`git_protocol::fetch::DelegateBlocking`] super-trait
/// constraint. We can simply make [`crate::protocol::fetch::Fetch`] parametric
/// over the packfile sink._
pub trait PackWriter {
type Output;
fn write_pack(
&self,
pack: impl AsyncBufRead + Unpin,
progress: impl Progress,
) -> io::Result<Self::Output>;
}
#[derive(Clone, Copy, Debug)]
pub struct Options {
/// How many threads the packfile indexer is allowed to spawn. `None` means
/// unlimited.
pub max_indexer_threads: Option<usize>,
/// The maximum size in bytes of the packfile.
///
/// If the remote sends a larger file, the transfer will be aborted.
pub max_pack_bytes: u64,
}
impl Default for Options {
fn default() -> Self {
Self {
max_indexer_threads: Some(1),
max_pack_bytes: u64::MAX,
}
}
}
#[cfg(feature = "git2")]
pub mod libgit {
use super::*;
#[derive(Clone, Copy, Debug)]
pub struct PackReceived {
pub objects: usize,
pub local_objects: usize,
pub deltas: usize,
}
impl From<git2::Progress<'_>> for PackReceived {
fn from(p: git2::Progress<'_>) -> Self {
Self {
objects: p.indexed_objects(),
local_objects: p.local_objects(),
deltas: p.indexed_deltas(),
}
}
}
pub struct Libgit {
opt: Options,
repo: git2::Repository,
stop: Arc<AtomicBool>,
}
impl Libgit {
pub fn new(opt: Options, repo: git2::Repository, stop: Arc<AtomicBool>) -> Self {
Self { opt, repo, stop }
}
fn guard_cancelled(&self) -> io::Result<()> {
if self.stop.load(Ordering::Acquire) {
Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled"))
} else {
Ok(())
}
}
}
impl PackWriter for Libgit {
type Output = Option<PackReceived>;
fn write_pack(
&self,
pack: impl AsyncBufRead + Unpin,
_: impl Progress,
) -> io::Result<Self::Output> {
let mut out = None;
let odb = self.repo.odb().map_err(io_error)?;
let mut writer = odb.packwriter().map_err(io_error)?;
self.guard_cancelled()?;
io::copy(
&mut BlockOn::new(TryTake::new(pack, self.opt.max_pack_bytes)),
&mut writer,
)?;
self.guard_cancelled()?;
writer
.progress(|p| {
out = Some(p.to_owned());
true
})
.commit()
.map(|_| ())
.map_err(io_error)?;
// Convince borrowchk that `out` can not possibly be borrowed anymore
drop(writer);
Ok(out.map(Into::into))
}
}
fn io_error(e: git2::Error) -> io::Error {
io::Error::new(io::ErrorKind::Other, e)
}
}
pub type PackReceived = pack::bundle::write::Outcome;
/// A lookup function to help "thicken" thin packs by finding missing base
/// objects.
///
/// The impl provided for [`odb::linked::Store`] does not use any pack caching.
pub trait Thickener {
fn find_object<'a>(&self, id: ObjectId, buf: &'a mut Vec<u8>)
-> Option<pack::data::Object<'a>>;
}
impl Thickener for odb::linked::Store {
fn find_object<'a>(
&self,
id: ObjectId,
buf: &'a mut Vec<u8>,
) -> Option<pack::data::Object<'a>> {
use git_odb::FindExt as _;
self.find(id, buf, &mut pack::cache::Never).ok()
}
}
/// A factory spewing out new [`Thickener`]s with static lifetimes.
///
/// `gitoxide` doesn't currently allow us to initialise thickening lazily (the
/// pack file may not be thin after all), but requires a static lookup function.
/// Instead of initialising a new [`odb::linked::Store`] for every pack stream,
/// users may share a pre-initialised object database provided appropriate
/// thread safety measures.
pub trait BuildThickener {
type Error: std::error::Error + Send + Sync + 'static;
type Thick: Thickener + 'static;
fn build_thickener(&self) -> Result<Self::Thick, Self::Error>;
}
pub struct StandardThickener {
git_dir: PathBuf,
}
impl StandardThickener {
pub fn new(git_dir: impl Into<PathBuf>) -> Self {
let git_dir = git_dir.into();
Self { git_dir }
}
}
impl BuildThickener for StandardThickener {
type Error = odb::linked::init::Error;
type Thick = odb::linked::Store;
fn build_thickener(&self) -> Result<Self::Thick, Self::Error> {
odb::linked::Store::at(self.git_dir.join("objects"))
}
}
/// The default [`PackWriter`].
///
/// Writes the packfile into the given output directory, along with a v2
/// index. The packfile is verified.
pub struct Standard<F> {
git_dir: PathBuf,
opt: Options,
thick: F,
stop: Arc<AtomicBool>,
}
impl<F> Standard<F> {
pub fn new(git_dir: impl AsRef<Path>, opt: Options, thick: F, stop: Arc<AtomicBool>) -> Self {
Self {
git_dir: git_dir.as_ref().to_owned(),
opt,
thick,
stop,
}
}
}
impl<F> Drop for Standard<F> {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
}
}
impl<F: BuildThickener> PackWriter for Standard<F> {
type Output = PackReceived;
fn write_pack(
&self,
pack: impl AsyncBufRead + Unpin,
prog: impl Progress,
) -> io::Result<Self::Output> {
use pack::{bundle::write::Options, data::input::Mode, index::Version, Bundle};
let opts = Options {
thread_limit: self.opt.max_indexer_threads,
index_kind: Version::V2,
iteration_mode: Mode::Verify,
};
let thickener = self
.thick
.build_thickener()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Bundle::write_to_directory(
BlockOn::new(TryTake::new(pack, self.opt.max_pack_bytes)),
Some(self.git_dir.join("objects").join("pack")),
prog,
&self.stop,
Some(Box::new(move |oid, buf| thickener.find_object(oid, buf))),
opts,
)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))
}
}
/// No-op [`PackWriter`] which just drains the input.
pub struct Discard;
impl PackWriter for Discard {
type Output = u64;
fn write_pack(
&self,
pack: impl AsyncBufRead + Unpin,
_: impl Progress,
) -> io::Result<Self::Output> {
io::copy(&mut BlockOn::new(pack), &mut io::sink())
}
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright © 2021 The Radicle Link Contributors
//
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
// Linking Exception. For full terms see the included LICENSE file.
use std::{
io,
pin::Pin,
task::{Context, Poll},
};
use futures_lite::io::{AsyncBufRead, AsyncRead};
/// Like [`futures_lite::io::Take`], but returns an error if and when the
/// `limit` is exceeded.
///
/// Note that, unlike [`futures_lite::io::Take`], if a single poll reads past
/// the limit, the excess bytes are _not_ discarded. Instead, an error is
/// returned on the next poll.
pub struct TryTake<R> {
limit: u64,
inner: R,
}
impl<R> TryTake<R> {
pub fn new(inner: R, limit: u64) -> Self {
Self { limit, inner }
}
}
impl<R> AsyncRead for TryTake<R>
where
R: AsyncRead + Unpin,
{
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context,
buf: &mut [u8],
) -> Poll<Result<usize, io::Error>> {
if self.limit == 0 {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::Other,
"max input size exceeded",
)));
}
let this = self.get_mut();
Pin::new(&mut this.inner).poll_read(cx, buf).map(|ready| {
if let Ok(siz) = ready {
this.limit = this.limit.saturating_sub(siz as u64);
}
ready
})
}
}
impl<R> AsyncBufRead for TryTake<R>
where
R: AsyncBufRead + Unpin,
{
fn poll_fill_buf(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Result<&[u8], io::Error>> {
if self.limit == 0 {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::Other,
"max input size exceeded",
)));
}
Pin::new(&mut self.get_mut().inner).poll_fill_buf(cx)
}
fn consume(self: Pin<&mut Self>, amt: usize) {
Pin::new(&mut self.get_mut().inner).consume(amt)
}
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright © 2021 The Radicle Link Contributors
//
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
// Linking Exception. For full terms see the included LICENSE file.
use bstr::BString;
use futures_lite::io::{AsyncRead, AsyncWrite};
use git_protocol::transport::{
client::{
self,
git::{ConnectMode, Connection},
SetServiceResponse,
Transport,
TransportWithoutIO,
},
Protocol,
Service,
};
pub struct Stateless<R, W> {
inner: Connection<R, W>,
}
impl<R, W> Stateless<R, W>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
pub fn new(repo: BString, recv: R, send: W) -> Self {
let url = format!("rad://{}", repo);
let inner = Connection::new(
recv,
send,
Protocol::V2,
repo,
None::<(String, Option<u16>)>,
ConnectMode::Daemon,
)
.custom_url(Some(url));
Self { inner }
}
}
impl<R, W> TransportWithoutIO for Stateless<R, W>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
fn request(
&mut self,
write_mode: client::WriteMode,
on_into_read: client::MessageKind,
) -> Result<client::RequestWriter<'_>, client::Error> {
self.inner.request(write_mode, on_into_read)
}
fn to_url(&self) -> String {
self.inner.to_url()
}
fn supported_protocol_versions(&self) -> &[Protocol] {
&[Protocol::V2]
}
fn connection_persists_across_multiple_requests(&self) -> bool {
false
}
}
#[async_trait(?Send)]
impl<R, W> Transport for Stateless<R, W>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
async fn handshake<'a>(
&mut self,
service: Service,
extra_parameters: &'a [(&'a str, Option<&'a str>)],
) -> Result<SetServiceResponse<'_>, client::Error> {
self.inner.handshake(service, extra_parameters).await
}
}
+238
View File
@@ -0,0 +1,238 @@
// Copyright © 2021 The Radicle Link Contributors
//
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
// Linking Exception. For full terms see the included LICENSE file.
use std::{future::Future, io, path::Path, process::ExitStatus, str::FromStr};
use async_process::{Command, Stdio};
use futures_lite::io::{copy, AsyncBufReadExt as _, AsyncRead, AsyncWrite, BufReader};
use futures_util::try_join;
use git_packetline::{self as packetline, PacketLineRef};
use once_cell::sync::Lazy;
use versions::Version;
mod legacy;
#[derive(Debug, PartialEq, Eq)]
pub struct Header {
pub path: String,
pub host: Option<(String, Option<u16>)>,
pub extra: Vec<(String, Option<String>)>,
}
impl FromStr for Header {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s
.strip_prefix("git-upload-pack ")
.ok_or("unsupported service")?
.split_terminator('\0');
let path = parts.next().ok_or("missing path").and_then(|path| {
if path.is_empty() {
Err("empty path")
} else {
Ok(path.to_owned())
}
})?;
let host = match parts.next() {
None | Some("") => None,
Some(host) => match host.strip_prefix("host=") {
None => return Err("invalid host"),
Some(host) => match host.split_once(':') {
None => Some((host.to_owned(), None)),
Some((host, port)) => {
let port = port.parse::<u16>().or(Err("invalid port"))?;
Some((host.to_owned(), Some(port)))
},
},
},
};
let extra = parts
.skip_while(|part| part.is_empty())
.map(|part| match part.split_once('=') {
None => (part.to_owned(), None),
Some((k, v)) => (k.to_owned(), Some(v.to_owned())),
})
.collect();
Ok(Self { path, host, extra })
}
}
pub async fn upload_pack<R, W>(
git_dir: impl AsRef<Path>,
recv: R,
mut send: W,
) -> io::Result<(Header, impl Future<Output = io::Result<ExitStatus>>)>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut recv = BufReader::new(recv);
let header: Header = match recv.fill_buf().await?.first() {
// legacy clients don't send a proper pktline header :(
Some(b'g') => {
let mut buf = String::with_capacity(256);
recv.read_line(&mut buf).await?;
buf.parse().map_err(invalid_data)?
},
Some(_) => {
let mut pktline = packetline::StreamingPeekableIter::new(recv, &[]);
let pkt = pktline
.read_line()
.await
.ok_or_else(|| invalid_data("missing header"))?
.map_err(invalid_data)?
.map_err(invalid_data)?;
let hdr = match pkt {
PacketLineRef::Data(data) => std::str::from_utf8(data)
.map_err(invalid_data)?
.parse()
.map_err(invalid_data),
_ => Err(invalid_data("not a header packet")),
}?;
recv = pktline.into_inner();
hdr
},
None => {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"expected header",
))
},
};
let namespace = header
.path
// legacy clients redundantly send a full URN
.strip_prefix("rad:git:")
.map(ToOwned::to_owned)
.unwrap_or_else(|| header.path.clone());
let protocol_version = header
.extra
.iter()
.find_map(|kv| match kv {
(ref k, Some(v)) if k == "version" => {
let version = match v.as_str() {
"2" => 2,
"1" => 1,
_ => 0,
};
Some(version)
},
_ => None,
})
.unwrap_or(0);
// legacy
let stateless_ls = header.extra.iter().any(|(k, _)| k == "ls");
let fut = async move {
if protocol_version < 2 {
if stateless_ls {
return legacy::advertise_refs(git_dir, &namespace, recv, send).await;
}
} else {
advertise_capabilities(&mut send).await?;
}
let mut child = {
let mut cmd = Command::new("git");
cmd.current_dir(git_dir)
.env_clear()
.envs(
std::env::vars()
.filter(|(key, _)| key == "PATH" || key.starts_with("GIT_TRACE")),
)
.env("GIT_PROTOCOL", format!("version={}", protocol_version))
.env("GIT_NAMESPACE", namespace)
.args(&[
"-c",
"uploadpack.allowanysha1inwant=true",
"-c",
"uploadpack.allowrefinwant=true",
"-c",
"lsrefs.unborn=ignore",
"upload-pack",
"--strict",
"--stateless-rpc",
".",
])
.stdout(Stdio::piped())
.stdin(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.reap_on_drop(true)
.spawn()?
};
let mut stdin = child.stdin.take().unwrap();
let mut stdout = child.stdout.take().unwrap();
try_join!(
copy(&mut recv, &mut stdin),
copy(&mut stdout, &mut send),
child.status(),
)
.map(|(_, _, status)| status)
};
Ok((header, fut))
}
async fn advertise_capabilities<W>(mut send: W) -> io::Result<()>
where
W: AsyncWrite + Unpin,
{
// Thou shallt not upgrade your `git` installation while a link instance is
// running!
static GIT_VERSION: Lazy<Version> = Lazy::new(|| git_version().unwrap());
static AGENT: Lazy<Vec<u8>> = Lazy::new(|| format!("agent=git/{}", *GIT_VERSION).into_bytes());
static CAPABILITIES: Lazy<[&[u8]; 4]> = Lazy::new(|| {
[
b"version 2",
AGENT.as_slice(),
b"object-format=sha1",
b"fetch=ref-in-want",
]
});
for cap in *CAPABILITIES {
packetline::encode::text_to_write(cap, &mut send).await?;
}
packetline::encode::flush_to_write(&mut send).await?;
Ok(())
}
fn git_version() -> io::Result<Version> {
let out = std::process::Command::new("git")
.arg("--version")
.output()?;
if !out.status.success() {
return Err(io::Error::new(
io::ErrorKind::Other,
"failed to read `git` version",
));
}
// parse: git version 2.30.1 <other optional tokens>
out.stdout
.split(|x| x == &b' ')
.nth(2)
.and_then(|s| {
let s = std::str::from_utf8(s).ok()?;
Version::new(s.trim())
})
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "failed to parse `git` version"))
}
fn invalid_data<E>(inner: E) -> io::Error
where
E: Into<Box<dyn std::error::Error + Sync + Send>>,
{
io::Error::new(io::ErrorKind::InvalidData, inner)
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright © 2019-2020 The Radicle Foundation <hello@radicle.foundation>
// Copyright © 2021 The Radicle Link Contributors
//
// This file is part of radicle-link, distributed under the GPLv3 with Radicle
// Linking Exception. For full terms see the included LICENSE file.
use std::{io, path::Path, process::ExitStatus};
use async_process::{Command, Stdio};
use futures_lite::io::{copy, AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
use futures_util::try_join;
use git_ref::{
file::{Store as Refdb, WriteReflog},
FullName,
Reference,
};
pub(super) async fn advertise_refs<R, W>(
git_dir: impl AsRef<Path>,
namespace: &str,
mut recv: R,
mut send: W,
) -> io::Result<ExitStatus>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let unhide = blocking::unblock({
let git_dir = git_dir.as_ref().to_path_buf();
let prefix = Path::new("refs")
.join("namespaces")
.join(namespace)
.join("refs");
move || -> io::Result<Vec<FullName>> {
let refdb = Refdb::at(git_dir, WriteReflog::Disable);
let packed = refdb
.packed_buffer()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let refs = refdb
.iter_prefixed(packed.as_ref(), prefix)?
.filter_map(|r| r.ok().map(|Reference { name, .. }| name))
.filter(|name| {
const PATTERN: &[u8] = b"rad/ids/any";
const SEPARAT: u8 = b'/';
name.as_bstr()
.rsplit(|b| b == &SEPARAT)
.zip(PATTERN.rsplit(|b| b == &SEPARAT))
.skip(1)
.all(|(a, b)| a == b)
})
.collect::<Vec<_>>();
Ok(refs)
}
})
.await?;
let mut child = {
let mut cmd = Command::new("git");
cmd.current_dir(git_dir)
.env_clear()
.envs(std::env::vars().filter(|(key, _)| key == "PATH" || key.starts_with("GIT_TRACE")))
.arg("-c")
.arg("uploadpack.hiderefs=refs/")
.arg("-c")
.arg(format!(
"uploadpack.hiderefs=!refs/namespaces/{}",
namespace
));
for r in unhide {
cmd.arg("-c")
.arg(format!("uploadpack.hiderefs=!{}", r.as_bstr()));
}
cmd.args(&[
"upload-pack",
"--strict",
"--timeout=5",
"--stateless-rpc",
"--advertise-refs",
".",
])
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.reap_on_drop(true)
.spawn()?
};
let mut stdout = child.stdout.take().unwrap();
const HEADER: &[u8] = b"001e# service=git-upload-pack\n0000";
send.write_all(HEADER).await?;
let status = try_join!(copy(&mut stdout, &mut send), child.status()).map(|x| x.1);
// Read one byte off the read stream to ensure it is driven to completion
// (we expect EOF immediately). Failure to do so may cause resource leaks.
//
// Cf. 900b6cf6 (replication: Ensure git stream is closed, 2021-04-26)
let mut buf = [0; 1];
recv.read(&mut buf).await?;
status
}