Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle/sigrefs: Find first non-replayed commit
Lorenz Leutgeb committed 1 month ago
commit 9a4539fe82f5a0d3afa3659116fbd1cf074ca866
parent b5dc348
3 files changed +90 -85
modified CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

+
## [Unreleased]
+

+
## Fixed Bugs
+

+
- Fix the signed references reading process by correctly choosing the first,
+
  non-replayed commit. This only occurs if duplicate signatures are found and
+
  the process needs to find the first legitimate commit of the namespace.
+

## 1.7.1

## Fixed Bugs
modified crates/radicle/src/storage/refs/sigrefs/read.rs
@@ -101,16 +101,17 @@ where
        }
    }

-
    /// Read a [`VerifiedCommit`] using the [`SignedRefsReader`].
+
    /// Read a [`VerifiedCommit`] using the [`SignedRefsReader`], from a
+
    /// linear history.
    ///
-
    /// The [`VerifiedCommit`] will be the first commit, if the commit verifies
+
    /// The [`VerifiedCommit`] will be the latest commit, if the commit verifies
    /// and contains its parent in its [`Refs`] entry.
+
    ///
    /// If the commit does not contain a parent, but its signature is not
    /// repeated, then it is still returned.
-
    /// Otherwise, the commit that is returned is either:
-
    /// - The first commit which has no repeated signatures, i.e. it has no replay attacks.
-
    /// - The first commit which is not a replay commit, i.e. the commit that
-
    ///   replay attacks are based on.
+
    ///
+
    /// Otherwise, the latest commit that has no duplicate signatures in its
+
    /// ancestry is returned.
    ///
    /// # Replay Attacks
    ///
@@ -145,6 +146,10 @@ where
            return Ok(head);
        }

+
        // Note that for all sets of commits which share the same signature,
+
        // the `NonEmpty` in `seen` will be in reverse order of the walk.
+
        // That is, the latest commit in the set will be at the first position,
