use std::collections::HashSet;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DriveOperation {
Read,
Create,
Upload,
Edit,
}
impl std::fmt::Display for DriveOperation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Read => "read",
Self::Create => "create",
Self::Upload => "upload",
Self::Edit => "edit",
};
write!(f, "{s}")
}
}
impl DriveOperation {
fn default_policy(self) -> Verdict {
match self {
Self::Read => Verdict::Allow,
Self::Create | Self::Upload | Self::Edit => Verdict::Deny,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FolderPermissionRule {
pub folder_id: String,
#[serde(default)]
pub recursive: bool,
#[serde(default)]
pub allow: HashSet<DriveOperation>,
#[serde(default)]
pub deny: HashSet<DriveOperation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Allow,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DecidingRule {
pub folder_id: String,
pub depth: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Decision {
pub verdict: Verdict,
pub decided_by: Option<DecidingRule>,
}
#[must_use]
pub fn resolve(chain: &[String], op: DriveOperation, rules: &[FolderPermissionRule]) -> Decision {
let mut best: Option<(usize, Verdict)> = None;
for (depth, folder_id) in chain.iter().enumerate() {
let mut deny_here = false;
let mut allow_here = false;
for rule in rules {
if rule.folder_id != *folder_id {
continue;
}
if depth > 0 && !rule.recursive {
continue;
}
deny_here |= rule.deny.contains(&op);
allow_here |= rule.allow.contains(&op);
}
if !deny_here && !allow_here {
continue;
}
let verdict = if deny_here {
Verdict::Deny
} else {
Verdict::Allow
};
let is_closer = match best {
Some((best_depth, _)) => depth < best_depth,
None => true,
};
if is_closer {
best = Some((depth, verdict));
}
}
match best {
Some((depth, verdict)) => Decision {
verdict,
decided_by: Some(DecidingRule {
folder_id: chain[depth].clone(),
depth,
}),
},
None => Decision {
verdict: op.default_policy(),
decided_by: None,
},
}
}
#[must_use]
pub fn combine_across_parents(
first: Decision,
rest: impl IntoIterator<Item = Decision>,
) -> Decision {
rest.into_iter().fold(first, |acc, next| {
if acc.verdict == Verdict::Deny {
acc
} else {
next
}
})
}
#[must_use]
pub fn decided_by_log_fields(decided_by: Option<&DecidingRule>) -> (Option<String>, Option<usize>) {
match decided_by {
Some(rule) => (Some(rule.folder_id.clone()), Some(rule.depth)),
None => (None, None),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn rule(
folder_id: &str,
recursive: bool,
allow: &[DriveOperation],
deny: &[DriveOperation],
) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: folder_id.to_string(),
recursive,
allow: allow.iter().copied().collect(),
deny: deny.iter().copied().collect(),
}
}
fn chain(ids: &[&str]) -> Vec<String> {
ids.iter().copied().map(ToString::to_string).collect()
}
#[test]
fn default_policy_allows_read_with_no_rules() {
let decision = resolve(&chain(&["a"]), DriveOperation::Read, &[]);
assert_eq!(decision.verdict, Verdict::Allow);
assert_eq!(decision.decided_by, None);
}
#[test]
fn default_policy_denies_create_upload_edit_with_no_rules() {
for op in [
DriveOperation::Create,
DriveOperation::Upload,
DriveOperation::Edit,
] {
let decision = resolve(&chain(&["a"]), op, &[]);
assert_eq!(
decision.verdict,
Verdict::Deny,
"{op:?} should default-deny"
);
assert_eq!(decision.decided_by, None);
}
}
#[test]
fn recursive_rule_matches_deep_descendant() {
let rules = [rule("root", true, &[DriveOperation::Create], &[])];
let decision = resolve(
&chain(&["child", "grandchild", "root"]),
DriveOperation::Create,
&rules,
);
assert_eq!(decision.verdict, Verdict::Allow);
assert_eq!(decision.decided_by.unwrap().folder_id, "root");
}
#[test]
fn non_recursive_rule_matches_own_folder_only() {
let rules = [rule("target", false, &[DriveOperation::Create], &[])];
let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
assert_eq!(decision.verdict, Verdict::Allow);
}
#[test]
fn non_recursive_rule_does_not_match_child() {
let rules = [rule("parent", false, &[DriveOperation::Create], &[])];
let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
assert_eq!(decision.verdict, Verdict::Deny);
assert_eq!(decision.decided_by, None);
}
#[test]
fn closest_ancestor_wins_deny_over_broader_allow() {
let rules = [
rule("child", true, &[], &[DriveOperation::Create]),
rule("parent", true, &[DriveOperation::Create], &[]),
];
let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
assert_eq!(decision.verdict, Verdict::Deny);
assert_eq!(decision.decided_by.unwrap().folder_id, "child");
}
#[test]
fn closest_ancestor_wins_allow_over_broader_deny() {
let rules = [
rule("child", true, &[DriveOperation::Create], &[]),
rule("parent", true, &[], &[DriveOperation::Create]),
];
let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
assert_eq!(decision.verdict, Verdict::Allow);
assert_eq!(decision.decided_by.unwrap().folder_id, "child");
}
#[test]
fn deny_beats_allow_at_equal_depth() {
let rules = [
rule("target", false, &[DriveOperation::Create], &[]),
rule("target", false, &[], &[DriveOperation::Create]),
];
let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
assert_eq!(decision.verdict, Verdict::Deny);
}
#[test]
fn rule_on_unrelated_folder_does_not_apply() {
let rules = [rule("unrelated", true, &[DriveOperation::Create], &[])];
let decision = resolve(
&chain(&["target", "parent"]),
DriveOperation::Create,
&rules,
);
assert_eq!(decision.verdict, Verdict::Deny);
assert_eq!(decision.decided_by, None);
}
#[test]
fn empty_chain_orphan_file_uses_default_policy_only() {
let rules = [rule("some-folder", true, &[DriveOperation::Create], &[])];
let decision = resolve(&[], DriveOperation::Create, &rules);
assert_eq!(decision.verdict, Verdict::Deny);
assert_eq!(decision.decided_by, None);
}
#[test]
fn display_matches_the_serde_lowercase_wire_form() {
assert_eq!(DriveOperation::Read.to_string(), "read");
assert_eq!(DriveOperation::Create.to_string(), "create");
assert_eq!(DriveOperation::Upload.to_string(), "upload");
assert_eq!(DriveOperation::Edit.to_string(), "edit");
}
#[test]
fn operations_on_one_rule_are_independent() {
let rules = [rule("target", false, &[DriveOperation::Create], &[])];
let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
assert_eq!(create.verdict, Verdict::Allow);
assert_eq!(
upload.verdict,
Verdict::Deny,
"an allow:[create] rule must not leak into upload"
);
}
#[test]
fn deny_list_and_allow_list_on_same_rule_apply_to_different_ops_independently() {
let rules = [rule(
"target",
false,
&[DriveOperation::Create],
&[DriveOperation::Edit],
)];
let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
assert_eq!(create.verdict, Verdict::Allow);
assert_eq!(edit.verdict, Verdict::Deny);
assert_eq!(
upload.verdict,
Verdict::Deny,
"no rule named upload; falls to default policy"
);
}
fn decision(verdict: Verdict) -> Decision {
Decision {
verdict,
decided_by: None,
}
}
#[test]
fn combine_across_parents_single_decision_returns_it_unchanged() {
let combined = combine_across_parents(decision(Verdict::Allow), []);
assert_eq!(combined.verdict, Verdict::Allow);
}
#[test]
fn combine_across_parents_deny_beats_allow_deny_first() {
let combined = combine_across_parents(decision(Verdict::Deny), [decision(Verdict::Allow)]);
assert_eq!(combined.verdict, Verdict::Deny);
}
#[test]
fn combine_across_parents_deny_beats_allow_allow_first() {
let combined = combine_across_parents(decision(Verdict::Allow), [decision(Verdict::Deny)]);
assert_eq!(combined.verdict, Verdict::Deny);
}
#[test]
fn decided_by_log_fields_none_yields_none_pair() {
assert_eq!(decided_by_log_fields(None), (None, None));
}
#[test]
fn decided_by_log_fields_some_extracts_folder_id_and_depth() {
let rule = DecidingRule {
folder_id: "folder-1".to_string(),
depth: 2,
};
assert_eq!(
decided_by_log_fields(Some(&rule)),
(Some("folder-1".to_string()), Some(2))
);
}
#[test]
fn combine_across_parents_all_allow_returns_allow() {
let combined = combine_across_parents(
decision(Verdict::Allow),
[decision(Verdict::Allow), decision(Verdict::Allow)],
);
assert_eq!(combined.verdict, Verdict::Allow);
}
}