| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | use model::{Repo, Visibility}; |
| 16 | |
| 17 | |
| 18 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 19 | pub enum Access { |
| 20 | |
| 21 | None, |
| 22 | |
| 23 | Read, |
| 24 | |
| 25 | Write, |
| 26 | |
| 27 | Admin, |
| 28 | } |
| 29 | |
| 30 | |
| 31 | |
| 32 | |
| 33 | |
| 34 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 35 | pub enum Permission { |
| 36 | |
| 37 | Read, |
| 38 | |
| 39 | Write, |
| 40 | |
| 41 | Admin, |
| 42 | } |
| 43 | |
| 44 | impl Permission { |
| 45 | |
| 46 | #[must_use] |
| 47 | pub fn as_str(self) -> &'static str { |
| 48 | match self { |
| 49 | Self::Read => "read", |
| 50 | Self::Write => "write", |
| 51 | Self::Admin => "admin", |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | |
| 56 | |
| 57 | #[must_use] |
| 58 | pub fn parse(s: &str) -> Option<Self> { |
| 59 | match s { |
| 60 | "read" => Some(Self::Read), |
| 61 | "write" => Some(Self::Write), |
| 62 | "admin" => Some(Self::Admin), |
| 63 | _ => None, |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | impl From<Permission> for Access { |
| 69 | fn from(permission: Permission) -> Self { |
| 70 | match permission { |
| 71 | Permission::Read => Self::Read, |
| 72 | Permission::Write => Self::Write, |
| 73 | Permission::Admin => Self::Admin, |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | |
| 79 | |
| 80 | |
| 81 | |
| 82 | |
| 83 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 84 | pub struct Viewer { |
| 85 | |
| 86 | pub id: String, |
| 87 | |
| 88 | pub is_admin: bool, |
| 89 | } |
| 90 | |
| 91 | |
| 92 | |
| 93 | |
| 94 | |
| 95 | |
| 96 | |
| 97 | |
| 98 | |
| 99 | |
| 100 | |
| 101 | |
| 102 | |
| 103 | |
| 104 | |
| 105 | |
| 106 | |
| 107 | #[must_use] |
| 108 | pub fn access( |
| 109 | viewer: Option<&Viewer>, |
| 110 | repo: &Repo, |
| 111 | collaborator: Option<Permission>, |
| 112 | allow_anonymous: bool, |
| 113 | ) -> Access { |
| 114 | if let Some(v) = viewer { |
| 115 | if v.is_admin { |
| 116 | return Access::Admin; |
| 117 | } |
| 118 | if v.id == repo.owner_id { |
| 119 | return Access::Admin; |
| 120 | } |
| 121 | if let Some(permission) = collaborator { |
| 122 | return permission.into(); |
| 123 | } |
| 124 | } |
| 125 | match repo.visibility { |
| 126 | Visibility::Public if viewer.is_some() || allow_anonymous => Access::Read, |
| 127 | Visibility::Internal if viewer.is_some() => Access::Read, |
| 128 | _ => Access::None, |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | #[cfg(test)] |
| 133 | mod tests { |
| 134 | #![allow(clippy::unwrap_used)] |
| 135 | |
| 136 | use super::*; |
| 137 | |
| 138 | |
| 139 | |
| 140 | fn repo(owner: &str, visibility: Visibility) -> Repo { |
| 141 | Repo { |
| 142 | id: "01reporeporeporeporeporepo".to_string(), |
| 143 | owner_id: owner.to_string(), |
| 144 | group_id: None, |
| 145 | name: "r".to_string(), |
| 146 | path: "r".to_string(), |
| 147 | description: None, |
| 148 | visibility, |
| 149 | default_branch: "main".to_string(), |
| 150 | object_format: "sha1".to_string(), |
| 151 | issues_enabled: true, |
| 152 | pulls_enabled: true, |
| 153 | releases_enabled: true, |
| 154 | is_mirror: false, |
| 155 | fork_parent_id: None, |
| 156 | next_iid: 1, |
| 157 | archived_at: None, |
| 158 | size_bytes: 0, |
| 159 | pushed_at: None, |
| 160 | created_at: 0, |
| 161 | updated_at: 0, |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | |
| 166 | const ALL_VIS: [Visibility; 3] = [ |
| 167 | Visibility::Public, |
| 168 | Visibility::Internal, |
| 169 | Visibility::Private, |
| 170 | ]; |
| 171 | |
| 172 | fn viewer(id: &str, is_admin: bool) -> Viewer { |
| 173 | Viewer { |
| 174 | id: id.to_string(), |
| 175 | is_admin, |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn permission_strings_round_trip() { |
| 181 | for p in [Permission::Read, Permission::Write, Permission::Admin] { |
| 182 | assert_eq!(Permission::parse(p.as_str()), Some(p)); |
| 183 | } |
| 184 | assert_eq!(Permission::parse("owner"), None); |
| 185 | } |
| 186 | |
| 187 | #[test] |
| 188 | fn access_is_ordered() { |
| 189 | assert!(Access::None < Access::Read); |
| 190 | assert!(Access::Read < Access::Write); |
| 191 | assert!(Access::Write < Access::Admin); |
| 192 | } |
| 193 | |
| 194 | |
| 195 | struct Case { |
| 196 | viewer: Option<Viewer>, |
| 197 | collaborator: Option<Permission>, |
| 198 | visibility: Visibility, |
| 199 | allow_anonymous: bool, |
| 200 | expect: Access, |
| 201 | } |
| 202 | |
| 203 | |
| 204 | |
| 205 | |
| 206 | #[test] |
| 207 | fn access_matrix_is_exhaustive() { |
| 208 | |
| 209 | let admin = viewer("boss", true); |
| 210 | let owner = viewer("owner", false); |
| 211 | let other = viewer("stranger", false); |
| 212 | |
| 213 | let mut cases = Vec::new(); |
| 214 | let mut push = |viewer: Option<&Viewer>, |
| 215 | collaborator: Option<Permission>, |
| 216 | visibility: Visibility, |
| 217 | allow_anonymous: bool, |
| 218 | expect: Access| { |
| 219 | cases.push(Case { |
| 220 | viewer: viewer.cloned(), |
| 221 | collaborator, |
| 222 | visibility, |
| 223 | allow_anonymous, |
| 224 | expect, |
| 225 | }); |
| 226 | }; |
| 227 | |
| 228 | for &vis in &ALL_VIS { |
| 229 | for &anon in &[true, false] { |
| 230 | |
| 231 | |
| 232 | for collab in [None, Some(Permission::Read)] { |
| 233 | push(Some(&admin), collab, vis, anon, Access::Admin); |
| 234 | } |
| 235 | |
| 236 | |
| 237 | for collab in [None, Some(Permission::Read), Some(Permission::Write)] { |
| 238 | push(Some(&owner), collab, vis, anon, Access::Admin); |
| 239 | } |
| 240 | |
| 241 | |
| 242 | push( |
| 243 | Some(&other), |
| 244 | Some(Permission::Read), |
| 245 | vis, |
| 246 | anon, |
| 247 | Access::Read, |
| 248 | ); |
| 249 | push( |
| 250 | Some(&other), |
| 251 | Some(Permission::Write), |
| 252 | vis, |
| 253 | anon, |
| 254 | Access::Write, |
| 255 | ); |
| 256 | push( |
| 257 | Some(&other), |
| 258 | Some(Permission::Admin), |
| 259 | vis, |
| 260 | anon, |
| 261 | Access::Admin, |
| 262 | ); |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | |
| 267 | |
| 268 | for &anon in &[true, false] { |
| 269 | push(Some(&other), None, Visibility::Public, anon, Access::Read); |
| 270 | push(Some(&other), None, Visibility::Internal, anon, Access::Read); |
| 271 | push(Some(&other), None, Visibility::Private, anon, Access::None); |
| 272 | } |
| 273 | |
| 274 | push(None, None, Visibility::Public, true, Access::Read); |
| 275 | push(None, None, Visibility::Public, false, Access::None); |
| 276 | push(None, None, Visibility::Internal, true, Access::None); |
| 277 | push(None, None, Visibility::Internal, false, Access::None); |
| 278 | push(None, None, Visibility::Private, true, Access::None); |
| 279 | push(None, None, Visibility::Private, false, Access::None); |
| 280 | |
| 281 | for case in &cases { |
| 282 | let repo = repo("owner", case.visibility); |
| 283 | let got = access( |
| 284 | case.viewer.as_ref(), |
| 285 | &repo, |
| 286 | case.collaborator, |
| 287 | case.allow_anonymous, |
| 288 | ); |
| 289 | assert_eq!( |
| 290 | got, case.expect, |
| 291 | "viewer={:?} collab={:?} vis={} anon={}", |
| 292 | case.viewer, case.collaborator, case.visibility, case.allow_anonymous |
| 293 | ); |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | #[test] |
| 298 | fn private_repo_never_leaks_to_a_stranger() { |
| 299 | |
| 300 | |
| 301 | |
| 302 | let stranger = viewer("stranger", false); |
| 303 | for &anon in &[true, false] { |
| 304 | assert_eq!( |
| 305 | access( |
| 306 | Some(&stranger), |
| 307 | &repo("owner", Visibility::Private), |
| 308 | None, |
| 309 | anon |
| 310 | ), |
| 311 | Access::None |
| 312 | ); |
| 313 | assert_eq!( |
| 314 | access(None, &repo("owner", Visibility::Private), None, anon), |
| 315 | Access::None |
| 316 | ); |
| 317 | assert_eq!( |
| 318 | access(None, &repo("owner", Visibility::Internal), None, anon), |
| 319 | Access::None |
| 320 | ); |
| 321 | } |
| 322 | } |
| 323 | } |