Skip to content
Snippets Groups Projects
  • Georges Racinet's avatar
    ff013182a019
    RHGitaly: updating hg-core to version 6.6.1 · ff013182a019
    Georges Racinet authored
    - using the node id rather than the tag in `mercurial.rev`,
      to dodge the usual problems with tags
    - had to pin down the new `toml` dependency and the `serde_spanned`
      it brings because MSRV of serde_spanned 0.6.5 is 1.67.0
    - a few into() were needed due to the introduction of `UncheckedRevision`.
      Mostly because we get `Revision` from nodemap calls. Perhaps this
      can be simplified later on. This is expected to be 0-cost anyway.
    ff013182a019
    History
    RHGitaly: updating hg-core to version 6.6.1
    Georges Racinet authored
    - using the node id rather than the tag in `mercurial.rev`,
      to dodge the usual problems with tags
    - had to pin down the new `toml` dependency and the `serde_spanned`
      it brings because MSRV of serde_spanned 0.6.5 is 1.67.0
    - a few into() were needed due to the introduction of `UncheckedRevision`.
      Mostly because we get `Revision` from nodemap calls. Perhaps this
      can be simplified later on. This is expected to be 0-cost anyway.
message.rs 14.68 KiB
// Copyright 2023 Georges Racinet <georges.racinet@octobus.net>
//
// This software may be used and distributed according to the terms of the
// GNU General Public License version 2 or any later version.
// SPDX-License-Identifier: GPL-2.0-or-later
//! Adapters for common Gitaly / HGitaly messages
use hg::changelog::Changelog;
use hg::changelog::{ChangelogEntry, ChangelogRevisionData};
use hg::errors::HgError;
use hg::revlog::{NodePrefix, RevlogError, NULL_REVISION};
use std::fmt;

use crate::gitaly::{
    get_blobs_request::RevisionPath, CommitAuthor, GetBlobResponse, GetBlobsResponse, GitCommit,
    TreeEntryResponse,
};
use crate::mercurial::ObjectMetadata;
use prost_types::Timestamp;
use std::str::FromStr;

#[derive(Debug, PartialEq, Eq)]
pub enum TimeStampParseError {
    Empty,
    MissingTimeZone,
    InvalidUtcTime(String),
}

impl fmt::Display for TimeStampParseError {
    // This trait requires `fmt` with this exact signature.
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        // Write strictly the first element into the supplied output
        // stream: `f`. Returns `fmt::Result` which indicates whether the
        // operation succeeded or failed. Note that `write!` uses syntax which
        // is very similar to `println!`.
        match *self {
            Self::Empty => write!(f, "empty timestamp line"),
            Self::MissingTimeZone => write!(f, "timestamp line without a timezone"),
            Self::InvalidUtcTime(ref s) => write!(f, "invalid UTC time in timestamp line: {}", s),
        }
    }
}

/// Parse the timestamp line from changelog data into numbers
///
/// This is meant to be equivalent to the reference Python implementation.
/// See the `_raw_date` and `date` properties of class changelog.changelogrevision in the
/// Python Mercurial reference implementation.
/// In particular the Python implementation converts seconds to `float` (in practice, the data does
/// never seem to contain a fractional part). Using `f64` because `f32` introduces rounding errors.
/// TODO should be upstreamed to hg-core
fn parse_timestamp_line(line: &[u8]) -> Result<(f64, i32), TimeStampParseError> {
    if line.is_empty() {
        return Err(TimeStampParseError::Empty);
    }
    let mut split = line.splitn(3, |b| *b == b' ');
    let time_str =
        String::from_utf8_lossy(split.next().expect("SplitN always emits at least once"));
    let time = f64::from_str(&time_str)
        .map_err(|e| TimeStampParseError::InvalidUtcTime(format!("{}", e)))?;
    let tz_str = String::from_utf8_lossy(split.next().ok_or(TimeStampParseError::MissingTimeZone)?);
    // according to reference implementation "Various tools did silly things with the timezone"
    // and then it defaults to 0.
    let tz_offset = i32::from_str(&tz_str).unwrap_or(0);
    Ok((time, tz_offset))
}

