1use anyhow::Result;
15
16use crate::drive::files_api::FilesApi;
17use crate::drive::types::DriveFile;
18use crate::drive::write_gate::{self, Decision, DriveOperation, FolderPermissionRule};
19
20const MAX_CHAIN_DEPTH: usize = 100;
23
24#[derive(Debug, Clone)]
29pub struct AncestorChain {
30 pub folders: Vec<DriveFile>,
32}
33
34impl AncestorChain {
35 #[must_use]
37 pub fn folder_ids(&self) -> Vec<String> {
38 self.folders.iter().map(|f| f.id.clone()).collect()
39 }
40}
41
42pub async fn resolve_ancestor_chain(
57 files_api: &FilesApi<'_>,
58 start_folder_id: &str,
59) -> Result<AncestorChain> {
60 let mut folders = Vec::new();
61 let mut current_id = start_folder_id.to_string();
62 loop {
63 anyhow::ensure!(
64 folders.len() < MAX_CHAIN_DEPTH,
65 "folder ancestry for '{start_folder_id}' exceeds {MAX_CHAIN_DEPTH} levels; \
66 refusing to resolve a possibly-cyclic or pathological chain rather than \
67 truncating it"
68 );
69 let folder = files_api.get_metadata(¤t_id).await?;
70 let next_parent = folder.parents.first().cloned();
71 folders.push(folder);
72 match next_parent {
73 Some(parent_id) => current_id = parent_id,
74 None => break,
75 }
76 }
77 Ok(AncestorChain { folders })
78}
79
80pub async fn resolve_decision(
94 files_api: &FilesApi<'_>,
95 start_folder_id: &str,
96 op: DriveOperation,
97 rules: &[FolderPermissionRule],
98) -> Result<Decision> {
99 let start = files_api.get_metadata(start_folder_id).await?;
100 resolve_decision_from(files_api, start, op, rules).await
101}
102
103pub async fn resolve_decision_from(
110 files_api: &FilesApi<'_>,
111 start: DriveFile,
112 op: DriveOperation,
113 rules: &[FolderPermissionRule],
114) -> Result<Decision> {
115 let start_id = start.id.clone();
116 let mut next_parent = start.parents.first().cloned();
117 let mut folder_ids = vec![start.id];
118 loop {
119 let decision = write_gate::resolve(&folder_ids, op, rules);
125 if decision.decided_by.is_some() {
126 return Ok(decision);
127 }
128 let Some(parent_id) = next_parent else {
129 return Ok(decision);
130 };
131 anyhow::ensure!(
132 folder_ids.len() < MAX_CHAIN_DEPTH,
133 "folder ancestry for '{start_id}' exceeds {MAX_CHAIN_DEPTH} levels; refusing to \
134 resolve a possibly-cyclic or pathological chain rather than truncating it"
135 );
136 let folder = files_api.get_metadata(&parent_id).await?;
137 next_parent = folder.parents.first().cloned();
138 folder_ids.push(folder.id);
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum DecisionSource {
148 FileRule,
152 FolderChain,
155 NoVisibleParents,
161}
162
163#[derive(Debug, Clone)]
165pub struct FileTargetDecision {
166 pub decision: Decision,
168 pub resolved_folder_id: Option<String>,
172 pub source: DecisionSource,
174 pub requires_lease: bool,
200}
201
202pub async fn resolve_decision_for_file_target(
221 files_api: &FilesApi<'_>,
222 target: &DriveFile,
223 op: DriveOperation,
224 rules: &[FolderPermissionRule],
225) -> Result<FileTargetDecision> {
226 if let Some(decision) = write_gate::resolve_file_rule(&target.id, op, rules) {
227 let requires_lease =
228 write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
229 return Ok(FileTargetDecision {
230 decision,
231 resolved_folder_id: None,
232 source: DecisionSource::FileRule,
233 requires_lease,
234 });
235 }
236 if target.parents.is_empty() {
237 let decision = write_gate::resolve(&[], op, rules);
238 let requires_lease =
239 write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
240 return Ok(FileTargetDecision {
241 decision,
242 resolved_folder_id: None,
243 source: DecisionSource::NoVisibleParents,
244 requires_lease,
245 });
246 }
247 let (decision, resolved_folder_id, requires_lease) =
248 resolve_decision_for_parents(files_api, &target.parents, op, rules).await?;
249 Ok(FileTargetDecision {
250 decision,
251 resolved_folder_id,
252 source: DecisionSource::FolderChain,
253 requires_lease,
254 })
255}
256
257async fn resolve_decision_for_parents(
281 files_api: &FilesApi<'_>,
282 parents: &[String],
283 op: DriveOperation,
284 rules: &[FolderPermissionRule],
285) -> Result<(Decision, Option<String>, bool)> {
286 let Some((first_parent, rest_parents)) = parents.split_first() else {
287 let decision = write_gate::resolve(&[], op, rules);
288 let requires_lease =
289 write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
290 return Ok((decision, None, requires_lease));
291 };
292 let mut combined = resolve_decision(files_api, first_parent, op, rules).await?;
293 let mut requires_lease =
294 write_gate::decided_rule_requires_lease(combined.decided_by.as_ref(), op, rules);
295 for parent_id in rest_parents {
296 let decision = resolve_decision(files_api, parent_id, op, rules).await?;
297 requires_lease |=
298 write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
299 combined = write_gate::combine_across_parents(combined, [decision]);
300 }
301 let resolved_folder_id = rest_parents.is_empty().then(|| first_parent.clone());
302 Ok((combined, resolved_folder_id, requires_lease))
303}
304
305#[cfg(test)]
306#[allow(clippy::unwrap_used, clippy::expect_used)]
307mod tests {
308 use super::*;
309 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
310 use crate::drive::client::DriveClient;
311 use crate::utils::secret::Secret;
312
313 fn test_credentials() -> DriveCredentials {
314 DriveCredentials {
315 client_id: "client-1".to_string(),
316 client_secret: Secret::new("secret-1"),
317 refresh_token: Secret::new("refresh-1"),
318 scope: DriveGrantedScopes::READONLY,
319 }
320 }
321
322 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
323 wiremock::Mock::given(wiremock::matchers::method("POST"))
324 .and(wiremock::matchers::path("/token"))
325 .respond_with(
326 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
327 "access_token": "test-token",
328 "expires_in": 3600,
329 })),
330 )
331 .mount(server)
332 .await;
333
334 let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
335 crate::drive::client::test_support::replace_session(
336 &mut client,
337 &test_credentials(),
338 &format!("{}/token", server.uri()),
339 );
340 client
341 }
342
343 fn mount_folder(id: &str, parent: Option<&str>) -> wiremock::Mock {
344 let parents: Vec<&str> = parent.into_iter().collect();
345 wiremock::Mock::given(wiremock::matchers::method("GET"))
346 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
347 .respond_with(
348 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
349 "id": id,
350 "name": id,
351 "mimeType": "application/vnd.google-apps.folder",
352 "parents": parents,
353 })),
354 )
355 }
356
357 #[tokio::test]
358 async fn resolves_a_single_folder_with_no_parent_as_a_one_element_chain() {
359 let server = wiremock::MockServer::start().await;
360 let client = client_with_bootstrapped_token(&server).await;
361 mount_folder("root", None).mount(&server).await;
362 let files_api = FilesApi::new(&client);
363
364 let chain = resolve_ancestor_chain(&files_api, "root").await.unwrap();
365 assert_eq!(chain.folder_ids(), vec!["root".to_string()]);
366 }
367
368 #[tokio::test]
369 async fn walks_the_full_ancestor_chain_via_files_get() {
370 let server = wiremock::MockServer::start().await;
371 let client = client_with_bootstrapped_token(&server).await;
372 mount_folder("child", Some("parent")).mount(&server).await;
373 mount_folder("parent", Some("grandparent"))
374 .mount(&server)
375 .await;
376 mount_folder("grandparent", None).mount(&server).await;
377 let files_api = FilesApi::new(&client);
378
379 let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
380 assert_eq!(
381 chain.folder_ids(),
382 vec![
383 "child".to_string(),
384 "parent".to_string(),
385 "grandparent".to_string(),
386 ]
387 );
388 }
389
390 #[tokio::test]
391 async fn legacy_multi_parent_folder_walks_the_first_parent_only() {
392 let server = wiremock::MockServer::start().await;
393 let client = client_with_bootstrapped_token(&server).await;
394 wiremock::Mock::given(wiremock::matchers::method("GET"))
395 .and(wiremock::matchers::path("/drive/v3/files/child"))
396 .respond_with(
397 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
398 "id": "child",
399 "name": "child",
400 "mimeType": "application/vnd.google-apps.folder",
401 "parents": ["first-parent", "second-parent"],
402 })),
403 )
404 .mount(&server)
405 .await;
406 mount_folder("first-parent", None).mount(&server).await;
407 let files_api = FilesApi::new(&client);
410
411 let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
412 assert_eq!(
413 chain.folder_ids(),
414 vec!["child".to_string(), "first-parent".to_string()]
415 );
416 }
417
418 #[tokio::test]
425 async fn ancestor_chain_fetch_failure_returns_err_not_a_truncated_chain() {
426 let server = wiremock::MockServer::start().await;
427 let client = client_with_bootstrapped_token(&server).await;
428 mount_folder("child", Some("parent")).mount(&server).await;
429 wiremock::Mock::given(wiremock::matchers::method("GET"))
430 .and(wiremock::matchers::path("/drive/v3/files/parent"))
431 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
432 .mount(&server)
433 .await;
434 let files_api = FilesApi::new(&client);
435
436 let result = resolve_ancestor_chain(&files_api, "child").await;
437 assert!(
438 result.is_err(),
439 "a mid-walk fetch failure must be Err, not an Ok chain truncated at \"child\""
440 );
441 }
442
443 #[tokio::test]
444 async fn missing_start_folder_is_an_error() {
445 let server = wiremock::MockServer::start().await;
446 let client = client_with_bootstrapped_token(&server).await;
447 wiremock::Mock::given(wiremock::matchers::method("GET"))
448 .and(wiremock::matchers::path("/drive/v3/files/missing"))
449 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
450 .mount(&server)
451 .await;
452 let files_api = FilesApi::new(&client);
453
454 let result = resolve_ancestor_chain(&files_api, "missing").await;
455 assert!(result.is_err());
456 }
457
458 #[tokio::test]
461 async fn resolve_decision_stops_walking_once_a_rule_decides_it() {
462 let server = wiremock::MockServer::start().await;
463 let client = client_with_bootstrapped_token(&server).await;
464 mount_folder("child", Some("parent")).mount(&server).await;
465 let files_api = FilesApi::new(&client);
469 let rules = [FolderPermissionRule {
470 folder_id: Some("child".to_string()),
471 file_id: None,
472 recursive: false,
473 allow: std::iter::once(DriveOperation::Create).collect(),
474 deny: std::collections::HashSet::default(),
475 require_lease: true,
476 }];
477
478 let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &rules)
479 .await
480 .unwrap();
481 assert_eq!(decision.verdict, write_gate::Verdict::Allow);
482 }
483
484 #[tokio::test]
485 async fn resolve_decision_walks_to_root_when_nothing_matches() {
486 let server = wiremock::MockServer::start().await;
487 let client = client_with_bootstrapped_token(&server).await;
488 mount_folder("child", Some("parent")).mount(&server).await;
489 mount_folder("parent", Some("grandparent"))
490 .mount(&server)
491 .await;
492 mount_folder("grandparent", None).mount(&server).await;
493 let files_api = FilesApi::new(&client);
494
495 let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &[])
496 .await
497 .unwrap();
498 assert_eq!(decision.verdict, write_gate::Verdict::Deny);
499 assert_eq!(decision.decided_by, None);
500 }
501
502 #[tokio::test]
503 async fn resolve_decision_fetch_failure_returns_err_not_allow() {
504 let server = wiremock::MockServer::start().await;
505 let client = client_with_bootstrapped_token(&server).await;
506 mount_folder("child", Some("parent")).mount(&server).await;
507 wiremock::Mock::given(wiremock::matchers::method("GET"))
508 .and(wiremock::matchers::path("/drive/v3/files/parent"))
509 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
510 .mount(&server)
511 .await;
512 let files_api = FilesApi::new(&client);
513
514 let result = resolve_decision(&files_api, "child", DriveOperation::Create, &[]).await;
515 assert!(result.is_err());
516 }
517
518 #[tokio::test]
519 async fn resolve_decision_from_never_refetches_the_supplied_start() {
520 let server = wiremock::MockServer::start().await;
521 let client = client_with_bootstrapped_token(&server).await;
522 let files_api = FilesApi::new(&client);
525 let start = DriveFile {
526 id: "child".to_string(),
527 name: "child".to_string(),
528 mime_type: "application/vnd.google-apps.folder".to_string(),
529 parents: vec![],
530 ..Default::default()
531 };
532 let rules = [FolderPermissionRule {
533 folder_id: Some("child".to_string()),
534 file_id: None,
535 recursive: false,
536 allow: std::iter::once(DriveOperation::Read).collect(),
537 deny: std::collections::HashSet::default(),
538 require_lease: true,
539 }];
540
541 let decision = resolve_decision_from(&files_api, start, DriveOperation::Read, &rules)
542 .await
543 .unwrap();
544 assert_eq!(decision.verdict, write_gate::Verdict::Allow);
545 }
546
547 #[tokio::test]
550 async fn resolve_decision_for_parents_empty_parents_uses_default_policy() {
551 let server = wiremock::MockServer::start().await;
552 let client = client_with_bootstrapped_token(&server).await;
553 let files_api = FilesApi::new(&client);
554
555 let (decision, resolved_folder_id, requires_lease) =
556 resolve_decision_for_parents(&files_api, &[], DriveOperation::Edit, &[])
557 .await
558 .unwrap();
559 assert_eq!(decision.verdict, write_gate::Verdict::Deny);
560 assert_eq!(resolved_folder_id, None);
561 assert!(requires_lease, "no decided_by defaults to requiring one");
562 }
563
564 #[tokio::test]
565 async fn resolve_decision_for_parents_single_parent_reports_its_folder_id() {
566 let server = wiremock::MockServer::start().await;
567 let client = client_with_bootstrapped_token(&server).await;
568 mount_folder("parent-1", None).mount(&server).await;
569 let files_api = FilesApi::new(&client);
570 let rules = [FolderPermissionRule {
571 folder_id: Some("parent-1".to_string()),
572 file_id: None,
573 recursive: false,
574 allow: std::iter::once(DriveOperation::Edit).collect(),
575 deny: std::collections::HashSet::default(),
576 require_lease: true,
577 }];
578
579 let (decision, resolved_folder_id, _requires_lease) = resolve_decision_for_parents(
580 &files_api,
581 &["parent-1".to_string()],
582 DriveOperation::Edit,
583 &rules,
584 )
585 .await
586 .unwrap();
587 assert_eq!(decision.verdict, write_gate::Verdict::Allow);
588 assert_eq!(resolved_folder_id, Some("parent-1".to_string()));
589 }
590
591 #[tokio::test]
592 async fn resolve_decision_for_parents_deny_wins_across_parents_and_reports_no_single_folder() {
593 let server = wiremock::MockServer::start().await;
594 let client = client_with_bootstrapped_token(&server).await;
595 mount_folder("allow-parent", None).mount(&server).await;
596 mount_folder("deny-parent", None).mount(&server).await;
597 let files_api = FilesApi::new(&client);
598 let rules = [
599 FolderPermissionRule {
600 folder_id: Some("allow-parent".to_string()),
601 file_id: None,
602 recursive: false,
603 allow: std::iter::once(DriveOperation::Edit).collect(),
604 deny: std::collections::HashSet::default(),
605 require_lease: true,
606 },
607 FolderPermissionRule {
608 folder_id: Some("deny-parent".to_string()),
609 file_id: None,
610 recursive: false,
611 allow: std::collections::HashSet::default(),
612 deny: std::iter::once(DriveOperation::Edit).collect(),
613 require_lease: true,
614 },
615 ];
616
617 let (decision, resolved_folder_id, _requires_lease) = resolve_decision_for_parents(
618 &files_api,
619 &["allow-parent".to_string(), "deny-parent".to_string()],
620 DriveOperation::Edit,
621 &rules,
622 )
623 .await
624 .unwrap();
625 assert_eq!(decision.verdict, write_gate::Verdict::Deny);
626 assert_eq!(resolved_folder_id, None);
627 }
628
629 #[tokio::test]
630 async fn resolve_decision_for_parents_lease_requirement_is_the_or_of_every_parent_even_though_only_one_decides_the_verdict(
631 ) {
632 let server = wiremock::MockServer::start().await;
640 let client = client_with_bootstrapped_token(&server).await;
641 mount_folder("strict-parent", None).mount(&server).await;
642 mount_folder("lenient-parent", None).mount(&server).await;
643 let files_api = FilesApi::new(&client);
644 let rules = [
645 FolderPermissionRule::folder("strict-parent").allowing([DriveOperation::Edit]),
646 FolderPermissionRule::folder("lenient-parent")
647 .allowing([DriveOperation::Edit])
648 .requiring_lease(false),
649 ];
650
651 let (decision, _resolved_folder_id, requires_lease) = resolve_decision_for_parents(
652 &files_api,
653 &["strict-parent".to_string(), "lenient-parent".to_string()],
654 DriveOperation::Edit,
655 &rules,
656 )
657 .await
658 .unwrap();
659 assert_eq!(decision.verdict, write_gate::Verdict::Allow);
660 assert!(
661 requires_lease,
662 "strict-parent's lease requirement must survive even though \
663 lenient-parent's decision is the one the combined verdict reports"
664 );
665 }
666
667 fn target_file(id: &str, parents: &[&str]) -> DriveFile {
670 DriveFile {
671 id: id.to_string(),
672 name: format!("{id}-name"),
673 parents: parents.iter().map(|p| (*p).to_string()).collect(),
674 ..DriveFile::default()
675 }
676 }
677
678 #[tokio::test]
679 async fn a_file_rule_decides_without_fetching_any_parent() {
680 let server = wiremock::MockServer::start().await;
687 let client = client_with_bootstrapped_token(&server).await;
688 let files_api = FilesApi::new(&client);
689 let rules = [FolderPermissionRule::file("target").allowing([DriveOperation::SheetsWrite])];
690
691 let evaluated = resolve_decision_for_file_target(
692 &files_api,
693 &target_file("target", &["never-mounted"]),
694 DriveOperation::SheetsWrite,
695 &rules,
696 )
697 .await
698 .unwrap();
699
700 assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
701 assert_eq!(evaluated.source, DecisionSource::FileRule);
702 assert_eq!(evaluated.resolved_folder_id, None);
703 }
704
705 #[tokio::test]
706 async fn a_parentless_target_with_no_file_rule_reports_no_visible_parents() {
707 let server = wiremock::MockServer::start().await;
708 let client = client_with_bootstrapped_token(&server).await;
709 let files_api = FilesApi::new(&client);
710
711 let evaluated = resolve_decision_for_file_target(
712 &files_api,
713 &target_file("orphan", &[]),
714 DriveOperation::SheetsWrite,
715 &[],
716 )
717 .await
718 .unwrap();
719
720 assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
721 assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Deny);
722 assert_eq!(evaluated.decision.decided_by, None);
723 }
724
725 #[tokio::test]
726 async fn no_visible_parents_still_allows_read_by_default_policy() {
727 let server = wiremock::MockServer::start().await;
730 let client = client_with_bootstrapped_token(&server).await;
731 let files_api = FilesApi::new(&client);
732
733 let evaluated = resolve_decision_for_file_target(
734 &files_api,
735 &target_file("orphan", &[]),
736 DriveOperation::Read,
737 &[],
738 )
739 .await
740 .unwrap();
741
742 assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
743 assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
744 }
745
746 #[tokio::test]
747 async fn a_file_allow_beats_a_denying_parent() {
748 let server = wiremock::MockServer::start().await;
751 let client = client_with_bootstrapped_token(&server).await;
752 let files_api = FilesApi::new(&client);
753 let rules = [
754 FolderPermissionRule::folder("deny-parent").denying([DriveOperation::Edit]),
755 FolderPermissionRule::file("target").allowing([DriveOperation::Edit]),
756 ];
757
758 let evaluated = resolve_decision_for_file_target(
759 &files_api,
760 &target_file("target", &["deny-parent"]),
761 DriveOperation::Edit,
762 &rules,
763 )
764 .await
765 .unwrap();
766
767 assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
768 assert_eq!(evaluated.source, DecisionSource::FileRule);
769 }
770
771 #[tokio::test]
772 async fn a_target_with_parents_and_no_file_rule_walks_the_chain_as_before() {
773 let server = wiremock::MockServer::start().await;
774 let client = client_with_bootstrapped_token(&server).await;
775 mount_folder("parent", None).mount(&server).await;
776 let files_api = FilesApi::new(&client);
777 let rules = [FolderPermissionRule::folder("parent").allowing([DriveOperation::Edit])];
778
779 let evaluated = resolve_decision_for_file_target(
780 &files_api,
781 &target_file("target", &["parent"]),
782 DriveOperation::Edit,
783 &rules,
784 )
785 .await
786 .unwrap();
787
788 assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
789 assert_eq!(evaluated.source, DecisionSource::FolderChain);
790 assert_eq!(evaluated.resolved_folder_id.as_deref(), Some("parent"));
791 }
792}