Update gix-* crates

Fixes [CVE-2026-0810].

The `gix` crates require updating due to the security vulnerability above.
They require updating together in lock-step so both `radicle-fetch`
and `radicle-oid` are affected.

`radicle-oid` handles the non-exhaustive nature of `ObjectId`.

`radicle-fetch` updates to the new API types, however, this comes with
some updates to the `ls_refs` protocol.

A regression is documented in [gitoxide issue 2429].
The regression highlighted that it is the duty of the caller to also
filter the outcome of ls-refs, and `ref-prefix` is simply an
optimisation.

The ls-refs code is refactored to better represent and handle this operation.

[CVE-2026-0810]: https://www.cve.org/CVERecord?id=CVE-2026-0810
[gitoxide issue 2429]: https://github.com/GitoxideLabs/gitoxide/issues/2429
This commit is contained in:
Fintan Halpenny 2026-02-11 10:38:43 +00:00
parent 391571178e
commit 7862e108b6
10 changed files with 437 additions and 274 deletions

519
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,7 @@ cyphernet = "0.5.2"
dunce = "1.0.5"
fastrand = { version = "2.0.0", default-features = false }
git2 = { version = "0.19.0", default-features = false, features = ["vendored-libgit2"] }
gix-hash = { version = "0.19.0", default-features = false }
gix-hash = { version = "0.22.1", default-features = false, features = ["sha1"] }
human-panic = "2.0.6"
itertools = "0.14"
lexopt = "0.3.0"

View File

@ -11,12 +11,13 @@ rust-version.workspace = true
[dependencies]
bstr = { workspace = true }
either = "1.9.0"
gix-features = { version = "0.43.1", features = ["progress"] }
gix-features = { version = "0.46", features = ["progress"] }
gix-hash = { workspace = true }
gix-odb = "0.70.0"
gix-pack = "0.60.0"
gix-protocol = { version = "0.51.0", features = ["blocking-client"] }
gix-transport = { version = "0.48.0", features = ["blocking-client"] }
gix-odb = "0.75.0"
gix-pack = "0.65.0"
gix-protocol = { version = "0.57.0", features = ["blocking-client"] }
gix-refspec = "0.37"
gix-transport = { version = "0.54.0", features = ["blocking-client"] }
log = { workspace = true, features = ["std"] }
nonempty = { workspace = true }
radicle = { workspace = true }

View File

@ -12,7 +12,7 @@ mod state;
use std::io;
use std::time::Instant;
use gix_protocol::handshake;
use gix_protocol::{handshake, Handshake};
pub use gix_protocol::{transport::bstr::ByteSlice, RemoteProgress};
pub use handle::Handle;
@ -131,7 +131,7 @@ where
result
}
fn perform_handshake<R, S>(handle: &mut Handle<R, S>) -> Result<handshake::Outcome, Error>
fn perform_handshake<R, S>(handle: &mut Handle<R, S>) -> Result<Handshake, Error>
where
S: transport::ConnectionStream,
{

View File

@ -32,7 +32,7 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use bstr::BString;
use bstr::{BStr, BString};
use either::Either;
use gix_protocol::handshake::Ref;
use nonempty::NonEmpty;
@ -123,6 +123,36 @@ impl RefPrefix {
RefPrefix::AllNamespaces => "refs/namespaces".into(),
}
}
/// Convert the [`RefPrefix`] into its equivalent [`RefSpec`].
///
/// See the [`RefPrefix`] variants for their [`BString`] values.
///
/// # Panics
///
/// This will panic if the reference as a [`BString`] value no longer parses
/// in the upstream [`gix_refspec`] crate.
///
/// [`RefSpec`]: gix_refspec::RefSpec
pub fn as_refspec(&self) -> gix_refspec::RefSpec {
use gix_refspec::parse::Operation;
let parse = |spec: &BStr| -> gix_refspec::RefSpec {
gix_refspec::parse(spec, Operation::Fetch)
.expect("RefPrefix should be valid refspec")
.to_owned()
};
match self {
RefPrefix::RadId => parse(refs::REFS_RAD_ID.as_bstr()),
RefPrefix::NamespacedRadId { namespace } => {
parse(radicle::git::refs::storage::id(namespace).as_bstr())
}
RefPrefix::NamespacedRadSigrefs { namespace } => {
parse(radicle::git::refs::storage::sigrefs(namespace).as_bstr())
}
RefPrefix::AllNamespaces => parse(BStr::new("refs/namespaces")),
}
}
}
/// A [`ProtocolStage`] describes a single roundtrip with the Radicle
@ -657,3 +687,26 @@ where
Ok(())
}
#[cfg(test)]
mod test {
use super::RefPrefix;
/// Ensure that the call to [`RefPrefix::as_refspec`] does not panic
#[test]
fn valid_refspecs() {
let namespace = "z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
.parse()
.unwrap();
let prefixes = [
RefPrefix::AllNamespaces,
RefPrefix::RadId,
RefPrefix::NamespacedRadId { namespace },
RefPrefix::NamespacedRadSigrefs { namespace },
];
for prefix in prefixes {
prefix.as_refspec();
}
}
}

