1use std::collections::HashSet;
44
45use serde::{Deserialize, Serialize};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum DriveOperation {
53 Read,
55 Create,
57 Upload,
59 Edit,
61}
62
63impl std::fmt::Display for DriveOperation {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 let s = match self {
68 Self::Read => "read",
69 Self::Create => "create",
70 Self::Upload => "upload",
71 Self::Edit => "edit",
72 };
73 write!(f, "{s}")
74 }
75}
76
77impl DriveOperation {
78 fn default_policy(self) -> Verdict {
84 match self {
85 Self::Read => Verdict::Allow,
86 Self::Create | Self::Upload | Self::Edit => Verdict::Deny,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct FolderPermissionRule {
100 pub folder_id: String,
102 #[serde(default)]
106 pub recursive: bool,
107 #[serde(default)]
109 pub allow: HashSet<DriveOperation>,
110 #[serde(default)]
112 pub deny: HashSet<DriveOperation>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Verdict {
118 Allow,
120 Deny,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131pub struct DecidingRule {
132 pub folder_id: String,
134 pub depth: usize,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Decision {
143 pub verdict: Verdict,
145 pub decided_by: Option<DecidingRule>,
148}
149
150#[must_use]
154pub fn resolve(chain: &[String], op: DriveOperation, rules: &[FolderPermissionRule]) -> Decision {
155 let mut best: Option<(usize, Verdict)> = None;
156 for (depth, folder_id) in chain.iter().enumerate() {
157 let mut deny_here = false;
158 let mut allow_here = false;
159 for rule in rules {
160 if rule.folder_id != *folder_id {
161 continue;
162 }
163 if depth > 0 && !rule.recursive {
164 continue;
165 }
166 deny_here |= rule.deny.contains(&op);
167 allow_here |= rule.allow.contains(&op);
168 }
169 if !deny_here && !allow_here {
170 continue;
171 }
172 let verdict = if deny_here {
173 Verdict::Deny
174 } else {
175 Verdict::Allow
176 };
177 let is_closer = match best {
178 Some((best_depth, _)) => depth < best_depth,
179 None => true,
180 };
181 if is_closer {
182 best = Some((depth, verdict));
183 }
184 }
185 match best {
186 Some((depth, verdict)) => Decision {
187 verdict,
188 decided_by: Some(DecidingRule {
189 folder_id: chain[depth].clone(),
190 depth,
191 }),
192 },
193 None => Decision {
194 verdict: op.default_policy(),
195 decided_by: None,
196 },
197 }
198}
199
200#[must_use]
217pub fn combine_across_parents(
218 first: Decision,
219 rest: impl IntoIterator<Item = Decision>,
220) -> Decision {
221 rest.into_iter().fold(first, |acc, next| {
222 if acc.verdict == Verdict::Deny {
223 acc
224 } else {
225 next
226 }
227 })
228}
229
230#[must_use]
243pub fn decided_by_log_fields(decided_by: Option<&DecidingRule>) -> (Option<String>, Option<usize>) {
244 match decided_by {
245 Some(rule) => (Some(rule.folder_id.clone()), Some(rule.depth)),
246 None => (None, None),
247 }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used, clippy::expect_used)]
252mod tests {
253 use super::*;
254
255 fn rule(
256 folder_id: &str,
257 recursive: bool,
258 allow: &[DriveOperation],
259 deny: &[DriveOperation],
260 ) -> FolderPermissionRule {
261 FolderPermissionRule {
262 folder_id: folder_id.to_string(),
263 recursive,
264 allow: allow.iter().copied().collect(),
265 deny: deny.iter().copied().collect(),
266 }
267 }
268
269 fn chain(ids: &[&str]) -> Vec<String> {
270 ids.iter().copied().map(ToString::to_string).collect()
271 }
272
273 #[test]
274 fn default_policy_allows_read_with_no_rules() {
275 let decision = resolve(&chain(&["a"]), DriveOperation::Read, &[]);
276 assert_eq!(decision.verdict, Verdict::Allow);
277 assert_eq!(decision.decided_by, None);
278 }
279
280 #[test]
281 fn default_policy_denies_create_upload_edit_with_no_rules() {
282 for op in [
283 DriveOperation::Create,
284 DriveOperation::Upload,
285 DriveOperation::Edit,
286 ] {
287 let decision = resolve(&chain(&["a"]), op, &[]);
288 assert_eq!(
289 decision.verdict,
290 Verdict::Deny,
291 "{op:?} should default-deny"
292 );
293 assert_eq!(decision.decided_by, None);
294 }
295 }
296
297 #[test]
298 fn recursive_rule_matches_deep_descendant() {
299 let rules = [rule("root", true, &[DriveOperation::Create], &[])];
300 let decision = resolve(
301 &chain(&["child", "grandchild", "root"]),
302 DriveOperation::Create,
303 &rules,
304 );
305 assert_eq!(decision.verdict, Verdict::Allow);
306 assert_eq!(decision.decided_by.unwrap().folder_id, "root");
307 }
308
309 #[test]
310 fn non_recursive_rule_matches_own_folder_only() {
311 let rules = [rule("target", false, &[DriveOperation::Create], &[])];
312 let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
313 assert_eq!(decision.verdict, Verdict::Allow);
314 }
315
316 #[test]
317 fn non_recursive_rule_does_not_match_child() {
318 let rules = [rule("parent", false, &[DriveOperation::Create], &[])];
319 let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
320 assert_eq!(decision.verdict, Verdict::Deny);
323 assert_eq!(decision.decided_by, None);
324 }
325
326 #[test]
327 fn closest_ancestor_wins_deny_over_broader_allow() {
328 let rules = [
329 rule("child", true, &[], &[DriveOperation::Create]),
330 rule("parent", true, &[DriveOperation::Create], &[]),
331 ];
332 let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
333 assert_eq!(decision.verdict, Verdict::Deny);
334 assert_eq!(decision.decided_by.unwrap().folder_id, "child");
335 }
336
337 #[test]
338 fn closest_ancestor_wins_allow_over_broader_deny() {
339 let rules = [
342 rule("child", true, &[DriveOperation::Create], &[]),
343 rule("parent", true, &[], &[DriveOperation::Create]),
344 ];
345 let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
346 assert_eq!(decision.verdict, Verdict::Allow);
347 assert_eq!(decision.decided_by.unwrap().folder_id, "child");
348 }
349
350 #[test]
351 fn deny_beats_allow_at_equal_depth() {
352 let rules = [
353 rule("target", false, &[DriveOperation::Create], &[]),
354 rule("target", false, &[], &[DriveOperation::Create]),
355 ];
356 let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
357 assert_eq!(decision.verdict, Verdict::Deny);
358 }
359
360 #[test]
361 fn rule_on_unrelated_folder_does_not_apply() {
362 let rules = [rule("unrelated", true, &[DriveOperation::Create], &[])];
363 let decision = resolve(
364 &chain(&["target", "parent"]),
365 DriveOperation::Create,
366 &rules,
367 );
368 assert_eq!(decision.verdict, Verdict::Deny);
369 assert_eq!(decision.decided_by, None);
370 }
371
372 #[test]
373 fn empty_chain_orphan_file_uses_default_policy_only() {
374 let rules = [rule("some-folder", true, &[DriveOperation::Create], &[])];
375 let decision = resolve(&[], DriveOperation::Create, &rules);
376 assert_eq!(decision.verdict, Verdict::Deny);
377 assert_eq!(decision.decided_by, None);
378 }
379
380 #[test]
381 fn display_matches_the_serde_lowercase_wire_form() {
382 assert_eq!(DriveOperation::Read.to_string(), "read");
383 assert_eq!(DriveOperation::Create.to_string(), "create");
384 assert_eq!(DriveOperation::Upload.to_string(), "upload");
385 assert_eq!(DriveOperation::Edit.to_string(), "edit");
386 }
387
388 #[test]
389 fn operations_on_one_rule_are_independent() {
390 let rules = [rule("target", false, &[DriveOperation::Create], &[])];
391 let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
392 let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
393 assert_eq!(create.verdict, Verdict::Allow);
394 assert_eq!(
395 upload.verdict,
396 Verdict::Deny,
397 "an allow:[create] rule must not leak into upload"
398 );
399 }
400
401 #[test]
402 fn deny_list_and_allow_list_on_same_rule_apply_to_different_ops_independently() {
403 let rules = [rule(
404 "target",
405 false,
406 &[DriveOperation::Create],
407 &[DriveOperation::Edit],
408 )];
409 let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
410 let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
411 let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
412 assert_eq!(create.verdict, Verdict::Allow);
413 assert_eq!(edit.verdict, Verdict::Deny);
414 assert_eq!(
415 upload.verdict,
416 Verdict::Deny,
417 "no rule named upload; falls to default policy"
418 );
419 }
420
421 fn decision(verdict: Verdict) -> Decision {
424 Decision {
425 verdict,
426 decided_by: None,
427 }
428 }
429
430 #[test]
431 fn combine_across_parents_single_decision_returns_it_unchanged() {
432 let combined = combine_across_parents(decision(Verdict::Allow), []);
433 assert_eq!(combined.verdict, Verdict::Allow);
434 }
435
436 #[test]
437 fn combine_across_parents_deny_beats_allow_deny_first() {
438 let combined = combine_across_parents(decision(Verdict::Deny), [decision(Verdict::Allow)]);
439 assert_eq!(combined.verdict, Verdict::Deny);
440 }
441
442 #[test]
443 fn combine_across_parents_deny_beats_allow_allow_first() {
444 let combined = combine_across_parents(decision(Verdict::Allow), [decision(Verdict::Deny)]);
445 assert_eq!(combined.verdict, Verdict::Deny);
446 }
447
448 #[test]
451 fn decided_by_log_fields_none_yields_none_pair() {
452 assert_eq!(decided_by_log_fields(None), (None, None));
453 }
454
455 #[test]
456 fn decided_by_log_fields_some_extracts_folder_id_and_depth() {
457 let rule = DecidingRule {
458 folder_id: "folder-1".to_string(),
459 depth: 2,
460 };
461 assert_eq!(
462 decided_by_log_fields(Some(&rule)),
463 (Some("folder-1".to_string()), Some(2))
464 );
465 }
466
467 #[test]
468 fn combine_across_parents_all_allow_returns_allow() {
469 let combined = combine_across_parents(
470 decision(Verdict::Allow),
471 [decision(Verdict::Allow), decision(Verdict::Allow)],
472 );
473 assert_eq!(combined.verdict, Verdict::Allow);
474 }
475}