use std::collections::BTreeSet;
use crate::drive::types::DrivePermission;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Principal {
User(String),
Group(String),
Domain(String),
Anyone,
}
impl std::fmt::Display for Principal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User(email) => write!(f, "user:{email}"),
Self::Group(email) => write!(f, "group:{email}"),
Self::Domain(domain) => write!(f, "domain:{domain}"),
Self::Anyone => write!(f, "anyone"),
}
}
}
#[must_use]
pub fn principal_set(perms: &[DrivePermission]) -> BTreeSet<Principal> {
perms.iter().filter_map(principal_of).collect()
}
fn principal_of(perm: &DrivePermission) -> Option<Principal> {
match perm.permission_type.as_str() {
"user" => perm.email_address.clone().map(Principal::User),
"group" => perm.email_address.clone().map(Principal::Group),
"domain" => perm.domain.clone().map(Principal::Domain),
"anyone" => Some(Principal::Anyone),
_ => None,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VisibilityDiff {
pub added: BTreeSet<Principal>,
pub removed: BTreeSet<Principal>,
}
#[must_use]
pub fn diff_visibility(
file_perms: &[DrivePermission],
current_parent_perms: &[DrivePermission],
dest_folder_perms: &[DrivePermission],
) -> VisibilityDiff {
let before = principal_set(file_perms);
let current_parent = principal_set(current_parent_perms);
let dest = principal_set(dest_folder_perms);
let direct_on_file: BTreeSet<Principal> = before.difference(¤t_parent).cloned().collect();
let after: BTreeSet<Principal> = direct_on_file.union(&dest).cloned().collect();
let added = after.difference(&before).cloned().collect();
let removed = before.difference(&after).cloned().collect();
VisibilityDiff { added, removed }
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
pub struct BlockReasons {
pub visibility_increase: bool,
pub visibility_decrease: bool,
pub drive_boundary_crossing: bool,
}
impl BlockReasons {
#[must_use]
pub fn any(self) -> bool {
self.visibility_increase || self.visibility_decrease || self.drive_boundary_crossing
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MoveGateFlags {
pub allow_visibility_increase: bool,
pub allow_visibility_decrease: bool,
pub allow_drive_boundary_crossing: bool,
}
#[must_use]
pub fn classify(
diff: &VisibilityDiff,
crosses_boundary: bool,
flags: MoveGateFlags,
) -> Option<BlockReasons> {
let reasons = BlockReasons {
visibility_increase: !diff.added.is_empty() && !flags.allow_visibility_increase,
visibility_decrease: !diff.removed.is_empty() && !flags.allow_visibility_decrease,
drive_boundary_crossing: crosses_boundary && !flags.allow_drive_boundary_crossing,
};
reasons.any().then_some(reasons)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn user(email: &str) -> DrivePermission {
DrivePermission {
id: format!("perm-{email}"),
permission_type: "user".to_string(),
role: "reader".to_string(),
email_address: Some(email.to_string()),
domain: None,
}
}
fn group(email: &str) -> DrivePermission {
DrivePermission {
permission_type: "group".to_string(),
email_address: Some(email.to_string()),
..user(email)
}
}
fn domain(name: &str) -> DrivePermission {
DrivePermission {
id: format!("perm-{name}"),
permission_type: "domain".to_string(),
role: "reader".to_string(),
email_address: None,
domain: Some(name.to_string()),
}
}
fn anyone() -> DrivePermission {
DrivePermission {
id: "perm-anyone".to_string(),
permission_type: "anyone".to_string(),
role: "reader".to_string(),
email_address: None,
domain: None,
}
}
#[test]
fn principal_set_maps_every_recognized_type() {
let perms = vec![
user("alice@example.com"),
group("team@example.com"),
domain("example.com"),
anyone(),
];
let set = principal_set(&perms);
assert!(set.contains(&Principal::User("alice@example.com".to_string())));
assert!(set.contains(&Principal::Group("team@example.com".to_string())));
assert!(set.contains(&Principal::Domain("example.com".to_string())));
assert!(set.contains(&Principal::Anyone));
assert_eq!(set.len(), 4);
}
#[test]
fn principal_set_skips_unrecognized_types() {
let mut weird = user("alice@example.com");
weird.permission_type = "somethingNew".to_string();
let set = principal_set(&[weird]);
assert!(set.is_empty());
}
#[test]
fn principal_set_skips_user_with_no_email_address() {
let mut malformed = user("alice@example.com");
malformed.email_address = None;
let set = principal_set(&[malformed]);
assert!(set.is_empty());
}
#[test]
fn principal_set_is_empty_for_no_permissions() {
assert!(principal_set(&[]).is_empty());
}
#[test]
fn diff_visibility_no_change_when_dest_grants_same_as_current_parent() {
let current_parent = vec![user("alice@example.com")];
let file = current_parent.clone(); let dest = vec![user("alice@example.com")];
let diff = diff_visibility(&file, ¤t_parent, &dest);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
}
#[test]
fn diff_visibility_detects_a_pure_increase() {
let current_parent = vec![user("alice@example.com")];
let file = current_parent.clone();
let dest = vec![user("alice@example.com"), user("bob@example.com")];
let diff = diff_visibility(&file, ¤t_parent, &dest);
assert_eq!(
diff.added,
BTreeSet::from([Principal::User("bob@example.com".to_string())])
);
assert!(diff.removed.is_empty());
}
#[test]
fn diff_visibility_detects_a_pure_decrease() {
let current_parent = vec![user("alice@example.com"), user("bob@example.com")];
let file = current_parent.clone();
let dest = vec![user("alice@example.com")];
let diff = diff_visibility(&file, ¤t_parent, &dest);
assert!(diff.added.is_empty());
assert_eq!(
diff.removed,
BTreeSet::from([Principal::User("bob@example.com".to_string())])
);
}
#[test]
fn diff_visibility_detects_both_an_increase_and_a_decrease() {
let current_parent = vec![user("alice@example.com")];
let file = current_parent.clone();
let dest = vec![user("bob@example.com")];
let diff = diff_visibility(&file, ¤t_parent, &dest);
assert_eq!(
diff.added,
BTreeSet::from([Principal::User("bob@example.com".to_string())])
);
assert_eq!(
diff.removed,
BTreeSet::from([Principal::User("alice@example.com".to_string())])
);
}
#[test]
fn diff_visibility_preserves_a_direct_grant_on_the_file_across_the_move() {
let file = vec![user("alice@example.com")];
let current_parent = vec![];
let dest = vec![];
let diff = diff_visibility(&file, ¤t_parent, &dest);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
}
#[test]
fn diff_visibility_orphan_file_with_no_current_parent_treats_everything_as_direct() {
let file = vec![user("alice@example.com")];
let current_parent = vec![]; let dest = vec![];
let diff = diff_visibility(&file, ¤t_parent, &dest);
assert!(diff.removed.is_empty());
}
#[test]
fn diff_visibility_unions_permissions_across_multiple_current_parents() {
let parent_a_perms = vec![user("carol@example.com")];
let parent_b_perms = vec![user("alice@example.com")];
let current_parent: Vec<DrivePermission> =
parent_a_perms.into_iter().chain(parent_b_perms).collect();
let file = current_parent.clone(); let dest = vec![user("carol@example.com")]; let diff = diff_visibility(&file, ¤t_parent, &dest);
assert_eq!(
diff.removed,
BTreeSet::from([Principal::User("alice@example.com".to_string())])
);
}
#[test]
fn diff_visibility_shadowed_grant_only_ever_produces_a_false_positive_on_removed() {
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);
assert!(diff
.removed
.contains(&Principal::User("alice@example.com".to_string())));
assert_eq!(
diff.added,
BTreeSet::from([Principal::User("bob@example.com".to_string())])
);
}
#[test]
fn diff_visibility_no_op_move_reports_no_change() {
let perms = vec![user("alice@example.com"), domain("example.com")];
let diff = diff_visibility(&perms, &perms, &perms);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
}
fn diff_with(added: &[&str], removed: &[&str]) -> VisibilityDiff {
VisibilityDiff {
added: added
.iter()
.map(|e| Principal::User((*e).to_string()))
.collect(),
removed: removed
.iter()
.map(|e| Principal::User((*e).to_string()))
.collect(),
}
}
fn flags(increase: bool, decrease: bool, boundary: bool) -> MoveGateFlags {
MoveGateFlags {
allow_visibility_increase: increase,
allow_visibility_decrease: decrease,
allow_drive_boundary_crossing: boundary,
}
}
#[test]
fn classify_is_clear_when_nothing_changes_and_no_boundary_crossing() {
let diff = diff_with(&[], &[]);
assert_eq!(classify(&diff, false, flags(false, false, false)), None);
}
#[test]
fn classify_blocks_an_unallowed_increase() {
let diff = diff_with(&["bob@example.com"], &[]);
let reasons = classify(&diff, false, flags(false, false, false)).unwrap();
assert!(reasons.visibility_increase);
assert!(!reasons.visibility_decrease);
assert!(!reasons.drive_boundary_crossing);
}
#[test]
fn classify_allows_an_increase_when_opted_in() {
let diff = diff_with(&["bob@example.com"], &[]);
assert_eq!(classify(&diff, false, flags(true, false, false)), None);
}
#[test]
fn classify_blocks_an_unallowed_decrease() {
let diff = diff_with(&[], &["alice@example.com"]);
let reasons = classify(&diff, false, flags(false, false, false)).unwrap();
assert!(!reasons.visibility_increase);
assert!(reasons.visibility_decrease);
assert!(!reasons.drive_boundary_crossing);
}
#[test]
fn classify_allows_a_decrease_when_opted_in() {
let diff = diff_with(&[], &["alice@example.com"]);
assert_eq!(classify(&diff, false, flags(false, true, false)), None);
}
#[test]
fn classify_blocks_an_unallowed_boundary_crossing_even_with_no_visibility_change() {
let diff = diff_with(&[], &[]);
let reasons = classify(&diff, true, flags(false, false, false)).unwrap();
assert!(!reasons.visibility_increase);
assert!(!reasons.visibility_decrease);
assert!(reasons.drive_boundary_crossing);
}
#[test]
fn classify_allows_a_boundary_crossing_when_opted_in() {
let diff = diff_with(&[], &[]);
assert_eq!(classify(&diff, true, flags(false, false, true)), None);
}
#[test]
fn classify_reports_every_simultaneously_failing_gate() {
let diff = diff_with(&["bob@example.com"], &["alice@example.com"]);
let reasons = classify(&diff, true, flags(false, false, false)).unwrap();
assert!(reasons.visibility_increase);
assert!(reasons.visibility_decrease);
assert!(reasons.drive_boundary_crossing);
}
#[test]
fn classify_allows_when_every_relevant_flag_is_opted_in() {
let diff = diff_with(&["bob@example.com"], &["alice@example.com"]);
assert_eq!(classify(&diff, true, flags(true, true, true)), None);
}
#[test]
fn block_reasons_any_is_false_by_default() {
assert!(!BlockReasons::default().any());
}
}