View File

@ -1,7 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::time::Instant;
use gix_protocol::handshake;
use gix_protocol::Handshake;
use radicle::crypto::PublicKey;
use radicle::git::{fmt::Qualified, Oid};
use radicle::identity::{Did, Doc, DocError};
@ -214,7 +214,7 @@ impl FetchState {
pub(super) fn run_stage<R, S, F>(
&mut self,
handle: &mut Handle<R, S>,
handshake: &handshake::Outcome,
handshake: &Handshake,
step: &F,
) -> Result<BTreeSet<PublicKey>, error::Step>
where
@ -231,7 +231,7 @@ impl FetchState {
.collect::<Vec<_>>(),
None => vec![],
};
log::trace!("Received refs {refs:?}");
log::trace!("Received refs {refs:#?}");
step.pre_validate(&refs)?;
let wants_haves = step.wants_haves(handle.repository(), &refs)?;
@ -288,7 +288,7 @@ impl FetchState {
fn run_special_refs<R, S>(
&mut self,
handle: &mut Handle<R, S>,
handshake: &handshake::Outcome,
handshake: &Handshake,
delegates: BTreeSet<PublicKey>,
threshold: usize,
limit: &FetchLimit,
@ -357,7 +357,7 @@ impl FetchState {
pub(super) fn run<R, S>(
mut self,
handle: &mut Handle<R, S>,
handshake: &handshake::Outcome,
handshake: &Handshake,
limit: FetchLimit,
remote: PublicKey,
refs_at: Option<Vec<RefsAt>>,

View File

@ -10,6 +10,7 @@ use std::sync::Arc;
use bstr::BString;
use gix_features::progress::prodash::progress;
use gix_protocol::handshake;
use gix_protocol::Handshake;
use gix_transport::client;
use gix_transport::Protocol;
use gix_transport::Service;
@ -91,11 +92,12 @@ where
/// Perform the handshake with the server side.
#[allow(clippy::result_large_err)]
pub(crate) fn handshake(&mut self) -> Result<handshake::Outcome, handshake::Error> {
pub(crate) fn handshake(&mut self) -> Result<Handshake, handshake::Error> {
log::trace!("Performing handshake for {}", self.repo);
let (read, write) = self.stream.open();
gix_protocol::fetch::handshake(
gix_protocol::handshake(
&mut Connection::new(read, write, self.repo.clone()),
Service::UploadPack,
|_| Ok(None),
vec![],
&mut progress::Discard,
@ -106,7 +108,7 @@ where
pub(crate) fn ls_refs(
&mut self,
prefixes: impl IntoIterator<Item = RefPrefix>,
handshake: &handshake::Outcome,
handshake: &Handshake,
) -> Result<Vec<handshake::Ref>, Error> {
let prefixes = prefixes.into_iter().collect::<BTreeSet<_>>();
let (read, write) = self.stream.open();
@ -126,7 +128,7 @@ where
&mut self,
wants_haves: WantsHaves,
interrupt: Arc<AtomicBool>,
handshake: &handshake::Outcome,
handshake: &Handshake,
) -> Result<Option<Keepfile>, Error> {
log::trace!(
"Running fetch wants={:?}, haves={:?}",
@ -179,7 +181,7 @@ where
}
pub(crate) struct Connection<R, W> {
inner: client::git::Connection<R, W>,
inner: client::git::blocking_io::Connection<R, W>,
}
impl<R, W> Connection<R, W>
@ -189,7 +191,7 @@ where
{
pub fn new(read: R, write: W, repo: BString) -> Self {
Self {
inner: client::git::Connection::new(
inner: client::git::blocking_io::Connection::new(
read,
write,
Protocol::V2,
@ -202,21 +204,7 @@ where
}
}
impl<R, W> client::Transport for Connection<R, W>
where
R: std::io::Read,
W: std::io::Write,
{
fn handshake<'b>(
&mut self,
service: Service,
extra_parameters: &'b [(&'b str, Option<&'b str>)],
) -> Result<client::SetServiceResponse<'_>, client::Error> {
self.inner.handshake(service, extra_parameters)
}
}
impl<R, W> client::TransportWithoutIO for Connection<R, W>
impl<R, W> client::blocking_io::Transport for Connection<R, W>
where
R: std::io::Read,
W: std::io::Write,
@ -226,10 +214,24 @@ where
write_mode: client::WriteMode,
on_into_read: client::MessageKind,
trace: bool,
) -> Result<client::RequestWriter<'_>, client::Error> {
) -> Result<client::blocking_io::RequestWriter<'_>, client::Error> {
self.inner.request(write_mode, on_into_read, trace)
}
fn handshake<'b>(
&mut self,
service: Service,
extra_parameters: &'b [(&'b str, Option<&'b str>)],
) -> Result<client::blocking_io::SetServiceResponse<'_>, client::Error> {
self.inner.handshake(service, extra_parameters)
}
}
impl<R, W> client::TransportWithoutIO for Connection<R, W>
where
R: std::io::Read,
W: std::io::Write,
{
fn to_url(&self) -> std::borrow::Cow<'_, bstr::BStr> {
self.inner.to_url()
}

View File

@ -4,10 +4,9 @@ use std::sync::{atomic::AtomicBool, Arc};
use gix_features::progress::{DynNestedProgress, NestedProgress};
use gix_pack as pack;
use gix_protocol::fetch;
use gix_protocol::fetch::negotiate::one_round::State;
use gix_protocol::handshake;
use gix_protocol::handshake::Ref;
use gix_protocol::{fetch, Handshake};
use crate::git::packfile;
@ -149,7 +148,7 @@ impl fetch::Negotiate for Negotiate {
pub(crate) fn run<P, R, W>(
wants_haves: WantsHaves,
pack_writer: PackWriter,
handshake: &handshake::Outcome,
handshake: &Handshake,
mut conn: Connection<R, W>,
progress: &mut P,
) -> Result<FetchOut, Error>

View File

@ -3,10 +3,10 @@ use std::collections::BTreeSet;
use std::io;
use gix_features::progress::Progress;
use gix_protocol::handshake::{self, Ref};
use gix_protocol::ls_refs;
use gix_protocol::handshake::Ref;
use gix_protocol::transport::Protocol;
use gix_transport::bstr::{BString, ByteVec};
use gix_protocol::{ls_refs, Handshake};
use gix_transport::bstr::BString;
use crate::stage::RefPrefix;
@ -33,7 +33,7 @@ pub struct Config {
/// the `config`.
pub(crate) fn run<R, W>(
config: Config,
handshake: &handshake::Outcome,
handshake: &Handshake,
mut conn: Connection<R, W>,
progress: &mut impl Progress,
) -> Result<Vec<Ref>, ls_refs::Error>
@ -42,7 +42,7 @@ where
W: io::Write,
{
log::trace!("Performing ls-refs: {:?}", config.prefixes);
let handshake::Outcome {
let Handshake {
server_protocol_version: protocol,
capabilities,
..
@ -54,32 +54,32 @@ where
)));
}
let prefixes = config
.prefixes
.into_iter()
.map(|prefix| prefix.into_bstring())
.collect::<BTreeSet<_>>();
let refs = gix_protocol::ls_refs(
&mut conn,
capabilities,
|_caps, args, features| {
for prefix in &prefixes {
let mut arg = BString::from("ref-prefix ");
arg.push_str(prefix);
args.push(arg)
}
features.push(("agent", Some(Cow::Owned(agent_name()))));
Ok(gix_protocol::ls_refs::Action::Continue)
let (refspecs, prefixes) = {
let n = config.prefixes.len();
config.prefixes.into_iter().fold(
(Vec::with_capacity(n), Vec::with_capacity(n)),
|(mut specs, mut prefixes), prefix| {
specs.push(prefix.as_refspec());
prefixes.push(prefix.into_bstring());
(specs, prefixes)
},
progress,
false, /* trace packetlines */
)?;
)
};
// Even though we sent `ref-prefix`, listed refs must still be
// filtered, since `ref-prefix` is just an optimization hint.
// See <https://git-scm.com/docs/protocol-v2#_ls_refs>.
let refs = refs
log::trace!("ls-refs prefixes: {:#?}", refspecs);
let ls_refs = gix_protocol::LsRefsCommand::new(
Some(&refspecs),
capabilities,
("agent", Some(Cow::Owned(agent_name()))),
);
// According to [1], in the section on `ls-refs`, we must still filter on
// this side, since `ref-prefix` is simply an optimization.
//
// [1]: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/gitprotocol-v2.html
let refs = ls_refs
.invoke_blocking(&mut conn, progress, false)?
.into_iter()
.filter(|r| {
let (refname, _, _) = r.unpack();
@ -87,5 +87,6 @@ where
})
.collect();
log::trace!("ls-refs received: {refs:#?}");
Ok(refs)
}

View File

@ -367,6 +367,7 @@ mod gix {
fn from(other: Other) -> Self {
match other {
Other::Sha1(digest) => Self::Sha1(digest),
_ => panic!("unexpected SHA variant was returned for `gix_hash::ObjectId`"),
}
}
}
@ -383,6 +384,7 @@ mod gix {
fn eq(&self, other: &Other) -> bool {
match (self, other) {
(Oid::Sha1(a), Other::Sha1(b)) => a == b,
_ => panic!("unexpected SHA variant was returned for `gix_hash::ObjectId`"),
}
}
}