+
        // and the earliest commit will be at the last position.
        let seen = iter::Walk::new(*head.id(), self.repository).try_fold(
            HashMap::<crypto::Signature, NonEmpty<Oid>>::new(),
            |mut seen, commit| {
@@ -181,7 +186,6 @@ where

        // The second walk can start from the parent of head. We do not need to
        // verify head twice, and we already know that the parent exists.
-
        let mut last = None;
        for commit in iter::Walk::new(parent, self.repository) {
            let commit = commit
                .map_err(error::Read::Commit)?
@@ -192,19 +196,26 @@ where

            if commits.len_nonzero() == ONE {
                return Ok(commit);
-
            } else {
+
            }
+

+
            let id = commit.id();
+

+
            if id == commits.last() {
+
                // If this commit is the last element of `commits`,
+
                // then this means it is the earliest of all that share
+
                // its signature. It thus cannot have been replayed.
+
                return Ok(commit);
+
            }
+

+
            if id == commits.first() {
+
                // We only log one warning per set of duplicates, and that is
+
                // when we reach the first element of `commits`, which is the
+
                // latest in the history.
                log::warn!("Duplicate sigrefs found in commits {commits:?}");
-
                last = Some(commit);
            }
        }

-
        // In the extreme case where all commits in the walk contain duplicate
-
        // signatures, return the oldest commit reached — the last one visited
-
        // by the walk, which follows the chain from newest to oldest.
-
        // `last` is always `Some` here because `parent` is guaranteed to exist
-
        // (head had a duplicate signature, so it must have a parent), meaning
-
        // the walk yields at least one commit.
-
        Ok(last.unwrap_or(head))
+
        unreachable!()
    }

    fn resolve_tip(&self) -> Result<Oid, error::Read> {
modified crates/radicle/src/storage/refs/sigrefs/read/test/signed_refs_reader.rs
@@ -165,77 +165,63 @@ fn two_commits() {
    assert_eq!(*vc.id(), head);
}

-
/// Commit chain:
-
/// `A <- B <- A`
-
/// Where A is replayed
+
/// We test a handful scenarios with replayed commits (or rather, references
+
/// and signatures within commits).
///
-
/// Expected Result: B
-
#[test]
-
fn head_replays_root() {
-
    const SIGNATURE_A: u8 = 1;
-
    const SIGNATURE_B: u8 = 2;
-

-
    let a1 = mock::oid(1);
-
    let b = mock::oid(2);
-
    let a2 = mock::oid(3);
-
    let repo = mock::setup_chain([
-
        (a1, SIGNATURE_A, refs_without_parent(mock::oid(10))),
-
        (b, SIGNATURE_B, refs_without_parent(mock::oid(10))),
-
        (a2, SIGNATURE_A, refs_without_parent(mock::oid(10))),
-
    ]);
-

-
    let vc = read(a2, repo).unwrap();
-
    assert_eq!(*vc.id(), b);
-
}
-

-
/// Commit chain:
-
/// `A <- A <- A`
-
/// Where A is replayed twice
-
///
-
/// Expected Result: first A
-
#[test]
-
fn replay_chain() {
-
    const SIGNATURE_A: u8 = 1;
-
    let a1 = mock::oid(1);
-
    let a2 = mock::oid(2);
-
    let a3 = mock::oid(3);
-
    let repo = mock::setup_chain([
-
        (a1, SIGNATURE_A, refs_without_parent(mock::oid(10))),
-
        (a2, SIGNATURE_A, refs_without_parent(mock::oid(10))),
-
        (a3, SIGNATURE_A, refs_without_parent(mock::oid(10))),
-
    ]);
-

-
    let vc = read(a3, repo).unwrap();
-
    assert_eq!(*vc.id(), a1);
-
}
-

-
/// Commit chain:
-
/// `C <- C <- B <- A <- A`
-
/// Where C and A are replayed twice
-
///
-
/// Expected Result: first A
-
#[test]
-
fn multiple_replays() {
-
    const SIGNATURE_A: u8 = 3;
-
    const SIGNATURE_B: u8 = 2;
-
    const SIGNATURE_C: u8 = 1;
-

-
    let c1 = mock::oid(1);
-
    let c2 = mock::oid(2);
-
    let b = mock::oid(3);
-
    let a1 = mock::oid(4);
-
    let a2 = mock::oid(5);
-
    let r = refs_without_parent(mock::oid(10));
-
    let repo = mock::setup_chain([
-
        (a2, SIGNATURE_C, r.clone()),
-
        (a1, SIGNATURE_C, r.clone()),
-
        (b, SIGNATURE_B, r.clone()),
-
        (c2, SIGNATURE_A, r.clone()),
-
        (c1, SIGNATURE_A, r),
-
    ]);
+
/// For every test we define:
+
///  - A history, which is a linear history of commits,
+
///    where the earliest and leftmost commit is a root commit.
+
///  - Which commit we expect to be loaded, as a zero based index in the
+
///    history.
+
mod replay {
+
    use super::*;
+

+
    /// Mocks a chain of commits, where their OID is their zero-based index
+
    /// in `chain` (note that since this is only mocked, it is not an issue
+
    /// that the first commit in the chain, at index zero, is identified by
+
    /// the zero OID).
+
    ///
+
    /// Asserts that the result of [`read`] on the chain is `expected`.
+
    fn replay(chain: impl IntoIterator<Item = u8>, expected: u8) {
+
        let refs = refs_without_parent(mock::oid(10));
+

+
        let chain: Vec<_> = chain.into_iter().collect();
+
        let mut repo = MockRepository::new();
+
        let mut parent = None;
+
        for (i, signature) in chain.iter().enumerate() {
+
            let i = mock::oid(i as u8);
+
            repo = repo
+
                .with_commit(i, mock::commit_data(parent))
+
                .with_refs(i, refs.clone())
+
                .with_signature(i, *signature);
+
            parent = Some(i);
+
        }

-
    let vc = read(c1, repo).unwrap();
-
    assert_eq!(*vc.id(), b);
+
        assert_eq!(
+
            *read(mock::oid((chain.len() - 1) as u8), repo).unwrap().id(),
+
            mock::oid(expected)
+
        )
+
    }
+

+
    #[test]
+
    fn root_at_head() {
+
        replay([1, 2, 1], 1)
+
    }
+

+
    #[test]
+
    fn chain() {
+
        replay([1, 1, 1], 0)
+
    }
+

+
    #[test]
+
    fn multiple() {
+
        replay([1, 1, 2, 3, 3], 3)
+
    }
+

+
    #[test]
+
    fn alternating() {
+
        replay([1, 2, 1, 2], 1)
+
    }
}

#[test]