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: 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 related.sort();
126 related.dedup();
127
128 let (id, timestamp) = write_commit(
129 self,
130 resource.map(|o| *o),
131 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 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 {
354 #[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 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 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}