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