1use 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
24pub const MANIFEST_BLOB_NAME: &str = "manifest";
26pub 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: signature::Signer<Self::Signatures>,
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 = signer.sign(revision.as_bytes());
119
120 related.sort();
122 related.dedup();
123
124 let (id, timestamp) = write_commit(
125 self,
126 resource.map(|o| *o),
127 tips.iter()
129 .cloned()
130 .chain(related.clone())
131 .chain(resource)
132 .map(git2::Oid::from),
133 message,
134 signature.clone(),
135 related
136 .iter()
137 .map(|p| trailers::CommitTrailer::Related(**p).into()),
138 tree,
139 )?;
140
141 Ok(Entry {
142 id,
143 revision: revision.into(),
144 signature,
145 resource,
146 parents: tips,
147 related,
148 manifest,
149 contents,
150 timestamp,
151 })
152 }
153
154 fn parents_of(&self, id: &Oid) -> Result<Vec<Oid>, Self::LoadError> {
155 Ok(self
156 .find_commit(**id)?
157 .parent_ids()
158 .map(Oid::from)
159 .collect::<Vec<_>>())
160 }
161
162 fn load(&self, id: Self::ObjectId) -> Result<Entry, Self::LoadError> {
163 let commit = Commit::read(self, id.into())?;
164 let timestamp = git2::Time::from(commit.committer().time).seconds() as u64;
165 let trailers = parse_trailers(commit.trailers())?;
166 let (resources, related): (Vec<_>, Vec<_>) = trailers.iter().partition(|t| match t {
167 CommitTrailer::Resource(_) => true,
168 CommitTrailer::Related(_) => false,
169 });
170 let mut resources = resources
171 .into_iter()
172 .map(|r| r.oid().into())
173 .collect::<Vec<_>>();
174 let related = related
175 .into_iter()
176 .map(|r| r.oid().into())
177 .collect::<Vec<_>>();
178 let parents = commit
179 .parents()
180 .map(Oid::from)
181 .filter(|p| !resources.contains(p) && !related.contains(p))
182 .collect();
183 let mut signatures = Signatures::try_from(&commit)?
184 .into_iter()
185 .collect::<Vec<_>>();
186 let Some((key, sig)) = signatures.pop() else {
187 return Err(error::Load::ChangeNotSigned(id));
188 };
189 if !signatures.is_empty() {
190 return Err(error::Load::TooManySignatures(id));
191 }
192 if resources.len() > 1 {
193 return Err(error::Load::TooManyResources(id));
194 };
195
196 let tree = self.find_tree(*commit.tree())?;
197 let manifest = load_manifest(self, &tree)?;
198 let contents = load_contents(self, &tree)?;
199
200 Ok(Entry {
201 id,
202 revision: tree.id().into(),
203 signature: ExtendedSignature::new(key, sig),
204 resource: resources.pop(),
205 related,
206 parents,
207 manifest,
208 contents,
209 timestamp,
210 })
211 }
212}
213
214fn parse_trailers<'a>(
215 trailers: impl Iterator<Item = &'a OwnedTrailer>,
216) -> Result<Vec<trailers::CommitTrailer>, error::Load> {
217 let mut parsed = Vec::new();
218 for trailer in trailers {
219 match trailers::CommitTrailer::try_from(trailer) {
220 Err(trailers::error::InvalidResourceTrailer::WrongToken) => {
221 continue;
222 }
223 Err(err) => return Err(err.into()),
224 Ok(t) => parsed.push(t),
225 }
226 }
227 Ok(parsed)
228}
229
230fn load_manifest(
231 repo: &git2::Repository,
232 tree: &git2::Tree,
233) -> Result<store::Manifest, error::Load> {
234 let manifest_tree_entry = tree
235 .get_name(MANIFEST_BLOB_NAME)
236 .ok_or_else(|| error::Load::NoManifest(tree.id().into()))?;
237 let manifest_object = manifest_tree_entry.to_object(repo)?;
238 let manifest_blob = manifest_object
239 .as_blob()
240 .ok_or_else(|| error::Load::ManifestIsNotBlob(tree.id().into()))?;
241
242 serde_json::from_slice(manifest_blob.content()).map_err(|err| error::Load::InvalidManifest {
243 id: tree.id().into(),
244 err,
245 })
246}
247
248fn load_contents(repo: &git2::Repository, tree: &git2::Tree) -> Result<Contents, error::Load> {
249 let ops = tree
250 .iter()
251 .filter_map(|entry| {
252 entry.kind().and_then(|kind| match kind {
253 git2::ObjectType::Blob => {
254 let name = entry.name()?.parse::<i8>().ok()?;
255 let blob = entry
256 .to_object(repo)
257 .and_then(|object| object.peel_to_blob())
258 .map(|blob| blob.content().to_owned())
259 .map(|b| (name, b));
260
261 Some(blob)
262 }
263 _ => None,
264 })
265 })
266 .collect::<Result<BTreeMap<_, _>, _>>()?;
267
268 NonEmpty::collect(ops.into_values()).ok_or_else(|| error::Load::NoChange(tree.id().into()))
269}
270
271fn write_commit(
272 repo: &git2::Repository,
273 resource: Option<git2::Oid>,
274 parents: impl IntoIterator<Item = git2::Oid>,
275 message: String,
276 signature: ExtendedSignature,
277 trailers: impl IntoIterator<Item = OwnedTrailer>,
278 tree: git2::Tree,
279) -> Result<(Oid, Timestamp), error::Create> {
280 let trailers: Vec<OwnedTrailer> = trailers
281 .into_iter()
282 .chain(resource.map(|r| trailers::CommitTrailer::Resource(r).into()))
283 .collect();
284 let author = repo.signature()?;
285 #[allow(unused_variables)]
286 let timestamp = author.when().seconds();
287
288 let mut headers = Headers::new();
289 headers.push(
290 "gpgsig",
291 signature
292 .to_pem()
293 .map_err(signatures::error::Signatures::from)?
294 .as_str(),
295 );
296 let author = Author::try_from(&author)?;
297
298 #[cfg(feature = "stable-commit-ids")]
299 let (author, timestamp) = {
301 let stable = crate::git::stable::read_timestamp();
302 (
303 Author {
304 time: git_ext::author::Time::new(stable, 0),
305 ..author
306 },
307 stable,
308 )
309 };
310 let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::GIT_COMMITTER_DATE) {
311 let Ok(timestamp) = s.trim().parse::<i64>() else {
312 panic!(
313 "Invalid timestamp value {s:?} for `{}`",
314 crate::git::GIT_COMMITTER_DATE
315 );
316 };
317 let author = Author {
318 time: git_ext::author::Time::new(timestamp, 0),
319 ..author
320 };
321 (author, timestamp)
322 } else {
323 (author, timestamp)
324 };
325
326 let oid = Commit::new(
327 tree.id(),
328 parents,
329 author.clone(),
330 author,
331 headers,
332 message,
333 trailers,
334 )
335 .write(repo)?;
336
337 Ok((Oid::from(oid), timestamp as u64))
338}
339
340fn write_manifest(
341 repo: &git2::Repository,
342 manifest: &store::Manifest,
343 embeds: Vec<Embed<Oid>>,
344 contents: &NonEmpty<Vec<u8>>,
345) -> Result<git2::Oid, git2::Error> {
346 let mut root = repo.treebuilder(None)?;
347
348 {
350 #[allow(clippy::unwrap_used)]
353 let manifest = serde_json::to_vec(manifest).unwrap();
354 let manifest_oid = repo.blob(&manifest)?;
355
356 root.insert(
357 MANIFEST_BLOB_NAME,
358 manifest_oid,
359 git2::FileMode::Blob.into(),
360 )?;
361 }
362
363 for (ix, op) in contents.iter().enumerate() {
365 let oid = repo.blob(op.as_ref())?;
366 root.insert(ix.to_string(), oid, git2::FileMode::Blob.into())?;
367 }
368
369 if !embeds.is_empty() {
371 let mut embeds_tree = repo.treebuilder(None)?;
372
373 for embed in embeds {
374 let oid = embed.content;
375 let path = PathBuf::from(embed.name);
376
377 embeds_tree.insert(path, *oid, git2::FileMode::Blob.into())?;
378 }
379 let oid = embeds_tree.write()?;
380
381 root.insert(&*EMBEDS_PATH, oid, git2::FileMode::Tree.into())?;
382 }
383 let oid = root.write()?;
384
385 Ok(oid)
386}