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