#[derive(Debug, PartialEq, Eq)]
struct PersonWithEmail {
    person: Vec<u8>,
    email: Option<Vec<u8>>,
}

/// Inspired by the [trim_ascii functions](std::primitite.slice::trim_ascii_end), with the following
///
/// diffenences:
/// - we need to strip also double quotes
/// - we don't care about being `const`
fn trim_person_end(person: &[u8]) -> &[u8] {
    let mut bytes = person;
    while let [rest @ .., last] = bytes {
        if *last == b' ' || *last == b'"' {
            bytes = rest
        } else {
            break;
        }
    }
    bytes
}

fn copy_cleanup_person(person: &[u8]) -> Vec<u8> {
    let trimmed = trim_person_end(person);
    let mut res = Vec::with_capacity(trimmed.len());
    let mut it = trimmed.iter();

    let mut at_start = true;
    while let Some(b) = it.next() {
        let current = *b;
        if at_start && (current == b' ' || current == b'"') {
            continue;
        } else {
            at_start = false
        };

        if current == b'\\' {
            match it.next() {
                None => {
                    res.push(current);
                }
                Some(c) => {
                    let after_backslash = *c;
                    if after_backslash == b'"' {
                        res.push(b'"');
                    } else {
                        res.push(current);
                        res.push(after_backslash);
                    }
                }
            }
        } else {
            res.push(current);
        }
    }
    res
}

fn copy_replace_dot(s: &[u8]) -> Vec<u8> {
    s.iter()
        .map(|b| if *b == b'.' { b' ' } else { *b })
        .collect()
}

/// Parse the full user line, typically in the form "Fullname <email address>"
///
/// This follows the Python reference implementation as `mercurial.utils.stringutil.person()`,
/// itself interpreting the line as per RFC 5322.
fn parse_user_line(line: &[u8]) -> PersonWithEmail {
    let mut at_split = line.splitn(2, |b| *b == b'@');
    let before_at = at_split.next().expect("SplitN always emits at least once");
    match at_split.next() {
        None => PersonWithEmail {
            person: line.into(),
            email: None,
        },
        Some(after_at) => {
            let mut opening_email_split = before_at.splitn(2, |b| *b == b'<');
            let person = copy_cleanup_person(
                opening_email_split
                    .next()
                    .expect("SplitN always emits at least once"),
            );
            match opening_email_split.next() {
                Some(email_before_at) => {
                    let mut email_after_at_split = after_at.splitn(2, |b| *b == b'>');
                    let email_after_at = email_after_at_split.next().expect("SplitN again");
                    match email_after_at_split.next() {
                        Some(_) => {
                            let mut email = email_before_at.to_vec();
                            email.push(b'@');
                            email.extend_from_slice(email_after_at);
                            PersonWithEmail {
                                person,
                                email: Some(email),
                            }
                        }
                        None => PersonWithEmail {
                            person,
                            email: None,
                        },
                    }
                }
                None => PersonWithEmail {
                    person: copy_replace_dot(before_at),
                    email: Some(line.to_vec()),
                },
            }
        }
    }
}

/// Parse ChangelogEntry timestamp line, returning information as expected in Gitaly fields
fn gitaly_timestamp_tz(timestamp_line: &[u8]) -> Result<(Timestamp, String), TimeStampParseError> {
    let (seconds, mut tz) = parse_timestamp_line(timestamp_line)?;

    let tz_sign = if tz <= 0 { '+' } else { '-' };
    if tz <= 0 {
        tz = -tz
    };
    let tz_minutes = tz / 60;
    Ok((
        Timestamp {
            seconds: seconds as i64,
            nanos: 0,
        }, // TODO why not provide nanos, then?
        format!("{}{:02}{:02}", tz_sign, tz_minutes / 60, tz_minutes % 60),
    ))
}

