diff --git a/knot2/crates/knot-acl/src/lib.rs b/knot2/crates/knot-acl/src/lib.rs index 95b4dc954..178afdbc4 100644 --- a/knot2/crates/knot-acl/src/lib.rs +++ b/knot2/crates/knot-acl/src/lib.rs @@ -707,7 +707,10 @@ mod tests { mod integration { use super::*; use knot_cob::{CobHome, CobStore, KnotAuthored}; - use knot_cobs::{CollaboratorsChange, Grant, MembersChange, Registration, RegistryChange}; + use knot_cobs::{ + BlocklistChange, CollaboratorsChange, Grant, MembersChange, Registration, + RegistryChange, + }; use knot_git::{Layout, Repo}; use knot_runtime::{K256Signer, SeededEntropy}; use knot_types::{KnotId, RepoName, RepoRkey, UnixSeconds}; @@ -858,6 +861,102 @@ mod tests { ); } + #[tokio::test] + async fn a_private_repo_reads_as_missing_to_everyone_but_its_unblocked_members() { + let (_dir, meta_path, layout, signer) = world(); + let at = UnixSeconds::new; + + let meta = Repo::open(&meta_path).unwrap(); + let store = CobStore::new(&meta); + let squid = repo("squid"); + let limpet = repo("limpet"); + let registry = store + .create( + &knot_home(), + &RegistryChange::Register(Registration { + visibility: knot_types::Visibility::Private, + ..registration("nel", "anemone", &squid, 1) + }), + &signer, + at(1), + ) + .unwrap(); + store + .update( + &knot_home(), + registry.object, + &RegistryChange::Register(registration("nel", "barnacle", &limpet, 1)), + &signer, + at(1), + ) + .unwrap(); + let git = layout.create(&squid).unwrap(); + let collaborators = CobStore::new(&git); + let roster = collaborators + .create( + &CobHome::from(&squid), + &CollaboratorsChange::Add(grant("lyna", 1)), + &signer, + at(1), + ) + .unwrap(); + collaborators + .update( + &CobHome::from(&squid), + roster.object, + &CollaboratorsChange::Add(grant("teq", 1)), + &signer, + at(1), + ) + .unwrap(); + seed( + &store, + &knot_home(), + &BlocklistChange::Add(grant("teq", 1)), + &signer, + at(1), + ); + + let index = Index::new(&meta_path, layout.clone()); + index.rebuild().unwrap(); + index.ensure_collaborators(&squid).unwrap(); + let reads = |repo: &RepoDid, viewer: Viewer| { + read_grant(&index, repo.clone(), &viewer).map(|grant| grant.is_some()) + }; + + assert_eq!(reads(&squid, Viewer::of(acc("nel"))), Resolved::Ready(true)); + assert_eq!( + reads(&squid, Viewer::of(acc("lyna"))), + Resolved::Ready(true) + ); + for stranger in [Viewer::anonymous(), Viewer::of(acc("olaren"))] { + assert_eq!( + reads(&squid, stranger), + reads(&repo("nobody"), Viewer::anonymous()), + "a stranger can't tell a private repo from a missing one" + ); + } + assert_eq!( + reads(&squid, Viewer::of(acc("teq"))), + Resolved::Ready(false), + "a blocked collaborator loses read too" + ); + assert_eq!( + reads(&limpet, Viewer::anonymous()), + Resolved::Ready(true), + "public repos stay open to anyone" + ); + + let cold = Index::new(&meta_path, layout); + cold.rebuild().unwrap(); + assert_eq!( + read_grant(&cold, squid.clone(), &Viewer::of(acc("lyna"))) + .map(|grant| grant.is_some()), + Resolved::Ready(false), + "collaborators not loaded yet read as a stranger, never as warming" + ); + } + #[tokio::test] async fn a_repo_re_registered_under_a_second_owner_grants_push_only_to_the_later_owner() { let (_dir, meta_path, layout, signer) = world(); diff --git a/knot2/crates/knot-acl/src/repos.rs b/knot2/crates/knot-acl/src/repos.rs index 46f3ff776..cdf87f129 100644 --- a/knot2/crates/knot-acl/src/repos.rs +++ b/knot2/crates/knot-acl/src/repos.rs @@ -4,6 +4,7 @@ use knot_git::{GitError, Layout, Repo}; use knot_index::{Index, Resolved, SocialIndex, SocialView}; use knot_types::{ AccountDid, ClonePath, ContributionPermission, KnotId, ObjectFormat, OwnerDid, RepoDid, + Visibility, }; use crate::{Acl, Viewer, can_push}; @@ -120,10 +121,26 @@ impl Social { } } -pub fn read_grant(index: &Index, repo: RepoDid, _viewer: &Viewer) -> Resolved> { +pub fn read_grant(index: &Index, repo: RepoDid, viewer: &Viewer) -> Resolved> { index - .owner_of(&repo) - .map(|owner| owner.map(|_| ReadGrant(repo))) + .visibility_of(&repo) + .map(|visibility| match visibility? { + Visibility::Public => Some(ReadGrant(repo)), + Visibility::Private => reads_private(index, &repo, viewer).then_some(ReadGrant(repo)), + }) +} + +// a warming index reads as a stranger because answering "warming" would leak that the repo exists +fn reads_private(index: &Index, repo: &RepoDid, viewer: &Viewer) -> bool { + let Some(who) = viewer.account() else { + return false; + }; + let owns = matches!(index.owner_of(repo), Resolved::Ready(Some(owner)) if owner.is(who)); + let collaborates = matches!( + index.effective_collaborator(repo, who), + Resolved::Ready(true) + ); + (owns || collaborates) && matches!(index.is_blocked(who), Resolved::Ready(false)) } pub fn read_grant_by_path( diff --git a/knot2/crates/knot-acl/src/viewer.rs b/knot2/crates/knot-acl/src/viewer.rs index a66b0b7e2..dfc2a7e9e 100644 --- a/knot2/crates/knot-acl/src/viewer.rs +++ b/knot2/crates/knot-acl/src/viewer.rs @@ -20,6 +20,13 @@ impl Viewer { } } +#[cfg(test)] +impl Viewer { + pub(crate) fn of(account: AccountDid) -> Self { + Self(Some(account)) + } +} + impl From for Viewer { fn from(proof: Authenticated) -> Self { Self(Some(proof.into_did()))