radicle_cob/backend/git/
change.rs

1// Copyright © 2022 The Radicle Link Contributors
2
3use std::collections::BTreeMap;
4use std::convert::TryFrom;
5use std::path::PathBuf;
6
7use git_ext::author::Author;
8use git_ext::commit::{headers::Headers, Commit};
9use git_ext::Oid;
10use nonempty::NonEmpty;
11use once_cell::sync::Lazy;
12use radicle_git_ext::commit::trailers::OwnedTrailer;
13
14use crate::change::store::Version;
15use crate::signatures;
16use crate::trailers::CommitTrailer;
17use crate::{
18    change,
19    change::{store, Contents, Entry, Timestamp},
20    signatures::{ExtendedSignature, Signatures},
21    trailers, Embed,
22};
23
24/// Name of the COB manifest file.
25pub const MANIFEST_BLOB_NAME: &str = "manifest";
26/// Path under which COB embeds are kept.
27pub static EMBEDS_PATH: Lazy<PathBuf> = Lazy::new(|| PathBuf::from("embeds"));
28
29pub mod error {
30    use std::str::Utf8Error;
31    use std::string::FromUtf8Error;
32
33    use git_ext::commit;
34    use git_ext::Oid;
35    use thiserror::Error;
36
37    use crate::signatures::error::Signatures;
38
39    #[derive(Debug, Error)]
40    pub enum Create {
41        #[error(transparent)]
42        WriteCommit(#[from] commit::error::Write),
43        #[error(transparent)]
44        FromUtf8(#[from] FromUtf8Error),
45        #[error(transparent)]
46        Git(#[from] git2::Error),
47        #[error(transparent)]
48        Signer(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
49        #[error(transparent)]
50        Signatures(#[from] Signatures),
51        #[error(transparent)]
52        Utf8(#[from] Utf8Error),
53    }
54
55    #[derive(Debug, Error)]
56    pub enum Load {
57        #[error(transparent)]
58        Read(#[from] commit::error::Read),
59        #[error(transparent)]
60        Signatures(#[from] Signatures),
61        #[error(transparent)]
62        Git(#[from] git2::Error),
63        #[error("a 'manifest' file was expected be found in '{0}'")]
64        NoManifest(Oid),
65        #[error("the 'manifest' found at '{0}' was not a blob")]
66        ManifestIsNotBlob(Oid),
67        #[error("the 'manifest' found at '{id}' was invalid: {err}")]
68        InvalidManifest {
69            id: Oid,
70            #[source]
71            err: serde_json::Error,
72        },
73        #[error("a 'change' file was expected be found in '{0}'")]
74        NoChange(Oid),
75        #[error("the 'change' found at '{0}' was not a blob")]
76        ChangeNotBlob(Oid),
77        #[error("the 'change' found at '{0}' was not signed")]
78        ChangeNotSigned(Oid),
79        #[error("the 'change' found at '{0}' has more than one signature")]
80        TooManySignatures(Oid),
81        #[error("the 'change' found at '{0}' has more than one resource trailer")]
82        TooManyResources(Oid),
83        #[error(transparent)]
84        ResourceTrailer(#[from] super::trailers::error::InvalidResourceTrailer),
85        #[error("non utf-8 characters in commit message")]
86        Utf8(#[from] FromUtf8Error),
87    }
88}
89
90impl change::Storage for git2::Repository {
91    type StoreError = error::Create;
92    type LoadError = error::Load;
93
94    type ObjectId = Oid;
95    type Parent = Oid;
96    type Signatures = ExtendedSignature;
97
98    fn store<Signer>(
99        &self,
100        resource: Option<Self::Parent>,
101        mut related: Vec<Self::Parent>,
102        signer: &Signer,
103        spec: store::Template<Self::ObjectId>,
104    ) -> Result<Entry, Self::StoreError>
105    where
106        Signer: crypto::Signer,
107    {
108        let change::Template {
109            type_name,
110            tips,
111            message,
112            embeds,
113            contents,
114        } = spec;
115        let manifest = store::Manifest::new(type_name, Version::default());
116        let revision = write_manifest(self, &manifest, embeds, &contents)?;
117        let tree = self.find_tree(revision)?;
118        let signature = {
119            let sig = signer.sign(revision.as_bytes());
120            let key = signer.public_key();
121            ExtendedSignature::new(*key, sig)
122        };
123
124        // Make sure there are no duplicates in the related list.
125        related.sort();
126        related.dedup();
127
128        let (id, timestamp) = write_commit(
129            self,
130            resource.map(|o| *o),
131            // Commit to tips, extra parents and resource.
132            tips.iter()
133                .cloned()
134                .chain(related.clone())
135                .chain(resource)
136                .map(git2::Oid::from),
137            message,
138            signature.clone(),
139            related
140                .iter()
141                .map(|p| trailers::CommitTrailer::Related(**p).into()),
142            tree,
143        )?;
144
145        Ok(Entry {
146            id,
147            revision: revision.into(),
148            signature,
149            resource,
150            parents: tips,
151            related,
152            manifest,
153            contents,
154            timestamp,
155        })
156    }
157
158    fn parents_of(&self, id: &Oid) -> Result<Vec<Oid>, Self::LoadError> {
159        Ok(self
160            .find_commit(**id)?
161            .parent_ids()
162            .map(Oid::from)
163            .collect::<Vec<_>>())
164    }
165
166    fn load(&self, id: Self::ObjectId) -> Result<Entry, Self::LoadError> {
167        let commit = Commit::read(self, id.into())?;
168        let timestamp = git2::Time::from(commit.committer().time).seconds() as u64;
169        let trailers = parse_trailers(commit.trailers())?;
170        let (resources, related): (Vec<_>, Vec<_>) = trailers.iter().partition(|t| match t {
171            CommitTrailer::Resource(_) => true,
172            CommitTrailer::Related(_) => false,
173        });
174        let mut resources = resources
175            .into_iter()
176            .map(|r| r.oid().into())
177            .collect::<Vec<_>>();
178        let related = related
179            .into_iter()
180            .map(|r| r.oid().into())
181            .collect::<Vec<_>>();
182        let parents = commit
183            .parents()
184            .map(Oid::from)
185            .filter(|p| !resources.contains(p) && !related.contains(p))
186            .collect();
187        let mut signatures = Signatures::try_from(&commit)?
188            .into_iter()
189            .collect::<Vec<_>>();
190        let Some((key, sig)) = signatures.pop() else {
191            return Err(error::Load::ChangeNotSigned(id));
192        };
193        if !signatures.is_empty() {
194            return Err(error::Load::TooManySignatures(id));
195        }
196        if resources.len() > 1 {
197            return Err(error::Load::TooManyResources(id));
198        };
199
200        let tree = self.find_tree(*commit.tree())?;
201        let manifest = load_manifest(self, &tree)?;
202        let contents = load_contents(self, &tree)?;
203
204        Ok(Entry {
205            id,
206            revision: tree.id().into(),
207            signature: ExtendedSignature::new(key, sig),
208            resource: resources.pop(),
209            related,
210            parents,
211            manifest,
212            contents,
213            timestamp,
214        })
215    }
216}
217
218fn parse_trailers<'a>(
219    trailers: impl Iterator<Item = &'a OwnedTrailer>,
220) -> Result<Vec<trailers::CommitTrailer>, error::Load> {
221    let mut parsed = Vec::new();
222    for trailer in trailers {
223        match trailers::CommitTrailer::try_from(trailer) {
224            Err(trailers::error::InvalidResourceTrailer::WrongToken) => {
225                continue;
226            }
227            Err(err) => return Err(err.into()),
228            Ok(t) => parsed.push(t),
229        }
230    }
231    Ok(parsed)
232}
233
234fn load_manifest(
235    repo: &git2::Repository,
236    tree: &git2::Tree,
237) -> Result<store::Manifest, error::Load> {
238    let manifest_tree_entry = tree
239        .get_name(MANIFEST_BLOB_NAME)
240        .ok_or_else(|| error::Load::NoManifest(tree.id().into()))?;
241    let manifest_object = manifest_tree_entry.to_object(repo)?;
242    let manifest_blob = manifest_object
243        .as_blob()
244        .ok_or_else(|| error::Load::ManifestIsNotBlob(tree.id().into()))?;
245
246    serde_json::from_slice(manifest_blob.content()).map_err(|err| error::Load::InvalidManifest {
247        id: tree.id().into(),
248        err,
249    })
250}
251
252fn load_contents(repo: &git2::Repository, tree: &git2::Tree) -> Result<Contents, error::Load> {
253    let ops = tree
254        .iter()
255        .filter_map(|entry| {
256            entry.kind().and_then(|kind| match kind {
257                git2::ObjectType::Blob => {
258                    let name = entry.name()?.parse::<i8>().ok()?;
259                    let blob = entry
260                        .to_object(repo)
261                        .and_then(|object| object.peel_to_blob())
262                        .map(|blob| blob.content().to_owned())
263                        .map(|b| (name, b));
264
265                    Some(blob)
266                }
267                _ => None,
268            })
269        })
270        .collect::<Result<BTreeMap<_, _>, _>>()?;
271
272    NonEmpty::collect(ops.into_values()).ok_or_else(|| error::Load::NoChange(tree.id().into()))
273}
274
275fn write_commit(
276    repo: &git2::Repository,
277    resource: Option<git2::Oid>,
278    parents: impl IntoIterator<Item = git2::Oid>,
279    message: String,
280    signature: ExtendedSignature,
281    trailers: impl IntoIterator<Item = OwnedTrailer>,
282    tree: git2::Tree,
283) -> Result<(Oid, Timestamp), error::Create> {
284    let trailers: Vec<OwnedTrailer> = trailers
285        .into_iter()
286        .chain(resource.map(|r| trailers::CommitTrailer::Resource(r).into()))
287        .collect();
288    let author = repo.signature()?;
289    #[allow(unused_variables)]
290    let timestamp = author.when().seconds();
291
292    let mut headers = Headers::new();
293    headers.push(
294        "gpgsig",
295        signature
296            .to_pem()
297            .map_err(signatures::error::Signatures::from)?
298            .as_str(),
299    );
300    let author = Author::try_from(&author)?;
301
302    #[cfg(feature = "stable-commit-ids")]
303    // Ensures the commit id doesn't change on every run.
304    let (author, timestamp) = {
305        let stable = crate::git::stable::read_timestamp();
306        (
307            Author {
308                time: git_ext::author::Time::new(stable, 0),
309                ..author
310            },
311            stable,
312        )
313    };
314    let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::GIT_COMMITTER_DATE) {
315        let Ok(timestamp) = s.trim().parse::<i64>() else {
316            panic!(
317                "Invalid timestamp value {s:?} for `{}`",
318                crate::git::GIT_COMMITTER_DATE
319            );
320        };
321        let author = Author {
322            time: git_ext::author::Time::new(timestamp, 0),
323            ..author
324        };
325        (author, timestamp)
326    } else {
327        (author, timestamp)
328    };
329
330    let oid = Commit::new(
331        tree.id(),
332        parents,
333        author.clone(),
334        author,
335        headers,
336        message,
337        trailers,
338    )
339    .write(repo)?;
340
341    Ok((Oid::from(oid), timestamp as u64))
342}
343
344fn write_manifest(
345    repo: &git2::Repository,
346    manifest: &store::Manifest,
347    embeds: Vec<Embed<Oid>>,
348    contents: &NonEmpty<Vec<u8>>,
349) -> Result<git2::Oid, git2::Error> {
350    let mut root = repo.treebuilder(None)?;
351
352    // Insert manifest file into tree.
353    {
354        // SAFETY: we're serializing to an in memory buffer so the only source of
355        // errors here is a programming error, which we can't recover from.
356        #[allow(clippy::unwrap_used)]
357        let manifest = serde_json::to_vec(manifest).unwrap();
358        let manifest_oid = repo.blob(&manifest)?;
359
360        root.insert(
361            MANIFEST_BLOB_NAME,
362            manifest_oid,
363            git2::FileMode::Blob.into(),
364        )?;
365    }
366
367    // Insert each COB entry.
368    for (ix, op) in contents.iter().enumerate() {
369        let oid = repo.blob(op.as_ref())?;
370        root.insert(ix.to_string(), oid, git2::FileMode::Blob.into())?;
371    }
372
373    // Insert each embed in a tree at `/embeds`.
374    if !embeds.is_empty() {
375        let mut embeds_tree = repo.treebuilder(None)?;
376
377        for embed in embeds {
378            let oid = embed.content;
379            let path = PathBuf::from(embed.name);
380
381            embeds_tree.insert(path, *oid, git2::FileMode::Blob.into())?;
382        }
383        let oid = embeds_tree.write()?;
384
385        root.insert(&*EMBEDS_PATH, oid, git2::FileMode::Tree.into())?;
386    }
387    let oid = root.write()?;
388
389    Ok(oid)
390}