git_bug/entities/identity/legacy/
mod.rs

1// git-bug-rs - A rust library for interfacing with git-bug repositories
2//
3// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
4// SPDX-License-Identifier: GPL-3.0-or-later
5//
6// This file is part of git-bug-rs/git-gub.
7//
8// You should have received a copy of the License along with this program.
9// If not, see <https://www.gnu.org/licenses/agpl.txt>.
10
11//! Interface code, that can Read the legacy storage format of
12//! [`Identities`][`Identity`].
13
14use std::str::FromStr;
15
16use simd_json::{
17    base::ValueTryAsMutObject,
18    derived::{ValueTryAsScalar, ValueTryIntoObject, ValueTryIntoString},
19    owned,
20};
21use url::Url;
22
23use super::Identity;
24use crate::replica::{
25    Replica,
26    entity::{EntityRead, id::entity_id::EntityId, nonce::Nonce},
27};
28
29/// The stored JSON data in the legacy identity.
30///
31/// Git-bug currently stores each change to an identity, by creating the
32/// resulting “version” of it, and storing it. So to get the full history of an
33/// identity, you would need to calculate the changes in each “version” along
34/// the way.
35#[derive(Debug)]
36pub struct VersionEntry {
37    /// Additional field to version the data
38    pub version: u64,
39
40    /// The lamport times of the other entities at which this version becomes
41    /// effective
42    // Use a vec, to preserve order.
43    pub times: Vec<(String, u64)>,
44
45    /// The UNIX time stamp, at which this version was created.
46    pub unix_time: u64,
47
48    /// A set of arbitrary keys and values to store metadata about a version or
49    /// about an Identity in general.
50    pub metadata: Option<Vec<(String, String)>>,
51
52    /// The name at this version
53    pub name: Option<String>,
54
55    /// As defined in git or from a bridge when importing the identity
56    pub email: Option<String>,
57
58    /// Sourced from a bridge when importing the identity
59    pub login: Option<String>,
60
61    /// The avatar url at this version
62    pub avatar_url: Option<Url>,
63
64    // #[serde(serialize_if = Option::is_some)]
65    // /// The set of keys valid at that time, from this version onward, until they get removed
66    // /// in a new version. This allows to have multiple key for the same identity (e.g. one per
67    // /// device) as well as revoke key.
68    // pub_keys: Vec<Key>,
69    /// Mandatory random bytes to ensure a better randomness of the data of the
70    /// first version of an identity, used to later generate the ID
71    ///
72    /// It has no functional purpose and should be ignored.
73    pub(super) nonce: Nonce,
74}
75
76impl Identity {
77    /// Fetch an [`Identity`][`super::Identity`] from it's legacy encoding from
78    /// a git repository and decode it.
79    ///
80    /// The legacy encoding is used by `git-bug`.
81    /// This is only useful, if you need fine grained access to the concrete
82    /// data. If you simply want to access an [`Identity`], use
83    /// [`Identity::read`] instead.
84    ///
85    /// # Errors
86    /// If the associated git operations fail.
87    // Only expects.
88    #[allow(clippy::missing_panics_doc)]
89    #[allow(clippy::too_many_lines)]
90    pub fn read_legacy(
91        replica: &Replica,
92        id: EntityId<Self>,
93    ) -> Result<Vec<VersionEntry>, read::Error> {
94        const VERSION_ENTRY_NAME: &str = "version";
95
96        let root_id = Identity::last_git_commit(replica, id)?;
97        let bfs_order = Identity::breadth_first_search(replica.repo(), root_id)?;
98
99        if bfs_order.is_empty() {
100            return Err(read::Error::Empty);
101        }
102
103        let mut version_entries = vec![];
104        let mut is_first_commit = true;
105        for commit in bfs_order.into_iter().rev() {
106            // Verify DAG structure has a single chronological root, so only the root
107            // can have no parents. Said otherwise, the DAG needs to have exactly
108            // one leaf.
109            if !is_first_commit && commit.parent_ids().count() == 0 {
110                return Err(read::Error::MultipleLeafs);
111            }
112
113            // TODO(@bpeetz): Git-bug spends a lot of lines of code on correcting the
114            // sorting between entity operations in the normal Entity::Read, but
115            // here git-bug just trusts it? <2025-04-20>
116
117            {
118                let tree = commit.tree()?;
119                // TODO(@bpeetz): This format also does not fail early on a version mismatch,
120                // but instead relies on the fact that the JSON representation
121                // stayed the same for each version. I'm not a fan. <2025-04-20>
122
123                for entry in tree.iter() {
124                    let entry = entry.map_err(|err| {
125                        // Need to assert, the error type
126                        // (i.e., comp time only)
127                        #[allow(clippy::unit_cmp)]
128                        {
129                            // Check that the error is really useless
130                            // (we do not enable, the fancy error feature).
131                            assert_eq!(err.inner, ());
132                        }
133
134                        read::Error::FailedTreeEntryDecode()
135                    })?;
136                    let mut object = if entry.filename() == VERSION_ENTRY_NAME {
137                        entry.object().map_err(|err| read::Error::MissingObject {
138                            id: id.as_id(),
139                            error: err,
140                        })?
141                    } else {
142                        return Err(read::Error::UnknownEntityName(entry.filename().to_owned()));
143                    };
144
145                    let entry: VersionEntry = {
146                        use crate::replica::entity::operation::operation_data::get;
147
148                        let mut value =
149                            simd_json::to_owned_value(&mut object.data).map_err(|err| {
150                                read::Error::InvalidJsonVersionEntry {
151                                    got: String::from_utf8_lossy(&(object.data.clone()))
152                                        .to_string(),
153                                    error: err,
154                                }
155                            })?;
156                        let object = value.try_as_object_mut()?;
157
158                        VersionEntry {
159                            version: get! {object, "version", try_as_u64, read::Error},
160                            times: get! {
161                            @map[preserve-order] object,
162                            "times", try_as_u64, read::Error},
163                            unix_time: get! {object, "unix_time", try_as_u64, read::Error},
164                            metadata: get! {@option[next] object, "metadata", |some: owned::Value| {
165                                let object = some.try_into_object()?;
166
167                                Ok::<_, read::Error>(
168                                    Some(get! {@mk_map object, try_into_string, read::Error}))
169                            }, read::Error},
170                            name: get! {@option object, "name", try_into_string, read::Error},
171                            email: get! {@option object, "email", try_into_string, read::Error},
172                            login: get! {@option object, "login", try_into_string, read::Error},
173                            avatar_url:
174                                get! {@option object, "avatar_url", try_into_string, read::Error}
175                                    .map(|str| Url::from_str(&str))
176                                    .transpose()?,
177                            nonce: Nonce::try_from(
178                                get! {object, "nonce", try_into_string, read::Error},
179                            )?,
180                        }
181                    };
182
183                    if entry.version != 2 {
184                        return Err(read::Error::VersionMismatch {
185                            got: entry.version,
186                            expected: 2,
187                        });
188                    }
189                    version_entries.push(entry);
190                }
191            }
192
193            is_first_commit = false;
194        }
195        Ok(version_entries)
196    }
197}
198
199#[allow(missing_docs)]
200pub mod read {
201
202    #[derive(Debug, thiserror::Error)]
203    /// The error returned by [`Identity::read_legacy`][`super::Identity::read_legacy`].
204    pub enum Error {
205        #[error(transparent)]
206        ReferenceResolve(#[from] crate::replica::entity::find::Error),
207        #[error(transparent)]
208        BreadthFirstSearch(#[from] crate::replica::entity::bfs::Error),
209
210        #[error("This identity has no recorded legacy versions.")]
211        Empty,
212
213        #[error("Multiple leafs in the identity version DAG")]
214        MultipleLeafs,
215
216        #[error(
217            "Expected to find an tree with the legacy identity commit, but found none. Error: {0}"
218        )]
219        MissingTree(#[from] gix::object::commit::Error),
220
221        #[error("Failed to decode an entry from the legacy identity tree to access.")]
222        FailedTreeEntryDecode(),
223
224        #[error("Found unknown entity {0}, while decoding legacy identity tree.")]
225        UnknownEntityName(gix::bstr::BString),
226
227        #[error(
228            "Failed to find the object blob for the legacy identity version tree entry of {id}, \
229             because: {error}"
230        )]
231        MissingObject {
232            id: crate::replica::entity::id::Id,
233            error: gix::objs::find::existing::Error,
234        },
235
236        #[error("Failed to parse the legacy idenity version entry json ({got}), because: {error}")]
237        InvalidJsonVersionEntry {
238            got: String,
239            error: simd_json::Error,
240        },
241        #[error("Failed to get the expected json field: {field}")]
242        MissingJsonField { field: &'static str },
243        #[error("Failed to parse the field '{field}' as correct type: {err}")]
244        WrongJsonType {
245            err: simd_json::TryTypeError,
246            field: &'static str,
247        },
248        #[error("Expected a json object but got something else: {0}")]
249        ExpectedJsonObject(#[from] simd_json::TryTypeError),
250
251        #[error("Failed to decode the base64 nonce: {0}")]
252        NonceParse(#[from] base64::DecodeSliceError),
253        #[error("Failed to parse the avatar url: {0}")]
254        UrlParse(#[from] url::ParseError),
255
256        #[error(
257            "The legacy identity entry json's version did not match. Got {got}, but expected \
258             {expected}"
259        )]
260        VersionMismatch { got: u64, expected: i32 },
261
262        #[error(
263            "The sequnce of operation composing this legacy identity ({id}), is invalid: {error}"
264        )]
265        InvalidOperationSequence {
266            id: crate::replica::entity::id::Id,
267            error: crate::replica::entity::operation::operations::create::Error<
268                crate::entities::identity::Identity,
269            >,
270        },
271    }
272}