Skip to main content

bazaar/
testament.rs

1//! Testaments: signable summaries of a revision.
2//!
3//! A testament is a deterministic, human-readable byte form of a revision
4//! and its tree, designed so that two semantically equal revisions produce
5//! byte-for-byte equal testaments. They are what bzr signs, rather than the
6//! stored revision XML. Ported from `breezy.bzr.testament`.
7//!
8//! Three formats differ in their headers, whether they include the tree
9//! root, and what per-entry detail they record:
10//!
11//! - [`TestamentFormat::V1`] - the original; no per-entry revision or
12//!   executable bit, root excluded.
13//! - [`TestamentFormat::Strict`] - bundle format 0.8; adds the per-entry
14//!   revision and executable bit.
15//! - [`TestamentFormat::Strict3`] - bundle format 0.9+; like `Strict` but
16//!   includes the tree root (shown with path `.`).
17//!
18//! Unlike the Python class, this does not depend on a `Tree` object: the
19//! caller passes the revision fields and the tree entries directly (built
20//! from an inventory), keeping the module decoupled.
21
22use std::collections::BTreeMap;
23
24/// Which testament format to produce.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TestamentFormat {
27    /// `bazaar-ng testament version 1`.
28    V1,
29    /// `bazaar-ng testament version 2.1` (strict, bundle 0.8).
30    Strict,
31    /// `bazaar testament version 3 strict` (bundle 0.9+, includes root).
32    Strict3,
33}
34
35impl TestamentFormat {
36    fn long_header(self) -> &'static str {
37        match self {
38            TestamentFormat::V1 => "bazaar-ng testament version 1\n",
39            TestamentFormat::Strict => "bazaar-ng testament version 2.1\n",
40            TestamentFormat::Strict3 => "bazaar testament version 3 strict\n",
41        }
42    }
43
44    fn short_header(self) -> &'static str {
45        match self {
46            TestamentFormat::V1 => "bazaar-ng testament short form 1\n",
47            TestamentFormat::Strict => "bazaar-ng testament short form 2.1\n",
48            TestamentFormat::Strict3 => "bazaar testament short form 3 strict\n",
49        }
50    }
51
52    fn include_root(self) -> bool {
53        matches!(self, TestamentFormat::Strict3)
54    }
55
56    fn strict(self) -> bool {
57        matches!(self, TestamentFormat::Strict | TestamentFormat::Strict3)
58    }
59}
60
61/// The kind of a tree entry, as it appears in a testament line.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum EntryKind {
64    File,
65    Directory,
66    Symlink,
67    TreeReference,
68}
69
70impl EntryKind {
71    fn as_str(self) -> &'static str {
72        match self {
73            EntryKind::File => "file",
74            EntryKind::Directory => "directory",
75            EntryKind::Symlink => "symlink",
76            EntryKind::TreeReference => "tree-reference",
77        }
78    }
79}
80
81/// One tree entry contributing to a testament.
82///
83/// `path` is the tree-relative path (the root, when included, uses `.`).
84/// `content` is the file's text sha1 (hex) for files, or the symlink
85/// target for symlinks; it is ignored for other kinds. `revision` and
86/// `executable` are only emitted by the strict formats.
87#[derive(Debug, Clone)]
88pub struct TestamentEntry {
89    pub path: String,
90    pub kind: EntryKind,
91    pub file_id: Vec<u8>,
92    /// File text sha1 (hex) or symlink target, depending on `kind`.
93    pub content: Vec<u8>,
94    pub revision: Vec<u8>,
95    pub executable: bool,
96}
97
98/// Errors from building a testament.
99#[derive(Debug, PartialEq, Eq)]
100pub enum TestamentError {
101    /// A field that must not contain whitespace did (revision id, file id,
102    /// parent id, property name).
103    WhitespaceNotAllowed(Vec<u8>),
104    /// A field that must not contain line breaks did (committer, path).
105    LinebreakNotAllowed(String),
106    /// A file entry had no text sha1.
107    MissingFileSha1(Vec<u8>),
108    /// A symlink entry had no target.
109    MissingSymlinkTarget(Vec<u8>),
110}
111
112impl std::fmt::Display for TestamentError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            TestamentError::WhitespaceNotAllowed(v) => {
116                write!(
117                    f,
118                    "whitespace not allowed in {:?}",
119                    String::from_utf8_lossy(v)
120                )
121            }
122            TestamentError::LinebreakNotAllowed(s) => {
123                write!(f, "line break not allowed in {s:?}")
124            }
125            TestamentError::MissingFileSha1(v) => {
126                write!(f, "file {:?} has no text sha1", String::from_utf8_lossy(v))
127            }
128            TestamentError::MissingSymlinkTarget(v) => {
129                write!(f, "symlink {:?} has no target", String::from_utf8_lossy(v))
130            }
131        }
132    }
133}
134
135impl std::error::Error for TestamentError {}
136
137/// A testament: the revision fields plus the tree entries that summarise it.
138///
139/// Entries must be supplied in the order the testament should list them
140/// (the order `Tree.list_files` would yield). For [`TestamentFormat::Strict3`]
141/// the caller includes a root entry (path `.`); for the other formats the
142/// root must be omitted.
143pub struct Testament {
144    pub revision_id: Vec<u8>,
145    pub committer: String,
146    pub timestamp: i64,
147    pub timezone: i32,
148    pub message: String,
149    pub parent_ids: Vec<Vec<u8>>,
150    pub revprops: BTreeMap<String, String>,
151    pub entries: Vec<TestamentEntry>,
152}
153
154impl Testament {
155    /// The testament as a sequence of UTF-8 lines (each ending in `\n`).
156    pub fn as_text_lines(&self, format: TestamentFormat) -> Result<Vec<Vec<u8>>, TestamentError> {
157        if contains_whitespace_bytes(&self.revision_id) {
158            return Err(TestamentError::WhitespaceNotAllowed(
159                self.revision_id.clone(),
160            ));
161        }
162        if crate::osutils::contains_linebreaks(&self.committer) {
163            return Err(TestamentError::LinebreakNotAllowed(self.committer.clone()));
164        }
165
166        let mut r: Vec<String> = Vec::new();
167        r.push(format.long_header().to_string());
168        r.push(format!(
169            "revision-id: {}\n",
170            String::from_utf8_lossy(&self.revision_id)
171        ));
172        r.push(format!("committer: {}\n", self.committer));
173        r.push(format!("timestamp: {}\n", self.timestamp));
174        r.push(format!("timezone: {}\n", self.timezone));
175
176        r.push("parents:\n".to_string());
177        let mut parents = self.parent_ids.clone();
178        parents.sort();
179        for parent in &parents {
180            if contains_whitespace_bytes(parent) {
181                return Err(TestamentError::WhitespaceNotAllowed(parent.clone()));
182            }
183            r.push(format!("  {}\n", String::from_utf8_lossy(parent)));
184        }
185
186        r.push("message:\n".to_string());
187        for line in splitlines(&self.message) {
188            r.push(format!("  {line}\n"));
189        }
190
191        r.push("inventory:\n".to_string());
192        for entry in &self.entries {
193            r.push(self.entry_to_line(format, entry)?);
194        }
195
196        r.extend(self.revprops_to_lines()?);
197
198        Ok(r.into_iter().map(String::into_bytes).collect())
199    }
200
201    fn entry_to_line(
202        &self,
203        format: TestamentFormat,
204        entry: &TestamentEntry,
205    ) -> Result<String, TestamentError> {
206        if contains_whitespace_bytes(&entry.file_id) {
207            return Err(TestamentError::WhitespaceNotAllowed(entry.file_id.clone()));
208        }
209        let (content, spacer) = match entry.kind {
210            EntryKind::File => {
211                if entry.content.is_empty() {
212                    return Err(TestamentError::MissingFileSha1(entry.file_id.clone()));
213                }
214                (String::from_utf8_lossy(&entry.content).into_owned(), " ")
215            }
216            EntryKind::Symlink => {
217                if entry.content.is_empty() {
218                    return Err(TestamentError::MissingSymlinkTarget(entry.file_id.clone()));
219                }
220                (
221                    escape_path(&String::from_utf8_lossy(&entry.content), format)?,
222                    " ",
223                )
224            }
225            _ => (String::new(), ""),
226        };
227
228        let mut line = format!(
229            "  {} {} {}{}{}",
230            entry.kind.as_str(),
231            escape_path(&entry.path, format)?,
232            String::from_utf8_lossy(&entry.file_id),
233            spacer,
234            content,
235        );
236        if format.strict() {
237            line.push(' ');
238            line.push_str(&String::from_utf8_lossy(&entry.revision));
239            line.push_str(if entry.executable { " yes" } else { " no" });
240        }
241        line.push('\n');
242        Ok(line)
243    }
244
245    fn revprops_to_lines(&self) -> Result<Vec<String>, TestamentError> {
246        if self.revprops.is_empty() {
247            return Ok(Vec::new());
248        }
249        let mut r = vec!["properties:\n".to_string()];
250        // BTreeMap iterates in sorted key order, matching Python's sorted().
251        for (name, value) in &self.revprops {
252            if crate::osutils::contains_whitespace(name) {
253                return Err(TestamentError::WhitespaceNotAllowed(
254                    name.clone().into_bytes(),
255                ));
256            }
257            r.push(format!("  {name}:\n"));
258            for line in splitlines(value) {
259                r.push(format!("    {line}\n"));
260            }
261        }
262        Ok(r)
263    }
264
265    /// The full testament as a single UTF-8 byte string.
266    pub fn as_text(&self, format: TestamentFormat) -> Result<Vec<u8>, TestamentError> {
267        Ok(self.as_text_lines(format)?.concat())
268    }
269
270    /// The hex sha1 of the full testament text.
271    pub fn as_sha1(&self, format: TestamentFormat) -> Result<Vec<u8>, TestamentError> {
272        Ok(crate::weave::sha_strings(&self.as_text_lines(format)?))
273    }
274
275    /// The short, digest-based testament.
276    pub fn as_short_text(&self, format: TestamentFormat) -> Result<Vec<u8>, TestamentError> {
277        let sha1 = self.as_sha1(format)?;
278        let mut out = format.short_header().as_bytes().to_vec();
279        out.extend_from_slice(b"revision-id: ");
280        out.extend_from_slice(&self.revision_id);
281        out.push(b'\n');
282        out.extend_from_slice(b"sha1: ");
283        out.extend_from_slice(&sha1);
284        out.push(b'\n');
285        Ok(out)
286    }
287}
288
289/// Escape a path for a testament line: `\` becomes `/`, spaces are
290/// backslash-escaped, and (for strict3) an empty path becomes `.`.
291fn escape_path(path: &str, format: TestamentFormat) -> Result<String, TestamentError> {
292    if crate::osutils::contains_linebreaks(path) {
293        return Err(TestamentError::LinebreakNotAllowed(path.to_string()));
294    }
295    let path = if format.include_root() && path.is_empty() {
296        "."
297    } else {
298        path
299    };
300    Ok(path.replace('\\', "/").replace(' ', "\\ "))
301}
302
303/// Whether a byte string contains any whitespace character. Mirrors
304/// `osutils.contains_whitespace` applied to bytes (revision/file/parent
305/// ids are ascii in practice).
306fn contains_whitespace_bytes(s: &[u8]) -> bool {
307    s.iter()
308        .any(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c))
309}
310
311/// Split a string into lines, matching Python's `str.splitlines()` for
312/// the `\n`-delimited forms used here: an empty string yields no lines,
313/// and a trailing newline does not produce a trailing empty line.
314fn splitlines(s: &str) -> Vec<&str> {
315    if s.is_empty() {
316        return Vec::new();
317    }
318    let trimmed = s.strip_suffix('\n').unwrap_or(s);
319    trimmed.split('\n').collect()
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn rev1() -> Testament {
327        let mut revprops = BTreeMap::new();
328        revprops.insert("branch-nick".to_string(), "test branch".to_string());
329        Testament {
330            revision_id: b"test@user-1".to_vec(),
331            committer: "test@user".to_string(),
332            timestamp: 1129025423,
333            timezone: 0,
334            message: "initial null commit".to_string(),
335            parent_ids: Vec::new(),
336            revprops,
337            entries: Vec::new(),
338        }
339    }
340
341    fn rev2() -> Testament {
342        let mut revprops = BTreeMap::new();
343        revprops.insert("branch-nick".to_string(), "test branch".to_string());
344        Testament {
345            revision_id: b"test@user-2".to_vec(),
346            committer: "test@user".to_string(),
347            timestamp: 1129025483,
348            timezone: 36000,
349            message: "add files and directories".to_string(),
350            parent_ids: vec![b"test@user-1".to_vec()],
351            revprops,
352            entries: vec![
353                TestamentEntry {
354                    path: "hello".to_string(),
355                    kind: EntryKind::File,
356                    file_id: b"hello-id".to_vec(),
357                    content: b"34dd0ac19a24bf80c4d33b5c8960196e8d8d1f73".to_vec(),
358                    revision: b"test@user-2".to_vec(),
359                    executable: true,
360                },
361                TestamentEntry {
362                    path: "src".to_string(),
363                    kind: EntryKind::Directory,
364                    file_id: b"src-id".to_vec(),
365                    content: Vec::new(),
366                    revision: b"test@user-2".to_vec(),
367                    executable: false,
368                },
369                TestamentEntry {
370                    path: "src/foo.c".to_string(),
371                    kind: EntryKind::File,
372                    file_id: b"foo.c-id".to_vec(),
373                    content: b"a2a049c20f908ae31b231d98779eb63c66448f24".to_vec(),
374                    revision: b"test@user-2".to_vec(),
375                    executable: false,
376                },
377            ],
378        }
379    }
380
381    // Built with concat! so leading spaces are preserved (a `b"...\`
382    // line continuation would strip them).
383    const REV_1_V1: &[u8] = concat!(
384        "bazaar-ng testament version 1\n",
385        "revision-id: test@user-1\n",
386        "committer: test@user\n",
387        "timestamp: 1129025423\n",
388        "timezone: 0\n",
389        "parents:\n",
390        "message:\n",
391        "  initial null commit\n",
392        "inventory:\n",
393        "properties:\n",
394        "  branch-nick:\n",
395        "    test branch\n",
396    )
397    .as_bytes();
398
399    const REV_2_V1: &[u8] = concat!(
400        "bazaar-ng testament version 1\n",
401        "revision-id: test@user-2\n",
402        "committer: test@user\n",
403        "timestamp: 1129025483\n",
404        "timezone: 36000\n",
405        "parents:\n",
406        "  test@user-1\n",
407        "message:\n",
408        "  add files and directories\n",
409        "inventory:\n",
410        "  file hello hello-id 34dd0ac19a24bf80c4d33b5c8960196e8d8d1f73\n",
411        "  directory src src-id\n",
412        "  file src/foo.c foo.c-id a2a049c20f908ae31b231d98779eb63c66448f24\n",
413        "properties:\n",
414        "  branch-nick:\n",
415        "    test branch\n",
416    )
417    .as_bytes();
418
419    const REV_2_STRICT: &[u8] = concat!(
420        "bazaar-ng testament version 2.1\n",
421        "revision-id: test@user-2\n",
422        "committer: test@user\n",
423        "timestamp: 1129025483\n",
424        "timezone: 36000\n",
425        "parents:\n",
426        "  test@user-1\n",
427        "message:\n",
428        "  add files and directories\n",
429        "inventory:\n",
430        "  file hello hello-id 34dd0ac19a24bf80c4d33b5c8960196e8d8d1f73 test@user-2 yes\n",
431        "  directory src src-id test@user-2 no\n",
432        "  file src/foo.c foo.c-id a2a049c20f908ae31b231d98779eb63c66448f24 test@user-2 no\n",
433        "properties:\n",
434        "  branch-nick:\n",
435        "    test branch\n",
436    )
437    .as_bytes();
438
439    #[test]
440    fn rev1_v1_matches_breezy() {
441        assert_eq!(rev1().as_text(TestamentFormat::V1).unwrap(), REV_1_V1);
442    }
443
444    #[test]
445    fn rev2_v1_matches_breezy() {
446        assert_eq!(rev2().as_text(TestamentFormat::V1).unwrap(), REV_2_V1);
447    }
448
449    #[test]
450    fn rev2_strict_matches_breezy() {
451        assert_eq!(
452            rev2().as_text(TestamentFormat::Strict).unwrap(),
453            REV_2_STRICT
454        );
455    }
456
457    #[test]
458    fn strict3_includes_root() {
459        // Strict3 prepends the root entry (path ".").
460        let mut t = rev2();
461        t.entries.insert(
462            0,
463            TestamentEntry {
464                path: String::new(),
465                kind: EntryKind::Directory,
466                file_id: b"TREE_ROT".to_vec(),
467                content: Vec::new(),
468                revision: b"test@user-1".to_vec(),
469                executable: false,
470            },
471        );
472        let text = String::from_utf8(t.as_text(TestamentFormat::Strict3).unwrap()).unwrap();
473        assert!(text.starts_with("bazaar testament version 3 strict\n"));
474        assert!(text.contains("  directory . TREE_ROT test@user-1 no\n"));
475    }
476
477    #[test]
478    fn short_form_is_header_plus_sha() {
479        let t = rev1();
480        let sha = t.as_sha1(TestamentFormat::V1).unwrap();
481        let mut expected =
482            b"bazaar-ng testament short form 1\nrevision-id: test@user-1\nsha1: ".to_vec();
483        expected.extend_from_slice(&sha);
484        expected.push(b'\n');
485        assert_eq!(t.as_short_text(TestamentFormat::V1).unwrap(), expected);
486    }
487
488    #[test]
489    fn whitespace_in_revision_id_rejected() {
490        let mut t = rev1();
491        t.revision_id = b"bad id".to_vec();
492        assert!(matches!(
493            t.as_text_lines(TestamentFormat::V1),
494            Err(TestamentError::WhitespaceNotAllowed(_))
495        ));
496    }
497}