Skip to main content

bazaar/repository/
mod.rs

1//! Repository access: format metadata/registry plus the pack readers.
2//!
3//! The two reader families ([`Pack2aRepository`] groupcompress/CHK and
4//! [`KnitPackRepository`] knit/XML) implement the [`Repository`] trait,
5//! which exposes the common read and write operations. `get_inventory`
6//! returns a `Box<dyn Inventory>`, so each repository keeps its own natural
7//! inventory representation — 2a a lazy CHK inventory, knit-pack an
8//! in-memory one — behind the box, without converting one into the other.
9
10mod check;
11mod commit;
12mod fetch;
13pub mod format;
14#[cfg(feature = "knit")]
15mod knit_repo;
16mod pack_2a;
17mod pack_2a_writer;
18mod pack_collection;
19#[cfg(feature = "knitpack")]
20mod pack_index;
21#[cfg(feature = "knitpack")]
22mod pack_knit;
23mod tree;
24#[cfg(feature = "weave")]
25mod weave_repo;
26
27pub use check::{check, CheckResult};
28pub use commit::CommitBuilder;
29pub use fetch::fetch;
30
31/// The outcome of [`Repository::reconcile`]: what the reconcile dropped.
32#[derive(Debug, Default, Clone, PartialEq, Eq)]
33pub struct ReconcileResult {
34    /// Number of stored inventories that were unreachable and discarded.
35    pub garbage_inventories: usize,
36    /// Whether the repository's storage was regenerated (a new pack written).
37    pub repacked: bool,
38}
39pub use format::{all_formats, find_format, RepositoryFormat};
40#[cfg(feature = "knit")]
41pub use knit_repo::KnitRepository;
42pub use pack_2a::{Pack2aRepository, RepositoryError, SharedTransport};
43#[cfg(feature = "knitpack")]
44pub use pack_knit::KnitPackRepository;
45pub use tree::RevisionTree;
46#[cfg(feature = "weave")]
47pub use weave_repo::WeaveRepository;
48
49use crate::inventory::Inventory;
50
51/// The common read interface to a bzr repository.
52///
53/// Object-safe: `get_inventory` returns `Box<dyn Inventory>`, so a repository
54/// can be held as `Box<dyn Repository>` while each format keeps its own
55/// inventory representation (a lazy CHK inventory for 2a, an in-memory one
56/// for knit-pack) behind the box — no conversion between them.
57pub trait Repository: Send + Sync {
58    /// The format this repository was opened as.
59    fn format(&self) -> &'static RepositoryFormat;
60
61    /// Downcast support, so a backend's format-specific fast path can recover a
62    /// same-format `source` (e.g. another `Pack2aRepository`). Each backend
63    /// returns `self`; a downcast to a different concrete type fails and the
64    /// caller uses the generic path. Used by [`try_fetch_from`](Repository::try_fetch_from)
65    /// implementations, not by the generic fetcher.
66    fn as_any(&self) -> &dyn std::any::Any;
67
68    /// Try to copy `revision_ids` (topologically ordered, already filtered to
69    /// revisions absent here) from `source` into this repository using a
70    /// format-specific fast path, opening and committing the write group
71    /// itself.
72    ///
73    /// Returns `Ok(true)` if the fast path applied and the revisions were
74    /// copied, `Ok(false)` if no fast path is available for this
75    /// source/target pair (the caller then uses the generic rebuild). The
76    /// default has no fast path. This is where per-format streaming lives, so
77    /// [`crate::repository::fetch`] needs no knowledge of concrete formats.
78    fn try_fetch_from(
79        &mut self,
80        _source: &dyn Repository,
81        _revision_ids: &[Vec<u8>],
82    ) -> Result<bool, RepositoryError> {
83        Ok(false)
84    }
85
86    /// All revision ids in this repository, sorted.
87    fn all_revision_ids(&self) -> Result<Vec<Vec<u8>>, RepositoryError>;
88
89    /// The stored parent ids of each of `revision_ids`, as a map. Revision ids
90    /// not present in the repository are omitted from the result. The parents
91    /// come straight from the revision store's index (no body deserialisation),
92    /// which is the raw graph data callers build a revision graph from.
93    fn get_parent_map(
94        &self,
95        revision_ids: &[Vec<u8>],
96    ) -> Result<std::collections::HashMap<Vec<u8>, Vec<Vec<u8>>>, RepositoryError>;
97
98    /// Whether `revision_id` is present in this repository. Defaults to a
99    /// single-key [`get_parent_map`](Repository::get_parent_map) lookup;
100    /// backends may override with a cheaper index probe.
101    fn has_revision(&self, revision_id: &[u8]) -> Result<bool, RepositoryError> {
102        Ok(self
103            .get_parent_map(std::slice::from_ref(&revision_id.to_vec()))?
104            .contains_key(revision_id))
105    }
106
107    /// Read and parse a revision by id.
108    fn get_revision(
109        &self,
110        revision_id: &[u8],
111    ) -> Result<crate::revision::Revision, RepositoryError>;
112
113    /// Read the inventory for a revision.
114    fn get_inventory(&self, revision_id: &[u8]) -> Result<Box<dyn Inventory>, RepositoryError>;
115
116    /// A read-only view of the tree at `revision_id`: its inventory paired
117    /// with the revision id. This is the basis a commit builds its
118    /// inventory delta against.
119    fn revision_tree(&self, revision_id: &[u8]) -> Result<RevisionTree, RepositoryError> {
120        if revision_id == crate::branch::NULL_REVISION {
121            // The null revision is the empty tree (the basis of a first
122            // commit); there is no stored inventory for it.
123            let empty = crate::inventory::MutableInventory::new();
124            return Ok(RevisionTree::new(revision_id.to_vec(), Box::new(empty)));
125        }
126        let inventory = self.get_inventory(revision_id)?;
127        Ok(RevisionTree::new(revision_id.to_vec(), inventory))
128    }
129
130    /// Read the full text of a versioned file at a given revision.
131    fn get_file_text(&self, file_id: &[u8], revision: &[u8]) -> Result<Vec<u8>, RepositoryError>;
132
133    /// Read the full text of the file at tree-relative `path` in `revision`,
134    /// resolving the path to a file id through that revision's inventory.
135    /// Errors with [`RepositoryError::NoSuchRevision`] if `path` is not in the
136    /// tree (reusing the closest variant for "not found").
137    fn get_file_text_at_path(
138        &self,
139        path: &str,
140        revision: &[u8],
141    ) -> Result<Vec<u8>, RepositoryError> {
142        let tree = self.revision_tree(revision)?;
143        let file_id = tree
144            .path2id(path)
145            .ok_or_else(|| RepositoryError::NoSuchRevision(path.as_bytes().to_vec()))?;
146        self.get_file_text(file_id.as_bytes(), revision)
147    }
148
149    /// Open a write group: a batch of additions flushed atomically by
150    /// [`Repository::commit_write_group`].
151    fn start_write_group(&mut self) -> Result<(), RepositoryError>;
152
153    /// Add a revision to the open write group, serialising it with the
154    /// format's own revision serializer (bencode for 2a, XML for knit-pack).
155    fn add_revision(
156        &mut self,
157        revision: &crate::revision::Revision,
158        parents: &[Vec<u8>],
159    ) -> Result<(), RepositoryError>;
160
161    /// Build the inventory for a revision from `entries` and add it to the
162    /// open write group, returning the inventory sha1 to record on the
163    /// revision. Each format stores the inventory in its own representation
164    /// (a CHK inventory for 2a, serialised XML for knit-pack).
165    fn add_inventory_from_entries(
166        &mut self,
167        revision_id: &[u8],
168        parents: &[Vec<u8>],
169        root_id: &[u8],
170        entries: &[crate::inventory::Entry],
171    ) -> Result<Vec<u8>, RepositoryError>;
172
173    /// Build the inventory for `new_revision_id` by applying `delta` to the
174    /// already-committed `basis_revision_id` inventory, adding it to the open
175    /// write group and returning its sha1. Formats that can share storage
176    /// (2a's CHK inventory) write only the changed pages; others fall back to
177    /// re-serialising the whole inventory.
178    fn add_inventory_by_delta(
179        &mut self,
180        basis_revision_id: &[u8],
181        delta: &crate::inventory_delta::InventoryDelta,
182        new_revision_id: &[u8],
183        parents: &[Vec<u8>],
184    ) -> Result<Vec<u8>, RepositoryError>;
185
186    /// Add a file text (keyed by `(file_id, revision)`) to the open write
187    /// group.
188    fn add_text(
189        &mut self,
190        file_id: &[u8],
191        revision: &[u8],
192        parents: &[(Vec<u8>, Vec<u8>)],
193        bytes: &[u8],
194    ) -> Result<(), RepositoryError>;
195
196    /// Add a signature text for `revision_id` to the open write group.
197    fn add_signature_text(
198        &mut self,
199        revision_id: &[u8],
200        signature: &[u8],
201    ) -> Result<(), RepositoryError>;
202
203    /// The signature text stored for `revision_id`, or `None` if unsigned.
204    fn get_signature_text(&self, revision_id: &[u8]) -> Result<Option<Vec<u8>>, RepositoryError>;
205
206    /// Flush the open write group, committing its additions.
207    fn commit_write_group(&mut self) -> Result<(), RepositoryError>;
208
209    /// Combine the repository's packs into a single pack.
210    ///
211    /// The default is a no-op (formats without packs have nothing to combine);
212    /// the pack backends override it.
213    fn pack(&mut self) -> Result<(), RepositoryError> {
214        Ok(())
215    }
216
217    /// Repack the smallest packs if the repository has accumulated too many,
218    /// per the pack-distribution heuristic. Returns whether a repack happened.
219    ///
220    /// The default is a no-op returning `false`; the pack backends override it.
221    fn autopack(&mut self) -> Result<bool, RepositoryError> {
222        Ok(false)
223    }
224
225    /// Check the integrity of this repository, returning a report of any
226    /// inconsistencies (see [`CheckResult`]). Format-neutral: it cross-checks
227    /// the data every format exposes through this trait.
228    fn check(&self) -> Result<CheckResult, RepositoryError> {
229        check::check(self)
230    }
231
232    /// Reconcile this repository: regenerate its storage keeping only the data
233    /// reachable from its revisions, discarding garbage (e.g. inventories or
234    /// texts left behind by an interrupted operation), and report what was
235    /// dropped (see [`ReconcileResult`]).
236    ///
237    /// The default does nothing (formats without packs have no garbage to
238    /// collect); the pack backends override it.
239    fn reconcile(&mut self) -> Result<ReconcileResult, RepositoryError> {
240        Ok(ReconcileResult::default())
241    }
242
243    /// Add a fallback repository consulted for objects this one lacks.
244    ///
245    /// This is how a stacked branch wires its base repository in: reads that
246    /// miss in this repository are retried against the fallback chain, in
247    /// order. The default returns [`RepositoryError::UnsupportedFormat`]; only
248    /// [`StackedRepository`] (which a stacked-branch open wraps the primary in)
249    /// supports it.
250    fn add_fallback_repository(
251        &mut self,
252        _fallback: Box<dyn Repository>,
253    ) -> Result<(), RepositoryError> {
254        Err(RepositoryError::UnsupportedFormat(
255            "repository does not support fallbacks",
256        ))
257    }
258
259    /// Verify the stored GPG signature of `revision_id` against `certs`.
260    ///
261    /// Mirrors breezy's `verify_revision_signature`: an unsigned revision is
262    /// [`VerificationResult::NotSigned`](crate::gpg::VerificationResult::NotSigned);
263    /// otherwise the stored clearsigned text is verified and its plaintext is
264    /// compared byte-for-byte against the revision's V1 testament short text.
265    /// A plaintext that does not match the testament forces
266    /// [`VerificationResult::NotValid`](crate::gpg::VerificationResult::NotValid),
267    /// even for a cryptographically good signature.
268    #[cfg(feature = "gpg")]
269    fn verify_revision_signature(
270        &self,
271        revision_id: &[u8],
272        certs: &[sequoia_openpgp::Cert],
273    ) -> Result<crate::gpg::VerificationResult, RepositoryError> {
274        use crate::gpg::VerificationResult;
275        let Some(signature) = self.get_signature_text(revision_id)? else {
276            return Ok(VerificationResult::NotSigned);
277        };
278        let expected = testament_short_text_for_revision(self, revision_id)?;
279        let verification = crate::gpg::verify_clearsigned(&signature, certs);
280        if verification.plaintext.as_deref() != Some(expected.as_slice()) {
281            return Ok(VerificationResult::NotValid);
282        }
283        Ok(verification.result)
284    }
285
286    /// Like [`verify_revision_signature`](Repository::verify_revision_signature)
287    /// but taking a keyring as raw public-key blobs (ASCII-armored or binary),
288    /// so callers that do not depend on the OpenPGP crate (e.g. the Python
289    /// bindings) can pass keys through as bytes.
290    #[cfg(feature = "gpg")]
291    fn verify_revision_signature_bytes(
292        &self,
293        revision_id: &[u8],
294        keyring: &[Vec<u8>],
295    ) -> Result<crate::gpg::VerificationResult, RepositoryError> {
296        let certs = crate::gpg::parse_keyring(keyring)
297            .map_err(|e| RepositoryError::Corrupt(format!("keyring: {e}")))?;
298        self.verify_revision_signature(revision_id, &certs)
299    }
300}
301
302/// Build the V1 testament short text for a stored revision, the plaintext a
303/// valid signature must reproduce.
304///
305/// Assembles the V1 testament from the revision and its tree (the inventory
306/// entries, root excluded, in `iter_entries` order) and returns
307/// `as_short_text(V1)`.
308#[cfg(feature = "gpg")]
309fn testament_short_text_for_revision(
310    repo: &(impl Repository + ?Sized),
311    revision_id: &[u8],
312) -> Result<Vec<u8>, RepositoryError> {
313    use crate::testament::{EntryKind as TKind, Testament, TestamentEntry, TestamentFormat};
314
315    let revision = repo.get_revision(revision_id)?;
316    let tree = repo.revision_tree(revision_id)?;
317
318    let mut entries = Vec::new();
319    for (path, entry) in tree.iter_entries() {
320        // The V1 testament omits the root entry.
321        if path.is_empty() || path == "." {
322            continue;
323        }
324        let (kind, content) = match entry.kind() {
325            crate::osutils::Kind::File => (
326                TKind::File,
327                entry.text_sha1().map(|s| s.to_vec()).unwrap_or_default(),
328            ),
329            crate::osutils::Kind::Directory => (TKind::Directory, Vec::new()),
330            crate::osutils::Kind::Symlink => (
331                TKind::Symlink,
332                entry
333                    .symlink_target()
334                    .map(|t| t.as_bytes().to_vec())
335                    .unwrap_or_default(),
336            ),
337            crate::osutils::Kind::TreeReference => (TKind::TreeReference, Vec::new()),
338        };
339        entries.push(TestamentEntry {
340            path,
341            kind,
342            file_id: entry.file_id().as_bytes().to_vec(),
343            content,
344            revision: entry
345                .revision()
346                .map(|r| r.as_bytes().to_vec())
347                .unwrap_or_default(),
348            executable: entry.executable(),
349        });
350    }
351
352    let testament = Testament {
353        revision_id: revision_id.to_vec(),
354        committer: revision.committer.clone().unwrap_or_default(),
355        timestamp: revision.timestamp as i64,
356        timezone: revision.timezone.unwrap_or(0),
357        message: revision.message.clone(),
358        parent_ids: revision
359            .parent_ids
360            .iter()
361            .map(|p| p.as_bytes().to_vec())
362            .collect(),
363        revprops: revision
364            .properties
365            .iter()
366            .map(|(k, v)| (k.clone(), String::from_utf8_lossy(v).into_owned()))
367            .collect(),
368        entries,
369    };
370    testament
371        .as_short_text(TestamentFormat::V1)
372        .map_err(|e| RepositoryError::Corrupt(format!("testament: {e}")))
373}
374
375/// A repository that consults a chain of fallback repositories for objects its
376/// primary store lacks.
377///
378/// This is the data-path half of branch stacking: the primary is the stacked
379/// branch's own (thin) repository, and the fallbacks are the repositories of
380/// the branches it is stacked on. Reads try the primary first, then each
381/// fallback in order; writes go only to the primary. Mirrors breezy, where a
382/// `Repository` keeps a list of `_fallback_repositories` and
383/// `add_fallback_repository` appends to it.
384pub struct StackedRepository {
385    primary: Box<dyn Repository>,
386    fallbacks: Vec<Box<dyn Repository>>,
387}
388
389/// Whether `e` signals that an object is simply absent (so a fallback should be
390/// consulted) rather than a hard error.
391fn is_not_present(e: &RepositoryError) -> bool {
392    matches!(
393        e,
394        RepositoryError::NoSuchRevision(_) | RepositoryError::NoSuchFileText { .. }
395    )
396}
397
398impl StackedRepository {
399    /// Wrap `primary`, with no fallbacks yet.
400    pub fn new(primary: Box<dyn Repository>) -> Self {
401        StackedRepository {
402            primary,
403            fallbacks: Vec::new(),
404        }
405    }
406
407    /// Try `f` on the primary, then each fallback in order, returning the first
408    /// success. A "not present" error ([`RepositoryError::NoSuchRevision`] or
409    /// [`RepositoryError::NoSuchFileText`]) is treated as "not here, try the
410    /// next"; any other error propagates immediately. If every repository
411    /// misses, the primary's not-present error is returned.
412    fn first_present<T>(
413        &self,
414        mut f: impl FnMut(&dyn Repository) -> Result<T, RepositoryError>,
415    ) -> Result<T, RepositoryError> {
416        match f(self.primary.as_ref()) {
417            Err(e) if is_not_present(&e) => {
418                for fallback in &self.fallbacks {
419                    match f(fallback.as_ref()) {
420                        Err(e) if is_not_present(&e) => continue,
421                        other => return other,
422                    }
423                }
424                Err(e)
425            }
426            other => other,
427        }
428    }
429}
430
431impl Repository for StackedRepository {
432    fn format(&self) -> &'static RepositoryFormat {
433        self.primary.format()
434    }
435
436    fn as_any(&self) -> &dyn std::any::Any {
437        self
438    }
439
440    fn all_revision_ids(&self) -> Result<Vec<Vec<u8>>, RepositoryError> {
441        // breezy's all_revision_ids is the repository's own revisions only; a
442        // stacked repository does not enumerate its fallbacks' revisions.
443        self.primary.all_revision_ids()
444    }
445
446    fn get_parent_map(
447        &self,
448        revision_ids: &[Vec<u8>],
449    ) -> Result<std::collections::HashMap<Vec<u8>, Vec<Vec<u8>>>, RepositoryError> {
450        let mut map = self.primary.get_parent_map(revision_ids)?;
451        // Fill in any ids the primary did not know from the fallbacks.
452        let mut missing: Vec<Vec<u8>> = revision_ids
453            .iter()
454            .filter(|id| !map.contains_key(*id))
455            .cloned()
456            .collect();
457        for fallback in &self.fallbacks {
458            if missing.is_empty() {
459                break;
460            }
461            let found = fallback.get_parent_map(&missing)?;
462            missing.retain(|id| !found.contains_key(id));
463            map.extend(found);
464        }
465        Ok(map)
466    }
467
468    fn has_revision(&self, revision_id: &[u8]) -> Result<bool, RepositoryError> {
469        if self.primary.has_revision(revision_id)? {
470            return Ok(true);
471        }
472        for fallback in &self.fallbacks {
473            if fallback.has_revision(revision_id)? {
474                return Ok(true);
475            }
476        }
477        Ok(false)
478    }
479
480    fn get_revision(
481        &self,
482        revision_id: &[u8],
483    ) -> Result<crate::revision::Revision, RepositoryError> {
484        self.first_present(|r| r.get_revision(revision_id))
485    }
486
487    fn get_inventory(&self, revision_id: &[u8]) -> Result<Box<dyn Inventory>, RepositoryError> {
488        self.first_present(|r| r.get_inventory(revision_id))
489    }
490
491    fn get_file_text(&self, file_id: &[u8], revision: &[u8]) -> Result<Vec<u8>, RepositoryError> {
492        self.first_present(|r| r.get_file_text(file_id, revision))
493    }
494
495    fn start_write_group(&mut self) -> Result<(), RepositoryError> {
496        self.primary.start_write_group()
497    }
498
499    fn add_revision(
500        &mut self,
501        revision: &crate::revision::Revision,
502        parents: &[Vec<u8>],
503    ) -> Result<(), RepositoryError> {
504        self.primary.add_revision(revision, parents)
505    }
506
507    fn add_inventory_from_entries(
508        &mut self,
509        revision_id: &[u8],
510        parents: &[Vec<u8>],
511        root_id: &[u8],
512        entries: &[crate::inventory::Entry],
513    ) -> Result<Vec<u8>, RepositoryError> {
514        self.primary
515            .add_inventory_from_entries(revision_id, parents, root_id, entries)
516    }
517
518    fn add_inventory_by_delta(
519        &mut self,
520        basis_revision_id: &[u8],
521        delta: &crate::inventory_delta::InventoryDelta,
522        new_revision_id: &[u8],
523        parents: &[Vec<u8>],
524    ) -> Result<Vec<u8>, RepositoryError> {
525        self.primary
526            .add_inventory_by_delta(basis_revision_id, delta, new_revision_id, parents)
527    }
528
529    fn add_text(
530        &mut self,
531        file_id: &[u8],
532        revision: &[u8],
533        parents: &[(Vec<u8>, Vec<u8>)],
534        bytes: &[u8],
535    ) -> Result<(), RepositoryError> {
536        self.primary.add_text(file_id, revision, parents, bytes)
537    }
538
539    fn add_signature_text(
540        &mut self,
541        revision_id: &[u8],
542        signature: &[u8],
543    ) -> Result<(), RepositoryError> {
544        self.primary.add_signature_text(revision_id, signature)
545    }
546
547    fn get_signature_text(&self, revision_id: &[u8]) -> Result<Option<Vec<u8>>, RepositoryError> {
548        match self.primary.get_signature_text(revision_id)? {
549            Some(sig) => Ok(Some(sig)),
550            None => {
551                for fallback in &self.fallbacks {
552                    if let Some(sig) = fallback.get_signature_text(revision_id)? {
553                        return Ok(Some(sig));
554                    }
555                }
556                Ok(None)
557            }
558        }
559    }
560
561    fn commit_write_group(&mut self) -> Result<(), RepositoryError> {
562        self.primary.commit_write_group()
563    }
564
565    fn add_fallback_repository(
566        &mut self,
567        fallback: Box<dyn Repository>,
568    ) -> Result<(), RepositoryError> {
569        self.fallbacks.push(fallback);
570        Ok(())
571    }
572}
573
574/// Convert a knit-keyed parent map (`KnitKey -> [KnitKey]`, where each key's
575/// first element is the revision id) to the revision-id-keyed map the
576/// [`Repository::get_parent_map`] interface returns. Shared by the knit-pack
577/// and non-pack knit backends, which both key by `KnitKey`.
578pub(crate) fn unkey_knit_parent_map(
579    raw: std::collections::HashMap<crate::knit::KnitKey, Vec<crate::knit::KnitKey>>,
580) -> std::collections::HashMap<Vec<u8>, Vec<Vec<u8>>> {
581    let mut out = std::collections::HashMap::with_capacity(raw.len());
582    for (key, parents) in raw {
583        if let Some(revid) = key.into_iter().next() {
584            let parent_ids = parents
585                .into_iter()
586                .filter_map(|p| p.into_iter().next())
587                .collect();
588            out.insert(revid, parent_ids);
589        }
590    }
591    out
592}
593
594impl dyn Repository + '_ {
595    /// Start an incremental commit against the given parents (the first is
596    /// the basis the changes are recorded against; an empty list means a
597    /// first commit against the null revision). The repository must already
598    /// have an open write group.
599    pub fn get_commit_builder(
600        &mut self,
601        parents: Vec<Vec<u8>>,
602        new_revision_id: Vec<u8>,
603        committer: String,
604        timestamp: u64,
605        timezone: i32,
606    ) -> CommitBuilder<'_> {
607        CommitBuilder::new(
608            self,
609            parents,
610            new_revision_id,
611            committer,
612            timestamp,
613            timezone,
614        )
615    }
616}
617
618/// Open the repository at `transport` (rooted at `.bzr/repository`),
619/// dispatching to the right reader through the registered format's `open`
620/// function. Returns an abstract [`Repository`].
621pub fn open(transport: SharedTransport) -> Result<Box<dyn Repository>, RepositoryError> {
622    let marker = transport.get_bytes("format")?;
623    let format =
624        find_format(&marker).ok_or_else(|| RepositoryError::UnknownFormat(marker.clone()))?;
625    (format.open)(transport)
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::transport::LocalTransport;
632    use std::sync::Arc;
633
634    /// One repository format under test: a label, a closure that creates a
635    /// fresh repository over a transport, a closure that re-opens it, and
636    /// whether the format can store signatures.
637    struct Scenario {
638        label: &'static str,
639        create: fn(SharedTransport) -> Box<dyn Repository>,
640        reopen: fn(SharedTransport) -> Box<dyn Repository>,
641        signs: bool,
642    }
643
644    #[cfg(feature = "knitpack")]
645    fn knitpack6() -> &'static RepositoryFormat {
646        find_format(b"Bazaar RepositoryFormatKnitPack6 (bzr 1.9)\n").unwrap()
647    }
648    #[cfg(feature = "knit")]
649    fn knit1() -> &'static RepositoryFormat {
650        find_format(b"Bazaar-NG Knit Repository Format 1").unwrap()
651    }
652    #[cfg(feature = "weave")]
653    fn weave6() -> &'static RepositoryFormat {
654        find_format(b"Bazaar-NG branch, format 6\n").unwrap()
655    }
656
657    /// Every repository backend that implements the write side, each wrapped
658    /// so the shared round-trip runs against `Box<dyn Repository>`.
659    fn scenarios() -> Vec<Scenario> {
660        vec![
661            Scenario {
662                label: "2a",
663                create: |t| Box::new(Pack2aRepository::create(t).unwrap()),
664                reopen: |t| Box::new(Pack2aRepository::open(t).unwrap()),
665                signs: true,
666            },
667            #[cfg(feature = "knitpack")]
668            Scenario {
669                label: "knit-pack",
670                create: |t| Box::new(KnitPackRepository::create(t, knitpack6()).unwrap()),
671                reopen: |t| Box::new(KnitPackRepository::open(t).unwrap()),
672                signs: true,
673            },
674            #[cfg(feature = "knit")]
675            Scenario {
676                label: "knit",
677                create: |t| Box::new(KnitRepository::create(t, knit1()).unwrap()),
678                reopen: |t| Box::new(KnitRepository::open(t).unwrap()),
679                signs: true,
680            },
681            #[cfg(feature = "weave")]
682            Scenario {
683                label: "weave",
684                create: |t| Box::new(WeaveRepository::create(t, weave6()).unwrap()),
685                reopen: |t| Box::new(WeaveRepository::open(t, weave6()).unwrap()),
686                signs: true,
687            },
688        ]
689    }
690
691    fn revision(id: &[u8], parents: Vec<&[u8]>, message: &str) -> crate::revision::Revision {
692        crate::revision::Revision::new(
693            crate::RevisionId::from(id),
694            parents.into_iter().map(crate::RevisionId::from).collect(),
695            Some("T <t@e>".to_string()),
696            message.to_string(),
697            std::collections::HashMap::new(),
698            None,
699            1577880000.0,
700            Some(0),
701        )
702    }
703
704    fn entries(rev: &[u8]) -> Vec<crate::inventory::Entry> {
705        use crate::FileId;
706        let root = crate::inventory::ROOT_ID;
707        vec![
708            crate::inventory::Entry::root(FileId::from(root), Some(crate::RevisionId::from(rev))),
709            crate::inventory::Entry::file(
710                FileId::from(&b"file-1"[..]),
711                "a.txt".into(),
712                FileId::from(root),
713                Some(crate::RevisionId::from(rev)),
714                Some(crate::weave::sha_strings(&[b"hello\n"])),
715                Some(6),
716                Some(false),
717                None,
718            ),
719        ]
720    }
721
722    /// Two revisions, a file text with a per-file parent, an inventory, and a
723    /// signature (where supported) round-trip through every write-capable
724    /// repository backend. Replaces the per-backend copies of this test.
725    #[test]
726    fn revision_text_inventory_signature_round_trip() {
727        for s in scenarios() {
728            let dir = tempfile::tempdir().unwrap();
729            let t: SharedTransport = Arc::new(LocalTransport::new(dir.path()));
730            let mut repo = (s.create)(t.clone());
731
732            repo.start_write_group().unwrap();
733            repo.add_revision(&revision(b"rev-1", vec![], "first"), &[])
734                .unwrap();
735            repo.add_inventory_from_entries(
736                b"rev-1",
737                &[],
738                crate::inventory::ROOT_ID,
739                &entries(b"rev-1"),
740            )
741            .unwrap();
742            repo.add_text(b"file-1", b"rev-1", &[], b"hello\n").unwrap();
743            repo.add_text(
744                b"file-1",
745                b"rev-2",
746                &[(b"file-1".to_vec(), b"rev-1".to_vec())],
747                b"hello\ngoodbye\n",
748            )
749            .unwrap();
750            repo.add_revision(
751                &revision(b"rev-2", vec![b"rev-1"], "second"),
752                &[b"rev-1".to_vec()],
753            )
754            .unwrap();
755            if s.signs {
756                repo.add_signature_text(b"rev-1", b"-----SIG-----\nsigned\n")
757                    .unwrap();
758            }
759            repo.commit_write_group().unwrap();
760
761            let repo = (s.reopen)(t);
762            let mut ids = repo.all_revision_ids().unwrap();
763            ids.sort();
764            assert_eq!(
765                ids,
766                vec![b"rev-1".to_vec(), b"rev-2".to_vec()],
767                "{}",
768                s.label
769            );
770
771            // get_parent_map returns the stored parents; a missing revision is
772            // omitted. has_revision reflects presence.
773            let pm = repo
774                .get_parent_map(&[b"rev-1".to_vec(), b"rev-2".to_vec(), b"nope".to_vec()])
775                .unwrap();
776            assert_eq!(pm.get(&b"rev-1".to_vec()), Some(&vec![]), "{}", s.label);
777            assert_eq!(
778                pm.get(&b"rev-2".to_vec()),
779                Some(&vec![b"rev-1".to_vec()]),
780                "{}",
781                s.label
782            );
783            assert!(!pm.contains_key(&b"nope".to_vec()), "{}", s.label);
784            assert!(repo.has_revision(b"rev-2").unwrap(), "{}", s.label);
785            assert!(!repo.has_revision(b"nope").unwrap(), "{}", s.label);
786            assert_eq!(
787                repo.get_revision(b"rev-1").unwrap().message,
788                "first",
789                "{}",
790                s.label
791            );
792            let got2 = repo.get_revision(b"rev-2").unwrap();
793            assert_eq!(got2.message, "second", "{}", s.label);
794            assert_eq!(
795                got2.parent_ids
796                    .iter()
797                    .map(|p| p.as_bytes().to_vec())
798                    .collect::<Vec<_>>(),
799                vec![b"rev-1".to_vec()],
800                "{}",
801                s.label
802            );
803            assert_eq!(
804                repo.get_file_text(b"file-1", b"rev-1").unwrap(),
805                b"hello\n",
806                "{}",
807                s.label
808            );
809            assert_eq!(
810                repo.get_file_text(b"file-1", b"rev-2").unwrap(),
811                b"hello\ngoodbye\n",
812                "{}",
813                s.label
814            );
815            let inv = repo.get_inventory(b"rev-1").unwrap();
816            let paths: Vec<String> = inv.entries().unwrap().into_iter().map(|(p, _)| p).collect();
817            assert_eq!(paths, vec!["a.txt".to_string()], "{}", s.label);
818
819            // RevisionTree path lookups: path2id, iter_entries, and reading a
820            // file by path.
821            let tree = repo.revision_tree(b"rev-1").unwrap();
822            assert_eq!(
823                tree.path2id("a.txt").map(|f| f.as_bytes().to_vec()),
824                Some(b"file-1".to_vec()),
825                "{}",
826                s.label
827            );
828            assert!(tree.path2id("nope").is_none(), "{}", s.label);
829            let tree_paths: Vec<String> = tree.iter_entries().into_iter().map(|(p, _)| p).collect();
830            assert_eq!(tree_paths, vec!["a.txt".to_string()], "{}", s.label);
831            assert_eq!(
832                repo.get_file_text_at_path("a.txt", b"rev-1").unwrap(),
833                b"hello\n",
834                "{}",
835                s.label
836            );
837
838            // A stored signature reads back; an unsigned revision returns None.
839            let expected = if s.signs {
840                Some(b"-----SIG-----\nsigned\n".to_vec())
841            } else {
842                None
843            };
844            assert_eq!(
845                repo.get_signature_text(b"rev-1").unwrap(),
846                expected,
847                "{}",
848                s.label
849            );
850            assert_eq!(
851                repo.get_signature_text(b"rev-2").unwrap(),
852                None,
853                "{}",
854                s.label
855            );
856        }
857    }
858
859    /// Build a 2a repository with a single revision, file text and inventory.
860    fn make_2a_with_rev(dir: &std::path::Path, rev: &[u8]) -> Box<dyn Repository> {
861        let t: SharedTransport = Arc::new(LocalTransport::new(dir));
862        let mut repo =
863            Box::new(Pack2aRepository::create(t.clone()).unwrap()) as Box<dyn Repository>;
864        repo.start_write_group().unwrap();
865        repo.add_revision(&revision(rev, vec![], "msg"), &[])
866            .unwrap();
867        repo.add_inventory_from_entries(rev, &[], crate::inventory::ROOT_ID, &entries(rev))
868            .unwrap();
869        repo.add_text(b"file-1", rev, &[], b"hello\n").unwrap();
870        repo.commit_write_group().unwrap();
871        Box::new(Pack2aRepository::open(t).unwrap())
872    }
873
874    /// A stacked repository resolves revisions, inventories and file texts that
875    /// live only in a fallback, while its own all_revision_ids stays primary.
876    #[test]
877    fn stacked_repository_reads_through_fallback() {
878        let base_dir = tempfile::tempdir().unwrap();
879        let base = make_2a_with_rev(base_dir.path(), b"rev-base");
880
881        // An empty primary repository.
882        let top_dir = tempfile::tempdir().unwrap();
883        let top_t: SharedTransport = Arc::new(LocalTransport::new(top_dir.path()));
884        let primary = Box::new(Pack2aRepository::create(top_t).unwrap()) as Box<dyn Repository>;
885
886        let mut stacked = StackedRepository::new(primary);
887        assert!(!stacked.has_revision(b"rev-base").unwrap());
888        stacked.add_fallback_repository(base).unwrap();
889
890        // The fallback's revision is now visible through the stack.
891        assert!(stacked.has_revision(b"rev-base").unwrap());
892        assert_eq!(stacked.get_revision(b"rev-base").unwrap().message, "msg");
893        assert_eq!(
894            stacked.get_file_text(b"file-1", b"rev-base").unwrap(),
895            b"hello\n"
896        );
897        let pm = stacked.get_parent_map(&[b"rev-base".to_vec()]).unwrap();
898        assert_eq!(pm.get(&b"rev-base".to_vec()), Some(&vec![]));
899
900        // all_revision_ids reflects only the (empty) primary, not the fallback.
901        assert!(stacked.all_revision_ids().unwrap().is_empty());
902
903        // A genuinely absent revision still errors.
904        assert!(matches!(
905            stacked.get_revision(b"rev-missing"),
906            Err(RepositoryError::NoSuchRevision(_))
907        ));
908    }
909
910    /// A plain backend reports that it does not support fallbacks.
911    #[test]
912    fn plain_repository_rejects_fallback() {
913        let dir = tempfile::tempdir().unwrap();
914        let t: SharedTransport = Arc::new(LocalTransport::new(dir.path()));
915        let mut repo = Pack2aRepository::create(t).unwrap();
916        let other_dir = tempfile::tempdir().unwrap();
917        let other = make_2a_with_rev(other_dir.path(), b"rev-x");
918        assert!(matches!(
919            repo.add_fallback_repository(other),
920            Err(RepositoryError::UnsupportedFormat(_))
921        ));
922    }
923
924    #[cfg(feature = "gpg")]
925    fn gen_signing_cert() -> (sequoia_openpgp::Cert, Vec<u8>) {
926        use sequoia_openpgp::cert::CertBuilder;
927        use sequoia_openpgp::serialize::Serialize;
928        let (cert, _) = CertBuilder::new().add_signing_subkey().generate().unwrap();
929        let mut tsk = Vec::new();
930        cert.as_tsk().serialize(&mut tsk).unwrap();
931        (cert, tsk)
932    }
933
934    /// A revision signed over its own V1 testament verifies as valid, and an
935    /// unsigned revision reports NotSigned.
936    #[cfg(feature = "gpg")]
937    #[test]
938    fn verify_revision_signature_valid_and_unsigned() {
939        use crate::gpg::VerificationResult;
940
941        let dir = tempfile::tempdir().unwrap();
942        let t: SharedTransport = Arc::new(LocalTransport::new(dir.path()));
943        let repo = make_2a_with_rev_at(&t, b"rev-1");
944
945        // No signature yet.
946        assert_eq!(
947            repo.verify_revision_signature(b"rev-1", &[]).unwrap(),
948            VerificationResult::NotSigned
949        );
950
951        // Sign the revision's own testament short text and store it.
952        let (cert, tsk) = gen_signing_cert();
953        let testament = testament_short_text_for_revision(repo.as_ref(), b"rev-1").unwrap();
954        let signature = crate::gpg::clearsign(&testament, &tsk).unwrap();
955        let mut repo = repo;
956        repo.start_write_group().unwrap();
957        repo.add_signature_text(b"rev-1", &signature).unwrap();
958        repo.commit_write_group().unwrap();
959        // Reopen so the freshly written signature pack is visible to reads.
960        let repo = Box::new(Pack2aRepository::open(t.clone()).unwrap()) as Box<dyn Repository>;
961
962        assert_eq!(
963            repo.verify_revision_signature(b"rev-1", std::slice::from_ref(&cert))
964                .unwrap(),
965            VerificationResult::Valid
966        );
967    }
968
969    /// A signature over the wrong content fails the testament byte-compare even
970    /// though it is cryptographically good.
971    #[cfg(feature = "gpg")]
972    #[test]
973    fn verify_revision_signature_wrong_testament_is_not_valid() {
974        use crate::gpg::VerificationResult;
975
976        let dir = tempfile::tempdir().unwrap();
977        let t: SharedTransport = Arc::new(LocalTransport::new(dir.path()));
978        let mut repo = make_2a_with_rev_at(&t, b"rev-1");
979
980        let (cert, tsk) = gen_signing_cert();
981        // Sign something that is NOT the revision's testament.
982        let signature = crate::gpg::clearsign(b"not the testament\n", &tsk).unwrap();
983        repo.start_write_group().unwrap();
984        repo.add_signature_text(b"rev-1", &signature).unwrap();
985        repo.commit_write_group().unwrap();
986        let repo = Box::new(Pack2aRepository::open(t.clone()).unwrap()) as Box<dyn Repository>;
987
988        assert_eq!(
989            repo.verify_revision_signature(b"rev-1", std::slice::from_ref(&cert))
990                .unwrap(),
991            VerificationResult::NotValid
992        );
993    }
994
995    /// Build a 2a repository with a single committed revision at `t`, returned
996    /// open for read/write (so a signature can be added).
997    #[cfg(feature = "gpg")]
998    fn make_2a_with_rev_at(t: &SharedTransport, rev: &[u8]) -> Box<dyn Repository> {
999        let mut repo =
1000            Box::new(Pack2aRepository::create(t.clone()).unwrap()) as Box<dyn Repository>;
1001        repo.start_write_group().unwrap();
1002        repo.add_revision(&revision(rev, vec![], "msg"), &[])
1003            .unwrap();
1004        repo.add_inventory_from_entries(rev, &[], crate::inventory::ROOT_ID, &entries(rev))
1005            .unwrap();
1006        repo.add_text(b"file-1", rev, &[], b"hello\n").unwrap();
1007        repo.commit_write_group().unwrap();
1008        Box::new(Pack2aRepository::open(t.clone()).unwrap())
1009    }
1010}