From d2f423521ec76406944ad83098ec33afe20c692b Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Mon, 9 Jan 2023 13:18:33 +0100 Subject: This is it Squashed commit of all the exploration history. Development starts here. Signed-off-by: Kim Altintop --- src/patches/record.rs | 472 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 src/patches/record.rs (limited to 'src/patches/record.rs') diff --git a/src/patches/record.rs b/src/patches/record.rs new file mode 100644 index 0000000..6a95973 --- /dev/null +++ b/src/patches/record.rs @@ -0,0 +1,472 @@ +// Copyright © 2022 Kim Altintop +// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception + +use core::ops::Deref; +use std::{ + collections::{ + BTreeMap, + BTreeSet, + }, + fmt, + io::{ + self, + BufRead, + }, + path::{ + Path, + PathBuf, + }, + str::FromStr, +}; + +use anyhow::{ + anyhow, + bail, + ensure, + Context, +}; + +use hex::{ + FromHex, + ToHex, +}; + +use sha2::{ + Digest, + Sha256, +}; +use signature::{ + Signature as _, + Verifier, +}; + +use super::{ + traits::{ + to_tree, + BlobData, + Foldable, + TreeData, + }, + write_sharded, + Blob, + Bundle, + Topic, + BLOB_HEADS, + BLOB_META, + HTTP_HEADER_SIGNATURE, + TOPIC_MERGES, + TOPIC_SNAPSHOTS, +}; +use crate::{ + bundle, + error::NotFound, + git::{ + self, + Refname, + }, + iter::IteratorExt, + metadata::{ + self, + identity, + ContentHash, + }, +}; + +#[derive(Clone, Copy, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Heads(#[serde(with = "hex::serde")] [u8; 32]); + +impl Heads { + const TRAILER_PREFIX: &str = "Patch:"; + + pub fn from_commit(commit: &git2::Commit) -> crate::Result> { + commit.message_raw_bytes().lines().try_find_map(|line| { + line? + .strip_prefix(Self::TRAILER_PREFIX) + .map(|s| Self::from_str(s.trim()).map_err(crate::Error::from)) + .transpose() + }) + } + + pub fn as_trailer(&self) -> String { + format!("{} {}", Self::TRAILER_PREFIX, self) + } +} + +impl Deref for Heads { + type Target = [u8; 32]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef<[u8]> for Heads { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From<&bundle::Header> for Heads { + fn from(h: &bundle::Header) -> Self { + let tips = h.references.values().collect::>(); + let mut hasher = Sha256::new(); + for sha in tips { + hasher.update(sha.as_bytes()); + } + Self(hasher.finalize().into()) + } +} + +impl TryFrom<&git2::Commit<'_>> for Heads { + type Error = crate::Error; + + fn try_from(commit: &git2::Commit) -> Result { + Self::from_commit(commit)?.ok_or_else(|| { + anyhow!(NotFound { + what: "patch trailer", + whence: format!("commit {}", commit.id()), + }) + }) + } +} + +impl FromStr for Heads { + type Err = hex::FromHexError; + + fn from_str(s: &str) -> Result { + Self::from_hex(s) + } +} + +impl FromHex for Heads { + type Error = hex::FromHexError; + + fn from_hex>(hex: T) -> Result { + <[u8; 32]>::from_hex(hex).map(Self) + } +} + +impl fmt::Display for Heads { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.0)) + } +} + +impl fmt::Debug for Heads { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.0)) + } +} + +impl BlobData for Heads { + type Error = <[u8; 32] as FromHex>::Error; + + const MAX_BYTES: usize = 64; + + fn from_blob(data: &[u8]) -> Result { + Self::from_hex(data) + } + + fn write_blob(&self, mut writer: W) -> io::Result<()> { + writer.write_all(self.encode_hex::().as_bytes()) + } +} + +impl TreeData for Heads { + const BLOB_NAME: &'static str = BLOB_HEADS; +} + +impl Foldable for Heads { + fn folded_name(&self) -> String { + self.encode_hex() + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Signature { + pub signer: metadata::ContentHash, + pub signature: metadata::Signature, +} + +impl From for tiny_http::Header { + fn from(s: Signature) -> Self { + let value = format!( + "s1={}; s2={}; sd={}", + hex::encode(s.signer.sha1), + hex::encode(s.signer.sha2), + hex::encode(s.signature.as_ref()) + ); + + Self::from_bytes(HTTP_HEADER_SIGNATURE.as_bytes(), value).unwrap() + } +} + +impl TryFrom<&tiny_http::Header> for Signature { + type Error = crate::Error; + + fn try_from(hdr: &tiny_http::Header) -> Result { + ensure!( + hdr.field.equiv(HTTP_HEADER_SIGNATURE), + "not a {HTTP_HEADER_SIGNATURE} header" + ); + + let mut sha1: Option<[u8; 20]> = None; + let mut sha2: Option<[u8; 32]> = None; + let mut signature = None; + for part in hdr.value.as_str().split(';') { + match part.trim().split_at(2) { + ("s1", val) => { + let bytes = <[u8; 20]>::from_hex(val)?; + sha1 = Some(bytes); + }, + ("s2", val) => { + let bytes = <[u8; 32]>::from_hex(val)?; + sha2 = Some(bytes); + }, + ("sd", val) => { + let bytes = hex::decode(val)?; + signature = Some(metadata::Signature::from_bytes(&bytes)?); + }, + + _ => continue, + } + } + + let sha1 = sha1.ok_or_else(|| anyhow!("missing sha1 identity content hash"))?; + let sha2 = sha2.ok_or_else(|| anyhow!("missing sha2 identity content hash"))?; + let signature = signature.ok_or_else(|| anyhow!("missing signature bytes"))?; + + Ok(Self { + signer: metadata::ContentHash { sha1, sha2 }, + signature, + }) + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct Meta { + pub bundle: BundleInfo, + pub signature: Signature, +} + +impl BlobData for Meta { + type Error = serde_json::Error; + + const MAX_BYTES: usize = 100_000; + + fn from_blob(data: &[u8]) -> Result { + serde_json::from_slice(data) + } + + fn write_blob(&self, writer: W) -> io::Result<()> { + serde_json::to_writer_pretty(writer, self).map_err(Into::into) + } +} + +impl TreeData for Meta { + const BLOB_NAME: &'static str = BLOB_META; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Encryption { + Age, + Gpg, +} + +impl Encryption { + pub fn as_str(&self) -> &str { + match self { + Self::Age => "age", + Self::Gpg => "gpg", + } + } +} + +impl FromStr for Encryption { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct BundleInfo { + #[serde(flatten)] + pub info: bundle::Info, + pub prerequisites: BTreeSet, + pub references: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encryption: Option, +} + +impl BundleInfo { + pub fn as_expect(&self) -> bundle::Expect { + bundle::Expect::from(&self.info) + } +} + +impl From<&Bundle> for BundleInfo { + fn from(bundle: &Bundle) -> Self { + let (prerequisites, references) = { + let h = bundle.header(); + (h.prerequisites.clone(), h.references.clone()) + }; + Self { + info: bundle.info().clone(), + prerequisites, + references, + encryption: bundle.encryption(), + } + } +} + +/// Log record of a patch submission +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct Record { + pub topic: Topic, + pub heads: Heads, + pub meta: Meta, +} + +impl Record { + pub fn from_commit<'a>( + repo: &'a git2::Repository, + commit: &git2::Commit<'a>, + ) -> crate::Result { + let topic = Topic::from_commit(commit)?.ok_or_else(|| crate::error::NotFound { + what: "topic", + whence: format!("message of commit {}", commit.id()), + })?; + + let tree = commit.tree()?; + + let mut heads: Option = None; + let mut meta: Option = None; + + for entry in &tree { + match entry.name() { + Some(BLOB_HEADS) => { + heads = Some(Blob::::from_entry(repo, entry)?.content); + }, + Some(BLOB_META) => { + meta = Some(Blob::::from_entry(repo, entry)?.content); + }, + + None | Some(_) => continue, + } + } + + let whence = || format!("tree {}", tree.id()); + let heads = heads.ok_or_else(|| crate::error::NotFound { + what: BLOB_HEADS, + whence: whence(), + })?; + let meta = meta.ok_or_else(|| crate::error::NotFound { + what: BLOB_META, + whence: whence(), + })?; + + Ok(Self { topic, heads, meta }) + } + + pub fn commit( + &self, + signer: &mut S, + repo: &git2::Repository, + ids: &git2::Tree, + parent: Option<&git2::Commit>, + seen: Option<&mut git2::TreeBuilder>, + ) -> crate::Result + where + S: crate::keys::Signer, + { + let tree = { + let mut tb = repo.treebuilder(parent.map(|p| p.tree()).transpose()?.as_ref())?; + tb.insert("ids", ids.id(), git2::FileMode::Tree.into())?; + to_tree(repo, &mut tb, &self.heads)?; + to_tree(repo, &mut tb, &self.meta)?; + repo.find_tree(tb.write()?)? + }; + let oid = git::commit_signed( + signer, + repo, + self.topic.as_trailer(), + &tree, + &parent.into_iter().collect::>(), + )?; + + if let Some(seen) = seen { + write_sharded( + repo, + seen, + &self.heads, + tree.get_name(Heads::BLOB_NAME) + .expect("heads blob written above") + .id(), + )?; + } + + Ok(oid) + } + + pub fn signed_part(&self) -> [u8; 32] { + *self.heads + } + + pub fn verify_signature(&self, mut find_id: F) -> crate::Result<()> + where + F: FnMut(&ContentHash) -> crate::Result, + { + let signed_data = self.signed_part(); + let addr = &self.meta.signature.signer; + let signature = &self.meta.signature.signature; + let id = + find_id(addr).with_context(|| format!("invalid or non-existent id at {:?}", addr))?; + for key in id.identity().keys.values() { + if key.verify(&signed_data, signature).is_ok() { + return Ok(()); + } + } + bail!("signature key not in id at {:?}", addr); + } + + pub fn bundle_info(&self) -> &BundleInfo { + &self.meta.bundle + } + + pub fn bundle_hash(&self) -> &bundle::Hash { + &self.meta.bundle.info.hash + } + + pub fn bundle_path(&self, prefix: &Path) -> PathBuf { + let mut p = prefix.join(self.bundle_hash().to_string()); + p.set_extension(bundle::FILE_EXTENSION); + p + } + + pub fn is_encrypted(&self) -> bool { + self.meta.bundle.encryption.is_some() + } + + pub fn is_snapshot(&self) -> bool { + self.topic == *TOPIC_SNAPSHOTS + } + + pub fn is_mergepoint(&self) -> bool { + self.topic == *TOPIC_MERGES + } + + /// Remove traces of a record from the given tree + pub(crate) fn remove_from(tree: &mut git2::TreeBuilder) -> crate::Result<()> { + if tree.get(Heads::BLOB_NAME)?.is_some() { + tree.remove(Heads::BLOB_NAME)?; + } + if tree.get(Meta::BLOB_NAME)?.is_some() { + tree.remove(Meta::BLOB_NAME)?; + } + + Ok(()) + } +} -- cgit v1.2.3