pub fn commit<'changelog>(entry: ChangelogEntry<'changelog>) -> Result<GitCommit, RevlogError> {
    let node_id = format!("{:x}", entry.as_revlog_entry().node());

    let mut parent_ids = Vec::new();
    if let Some(parent) = entry.p1_entry()? {
        parent_ids.push(format!("{:x}", parent.as_revlog_entry().node()));
    }
    if let Some(parent) = entry.p2_entry()? {
        parent_ids.push(format!("{:x}", parent.as_revlog_entry().node()));
    }

    let data: ChangelogRevisionData<'changelog> = entry.data()?;
    let body = data.description();
    // changeset description cannot be empty unless some manual tinkering occurred,
    // but we are still treating it, hence returning an empty subhject in that case.
    let subject = body.splitn(2, |b| *b == b'\n').next().unwrap_or(&[]);

    let author = commit_author(&data)
        .map_err(|e| HgError::CorruptedRepository(format!("Changeset {}: {}", node_id, e)))?;

    Ok(GitCommit {
        id: node_id,
        parent_ids,
        subject: subject.to_vec(),
        body: body.to_vec(),
        body_size: body.len() as i64,
        committer: Some(author.clone()),
        author: Some(author),
        ..Default::default()
    })
}

fn commit_author(cr_data: &ChangelogRevisionData) -> Result<CommitAuthor, TimeStampParseError> {
    let person_with_email = parse_user_line(cr_data.user());
    let (ts, tz) = gitaly_timestamp_tz(cr_data.timestamp_line())?;

    Ok(CommitAuthor {
        name: person_with_email.person,
        email: person_with_email.email.unwrap_or_default(),
        date: Some(ts),
        timezone: tz.into(),
    })
}

/// Return the [`GitCommit`] message for a changeset given by Node prefix.
///
/// If the Node prefix resolves to [`NULL_REVISION`], the return value is `Ok(None)`.
pub fn commit_for_node_prefix(
    cl: &Changelog,
    node_prefix: NodePrefix,
) -> Result<Option<GitCommit>, RevlogError> {
    let rev = cl.rev_from_node(node_prefix)?;
    if rev == NULL_REVISION {
        return Ok(None);
    }

    Ok(Some(commit(cl.entry_for_rev(rev.into())?)?))
}

/// Common methods to producde blob responses, with or without metadata.
///
/// The usual Gitaly protocol convention when streaming several chunks of data for a single
/// object is to include metadata in the first response only for that object.
///
/// When several objects are streamed, as is, e.g, the case of the `GetBlobs` gRPC method,
/// the presence of metadata tells clients that the stream started treating another object.
pub trait BlobResponseChunk {
    fn with_metadata(chunk: &[u8], md: ObjectMetadata) -> Self;
    fn only_data(chunk: &[u8]) -> Self;
}

impl BlobResponseChunk for GetBlobsResponse {
    fn with_metadata(chunk: &[u8], md: ObjectMetadata) -> Self {
        let rev_path = md
            .revision_path
            .unwrap_or_else(|| (String::new(), Vec::new()));
        GetBlobsResponse {
            data: chunk.to_vec(),
            mode: md.mode,
            revision: rev_path.0,
            path: rev_path.1,
            is_submodule: false,
            oid: md.oid,
            size: md.size,
            r#type: md.obj_type as i32,
        }
    }
    fn only_data(chunk: &[u8]) -> Self {
        GetBlobsResponse {
            data: chunk.to_vec(),
            ..Default::default()
        }
    }
}

impl BlobResponseChunk for GetBlobResponse {
    fn with_metadata(chunk: &[u8], md: ObjectMetadata) -> Self {
        GetBlobResponse {
            data: chunk.to_vec(),
            oid: md.oid,
            size: md.size,
        }
    }
    fn only_data(chunk: &[u8]) -> Self {
        GetBlobResponse {
            data: chunk.to_vec(),
            ..Default::default()
        }
    }
}

impl BlobResponseChunk for TreeEntryResponse {
    fn with_metadata(chunk: &[u8], md: ObjectMetadata) -> Self {
        TreeEntryResponse {
            data: chunk.to_vec(),
            mode: md.mode,
            oid: md.oid,
            size: md.size,
            // TreeEntryResponse.ObjectType and shared.proto's ObjectType
            // do differ by a shift of 1 (because of `UNKNOWN = 0` in the latter)
            r#type: md.obj_type as i32 - 1,
        }
    }
    fn only_data(chunk: &[u8]) -> Self {
        TreeEntryResponse {
            data: chunk.to_vec(),
            ..Default::default()
        }
    }
}

