diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs index 50fac2d4..c3fbb117 100644 --- a/crates/radicle/src/git/canonical/rules.rs +++ b/crates/radicle/src/git/canonical/rules.rs @@ -9,6 +9,9 @@ //! the first matched rule, and this can be used to calculate the //! [`Canonical::quorum`]. +#[cfg(test)] +mod test; + use std::cmp::Ordering; use std::collections::BTreeMap; @@ -624,509 +627,3 @@ pub enum CanonicalError { #[error(transparent)] Git(#[from] crate::git::raw::Error), } - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use std::collections::BTreeMap; - - use nonempty::nonempty; - - use crate::Storage; - use crate::crypto::{Signer, test::signer::MockSigner}; - use crate::git; - use crate::git::fmt::RefString; - use crate::git::fmt::qualified_pattern; - use crate::identity::Visibility; - use crate::identity::doc::Doc; - use crate::node::device::Device; - use crate::rad; - use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH, SIGREFS_PARENT}; - use crate::storage::{ReadStorage, git::transport}; - use crate::test::{arbitrary, fixtures}; - - use super::*; - - fn roundtrip(rule: &Rule) { - let json = serde_json::to_string(rule).unwrap(); - assert_eq!( - *rule, - serde_json::from_str(&json).unwrap(), - "failed to roundtrip: {json}" - ) - } - - fn did(s: &str) -> Did { - s.parse().unwrap() - } - - fn raw_pattern(qp: QualifiedPattern<'static>) -> RawPattern { - qp - } - - fn pattern(qp: QualifiedPattern<'static>) -> Pattern { - Pattern::new(qp).unwrap() - } - - fn resolve_from_doc(doc: &Doc) -> doc::Delegates { - doc.delegates().clone() - } - - fn tag(name: RefString, head: git::raw::Oid, repo: &git::raw::Repository) -> git::Oid { - let commit = fixtures::commit(name.as_str(), &[head], repo); - let target = repo.find_object(commit.into(), None).unwrap(); - let tagger = repo.signature().unwrap(); - repo.tag(name.as_str(), &target, &tagger, name.as_str(), false) - .unwrap() - .into() - } - - #[test] - fn test_roundtrip() { - let rule1 = Rule::new(Allowed::Delegates, 1); - let rule2 = Rule::new(Allowed::Delegates, 1); - let rule3 = Rule::new(Allowed::Delegates, 1); - let mut rule4 = Rule::new( - Allowed::Set(nonempty![ - did("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K"), - did("did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"), - ]), - 2, - ); - rule4.add_extensions( - serde_json::json!({ - "foo": "bar", - "quux": 5, - }) - .as_object() - .cloned() - .unwrap(), - ); - roundtrip(&rule1); - roundtrip(&rule2); - roundtrip(&rule3); - roundtrip(&rule4); - } - - #[test] - fn test_deserialization() { - let examples = r#" -{ - "refs/heads/main": { - "threshold": 2, - "allow": [ - "did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K", - "did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye" - ] - }, - "refs/tags/releases/*": { - "threshold": 2, - "allow": [ - "did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56", - "did:key:z6Mkq2E5Se5H9gk1DsL1EMwR2t4CqSg3GFkNN2UeG4FNqXoP", - "did:key:z6MkqRmXW5fbP9hJ1Y8j2N4CgVdJ2XJ6TsyXYf3FQ2NJgXax" - ] - }, - "refs/heads/development": { - "threshold": 1, - "allow": [ - "did:key:z6MkhH7ENYE62JAjTiRZPU71MGZ6xCwnbyHHWfrBu3fr6PVG" - ] - }, - "refs/heads/release/*": { - "threshold": 1, - "allow": "delegates" - } -} - "#; - let expected = [ - ( - raw_pattern(qualified_pattern!("refs/heads/main")), - Rule::new( - Allowed::Set(nonempty![ - did("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K"), - did("did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"), - ]), - 2, - ), - ), - ( - raw_pattern(qualified_pattern!("refs/tags/releases/*")), - Rule::new( - Allowed::Set(nonempty![ - did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"), - did("did:key:z6Mkq2E5Se5H9gk1DsL1EMwR2t4CqSg3GFkNN2UeG4FNqXoP"), - did("did:key:z6MkqRmXW5fbP9hJ1Y8j2N4CgVdJ2XJ6TsyXYf3FQ2NJgXax") - ]), - 2, - ), - ), - ( - raw_pattern(qualified_pattern!("refs/heads/development")), - Rule::new( - Allowed::Set(nonempty![did( - "did:key:z6MkhH7ENYE62JAjTiRZPU71MGZ6xCwnbyHHWfrBu3fr6PVG" - )]), - 1, - ), - ), - ( - raw_pattern(qualified_pattern!("refs/heads/release/*")), - Rule::new(Allowed::Delegates, 1), - ), - ] - .into_iter() - .collect::(); - let rules = serde_json::from_str::>(examples) - .unwrap() - .into(); - assert_eq!(expected, rules) - } - - #[test] - fn test_order() { - assert!( - pattern(qualified_pattern!("refs/heads/a/b/c/d/*")) - < pattern(qualified_pattern!("refs/heads/*/x")), - "example 1" - ); - assert!( - pattern(qualified_pattern!("refs/heads/a")) - < pattern(qualified_pattern!("refs/heads/*")), - "example 2.a" - ); - assert!( - pattern(qualified_pattern!("refs/heads/abc")) - < pattern(qualified_pattern!("refs/heads/a*")), - "example 2.a" - ); - assert!( - pattern(qualified_pattern!("refs/heads/a/b/*")) - < pattern(qualified_pattern!("refs/heads/a/*/c")), - "example 2.a" - ); - assert!( - pattern(qualified_pattern!("refs/heads/aa*")) - < pattern(qualified_pattern!("refs/heads/a*")), - "example 2.b.A" - ); - assert!( - pattern(qualified_pattern!("refs/heads/a*b")) - < pattern(qualified_pattern!("refs/heads/a*")), - "example 2.b.B" - ); - - let pattern01 = pattern(qualified_pattern!("refs/tags/*")); - let pattern02 = pattern(qualified_pattern!("refs/tags/v1")); - let pattern04 = pattern(qualified_pattern!("refs/tags/v1.0.0")); - let pattern05 = pattern(qualified_pattern!("refs/tags/release/v1.0.0")); - let pattern03 = pattern(qualified_pattern!("refs/heads/main")); - let pattern06 = pattern(qualified_pattern!("refs/tags/*/v1.0.0")); - - let pattern07 = pattern(qualified_pattern!("refs/tags/x*")); - let pattern08 = pattern(qualified_pattern!("refs/tags/xx*")); - - let pattern09 = pattern(qualified_pattern!("refs/foos/*")); - - let pattern10 = pattern(qualified_pattern!("refs/heads/a")); - let pattern11 = pattern(qualified_pattern!("refs/heads/b")); - - let pattern12 = pattern(qualified_pattern!("refs/heads/a/*")); - let pattern13 = pattern(qualified_pattern!("refs/heads/b/*")); - - let pattern14 = pattern(qualified_pattern!("refs/heads/a/*/ab")); - let pattern15 = pattern(qualified_pattern!("refs/heads/a/*/a")); - - let pattern16 = pattern(qualified_pattern!("refs/heads/a/*/b")); - let pattern17 = pattern(qualified_pattern!("refs/heads/a/*/a")); - - // Test priority for path specificity - assert!( - pattern06 < pattern02, - "match for 06 is always more specific since it has more components" - ); - assert!(pattern02 < pattern01, "match for 02 is also match for 01"); - assert!(pattern08 < pattern07, "match for 08 is also match for 07"); - // Test equality - assert!(pattern02 == pattern02); - // Test lexicographical fallback when paths are equally specific - assert!(pattern02 < pattern04); - assert!(pattern03 < pattern01); - assert!(pattern09 < pattern01); - assert!(pattern10 < pattern11); - assert!(pattern12 < pattern13); - assert!(pattern15 < pattern14); - assert!( - pattern17 < pattern16, - "matches have same length, but lexicographically, 'a' < 'b'" - ); - - // Test example from docs - let pattern18 = pattern(qualified_pattern!("refs/tags/release/candidates/*")); - let pattern19 = pattern(qualified_pattern!("refs/tags/release/*")); - let pattern20 = pattern(qualified_pattern!("refs/tags/*")); - - assert!(pattern18 < pattern19); - assert!(pattern19 < pattern20); - - let pattern21 = pattern(qualified_pattern!("refs/heads/dev")); - - assert!(pattern21 < pattern03); - - let mut patterns = [ - pattern01.clone(), - pattern02.clone(), - pattern03.clone(), - pattern04.clone(), - pattern05.clone(), - pattern06.clone(), - ]; - patterns.sort(); - - assert_eq!( - patterns, - [ - pattern05, pattern06, pattern03, pattern02, pattern04, pattern01 - ] - ); - } - - #[test] - fn test_deserialize_extensions() { - let example = r#" -{ - "threshold": 2, - "allow": [ - "did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K", - "did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye" - ], - "foo": "bar", - "quux": 5 -} -"#; - let rule = serde_json::from_str::>(example).unwrap(); - assert!(!rule.extensions().is_empty()); - let extensions = rule.extensions(); - assert_eq!( - extensions.get("foo"), - Some(serde_json::Value::String("bar".to_string())).as_ref() - ); - assert_eq!( - extensions.get("quux"), - Some(serde_json::Value::Number(5.into())).as_ref() - ); - } - - #[test] - fn test_rule_validate_success() { - let doc = arbitrary::r#gen::(1); - let delegates = Allowed::Set(doc.delegates().as_ref().clone()); - let threshold = doc.majority(); - - let rule = Rule::new(delegates, threshold); - let result = rule.validate(&mut || resolve_from_doc(&doc)); - assert!(result.is_ok(), "failed to validate doc: {result:?}"); - - let rule = Rule::new(Allowed::Delegates, 1); - let result = rule.validate(&mut || resolve_from_doc(&doc)); - assert!(result.is_ok(), "failed to validate doc: {result:?}"); - } - - #[test] - fn test_rule_validate_failures() { - let doc = arbitrary::r#gen::(1); - let pattern = pattern(qualified_pattern!("refs/heads/main")); - - assert!(matches!( - Rule::new(Allowed::Delegates, 256).validate(&mut || resolve_from_doc(&doc)), - Err(ValidationError::Threshold(_)) - )); - - let threshold = doc.delegates().len().saturating_add(1); - assert!(matches!( - Rule::new(Allowed::Delegates, threshold).validate(&mut || resolve_from_doc(&doc)), - Err(ValidationError::Threshold(_)) - )); - - let delegates = NonEmpty::from_vec(arbitrary::vec::(256)).unwrap(); - assert!(matches!( - Rule::new(delegates.into(), 1).validate(&mut || resolve_from_doc(&doc)), - Err(ValidationError::Delegates(_)) - )); - - let delegates = nonempty![ - did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"), - did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56") - ]; - let expected = Rule { - allow: ResolvedDelegates::Set( - doc::Delegates::new(nonempty![did( - "did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56" - )]) - .unwrap(), - ), - threshold: doc::Threshold::MIN, - extensions: json::Map::new(), - }; - assert_eq!( - Rule::new(delegates.into(), 1) - .validate(&mut || resolve_from_doc(&doc)) - .unwrap(), - expected, - ); - - // Duplicate rules are overwritten - let rules = vec![ - ( - pattern.clone().into_inner(), - Rule::new(Allowed::Delegates, 1), - ), - ( - pattern.clone().into_inner(), - Rule::new(doc.delegates().as_ref().clone().into(), 1), - ), - ]; - let expected = [( - pattern, - Rule::new( - ResolvedDelegates::Set(doc.delegates().clone()), - doc::Threshold::MIN, - ), - )] - .into_iter() - .collect::(); - assert_eq!( - Rules::from_raw(rules, &mut || resolve_from_doc(&doc)).unwrap(), - expected - ); - } - - #[test] - fn test_canonical() { - let tempdir = tempfile::tempdir().unwrap(); - let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap(); - - transport::local::register(storage.clone()); - - let delegate = Device::mock_from_seed([0xff; 32]); - let contributor = MockSigner::from_seed([0xfe; 32]); - let (repo, head) = fixtures::repository(tempdir.path().join("working")); - let (rid, doc, _) = rad::init( - &repo, - "heartwood".try_into().unwrap(), - "Radicle Heartwood Protocol & Stack", - git::fmt::refname!("master"), - Visibility::default(), - &delegate, - &storage, - ) - .unwrap(); - - let mut doc = doc.edit(); - // Ensure there is a second delegate for testing overlapping rules - doc.delegate(contributor.public_key().into()); - - // Create tags and keep track of their OIDs - // - // follows the `refs/tags/release/candidates/*` rule - let failing_tag = git::fmt::refname!("release/candidates/v1.0"); - let tags = [ - // follows the `refs/tags/*` rule - git::fmt::refname!("v1.0"), - // follows the `refs/tags/release/*` rule - git::fmt::refname!("release/v1.0"), - failing_tag.clone(), - // follows the `refs/tags/*` rule - git::fmt::refname!("qa/v1.0"), - ] - .into_iter() - .map(|name| { - ( - git::fmt::lit::refs_tags(name.clone()).into(), - tag(name, head, &repo), - ) - }) - .collect::>(); - - git::push( - &repo, - &rad::REMOTE_NAME, - [ - ( - &git::fmt::qualified!("refs/tags/v1.0"), - &git::fmt::qualified!("refs/tags/v1.0"), - ), - ( - &git::fmt::qualified!("refs/tags/release/v1.0"), - &git::fmt::qualified!("refs/tags/release/v1.0"), - ), - ( - &git::fmt::qualified!("refs/tags/release/candidates/v1.0"), - &git::fmt::qualified!("refs/tags/release/candidates/v1.0"), - ), - ( - &git::fmt::qualified!("refs/tags/qa/v1.0"), - &git::fmt::qualified!("refs/tags/qa/v1.0"), - ), - ], - ) - .unwrap(); - - let rules = Rules::from_raw( - [ - ( - raw_pattern(qualified_pattern!("refs/tags/*")), - Rule::new(Allowed::Delegates, 1), - ), - ( - raw_pattern(qualified_pattern!("refs/tags/release/*")), - Rule::new(Allowed::Delegates, 1), - ), - // Ensure that none of the other rules apply by ensuring we need - // both delegates to get the quorum of the - // `refs/tags/release/candidates/v1.0` reference - ( - raw_pattern(qualified_pattern!("refs/tags/release/candidates/*")), - Rule::new(Allowed::Delegates, 2), - ), - ], - &mut || resolve_from_doc(&doc.clone().verified().unwrap()), - ) - .unwrap(); - - // All tags should succeed at getting their canonical commit other than the - // candidates tag. - let stored = storage.repository(rid).unwrap(); - let failing = git::fmt::Qualified::from(git::fmt::lit::refs_tags(failing_tag)); - for (refname, oid) in tags.into_iter() { - let canonical = rules - .canonical(refname.clone(), &stored) - .unwrap_or_else(|| { - panic!("there should be a matching rule for {refname}, rules: {rules:#?}") - }); - if refname == failing { - assert!(canonical.find_objects().unwrap().quorum().is_err()); - } else { - assert_eq!( - canonical - .find_objects() - .unwrap() - .quorum() - .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")), - canonical::Quorum { - refname, - object: canonical::Object::Tag { id: oid }, - } - ) - } - } - } - - #[test] - fn test_special_branches() { - assert!(Pattern::new((*IDENTITY_BRANCH).clone().into()).is_err()); - assert!(Pattern::new((*SIGREFS_BRANCH).clone().into()).is_err()); - assert!(Pattern::new((*SIGREFS_PARENT).clone().into()).is_err()); - assert!(Pattern::new((*IDENTITY_ROOT).clone().into()).is_err()); - } -} diff --git a/crates/radicle/src/git/canonical/rules/test.rs b/crates/radicle/src/git/canonical/rules/test.rs new file mode 100644 index 00000000..dd9532d8 --- /dev/null +++ b/crates/radicle/src/git/canonical/rules/test.rs @@ -0,0 +1,479 @@ +mod helper; + +use std::collections::BTreeMap; + +use nonempty::nonempty; + +use crate::Storage; +use crate::crypto::{Signer, test::signer::MockSigner}; +use crate::git; +use crate::git::fmt::qualified_pattern; +use crate::identity::Visibility; +use crate::identity::doc::Doc; +use crate::node::device::Device; +use crate::rad; +use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH, SIGREFS_PARENT}; +use crate::storage::{ReadStorage, git::transport}; +use crate::test::{arbitrary, fixtures}; + +use super::*; + +#[test] +fn roundtrip() { + use helper::*; + + let rule1 = Rule::new(Allowed::Delegates, 1); + let rule2 = Rule::new(Allowed::Delegates, 1); + let rule3 = Rule::new(Allowed::Delegates, 1); + let mut rule4 = Rule::new( + Allowed::Set(nonempty![ + did("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K"), + did("did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"), + ]), + 2, + ); + rule4.add_extensions( + serde_json::json!({ + "foo": "bar", + "quux": 5, + }) + .as_object() + .cloned() + .unwrap(), + ); + roundtrip(&rule1); + roundtrip(&rule2); + roundtrip(&rule3); + roundtrip(&rule4); +} + +#[test] +fn deserialization() { + use helper::*; + + let examples = r#" +{ + "refs/heads/main": { + "threshold": 2, + "allow": [ + "did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K", + "did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye" + ] + }, + "refs/tags/releases/*": { + "threshold": 2, + "allow": [ + "did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56", + "did:key:z6Mkq2E5Se5H9gk1DsL1EMwR2t4CqSg3GFkNN2UeG4FNqXoP", + "did:key:z6MkqRmXW5fbP9hJ1Y8j2N4CgVdJ2XJ6TsyXYf3FQ2NJgXax" + ] + }, + "refs/heads/development": { + "threshold": 1, + "allow": [ + "did:key:z6MkhH7ENYE62JAjTiRZPU71MGZ6xCwnbyHHWfrBu3fr6PVG" + ] + }, + "refs/heads/release/*": { + "threshold": 1, + "allow": "delegates" + } +} + "#; + let expected = [ + ( + raw_pattern(qualified_pattern!("refs/heads/main")), + Rule::new( + Allowed::Set(nonempty![ + did("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K"), + did("did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"), + ]), + 2, + ), + ), + ( + raw_pattern(qualified_pattern!("refs/tags/releases/*")), + Rule::new( + Allowed::Set(nonempty![ + did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"), + did("did:key:z6Mkq2E5Se5H9gk1DsL1EMwR2t4CqSg3GFkNN2UeG4FNqXoP"), + did("did:key:z6MkqRmXW5fbP9hJ1Y8j2N4CgVdJ2XJ6TsyXYf3FQ2NJgXax") + ]), + 2, + ), + ), + ( + raw_pattern(qualified_pattern!("refs/heads/development")), + Rule::new( + Allowed::Set(nonempty![did( + "did:key:z6MkhH7ENYE62JAjTiRZPU71MGZ6xCwnbyHHWfrBu3fr6PVG" + )]), + 1, + ), + ), + ( + raw_pattern(qualified_pattern!("refs/heads/release/*")), + Rule::new(Allowed::Delegates, 1), + ), + ] + .into_iter() + .collect::(); + let rules = serde_json::from_str::>(examples) + .unwrap() + .into(); + assert_eq!(expected, rules) +} + +#[test] +fn ordering() { + use helper::*; + + assert!( + pattern(qualified_pattern!("refs/heads/a/b/c/d/*")) + < pattern(qualified_pattern!("refs/heads/*/x")), + "example 1" + ); + assert!( + pattern(qualified_pattern!("refs/heads/a")) < pattern(qualified_pattern!("refs/heads/*")), + "example 2.a" + ); + assert!( + pattern(qualified_pattern!("refs/heads/abc")) + < pattern(qualified_pattern!("refs/heads/a*")), + "example 2.a" + ); + assert!( + pattern(qualified_pattern!("refs/heads/a/b/*")) + < pattern(qualified_pattern!("refs/heads/a/*/c")), + "example 2.a" + ); + assert!( + pattern(qualified_pattern!("refs/heads/aa*")) + < pattern(qualified_pattern!("refs/heads/a*")), + "example 2.b.A" + ); + assert!( + pattern(qualified_pattern!("refs/heads/a*b")) + < pattern(qualified_pattern!("refs/heads/a*")), + "example 2.b.B" + ); + + let pattern01 = pattern(qualified_pattern!("refs/tags/*")); + let pattern02 = pattern(qualified_pattern!("refs/tags/v1")); + let pattern04 = pattern(qualified_pattern!("refs/tags/v1.0.0")); + let pattern05 = pattern(qualified_pattern!("refs/tags/release/v1.0.0")); + let pattern03 = pattern(qualified_pattern!("refs/heads/main")); + let pattern06 = pattern(qualified_pattern!("refs/tags/*/v1.0.0")); + + let pattern07 = pattern(qualified_pattern!("refs/tags/x*")); + let pattern08 = pattern(qualified_pattern!("refs/tags/xx*")); + + let pattern09 = pattern(qualified_pattern!("refs/foos/*")); + + let pattern10 = pattern(qualified_pattern!("refs/heads/a")); + let pattern11 = pattern(qualified_pattern!("refs/heads/b")); + + let pattern12 = pattern(qualified_pattern!("refs/heads/a/*")); + let pattern13 = pattern(qualified_pattern!("refs/heads/b/*")); + + let pattern14 = pattern(qualified_pattern!("refs/heads/a/*/ab")); + let pattern15 = pattern(qualified_pattern!("refs/heads/a/*/a")); + + let pattern16 = pattern(qualified_pattern!("refs/heads/a/*/b")); + let pattern17 = pattern(qualified_pattern!("refs/heads/a/*/a")); + + // Test priority for path specificity + assert!( + pattern06 < pattern02, + "match for 06 is always more specific since it has more components" + ); + assert!(pattern02 < pattern01, "match for 02 is also match for 01"); + assert!(pattern08 < pattern07, "match for 08 is also match for 07"); + // Test equality + assert!(pattern02 == pattern02); + // Test lexicographical fallback when paths are equally specific + assert!(pattern02 < pattern04); + assert!(pattern03 < pattern01); + assert!(pattern09 < pattern01); + assert!(pattern10 < pattern11); + assert!(pattern12 < pattern13); + assert!(pattern15 < pattern14); + assert!( + pattern17 < pattern16, + "matches have same length, but lexicographically, 'a' < 'b'" + ); + + // Test example from docs + let pattern18 = pattern(qualified_pattern!("refs/tags/release/candidates/*")); + let pattern19 = pattern(qualified_pattern!("refs/tags/release/*")); + let pattern20 = pattern(qualified_pattern!("refs/tags/*")); + + assert!(pattern18 < pattern19); + assert!(pattern19 < pattern20); + + let pattern21 = pattern(qualified_pattern!("refs/heads/dev")); + + assert!(pattern21 < pattern03); + + let mut patterns = [ + pattern01.clone(), + pattern02.clone(), + pattern03.clone(), + pattern04.clone(), + pattern05.clone(), + pattern06.clone(), + ]; + patterns.sort(); + + assert_eq!( + patterns, + [ + pattern05, pattern06, pattern03, pattern02, pattern04, pattern01 + ] + ); +} + +#[test] +fn deserialize_extensions() { + let example = r#" +{ + "threshold": 2, + "allow": [ + "did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K", + "did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye" + ], + "foo": "bar", + "quux": 5 +} +"#; + let rule = serde_json::from_str::>(example).unwrap(); + assert!(!rule.extensions().is_empty()); + let extensions = rule.extensions(); + assert_eq!( + extensions.get("foo"), + Some(serde_json::Value::String("bar".to_string())).as_ref() + ); + assert_eq!( + extensions.get("quux"), + Some(serde_json::Value::Number(5.into())).as_ref() + ); +} + +#[test] +fn rule_validate_success() { + use helper::*; + + let doc = arbitrary::r#gen::(1); + let delegates = Allowed::Set(doc.delegates().as_ref().clone()); + let threshold = doc.majority(); + + let rule = Rule::new(delegates, threshold); + let result = rule.validate(&mut || resolve_from_doc(&doc)); + assert!(result.is_ok(), "failed to validate doc: {result:?}"); + + let rule = Rule::new(Allowed::Delegates, 1); + let result = rule.validate(&mut || resolve_from_doc(&doc)); + assert!(result.is_ok(), "failed to validate doc: {result:?}"); +} + +#[test] +fn rule_validate_failures() { + use helper::*; + + let doc = arbitrary::r#gen::(1); + let pattern = pattern(qualified_pattern!("refs/heads/main")); + + assert!(matches!( + Rule::new(Allowed::Delegates, 256).validate(&mut || resolve_from_doc(&doc)), + Err(ValidationError::Threshold(_)) + )); + + let threshold = doc.delegates().len().saturating_add(1); + assert!(matches!( + Rule::new(Allowed::Delegates, threshold).validate(&mut || resolve_from_doc(&doc)), + Err(ValidationError::Threshold(_)) + )); + + let delegates = NonEmpty::from_vec(arbitrary::vec::(256)).unwrap(); + assert!(matches!( + Rule::new(delegates.into(), 1).validate(&mut || resolve_from_doc(&doc)), + Err(ValidationError::Delegates(_)) + )); + + let delegates = nonempty![ + did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"), + did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56") + ]; + let expected = Rule { + allow: ResolvedDelegates::Set( + doc::Delegates::new(nonempty![did( + "did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56" + )]) + .unwrap(), + ), + threshold: doc::Threshold::MIN, + extensions: json::Map::new(), + }; + assert_eq!( + Rule::new(delegates.into(), 1) + .validate(&mut || resolve_from_doc(&doc)) + .unwrap(), + expected, + ); + + // Duplicate rules are overwritten + let rules = vec![ + ( + pattern.clone().into_inner(), + Rule::new(Allowed::Delegates, 1), + ), + ( + pattern.clone().into_inner(), + Rule::new(doc.delegates().as_ref().clone().into(), 1), + ), + ]; + let expected = [( + pattern, + Rule::new( + ResolvedDelegates::Set(doc.delegates().clone()), + doc::Threshold::MIN, + ), + )] + .into_iter() + .collect::(); + assert_eq!( + Rules::from_raw(rules, &mut || resolve_from_doc(&doc)).unwrap(), + expected + ); +} + +#[test] +fn canonical() { + use helper::*; + + let tempdir = tempfile::tempdir().unwrap(); + let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap(); + + transport::local::register(storage.clone()); + + let delegate = Device::mock_from_seed([0xff; 32]); + let contributor = MockSigner::from_seed([0xfe; 32]); + let (repo, head) = fixtures::repository(tempdir.path().join("working")); + let (rid, doc, _) = rad::init( + &repo, + "heartwood".try_into().unwrap(), + "Radicle Heartwood Protocol & Stack", + git::fmt::refname!("master"), + Visibility::default(), + &delegate, + &storage, + ) + .unwrap(); + + let mut doc = doc.edit(); + // Ensure there is a second delegate for testing overlapping rules + doc.delegate(contributor.public_key().into()); + + // Create tags and keep track of their OIDs + // + // follows the `refs/tags/release/candidates/*` rule + let failing_tag = git::fmt::refname!("release/candidates/v1.0"); + let tags = [ + // follows the `refs/tags/*` rule + git::fmt::refname!("v1.0"), + // follows the `refs/tags/release/*` rule + git::fmt::refname!("release/v1.0"), + failing_tag.clone(), + // follows the `refs/tags/*` rule + git::fmt::refname!("qa/v1.0"), + ] + .into_iter() + .map(|name| { + ( + git::fmt::lit::refs_tags(name.clone()).into(), + tag(name, head, &repo), + ) + }) + .collect::>(); + + git::push( + &repo, + &rad::REMOTE_NAME, + [ + ( + &git::fmt::qualified!("refs/tags/v1.0"), + &git::fmt::qualified!("refs/tags/v1.0"), + ), + ( + &git::fmt::qualified!("refs/tags/release/v1.0"), + &git::fmt::qualified!("refs/tags/release/v1.0"), + ), + ( + &git::fmt::qualified!("refs/tags/release/candidates/v1.0"), + &git::fmt::qualified!("refs/tags/release/candidates/v1.0"), + ), + ( + &git::fmt::qualified!("refs/tags/qa/v1.0"), + &git::fmt::qualified!("refs/tags/qa/v1.0"), + ), + ], + ) + .unwrap(); + + let rules = Rules::from_raw( + [ + ( + raw_pattern(qualified_pattern!("refs/tags/*")), + Rule::new(Allowed::Delegates, 1), + ), + ( + raw_pattern(qualified_pattern!("refs/tags/release/*")), + Rule::new(Allowed::Delegates, 1), + ), + // Ensure that none of the other rules apply by ensuring we need + // both delegates to get the quorum of the + // `refs/tags/release/candidates/v1.0` reference + ( + raw_pattern(qualified_pattern!("refs/tags/release/candidates/*")), + Rule::new(Allowed::Delegates, 2), + ), + ], + &mut || resolve_from_doc(&doc.clone().verified().unwrap()), + ) + .unwrap(); + + // All tags should succeed at getting their canonical commit other than the + // candidates tag. + let stored = storage.repository(rid).unwrap(); + let failing = git::fmt::Qualified::from(git::fmt::lit::refs_tags(failing_tag)); + for (refname, oid) in tags.into_iter() { + let canonical = rules + .canonical(refname.clone(), &stored) + .unwrap_or_else(|| { + panic!("there should be a matching rule for {refname}, rules: {rules:#?}") + }); + if refname == failing { + assert!(canonical.find_objects().unwrap().quorum().is_err()); + } else { + assert_eq!( + canonical + .find_objects() + .unwrap() + .quorum() + .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")), + canonical::Quorum { + refname, + object: canonical::Object::Tag { id: oid }, + } + ) + } + } +} + +#[test] +fn special_branches() { + assert!(Pattern::new((*IDENTITY_BRANCH).clone().into()).is_err()); + assert!(Pattern::new((*SIGREFS_BRANCH).clone().into()).is_err()); + assert!(Pattern::new((*SIGREFS_PARENT).clone().into()).is_err()); + assert!(Pattern::new((*IDENTITY_ROOT).clone().into()).is_err()); +} diff --git a/crates/radicle/src/git/canonical/rules/test/helper.rs b/crates/radicle/src/git/canonical/rules/test/helper.rs new file mode 100644 index 00000000..50239360 --- /dev/null +++ b/crates/radicle/src/git/canonical/rules/test/helper.rs @@ -0,0 +1,42 @@ +use radicle_git_ref_format::refspec::QualifiedPattern; + +use crate::git; +use crate::git::canonical::rules::{Allowed, Pattern, RawPattern, Rule}; +use crate::git::fmt::RefString; +use crate::identity::doc::{self, Doc}; +use crate::prelude::Did; +use crate::test::fixtures; + +pub fn roundtrip(rule: &Rule) { + let json = serde_json::to_string(rule).unwrap(); + assert_eq!( + *rule, + serde_json::from_str(&json).unwrap(), + "failed to roundtrip: {json}" + ) +} + +pub fn did(s: &str) -> Did { + s.parse().unwrap() +} + +pub fn raw_pattern(qp: QualifiedPattern<'static>) -> RawPattern { + qp +} + +pub fn pattern(qp: QualifiedPattern<'static>) -> Pattern { + Pattern::new(qp).unwrap() +} + +pub fn resolve_from_doc(doc: &Doc) -> doc::Delegates { + doc.delegates().clone() +} + +pub fn tag(name: RefString, head: git::raw::Oid, repo: &git::raw::Repository) -> git::Oid { + let commit = fixtures::commit(name.as_str(), &[head], repo); + let target = repo.find_object(commit.into(), None).unwrap(); + let tagger = repo.signature().unwrap(); + repo.tag(name.as_str(), &target, &tagger, name.as_str(), false) + .unwrap() + .into() +}