maincopy-server 0.1.0

Self-hosted publishing server with exact previews and explicit release approval
Documentation
use std::{fmt, str::FromStr};

use maincopy_shared::source::{GIT_SHA1_SOURCE_COMMIT_PREFIX, GIT_SHA256_SOURCE_COMMIT_PREFIX};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use thiserror::Error;

#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceCommitAlgorithm {
    Sha1,
    Sha256,
}

impl SourceCommitAlgorithm {
    const fn byte_length(self) -> usize {
        match self {
            Self::Sha1 => 20,
            Self::Sha256 => 32,
        }
    }
}

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SourceCommit {
    algorithm: SourceCommitAlgorithm,
    bytes: Box<[u8]>,
    encoded: Box<str>,
}

impl SourceCommit {
    pub fn parse(value: &str) -> Result<Self, SourceCommitParseError> {
        let (algorithm, hex) = if let Some(hex) = value.strip_prefix(GIT_SHA1_SOURCE_COMMIT_PREFIX)
        {
            (SourceCommitAlgorithm::Sha1, hex)
        } else if let Some(hex) = value.strip_prefix(GIT_SHA256_SOURCE_COMMIT_PREFIX) {
            (SourceCommitAlgorithm::Sha256, hex)
        } else {
            return Err(SourceCommitParseError::InvalidPrefix);
        };
        if hex.len() != algorithm.byte_length() * 2 {
            return Err(SourceCommitParseError::InvalidLength { algorithm });
        }
        if !hex
            .bytes()
            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
        {
            return Err(SourceCommitParseError::InvalidEncoding { algorithm });
        }
        let mut bytes = Vec::with_capacity(algorithm.byte_length());
        for pair in hex.as_bytes().as_chunks::<2>().0 {
            let high = decode_nibble(pair[0])
                .ok_or(SourceCommitParseError::InvalidEncoding { algorithm })?;
            let low = decode_nibble(pair[1])
                .ok_or(SourceCommitParseError::InvalidEncoding { algorithm })?;
            bytes.push(high << 4 | low);
        }
        Ok(Self {
            algorithm,
            bytes: bytes.into_boxed_slice(),
            encoded: value.into(),
        })
    }

    pub(crate) fn from_git_hex(value: &str) -> Result<Self, SourceCommitParseError> {
        match value.len() {
            40 => Self::parse(&format!("{GIT_SHA1_SOURCE_COMMIT_PREFIX}{value}")),
            64 => Self::parse(&format!("{GIT_SHA256_SOURCE_COMMIT_PREFIX}{value}")),
            _ => Err(SourceCommitParseError::UnsupportedObjectFormat),
        }
    }

    pub const fn algorithm(&self) -> SourceCommitAlgorithm {
        self.algorithm
    }

    pub fn as_bytes(&self) -> &[u8] {
        &self.bytes
    }

    pub fn as_str(&self) -> &str {
        &self.encoded
    }
}

impl fmt::Display for SourceCommit {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for SourceCommit {
    type Err = SourceCommitParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::parse(value)
    }
}

impl TryFrom<&[u8]> for SourceCommit {
    type Error = SourceCommitParseError;

    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
        let (algorithm, prefix) = match bytes.len() {
            20 => (SourceCommitAlgorithm::Sha1, GIT_SHA1_SOURCE_COMMIT_PREFIX),
            32 => (
                SourceCommitAlgorithm::Sha256,
                GIT_SHA256_SOURCE_COMMIT_PREFIX,
            ),
            _ => return Err(SourceCommitParseError::UnsupportedObjectFormat),
        };
        const HEX: &[u8; 16] = b"0123456789abcdef";
        let mut encoded = String::with_capacity(prefix.len() + bytes.len() * 2);
        encoded.push_str(prefix);
        for byte in bytes {
            encoded.push(char::from(HEX[usize::from(byte >> 4)]));
            encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
        }
        Ok(Self {
            algorithm,
            bytes: bytes.into(),
            encoded: encoded.into_boxed_str(),
        })
    }
}

impl Serialize for SourceCommit {
    fn serialize<SerializerType>(
        &self,
        serializer: SerializerType,
    ) -> Result<SerializerType::Ok, SerializerType::Error>
    where
        SerializerType: Serializer,
    {
        serializer.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for SourceCommit {
    fn deserialize<DeserializerType>(
        deserializer: DeserializerType,
    ) -> Result<Self, DeserializerType::Error>
    where
        DeserializerType: Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        Self::parse(&value).map_err(de::Error::custom)
    }
}

#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum SourceCommitParseError {
    #[error("source commit must start with git-sha1: or git-sha256:")]
    InvalidPrefix,
    #[error("{algorithm:?} source commit has the wrong encoded length")]
    InvalidLength { algorithm: SourceCommitAlgorithm },
    #[error("{algorithm:?} source commit must use lowercase hexadecimal")]
    InvalidEncoding { algorithm: SourceCommitAlgorithm },
    #[error("Git object format is not supported")]
    UnsupportedObjectFormat,
}

const fn decode_nibble(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn source_commits_are_strict_and_algorithm_typed() {
        let sha1 = SourceCommit::parse(&format!("git-sha1:{}", "ab".repeat(20))).unwrap();
        assert_eq!(sha1.algorithm(), SourceCommitAlgorithm::Sha1);
        assert_eq!(sha1.as_bytes().len(), 20);

        let sha256 = SourceCommit::parse(&format!("git-sha256:{}", "cd".repeat(32))).unwrap();
        assert_eq!(sha256.algorithm(), SourceCommitAlgorithm::Sha256);
        assert_eq!(sha256.as_bytes().len(), 32);

        for invalid in [
            "ab".repeat(20),
            format!("git-sha1:{}", "AB".repeat(20)),
            format!("git-sha1:{}", "ab".repeat(19)),
            format!("git-sha256:{}", "gg".repeat(32)),
        ] {
            assert!(SourceCommit::parse(&invalid).is_err(), "accepted {invalid}");
        }
    }

    #[test]
    fn source_commit_serde_preserves_the_versioned_wire_value() {
        let value = format!("git-sha1:{}", "01".repeat(20));
        let commit = SourceCommit::parse(&value).unwrap();
        assert_eq!(serde_json::to_value(&commit).unwrap(), value);
        assert_eq!(
            serde_json::from_value::<SourceCommit>(serde_json::json!(value)).unwrap(),
            commit
        );
    }

    #[test]
    fn stored_source_commit_bytes_preserve_the_canonical_encoding() {
        for width in [20, 32] {
            let bytes: Vec<_> = (0..width).map(|byte| byte * 7).collect();
            let commit = SourceCommit::try_from(bytes.as_slice()).unwrap();
            assert_eq!(commit.as_bytes(), bytes);
            assert_eq!(SourceCommit::parse(commit.as_str()).unwrap(), commit);
        }
        for width in [0, 19, 21, 31, 33] {
            assert_eq!(
                SourceCommit::try_from(vec![0; width].as_slice()),
                Err(SourceCommitParseError::UnsupportedObjectFormat),
            );
        }
    }

    #[test]
    fn source_commit_algorithm_wire_names_are_stable() {
        for (value, expected) in [
            (
                serde_json::to_value(SourceCommitAlgorithm::Sha1).unwrap(),
                "sha1",
            ),
            (
                serde_json::to_value(SourceCommitAlgorithm::Sha256).unwrap(),
                "sha256",
            ),
        ] {
            assert_eq!(value, serde_json::json!(expected));
        }
    }
}