use std::io; use std::io::Write; use std::process::{Command, ExitStatus, Stdio}; use std::time::Instant; use gix_protocol::transport::bstr::ByteSlice; use radicle::identity::RepoId; use radicle::node::events; use radicle::node::events::Emitter; use radicle::node::{Event, NodeId}; use radicle::storage::git::paths; use radicle::Storage; use crate::runtime::thread; /// Perform the Git upload-pack process, given that the Git request /// `header` has already been read and parsed. /// /// N.b. The upload-pack process itself is strict, i.e. it will read /// requests from the client indefinitely, and so the client side MUST /// send the EOF file message. pub fn upload_pack( nid: &NodeId, remote: NodeId, storage: &Storage, emitter: &Emitter, header: &pktline::GitRequest, mut recv: R, send: W, ) -> io::Result where R: io::Read + Send, W: io::Write + Send, { let timer = Instant::now(); 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); if protocol_version != 2 { return Err(io::Error::new( io::ErrorKind::InvalidData, "only Git protocol version 2 is supported", )); } let git_dir = paths::repository(storage, &header.repo); 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}")) .args([ "-c", "uploadpack.allowAnySha1InWant=true", "-c", "uploadpack.allowRefInWant=true", "-c", "lsrefs.unborn=ignore", "upload-pack", "--strict", ".", ]) .stdout(Stdio::piped()) .stdin(Stdio::piped()) .stderr(Stdio::inherit()); cmd.spawn()? }; let mut stdin = child.stdin.take().unwrap(); let mut stdout = io::BufReader::new(child.stdout.take().unwrap()); let mut reporter = Reporter { rid: header.repo, remote, emitter: emitter.clone(), send, }; thread::scope(|s| { thread::spawn_scoped(nid, "upload-pack", s, || { // N.b. we indefinitely copy stdout to the sender, // i.e. there's no need for a loop. match io::copy(&mut stdout, &mut reporter) { Ok(_) => {} Err(e) => { log::error!(target: "worker", "Worker channel disconnected for {}; aborting: {e}", header.repo); emitter.emit(events::UploadPack::error(header.repo, remote, e).into()); } } }); let reader = thread::spawn_scoped(nid, "upload-pack", s, || { let mut buffer = [0; u16::MAX as usize + 1]; loop { match recv.read(&mut buffer) { Ok(0) => break, Ok(n) => { if let Err(e) = stdin.write_all(&buffer[..n]) { log::warn!(target: "worker", "Error writing to upload-pack stdin: {e}"); break; } } Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { log::debug!(target: "worker", "Exiting upload-pack reader thread for {}", header.repo); break; } Err(e) => { log::error!(target: "worker", "Error on upload-pack channel read for {}: {e}", header.repo); emitter.emit(events::UploadPack::error(header.repo, remote, e).into()); break; } } } }); // N.b. we only care if the `reader` is finished. We then kill // the child which will end the thread for the sender. loop { if reader.is_finished() { child.kill()?; break; } else { std::thread::sleep(std::time::Duration::from_millis(100)); } } Ok::<_, io::Error>(()) })?; let status = child.wait()?; emitter.emit(events::UploadPack::done(header.repo, remote, status).into()); log::debug!(target: "worker", "Upload pack finished ({}ms)", timer.elapsed().as_millis()); Ok(status) } /// A combination of the upload-pack sender with an [`Emitter`] for reporting /// the progress events to subscribers. struct Reporter { rid: RepoId, remote: NodeId, emitter: Emitter, send: W, } impl Reporter { fn emit(&self, buf: &[u8]) { if let Some(progress) = Self::as_upload_pack_progress(buf) { log::trace!(target: "worker", "upload-pack progress: {progress}"); self.emitter .emit(events::UploadPack::write(self.rid, self.remote, progress).into()); } } fn as_upload_pack_progress(buf: &[u8]) -> Option { use events::upload_pack::Progress::*; let gix_protocol::RemoteProgress { action, step, max, .. } = gix_protocol::RemoteProgress::from_bytes(buf)?; if action.contains_str("Counting objects") { step.and_then(|processed| max.map(|total| Counting { processed, total })) } else if action.contains_str("Compressing objects") { step.and_then(|processed| max.map(|total| Compressing { processed, total })) } else if action.contains_str("Enumerating objects") { max.map(|total| Enumerating { total }) } else { None } } } impl io::Write for Reporter where W: io::Write, { fn write(&mut self, buf: &[u8]) -> io::Result { let n = self.send.write(buf)?; self.send.flush()?; self.emit(buf); Ok(n) } fn flush(&mut self) -> io::Result<()> { self.send.flush() } } pub(super) mod pktline { use std::io; use std::io::Read; use std::str; use radicle::prelude::RepoId; pub const HEADER_LEN: usize = 4; /// Read and parse the `GitRequest` data from the client side. pub fn git_request(reader: &mut R) -> io::Result where R: io::Read, { let mut reader = Reader::new(reader); let (header, _) = reader.read_request_pktline()?; Ok(header) } struct Reader<'a, R> { stream: &'a mut R, } impl<'a, R: io::Read> Reader<'a, R> { /// Create a new packet-line reader. pub fn new(stream: &'a mut R) -> Self { Self { stream } } /// Parse a Git request packet-line. /// /// Example: `0032git-upload-pack /project.git\0host=myserver.com\0` /// fn read_request_pktline(&mut self) -> io::Result<(GitRequest, Vec)> { let mut pktline = [0u8; 1024]; let length = self.read_pktline(&mut pktline)?; let Some(cmd) = GitRequest::parse(&pktline[4..length]) else { return Err(io::ErrorKind::InvalidInput.into()); }; Ok((cmd, Vec::from(&pktline[..length]))) } /// Parse a Git packet-line. fn read_pktline(&mut self, buf: &mut [u8]) -> io::Result { self.read_exact(&mut buf[..HEADER_LEN])?; let length = str::from_utf8(&buf[..HEADER_LEN]) .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; let length = usize::from_str_radix(length, 16) .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; self.read_exact(&mut buf[HEADER_LEN..length])?; Ok(length) } } impl<'a, R: io::Read> io::Read for Reader<'a, R> { fn read(&mut self, buf: &mut [u8]) -> io::Result { self.stream.read(buf) } } /// The Git request packet-line for a Heartwood repository. /// /// Example: `0032git-upload-pack /rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5.git\0host=myserver.com\0` #[derive(Debug)] pub struct GitRequest { pub repo: RepoId, pub path: String, pub host: Option<(String, Option)>, pub extra: Vec<(String, Option)>, } impl GitRequest { /// Parse a Git command from a packet-line. fn parse(input: &[u8]) -> Option { let input = str::from_utf8(input).ok()?; let mut parts = input .strip_prefix("git-upload-pack ")? .split_terminator('\0'); let path = parts.next()?.to_owned(); let repo = path.strip_prefix('/')?.parse().ok()?; let host = match parts.next() { None | Some("") => None, Some(host) => { let host = host.strip_prefix("host=")?; match host.split_once(':') { None => Some((host.to_owned(), None)), Some((host, port)) => { let port = port.parse::().ok()?; 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(); Some(Self { repo, path, host, extra, }) } } }