pr_01m47d15m3e54sn21z27rpy5n9/services/search/src/visibility.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Search across all of g1t, Explore, and a command palette | 1 | //! Who may see what. Checked twice, both times when the query runs: |
| 2 | //! | |
| 3 | //! 1. In the query itself, against the viewer's memberships as they are | |
| 4 | //! now: a row is read if its repository is public, as the index last | |
| 5 | //! heard, or in a workspace the viewer belongs to. | |
| 6 | //! 2. On the page about to be returned, against the repos service, which | |
| 7 | //! owns visibility: anything it does not say the viewer may read now is | |
| 8 | //! dropped, and the index is corrected. A repository made private a | |
| 9 | //! moment ago, before its event arrived, is never shown to anyone | |
| 10 | //! outside its workspace. | |
| 11 | //! | |
| 12 | //! Pure, so the rules are tested apart from the index. | |
| 13 | ||
| 14 | use std::collections::{HashMap, HashSet}; | |
| 15 | ||
| 16 | use g1t_contracts::Viewer; | |
| 17 | use g1t_contracts::repos::Repo; | |
| 18 | ||
| 19 | /// Who is reading: the workspaces they belong to, by slug. | |
| 20 | #[derive(Clone, Debug, Default)] | |
| 21 | pub struct Reader { | |
| 22 | pub member_of: Vec<String>, | |
| 23 | } | |
| 24 | ||
| 25 | impl Reader { | |
| 26 | pub fn of(viewer: &Viewer) -> Reader { | |
| 27 | Reader { | |
| 28 | member_of: viewer | |
| 29 | .iter() | |
| 30 | .flat_map(|user| &user.workspaces) | |
| 31 | .map(|membership| membership.slug.to_lowercase()) | |
| 32 | .collect(), | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | pub fn is_member(&self, namespace: &str) -> bool { | |
| 37 | self.member_of.iter().any(|slug| slug.eq_ignore_ascii_case(namespace)) | |
| 38 | } | |
| 39 | ||
| 40 | /// The first check: may the reader see a row of a repository in | |
| 41 | /// `namespace` that is `private` or not? | |
| 42 | pub fn may_see(&self, namespace: &str, private: bool) -> bool { | |
| 43 | !private || self.is_member(namespace) | |
| 44 | } | |
| 45 | ||
| 46 | /// The reader's workspaces as JSON, for `json_each` in a query. Never | |
| 47 | /// empty SQL: no workspaces is `[]`, which matches nothing. | |
| 48 | pub fn members_json(&self) -> String { | |
| 49 | serde_json::to_string(&self.member_of).unwrap_or_else(|_| "[]".into()) | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | /// The SQL the first check adds to every query, given the repository's | |
| 54 | /// table alias. Its one parameter is [`Reader::members_json`]. | |
| 55 | pub fn clause(alias: &str) -> String { | |
| 56 | format!("({alias}.private = 0 OR {alias}.namespace IN (SELECT value FROM json_each(?)))") | |
| 57 | } | |
| 58 | ||
| 59 | /// A repository as the index holds it. | |
| 60 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 61 | pub struct Indexed { | |
| 62 | pub repo_id: String, | |
| 63 | pub namespace: String, | |
| 64 | pub private: bool, | |
| 65 | } | |
| 66 | ||
| 67 | /// What the index should be told about a repository it holds wrongly. | |
| 68 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 69 | pub struct Correction { | |
| 70 | pub repo_id: String, | |
| 71 | pub private: bool, | |
| 72 | /// Its current path, when the repos service said. | |
| 73 | pub path: Option<(String, String)>, | |
| 74 | } | |
| 75 | ||
| 76 | #[derive(Debug, Default, PartialEq, Eq)] | |
| 77 | pub struct Verdict { | |
| 78 | /// The repositories whose rows may be returned. | |
| 79 | pub keep: HashSet<String>, | |
| 80 | pub corrections: Vec<Correction>, | |
| 81 | } | |
| 82 | ||
| 83 | /// The second check. `readable` is what the repos service says, for this | |
| 84 | /// viewer, of the page's repositories (`readable` leaves out what they may | |
| 85 | /// not read, and repositories that are gone). `None` when it could not be | |
| 86 | /// asked: then nothing private is kept, and nothing public of a workspace | |
| 87 | /// the reader is not in either, since it may have just gone private. | |
| 88 | pub fn check(reader: &Reader, indexed: &[Indexed], readable: Option<&[Repo]>) -> Verdict { | |
| 89 | let mut verdict = Verdict::default(); | |
| 90 | let Some(readable) = readable else { | |
| 91 | for row in indexed { | |
| 92 | if reader.is_member(&row.namespace) { | |
| 93 | verdict.keep.insert(row.repo_id.clone()); | |
| 94 | } | |
| 95 | } | |
| 96 | return verdict; | |
| 97 | }; | |
| 98 | let now: HashMap<&str, &Repo> = readable.iter().map(|repo| (repo.id.as_str(), repo)).collect(); | |
| 99 | let mut seen = HashSet::new(); | |
| 100 | for row in indexed { | |
| 101 | if !seen.insert(row.repo_id.as_str()) { | |
| 102 | continue; | |
| 103 | } | |
| 104 | match now.get(row.repo_id.as_str()) { | |
| 105 | Some(repo) => { | |
| 106 | // Readable now, and the first check agrees with what is true now. | |
| 107 | if reader.may_see(&repo.namespace, repo.is_private) { | |
| 108 | verdict.keep.insert(row.repo_id.clone()); | |
| 109 | } | |
| 110 | if repo.is_private != row.private || !repo.namespace.eq_ignore_ascii_case(&row.namespace) { | |
| 111 | verdict.corrections.push(Correction { | |
| 112 | repo_id: row.repo_id.clone(), | |
| 113 | private: repo.is_private, | |
| 114 | path: Some((repo.namespace.to_lowercase(), repo.name.to_lowercase())), | |
| 115 | }); | |
| 116 | } | |
| 117 | } | |
| 118 | // Not readable: private now (or gone). The index learns at once, | |
| 119 | // so counts stop including it before its event arrives. | |
| 120 | None if !row.private => verdict.corrections.push(Correction { | |
| 121 | repo_id: row.repo_id.clone(), | |
| 122 | private: true, | |
| 123 | path: None, | |
| 124 | }), | |
| 125 | None => {} | |
| 126 | } | |
| 127 | } | |
| 128 | verdict | |
| 129 | } | |
| 130 | ||
| 131 | #[cfg(test)] | |
| 132 | mod tests { | |
| 133 | use g1t_contracts::{Membership, User}; | |
| 134 | ||
| 135 | use super::*; | |
| 136 | ||
| 137 | fn viewer(workspaces: &[&str]) -> Viewer { | |
| 138 | Some(User { | |
| 139 | id: "usr_1".into(), | |
| 140 | username: "ana".into(), | |
| 141 | workspaces: workspaces.iter().map(|slug| Membership::member(*slug)).collect(), | |
| 142 | ..User::default() | |
| 143 | }) | |
| 144 | } | |
| 145 | ||
| 146 | fn row(id: &str, namespace: &str, private: bool) -> Indexed { | |
| 147 | Indexed { | |
| 148 | repo_id: id.into(), | |
| 149 | namespace: namespace.into(), | |
| 150 | private, | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | fn repo(id: &str, namespace: &str, private: bool) -> Repo { | |
| 155 | Repo { | |
| 156 | id: id.into(), | |
| 157 | namespace: namespace.into(), | |
| 158 | name: "web".into(), | |
| 159 | description: None, | |
| 160 | is_private: private, | |
| 161 | owner_id: "usr_0".into(), | |
| 162 | default_branch: "main".into(), | |
| 163 | fork_of: None, | |
| 164 | protected: false, | |
| 165 | created_at: String::new(), | |
| 166 | topics: Vec::new(), | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | #[test] | |
| 171 | fn anyone_sees_public_rows() { | |
| 172 | assert!(Reader::of(&None).may_see("acme", false)); | |
| 173 | assert!(Reader::of(&viewer(&["other"])).may_see("acme", false)); | |
| 174 | } | |
| 175 | ||
| 176 | #[test] | |
| 177 | fn private_rows_are_for_members_only() { | |
| 178 | assert!(!Reader::of(&None).may_see("acme", true)); | |
| 179 | assert!(!Reader::of(&viewer(&["other"])).may_see("acme", true)); | |
| 180 | assert!(Reader::of(&viewer(&["acme"])).may_see("acme", true)); | |
| 181 | assert!(Reader::of(&viewer(&["acme"])).may_see("ACME", true)); | |
| 182 | } | |
| 183 | ||
| 184 | #[test] | |
| 185 | fn the_query_clause_binds_memberships() { | |
| 186 | assert_eq!(clause("r"), "(r.private = 0 OR r.namespace IN (SELECT value FROM json_each(?)))"); | |
| 187 | assert_eq!(Reader::of(&None).members_json(), "[]"); | |
| 188 | assert_eq!(Reader::of(&viewer(&["Acme", "beta"])).members_json(), r#"["acme","beta"]"#); | |
| 189 | } | |
| 190 | ||
| 191 | #[test] | |
| 192 | fn a_repository_made_private_disappears_before_its_event() { | |
| 193 | // The index still says public; the repos service no longer lets | |
| 194 | // this outsider read it. | |
| 195 | let reader = Reader::of(&viewer(&["other"])); | |
| 196 | let verdict = check(&reader, &[row("rep_1", "acme", false)], Some(&[])); | |
| 197 | assert!(verdict.keep.is_empty()); | |
| 198 | assert_eq!( | |
| 199 | verdict.corrections, | |
| 200 | vec![Correction { repo_id: "rep_1".into(), private: true, path: None }] | |
| 201 | ); | |
| 202 | // Signed out, the same. | |
| 203 | let verdict = check(&Reader::of(&None), &[row("rep_1", "acme", false)], Some(&[])); | |
| 204 | assert!(verdict.keep.is_empty()); | |
| 205 | } | |
| 206 | ||
| 207 | #[test] | |
| 208 | fn members_still_see_a_repository_made_private() { | |
| 209 | let reader = Reader::of(&viewer(&["acme"])); | |
| 210 | let verdict = check(&reader, &[row("rep_1", "acme", false)], Some(&[repo("rep_1", "acme", true)])); | |
| 211 | assert!(verdict.keep.contains("rep_1")); | |
| 212 | assert_eq!(verdict.corrections[0].private, true); | |
| 213 | } | |
| 214 | ||
| 215 | #[test] | |
| 216 | fn a_repository_made_public_is_shown_and_corrected() { | |
| 217 | // The first check let a member's row through; the repos service | |
| 218 | // says it is public now, so the index is told. | |
| 219 | let reader = Reader::of(&viewer(&["acme"])); | |
| 220 | let verdict = check(&reader, &[row("rep_2", "acme", true)], Some(&[repo("rep_2", "acme", false)])); | |
| 221 | assert!(verdict.keep.contains("rep_2")); | |
| 222 | assert_eq!(verdict.corrections[0].private, false); | |
| 223 | // Once corrected, an outsider's first check lets it through too. | |
| 224 | assert!(Reader::of(&None).may_see("acme", false)); | |
| 225 | } | |
| 226 | ||
| 227 | #[test] | |
| 228 | fn private_rows_never_reach_outsiders_whatever_the_index_says() { | |
| 229 | // Even if a private row slipped through the first check, and the | |
| 230 | // repos service somehow answered with it, the reader is no member. | |
| 231 | let reader = Reader::of(&viewer(&["other"])); | |
| 232 | let verdict = check(&reader, &[row("rep_3", "acme", false)], Some(&[repo("rep_3", "acme", true)])); | |
| 233 | assert!(verdict.keep.is_empty()); | |
| 234 | } | |
| 235 | ||
| 236 | #[test] | |
| 237 | fn without_the_repos_service_only_members_rows_are_kept() { | |
| 238 | let reader = Reader::of(&viewer(&["acme"])); | |
| 239 | let verdict = check(&reader, &[row("rep_1", "acme", true), row("rep_2", "public-co", false)], None); | |
| 240 | assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()])); | |
| 241 | assert!(verdict.corrections.is_empty()); | |
| 242 | } | |
| 243 | ||
| 244 | #[test] | |
| 245 | fn a_moved_repository_is_corrected() { | |
| 246 | let reader = Reader::of(&None); | |
| 247 | let mut moved = repo("rep_4", "newname", false); | |
| 248 | moved.name = "Web".into(); | |
| 249 | let verdict = check(&reader, &[row("rep_4", "oldname", false)], Some(&[moved])); | |
| 250 | assert!(verdict.keep.contains("rep_4")); | |
| 251 | assert_eq!(verdict.corrections[0].path, Some(("newname".into(), "web".into()))); | |
| 252 | } | |
| 253 | } |