1use std::collections::BTreeSet;
56
57use crate::drive::types::DrivePermission;
58
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
67pub enum Principal {
68 User(String),
70 Group(String),
72 Domain(String),
74 Anyone,
76}
77
78impl std::fmt::Display for Principal {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 Self::User(email) => write!(f, "user:{email}"),
85 Self::Group(email) => write!(f, "group:{email}"),
86 Self::Domain(domain) => write!(f, "domain:{domain}"),
87 Self::Anyone => write!(f, "anyone"),
88 }
89 }
90}
91
92#[must_use]
100pub fn principal_set(perms: &[DrivePermission]) -> BTreeSet<Principal> {
101 perms.iter().filter_map(principal_of).collect()
102}
103
104fn principal_of(perm: &DrivePermission) -> Option<Principal> {
105 match perm.permission_type.as_str() {
106 "user" => perm.email_address.clone().map(Principal::User),
107 "group" => perm.email_address.clone().map(Principal::Group),
108 "domain" => perm.domain.clone().map(Principal::Domain),
109 "anyone" => Some(Principal::Anyone),
110 _ => None,
111 }
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct VisibilityDiff {
118 pub added: BTreeSet<Principal>,
120 pub removed: BTreeSet<Principal>,
123}
124
125#[must_use]
129pub fn diff_visibility(
130 file_perms: &[DrivePermission],
131 current_parent_perms: &[DrivePermission],
132 dest_folder_perms: &[DrivePermission],
133) -> VisibilityDiff {
134 let before = principal_set(file_perms);
135 let current_parent = principal_set(current_parent_perms);
136 let dest = principal_set(dest_folder_perms);
137
138 let direct_on_file: BTreeSet<Principal> = before.difference(¤t_parent).cloned().collect();
139 let after: BTreeSet<Principal> = direct_on_file.union(&dest).cloned().collect();
140
141 let added = after.difference(&before).cloned().collect();
142 let removed = before.difference(&after).cloned().collect();
143
144 VisibilityDiff { added, removed }
145}
146
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
153pub struct BlockReasons {
154 pub visibility_increase: bool,
156 pub visibility_decrease: bool,
159 pub drive_boundary_crossing: bool,
162}
163
164impl BlockReasons {
165 #[must_use]
167 pub fn any(self) -> bool {
168 self.visibility_increase || self.visibility_decrease || self.drive_boundary_crossing
169 }
170}
171
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180pub struct MoveGateFlags {
181 pub allow_visibility_increase: bool,
183 pub allow_visibility_decrease: bool,
185 pub allow_drive_boundary_crossing: bool,
187}
188
189#[must_use]
194pub fn classify(
195 diff: &VisibilityDiff,
196 crosses_boundary: bool,
197 flags: MoveGateFlags,
198) -> Option<BlockReasons> {
199 let reasons = BlockReasons {
200 visibility_increase: !diff.added.is_empty() && !flags.allow_visibility_increase,
201 visibility_decrease: !diff.removed.is_empty() && !flags.allow_visibility_decrease,
202 drive_boundary_crossing: crosses_boundary && !flags.allow_drive_boundary_crossing,
203 };
204 reasons.any().then_some(reasons)
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used, clippy::expect_used)]
209mod tests {
210 use super::*;
211
212 fn user(email: &str) -> DrivePermission {
213 DrivePermission {
214 id: format!("perm-{email}"),
215 permission_type: "user".to_string(),
216 role: "reader".to_string(),
217 email_address: Some(email.to_string()),
218 domain: None,
219 }
220 }
221
222 fn group(email: &str) -> DrivePermission {
223 DrivePermission {
224 permission_type: "group".to_string(),
225 email_address: Some(email.to_string()),
226 ..user(email)
227 }
228 }
229
230 fn domain(name: &str) -> DrivePermission {
231 DrivePermission {
232 id: format!("perm-{name}"),
233 permission_type: "domain".to_string(),
234 role: "reader".to_string(),
235 email_address: None,
236 domain: Some(name.to_string()),
237 }
238 }
239
240 fn anyone() -> DrivePermission {
241 DrivePermission {
242 id: "perm-anyone".to_string(),
243 permission_type: "anyone".to_string(),
244 role: "reader".to_string(),
245 email_address: None,
246 domain: None,
247 }
248 }
249
250 #[test]
253 fn principal_set_maps_every_recognized_type() {
254 let perms = vec![
255 user("alice@example.com"),
256 group("team@example.com"),
257 domain("example.com"),
258 anyone(),
259 ];
260 let set = principal_set(&perms);
261 assert!(set.contains(&Principal::User("alice@example.com".to_string())));
262 assert!(set.contains(&Principal::Group("team@example.com".to_string())));
263 assert!(set.contains(&Principal::Domain("example.com".to_string())));
264 assert!(set.contains(&Principal::Anyone));
265 assert_eq!(set.len(), 4);
266 }
267
268 #[test]
269 fn principal_set_skips_unrecognized_types() {
270 let mut weird = user("alice@example.com");
271 weird.permission_type = "somethingNew".to_string();
272 let set = principal_set(&[weird]);
273 assert!(set.is_empty());
274 }
275
276 #[test]
277 fn principal_set_skips_user_with_no_email_address() {
278 let mut malformed = user("alice@example.com");
279 malformed.email_address = None;
280 let set = principal_set(&[malformed]);
281 assert!(set.is_empty());
282 }
283
284 #[test]
285 fn principal_set_is_empty_for_no_permissions() {
286 assert!(principal_set(&[]).is_empty());
287 }
288
289 #[test]
299 fn diff_visibility_no_change_when_dest_grants_same_as_current_parent() {
300 let current_parent = vec![user("alice@example.com")];
301 let file = current_parent.clone(); let dest = vec![user("alice@example.com")];
303 let diff = diff_visibility(&file, ¤t_parent, &dest);
304 assert!(diff.added.is_empty());
305 assert!(diff.removed.is_empty());
306 }
307
308 #[test]
309 fn diff_visibility_detects_a_pure_increase() {
310 let current_parent = vec![user("alice@example.com")];
311 let file = current_parent.clone();
312 let dest = vec![user("alice@example.com"), user("bob@example.com")];
313 let diff = diff_visibility(&file, ¤t_parent, &dest);
314 assert_eq!(
315 diff.added,
316 BTreeSet::from([Principal::User("bob@example.com".to_string())])
317 );
318 assert!(diff.removed.is_empty());
319 }
320
321 #[test]
322 fn diff_visibility_detects_a_pure_decrease() {
323 let current_parent = vec![user("alice@example.com"), user("bob@example.com")];
324 let file = current_parent.clone();
325 let dest = vec![user("alice@example.com")];
326 let diff = diff_visibility(&file, ¤t_parent, &dest);
327 assert!(diff.added.is_empty());
328 assert_eq!(
329 diff.removed,
330 BTreeSet::from([Principal::User("bob@example.com".to_string())])
331 );
332 }
333
334 #[test]
335 fn diff_visibility_detects_both_an_increase_and_a_decrease() {
336 let current_parent = vec![user("alice@example.com")];
337 let file = current_parent.clone();
338 let dest = vec![user("bob@example.com")];
339 let diff = diff_visibility(&file, ¤t_parent, &dest);
340 assert_eq!(
341 diff.added,
342 BTreeSet::from([Principal::User("bob@example.com".to_string())])
343 );
344 assert_eq!(
345 diff.removed,
346 BTreeSet::from([Principal::User("alice@example.com".to_string())])
347 );
348 }
349
350 #[test]
351 fn diff_visibility_preserves_a_direct_grant_on_the_file_across_the_move() {
352 let file = vec![user("alice@example.com")];
356 let current_parent = vec![];
357 let dest = vec![];
358 let diff = diff_visibility(&file, ¤t_parent, &dest);
359 assert!(diff.added.is_empty());
360 assert!(diff.removed.is_empty());
361 }
362
363 #[test]
364 fn diff_visibility_orphan_file_with_no_current_parent_treats_everything_as_direct() {
365 let file = vec![user("alice@example.com")];
366 let current_parent = vec![]; let dest = vec![];
368 let diff = diff_visibility(&file, ¤t_parent, &dest);
369 assert!(diff.removed.is_empty());
372 }
373
374 #[test]
375 fn diff_visibility_unions_permissions_across_multiple_current_parents() {
376 let parent_a_perms = vec![user("carol@example.com")];
384 let parent_b_perms = vec![user("alice@example.com")];
385 let current_parent: Vec<DrivePermission> =
386 parent_a_perms.into_iter().chain(parent_b_perms).collect();
387 let file = current_parent.clone(); let dest = vec![user("carol@example.com")]; let diff = diff_visibility(&file, ¤t_parent, &dest);
390 assert_eq!(
391 diff.removed,
392 BTreeSet::from([Principal::User("alice@example.com".to_string())])
393 );
394 }
395
396 #[test]
397 fn diff_visibility_shadowed_grant_only_ever_produces_a_false_positive_on_removed() {
398 let file = vec![user("alice@example.com")]; let current_parent = vec![user("alice@example.com")]; let dest = vec![user("bob@example.com")]; let diff = diff_visibility(&file, ¤t_parent, &dest);
409 assert!(diff
412 .removed
413 .contains(&Principal::User("alice@example.com".to_string())));
414 assert_eq!(
416 diff.added,
417 BTreeSet::from([Principal::User("bob@example.com".to_string())])
418 );
419 }
420
421 #[test]
422 fn diff_visibility_no_op_move_reports_no_change() {
423 let perms = vec![user("alice@example.com"), domain("example.com")];
424 let diff = diff_visibility(&perms, &perms, &perms);
425 assert!(diff.added.is_empty());
426 assert!(diff.removed.is_empty());
427 }
428
429 fn diff_with(added: &[&str], removed: &[&str]) -> VisibilityDiff {
432 VisibilityDiff {
433 added: added
434 .iter()
435 .map(|e| Principal::User((*e).to_string()))
436 .collect(),
437 removed: removed
438 .iter()
439 .map(|e| Principal::User((*e).to_string()))
440 .collect(),
441 }
442 }
443
444 fn flags(increase: bool, decrease: bool, boundary: bool) -> MoveGateFlags {
445 MoveGateFlags {
446 allow_visibility_increase: increase,
447 allow_visibility_decrease: decrease,
448 allow_drive_boundary_crossing: boundary,
449 }
450 }
451
452 #[test]
453 fn classify_is_clear_when_nothing_changes_and_no_boundary_crossing() {
454 let diff = diff_with(&[], &[]);
455 assert_eq!(classify(&diff, false, flags(false, false, false)), None);
456 }
457
458 #[test]
459 fn classify_blocks_an_unallowed_increase() {
460 let diff = diff_with(&["bob@example.com"], &[]);
461 let reasons = classify(&diff, false, flags(false, false, false)).unwrap();
462 assert!(reasons.visibility_increase);
463 assert!(!reasons.visibility_decrease);
464 assert!(!reasons.drive_boundary_crossing);
465 }
466
467 #[test]
468 fn classify_allows_an_increase_when_opted_in() {
469 let diff = diff_with(&["bob@example.com"], &[]);
470 assert_eq!(classify(&diff, false, flags(true, false, false)), None);
471 }
472
473 #[test]
474 fn classify_blocks_an_unallowed_decrease() {
475 let diff = diff_with(&[], &["alice@example.com"]);
476 let reasons = classify(&diff, false, flags(false, false, false)).unwrap();
477 assert!(!reasons.visibility_increase);
478 assert!(reasons.visibility_decrease);
479 assert!(!reasons.drive_boundary_crossing);
480 }
481
482 #[test]
483 fn classify_allows_a_decrease_when_opted_in() {
484 let diff = diff_with(&[], &["alice@example.com"]);
485 assert_eq!(classify(&diff, false, flags(false, true, false)), None);
486 }
487
488 #[test]
489 fn classify_blocks_an_unallowed_boundary_crossing_even_with_no_visibility_change() {
490 let diff = diff_with(&[], &[]);
491 let reasons = classify(&diff, true, flags(false, false, false)).unwrap();
492 assert!(!reasons.visibility_increase);
493 assert!(!reasons.visibility_decrease);
494 assert!(reasons.drive_boundary_crossing);
495 }
496
497 #[test]
498 fn classify_allows_a_boundary_crossing_when_opted_in() {
499 let diff = diff_with(&[], &[]);
500 assert_eq!(classify(&diff, true, flags(false, false, true)), None);
501 }
502
503 #[test]
504 fn classify_reports_every_simultaneously_failing_gate() {
505 let diff = diff_with(&["bob@example.com"], &["alice@example.com"]);
506 let reasons = classify(&diff, true, flags(false, false, false)).unwrap();
507 assert!(reasons.visibility_increase);
508 assert!(reasons.visibility_decrease);
509 assert!(reasons.drive_boundary_crossing);
510 }
511
512 #[test]
513 fn classify_allows_when_every_relevant_flag_is_opted_in() {
514 let diff = diff_with(&["bob@example.com"], &["alice@example.com"]);
515 assert_eq!(classify(&diff, true, flags(true, true, true)), None);
516 }
517
518 #[test]
519 fn block_reasons_any_is_false_by_default() {
520 assert!(!BlockReasons::default().any());
521 }
522}