/// Return a suitable empty [`GetBlobsResponse`] for unresolved [`RevisionPath`]
///
/// The protocol specifies that all fields are to be empty, except for `revision` and `path`
/// that are echoes of the request.
pub fn empty_blobs_response(rev_path: RevisionPath) -> GetBlobsResponse {
    GetBlobsResponse {
        revision: rev_path.revision,
        path: rev_path.path,
        ..Default::default()
    }
}

#[cfg(test)]
mod tests {

    use super::*;

    #[test]
    fn test_parse_timestamp_line() {
        assert_eq!(
            parse_timestamp_line(b"1679951671 0").unwrap(),
            (1679951671.0, 0)
        );
        assert_eq!(
            parse_timestamp_line(b"1626786076.0 0").unwrap(),
            (1626786076.0, 0)
        );
        assert_eq!(
            parse_timestamp_line(b"1626786076.0 -7200").unwrap(),
            (1626786076.0, -7200)
        );
        assert_eq!(
            parse_timestamp_line(b"1626786076.0 4500 any other stuff").unwrap(),
            (1626786076.0, 4500)
        );
        assert_eq!(
            parse_timestamp_line(b"16.a 0"),
            Err(TimeStampParseError::InvalidUtcTime(
                "invalid float literal".to_string()
            ))
        );
        assert_eq!(
            parse_timestamp_line(b"1626786076.0"),
            Err(TimeStampParseError::MissingTimeZone),
        );
        assert_eq!(parse_timestamp_line(b""), Err(TimeStampParseError::Empty));
    }

    #[test]
    fn test_gitaly_timestamp_tz() -> Result<(), TimeStampParseError> {
        // full form
        assert_eq!(
            gitaly_timestamp_tz(b"1626786076.0 -7200")?,
            (
                Timestamp {
                    seconds: 1626786076,
                    nanos: 0
                },
                "+0200".to_string()
            )
        );
        // various timezones
        assert_eq!(&gitaly_timestamp_tz(b"0 -1800")?.1, "+0030");
        assert_eq!(&gitaly_timestamp_tz(b"0 5400")?.1, "-0130");
        Ok(())
    }

    /// These are the same examples as in Python's `mercurial.util.stringutil.person`
    #[test]
    fn test_parse_user_line() {
        assert_eq!(
            parse_user_line(b"foo@bar"),
            PersonWithEmail {
                person: b"foo".to_vec(),
                email: Some(b"foo@bar".to_vec())
            }
        );
        assert_eq!(
            parse_user_line(b"Foo Bar <foo@bar>"),
            PersonWithEmail {
                person: b"Foo Bar".to_vec(),
                email: Some(b"foo@bar".to_vec())
            }
        );
        assert_eq!(
            parse_user_line(b"\"Foo \\\"buz\\\" Bar\" <foo@bar>"),
            PersonWithEmail {
                person: b"Foo \"buz\" Bar".to_vec(),
                email: Some(b"foo@bar".to_vec())
            }
        );
        //  The following are invalid, but do exist in real-life
        assert_eq!(
            parse_user_line(b"Foo \"buz\" Bar <foo@bar>"),
            PersonWithEmail {
                person: b"Foo \"buz\" Bar".to_vec(),
                email: Some(b"foo@bar".to_vec())
            }
        );
        assert_eq!(
            parse_user_line(b"\"Foo Bar <foo@bar>"),
            PersonWithEmail {
                person: b"Foo Bar".to_vec(),
                email: Some(b"foo@bar".to_vec())
            }
        );
    }

    #[test]
    fn more_test_parse_user_line() {
        assert_eq!(
            parse_user_line(b"some.foo@bar"),
            PersonWithEmail {
                person: b"some foo".to_vec(),
                email: Some(b"some.foo@bar".to_vec())
            }
        );
    }
}