use std::sync::atomic::{AtomicBool, Ordering};
mod series;
pub use series::{CoverLetter, Numbering, Series, SeriesError, SeriesLimits, SeriesOptions};
use crate::diffstat::{Diffstat, StatLayout};
use crate::{Commit, ObjectId};
#[derive(Debug)]
pub struct Message<'a> {
commit: &'a Commit,
stat: &'a Diffstat,
patch: &'a [u8],
summary: &'a [u8],
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Position {
pub number: usize,
pub total: usize,
}
impl Position {
pub fn new(number: usize, total: usize) -> Result<Self, Error> {
if number == 0 || number > total {
return Err(Error::Position);
}
Ok(Self { number, total })
}
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Options<'a> {
pub subject_prefix: &'a [u8],
pub position: Option<Position>,
pub signature: Option<&'a [u8]>,
pub base: Option<ObjectId>,
}
impl Default for Options<'_> {
fn default() -> Self {
Self {
subject_prefix: b"PATCH",
position: None,
signature: Some(concat!("girt ", env!("CARGO_PKG_VERSION")).as_bytes()),
base: None,
}
}
}
impl<'a> Options<'a> {
#[must_use]
pub fn with_subject_prefix(mut self, prefix: &'a [u8]) -> Self {
self.subject_prefix = prefix;
self
}
#[must_use]
pub fn with_position(mut self, number: usize, total: usize) -> Self {
self.position = Some(Position { number, total });
self
}
#[must_use]
pub fn with_signature(mut self, signature: Option<&'a [u8]>) -> Self {
self.signature = signature;
self
}
#[must_use]
pub fn with_base(mut self, base: Option<ObjectId>) -> Self {
self.base = base;
self
}
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Limits {
pub max_metadata_bytes: usize,
pub max_output_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_metadata_bytes: 1024 * 1024,
max_output_bytes: 64 * 1024 * 1024,
}
}
}
impl Limits {
#[must_use]
pub fn with_max_metadata_bytes(mut self, bytes: usize) -> Self {
self.max_metadata_bytes = bytes;
self
}
#[must_use]
pub fn with_max_output_bytes(mut self, bytes: usize) -> Self {
self.max_output_bytes = bytes;
self
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("reading patch mail author")]
Commit(#[from] crate::CommitError),
#[error("rendering patch mail statistics")]
Stat(#[from] crate::diffstat::Error),
#[error("patch mail exceeds {0} limit")]
Limit(&'static str),
#[error("invalid patch mail metadata: {0}")]
Metadata(&'static str),
#[error("invalid patch mail series position")]
Position,
#[error("patch mail author date is out of range")]
Date,
#[error("patch mail rendering cancelled")]
Cancelled,
}
impl<'a> Message<'a> {
pub fn new(commit: &'a Commit, stat: &'a Diffstat, patch: &'a [u8]) -> Self {
Self {
commit,
stat,
patch,
summary: b"",
}
}
#[must_use]
pub fn with_change_summary(mut self, summary: &'a [u8]) -> Self {
self.summary = summary;
self
}
pub fn render(&self) -> Result<Vec<u8>, Error> {
self.render_with(
Options::default(),
Limits::default(),
&AtomicBool::new(false),
)
}
pub fn render_with(
&self,
options: Options<'_>,
limits: Limits,
cancel: &AtomicBool,
) -> Result<Vec<u8>, Error> {
self.render_as(options, limits, cancel, MailKind::Patch)
}
fn render_as(
&self,
options: Options<'_>,
limits: Limits,
cancel: &AtomicBool,
kind: MailKind,
) -> Result<Vec<u8>, Error> {
let mut out = Output {
bytes: Vec::new(),
limit: limits.max_output_bytes,
cancel,
};
out.check()?;
let headers = prepare_headers(
self.commit,
options,
limits,
matches!(kind, MailKind::Cover { .. }),
)?;
let envelope = match kind {
MailKind::Patch => self.commit.id(),
MailKind::Cover { envelope, .. } => envelope,
};
out.append(format!("From {envelope} Mon Sep 17 00:00:00 2001\n").as_bytes())?;
out.append(headers.from.as_bytes())?;
out.append(format!("Date: {}\n", headers.date).as_bytes())?;
out.append(headers.title.as_bytes())?;
if headers.mime {
out.append(b"MIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n")?;
}
out.append(b"\n")?;
let body = headers.body;
if !body.is_empty() {
out.append(body.as_bytes())?;
if !body.ends_with('\n') {
out.append(b"\n")?;
}
}
if let MailKind::Cover { include_stat, .. } = kind {
if include_stat {
out.append(&self.stat.stat(StatLayout::default())?)?;
out.append(b"\n")?;
}
} else if !self.patch.is_empty() || !self.stat.files().is_empty() {
out.append(b"---\n")?;
out.append(&self.stat.stat(StatLayout::default())?)?;
out.append(self.summary)?;
out.append(b"\n")?;
out.append(self.patch)?;
}
if let Some(base) = options.base {
out.append(format!("\nbase-commit: {base}\n").as_bytes())?;
}
if let Some(signature) = options.signature {
out.append(b"-- \n")?;
out.append(signature)?;
if !signature.ends_with(b"\n") {
out.append(b"\n")?;
}
out.append(b"\n")?;
}
out.check()?;
Ok(out.bytes)
}
}
enum MailKind {
Patch,
Cover {
envelope: ObjectId,
include_stat: bool,
},
}
struct PreparedHeaders<'a> {
from: String,
date: String,
title: String,
body: &'a str,
mime: bool,
}
fn prepare_headers<'a>(
commit: &'a Commit,
options: Options<'_>,
limits: Limits,
cover: bool,
) -> Result<PreparedHeaders<'a>, Error> {
let size = [
commit.as_bytes(),
options.subject_prefix,
options.signature.unwrap_or_default(),
]
.iter()
.try_fold(0usize, |size, bytes| size.checked_add(bytes.len()))
.ok_or(Error::Limit("metadata bytes"))?;
if size > limits.max_metadata_bytes {
return Err(Error::Limit("metadata bytes"));
}
if let Some(encoding) = commit.header(b"encoding")
&& !encoding.value.eq_ignore_ascii_case(b"UTF-8")
{
return Err(Error::Metadata("unsupported commit encoding"));
}
let author = commit.author()?.ok_or(Error::Metadata("missing author"))?;
let name = header(author.name, "author name")?;
header(author.email, "author email")?;
if !author.email.is_ascii()
|| author
.email
.iter()
.any(|b| matches!(b, b'<' | b'>' | b' ' | b'\t'))
{
return Err(Error::Metadata("author email"));
}
let prefix = if options.subject_prefix.is_empty() {
""
} else {
header(options.subject_prefix, "subject prefix")?
};
if !prefix.is_ascii() {
return Err(Error::Metadata("subject prefix"));
}
let text =
std::str::from_utf8(commit.message()).map_err(|_| Error::Metadata("message UTF-8"))?;
if text.contains('\0') {
return Err(Error::Metadata("message delimiters"));
}
let (subject, body) = split_message(commit, text)?;
if subject.contains('\r') {
return Err(Error::Metadata("subject delimiters"));
}
if subject.is_empty() {
return Err(Error::Metadata("missing subject"));
}
if options
.base
.is_some_and(|id| id.format() != commit.id().format())
{
return Err(Error::Metadata("base object format"));
}
let numbering = match options.position {
Some(Position { number, total })
if (number != 0 || cover) && total != 0 && number <= total =>
{
format!(
"{}{number:0width$}/{total}",
if prefix.is_empty() { "" } else { " " },
width = total.to_string().len(),
)
}
Some(_) => return Err(Error::Position),
None => String::new(),
};
let date = author.date()?;
let offset = jiff::tz::Offset::from_seconds(i32::from(date.offset_minutes) * 60)
.map_err(|_| Error::Date)?;
let timestamp = jiff::Timestamp::from_second(date.seconds).map_err(|_| Error::Date)?;
let date = timestamp
.to_zoned(jiff::tz::TimeZone::fixed(offset))
.strftime("%a, %-d %b %Y %T %z")
.to_string();
let mut from = String::from("From: ");
if needs_encoding(name) {
encoded(&mut from, name);
} else {
quoted_name(&mut from, name);
}
from.push_str(" <");
from.push_str(std::str::from_utf8(author.email).map_err(|_| Error::Metadata("author email"))?);
from.push_str(">\n");
let mut title = if prefix.is_empty() && numbering.is_empty() {
String::from("Subject: ")
} else {
format!("Subject: [{prefix}{numbering}] ")
};
if !needs_encoding(&subject) {
folded(&mut title, &subject);
} else {
encoded(&mut title, &subject);
}
title.push('\n');
Ok(PreparedHeaders {
from,
date,
title,
body,
mime: !name.is_ascii() || !text.is_ascii(),
})
}
fn header<'a>(bytes: &'a [u8], field: &'static str) -> Result<&'a str, Error> {
if bytes.is_empty() || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
return Err(Error::Metadata(field));
}
std::str::from_utf8(bytes).map_err(|_| Error::Metadata(field))
}
fn split_message<'a>(commit: &Commit, text: &'a str) -> Result<(String, &'a str), Error> {
let subject =
String::from_utf8(commit.subject()).map_err(|_| Error::Metadata("subject UTF-8"))?;
let mut started = false;
let mut offset = 0;
for line in text.split_inclusive('\n') {
offset += line.len();
if line.trim_ascii_end().is_empty() {
if started {
return Ok((subject, trim_blank_lines(&text[offset..])));
}
} else {
started = true;
}
}
Ok((subject, ""))
}
fn trim_blank_lines(mut body: &str) -> &str {
while let Some((line, remaining)) = body.split_once('\n') {
if !line.trim_ascii_end().is_empty() {
break;
}
body = remaining;
}
if body.trim_ascii_end().is_empty() {
""
} else {
body
}
}
fn needs_encoding(text: &str) -> bool {
!text.is_ascii() || text.contains("=?")
}
fn quoted_name(out: &mut String, name: &str) {
let quote = name.bytes().any(|b| b"\"\\,;<>@()[].:".contains(&b));
if quote {
out.push('"');
}
for ch in name.chars() {
if quote && matches!(ch, '"' | '\\') {
out.push('\\');
}
out.push(ch);
}
if quote {
out.push('"');
}
}
fn folded(out: &mut String, text: &str) {
let mut column = out.rsplit('\n').next().unwrap_or_default().len();
for (index, word) in text.split(' ').enumerate() {
let space = usize::from(index != 0);
if column + space + word.len() > 78 && index != 0 {
out.push_str("\n ");
column = 1;
} else if space != 0 {
out.push(' ');
column += 1;
}
out.push_str(word);
column += word.len();
}
}
fn encoded(out: &mut String, text: &str) {
let mut column = out.rsplit('\n').next().unwrap_or_default().len();
out.push_str("=?UTF-8?q?");
column += 10;
for ch in text.chars() {
let mut bytes = [0; 4];
let bytes = ch.encode_utf8(&mut bytes).as_bytes();
let mut group = String::new();
for byte in bytes {
if (b'!'..=b'~').contains(byte) && !b"=?_".contains(byte) {
group.push(char::from(*byte));
} else {
group.push_str(&format!("={byte:02X}"));
}
}
if column + group.len() + 2 > 76 {
out.push_str("?=\n =?UTF-8?q?");
column = 11;
}
out.push_str(&group);
column += group.len();
}
out.push_str("?=");
}
struct Output<'a> {
bytes: Vec<u8>,
limit: usize,
cancel: &'a AtomicBool,
}
impl Output<'_> {
fn check(&self) -> Result<(), Error> {
if self.cancel.load(Ordering::Relaxed) {
Err(Error::Cancelled)
} else {
Ok(())
}
}
fn append(&mut self, bytes: &[u8]) -> Result<(), Error> {
self.check()?;
let size = self
.bytes
.len()
.checked_add(bytes.len())
.ok_or(Error::Limit("output bytes"))?;
if size > self.limit {
return Err(Error::Limit("output bytes"));
}
self.bytes.extend_from_slice(bytes);
Ok(())
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
use crate::{ObjectFormat, Signature, Tree};
fn commit() -> Commit {
let author = Signature {
name: b"Author".to_vec(),
email: b"author@example.com".to_vec(),
seconds: 1_000_000_000,
offset_minutes: 0,
};
let tree = Tree::new(ObjectFormat::Sha1, vec![]).unwrap();
Commit::builder(tree.id(), author, b"Subject\n\nBody\n")
.build()
.unwrap()
}
#[rstest]
#[case::metadata(Limits::default().with_max_metadata_bytes(0), "metadata bytes")]
#[case::output(Limits::default().with_max_output_bytes(1), "output bytes")]
fn bounds_refuse_an_artifact(#[case] limits: Limits, #[case] bound: &str) {
let commit = commit();
let stat = Diffstat::new(vec![], Default::default()).unwrap();
let result = Message::new(&commit, &stat, b"patch\n").render_with(
Options::default(),
limits,
&AtomicBool::new(false),
);
assert!(matches!(result, Err(Error::Limit(name)) if name == bound));
}
#[rstest]
#[case::line_feed(b"PATCH\nInjected: yes")]
#[case::carriage_return(b"PATCH\rInjected: yes")]
#[case::nul(b"PATCH\0")]
fn header_injection_is_refused(#[case] prefix: &[u8]) {
let commit = commit();
let stat = Diffstat::new(vec![], Default::default()).unwrap();
let result = Message::new(&commit, &stat, b"patch\n").render_with(
Options::default().with_subject_prefix(prefix),
Limits::default(),
&AtomicBool::new(false),
);
assert!(matches!(result, Err(Error::Metadata("subject prefix"))));
}
#[test]
fn cancelled_rendering_returns_no_artifact() {
let commit = commit();
let stat = Diffstat::new(vec![], Default::default()).unwrap();
let result = Message::new(&commit, &stat, b"patch\n").render_with(
Options::default(),
Limits::default(),
&AtomicBool::new(true),
);
assert!(matches!(result, Err(Error::Cancelled)));
}
#[test]
fn invalid_series_position_is_refused() {
let commit = commit();
let stat = Diffstat::new(vec![], Default::default()).unwrap();
let result = Message::new(&commit, &stat, b"patch\n").render_with(
Options::default().with_position(2, 1),
Limits::default(),
&AtomicBool::new(false),
);
assert!(matches!(result, Err(Error::Position)));
}
}