diff --git a/radicle/src/git.rs b/radicle/src/git.rs index b5405d15..02e99b60 100644 --- a/radicle/src/git.rs +++ b/radicle/src/git.rs @@ -274,3 +274,180 @@ pub fn run, S: AsRef>( String::from_utf8_lossy(&output.stderr), )) } + +/// Parsing and formatting of commit objects. +/// This module exists to work with commits that have multiple signature headers. +pub mod commit { + use std::str::FromStr; + + /// A parsed commit object. + /// Contains the full commit header and body. + /// + /// Can be created with the [`FromStr`] instance, and formatted with the [`ToString`] + /// instance. + #[derive(Debug)] + pub struct CommitObject { + headers: Vec<(String, String)>, + message: String, + } + + impl CommitObject { + /// Get the commit message. + pub fn message(&self) -> &str { + self.message.as_str() + } + + /// Iterate over the headers, in order. + pub fn headers(&self) -> impl Iterator { + self.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())) + } + + /// Iterate over matching header values. + pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator + '_ { + self.headers + .iter() + .filter(move |(k, _)| k == name) + .map(|(_, v)| v.as_str()) + } + + /// Push a header to the end of the headers section. + pub fn push_header(&mut self, name: &str, value: &str) { + self.headers + .push((name.to_owned(), value.trim().to_owned())); + } + } + + #[derive(thiserror::Error, Debug)] + pub enum ParseError { + #[error("invalid git commit object format")] + InvalidFormat, + } + + impl FromStr for CommitObject { + type Err = ParseError; + + fn from_str(buffer: &str) -> Result { + let mut headers = Vec::new(); + let (header, message) = buffer.split_once("\n\n").ok_or(ParseError::InvalidFormat)?; + + for line in header.lines() { + if let Some(rest) = line.strip_prefix(' ') { + let value: &mut String = headers + .last_mut() + .map(|(_, v)| v) + .ok_or(ParseError::InvalidFormat)?; + value.push('\n'); + value.push_str(rest); + } else if let Some((name, value)) = line.split_once(' ') { + headers.push((name.to_owned(), value.to_owned())); + } else { + return Err(ParseError::InvalidFormat); + } + } + + Ok(Self { + headers, + message: message.to_owned(), + }) + } + } + + impl ToString for CommitObject { + fn to_string(&self) -> String { + let mut buf = String::new(); + + for (name, value) in &self.headers { + buf.push_str(name); + buf.push(' '); + buf.push_str(value.replace('\n', "\n ").as_str()); + buf.push('\n'); + } + buf.push('\n'); + buf.push_str(self.message.as_str()); + buf + } + } + + #[cfg(test)] + mod test { + use super::*; + + const UNSIGNED: &str = "\ +tree c66cc435f83ed0fba90ed4500e9b4b96e9bd001b +parent af06ad645133f580a87895353508053c5de60716 +author Alexis Sellier 1664467633 +0200 +committer Alexis Sellier 1664786099 +0200 + +Add SSH functionality with new `radicle-ssh` + +We borrow code from `thrussh`, refactored to be runtime-less. +"; + + const SIGNATURE: &str = "\ +-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvjrQogRxxLjzzWns8+mKJAGzEX +4fm2ALoN7pyvD2ttQAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 +AAAAQIQvhIewOgGfnXLgR5Qe1ZEr2vjekYXTdOfNWICi6ZiosgfZnIqV0enCPC4arVqQg+ +GPp0HqxaB911OnSAr6bwU= +-----END SSH SIGNATURE----- +"; + + const SIGNED: &str = "\ +tree c66cc435f83ed0fba90ed4500e9b4b96e9bd001b +parent af06ad645133f580a87895353508053c5de60716 +author Alexis Sellier 1664467633 +0200 +committer Alexis Sellier 1664786099 +0200 +other e6fe3c97619deb8ab4198620f9a7eb79d98363dd +gpgsig -----BEGIN SSH SIGNATURE----- + U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvjrQogRxxLjzzWns8+mKJAGzEX + 4fm2ALoN7pyvD2ttQAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 + AAAAQIQvhIewOgGfnXLgR5Qe1ZEr2vjekYXTdOfNWICi6ZiosgfZnIqV0enCPC4arVqQg+ + GPp0HqxaB911OnSAr6bwU= + -----END SSH SIGNATURE----- +gpgsig -----BEGIN SSH SIGNATURE----- + U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvjrQogRxxLjzzWns8+mKJAGzEX + 4fm2ALoN7pyvD2ttQAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 + AAAAQIQvhIewOgGfnXLgR5Qe1ZEr2vjekYXTdOfNWICi6ZiosgfZnIqV0enCPC4arVqQg+ + GPp0HqxaB911OnSAr6bwU= + -----END SSH SIGNATURE----- + +Add SSH functionality with new `radicle-ssh` + +We borrow code from `thrussh`, refactored to be runtime-less. +"; + + #[test] + fn test_push_header() { + let mut commit = CommitObject::from_str(UNSIGNED).unwrap(); + commit.push_header("other", "e6fe3c97619deb8ab4198620f9a7eb79d98363dd"); + commit.push_header("gpgsig", SIGNATURE); + commit.push_header("gpgsig", SIGNATURE); + + assert_eq!(commit.to_string(), SIGNED); + } + + #[test] + fn test_get_header() { + let commit = CommitObject::from_str(SIGNED).unwrap(); + + assert_eq!( + commit.values("gpgsig").collect::>(), + vec![SIGNATURE.trim(), SIGNATURE.trim()] + ); + assert_eq!( + commit.values("parent").collect::>(), + vec![String::from("af06ad645133f580a87895353508053c5de60716")], + ); + assert!(commit.values("unknown").next().is_none()); + } + + #[test] + fn test_conversion() { + assert_eq!(CommitObject::from_str(SIGNED).unwrap().to_string(), SIGNED); + assert_eq!( + CommitObject::from_str(UNSIGNED).unwrap().to_string(), + UNSIGNED + ); + } + } +}