use anyhow::Result;
use crate::drive::files_api::FilesApi;
use crate::drive::types::DriveFile;
use crate::drive::write_gate::{self, Decision, DriveOperation, FolderPermissionRule};
const MAX_CHAIN_DEPTH: usize = 100;
#[derive(Debug, Clone)]
pub struct AncestorChain {
pub folders: Vec<DriveFile>,
}
impl AncestorChain {
#[must_use]
pub fn folder_ids(&self) -> Vec<String> {
self.folders.iter().map(|f| f.id.clone()).collect()
}
}
pub async fn resolve_ancestor_chain(
files_api: &FilesApi<'_>,
start_folder_id: &str,
) -> Result<AncestorChain> {
let mut folders = Vec::new();
let mut current_id = start_folder_id.to_string();
loop {
anyhow::ensure!(
folders.len() < MAX_CHAIN_DEPTH,
"folder ancestry for '{start_folder_id}' exceeds {MAX_CHAIN_DEPTH} levels; \
refusing to resolve a possibly-cyclic or pathological chain rather than \
truncating it"
);
let folder = files_api.get_metadata(¤t_id).await?;
let next_parent = folder.parents.first().cloned();
folders.push(folder);
match next_parent {
Some(parent_id) => current_id = parent_id,
None => break,
}
}
Ok(AncestorChain { folders })
}
pub async fn resolve_decision(
files_api: &FilesApi<'_>,
start_folder_id: &str,
op: DriveOperation,
rules: &[FolderPermissionRule],
) -> Result<Decision> {
let start = files_api.get_metadata(start_folder_id).await?;
resolve_decision_from(files_api, start, op, rules).await
}
pub async fn resolve_decision_from(
files_api: &FilesApi<'_>,
start: DriveFile,
op: DriveOperation,
rules: &[FolderPermissionRule],
) -> Result<Decision> {
let start_id = start.id.clone();
let mut next_parent = start.parents.first().cloned();
let mut folder_ids = vec![start.id];
loop {
let decision = write_gate::resolve(&folder_ids, op, rules);
if decision.decided_by.is_some() {
return Ok(decision);
}
let Some(parent_id) = next_parent else {
return Ok(decision);
};
anyhow::ensure!(
folder_ids.len() < MAX_CHAIN_DEPTH,
"folder ancestry for '{start_id}' exceeds {MAX_CHAIN_DEPTH} levels; refusing to \
resolve a possibly-cyclic or pathological chain rather than truncating it"
);
let folder = files_api.get_metadata(&parent_id).await?;
next_parent = folder.parents.first().cloned();
folder_ids.push(folder.id);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionSource {
FileRule,
FolderChain,
NoVisibleParents,
}
#[derive(Debug, Clone)]
pub struct FileTargetDecision {
pub decision: Decision,
pub resolved_folder_id: Option<String>,
pub source: DecisionSource,
pub requires_lease: bool,
}
pub async fn resolve_decision_for_file_target(
files_api: &FilesApi<'_>,
target: &DriveFile,
op: DriveOperation,
rules: &[FolderPermissionRule],
) -> Result<FileTargetDecision> {
if let Some(decision) = write_gate::resolve_file_rule(&target.id, op, rules) {
let requires_lease =
write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
return Ok(FileTargetDecision {
decision,
resolved_folder_id: None,
source: DecisionSource::FileRule,
requires_lease,
});
}
if target.parents.is_empty() {
let decision = write_gate::resolve(&[], op, rules);
let requires_lease =
write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
return Ok(FileTargetDecision {
decision,
resolved_folder_id: None,
source: DecisionSource::NoVisibleParents,
requires_lease,
});
}
let (decision, resolved_folder_id, requires_lease) =
resolve_decision_for_parents(files_api, &target.parents, op, rules).await?;
Ok(FileTargetDecision {
decision,
resolved_folder_id,
source: DecisionSource::FolderChain,
requires_lease,
})
}
async fn resolve_decision_for_parents(
files_api: &FilesApi<'_>,
parents: &[String],
op: DriveOperation,
rules: &[FolderPermissionRule],
) -> Result<(Decision, Option<String>, bool)> {
let Some((first_parent, rest_parents)) = parents.split_first() else {
let decision = write_gate::resolve(&[], op, rules);
let requires_lease =
write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
return Ok((decision, None, requires_lease));
};
let mut combined = resolve_decision(files_api, first_parent, op, rules).await?;
let mut requires_lease =
write_gate::decided_rule_requires_lease(combined.decided_by.as_ref(), op, rules);
for parent_id in rest_parents {
let decision = resolve_decision(files_api, parent_id, op, rules).await?;
requires_lease |=
write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
combined = write_gate::combine_across_parents(combined, [decision]);
}
let resolved_folder_id = rest_parents.is_empty().then(|| first_parent.clone());
Ok((combined, resolved_folder_id, requires_lease))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::client::DriveClient;
use crate::utils::secret::Secret;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
fn mount_folder(id: &str, parent: Option<&str>) -> wiremock::Mock {
let parents: Vec<&str> = parent.into_iter().collect();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id,
"name": id,
"mimeType": "application/vnd.google-apps.folder",
"parents": parents,
})),
)
}
#[tokio::test]
async fn resolves_a_single_folder_with_no_parent_as_a_one_element_chain() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("root", None).mount(&server).await;
let files_api = FilesApi::new(&client);
let chain = resolve_ancestor_chain(&files_api, "root").await.unwrap();
assert_eq!(chain.folder_ids(), vec!["root".to_string()]);
}
#[tokio::test]
async fn walks_the_full_ancestor_chain_via_files_get() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("child", Some("parent")).mount(&server).await;
mount_folder("parent", Some("grandparent"))
.mount(&server)
.await;
mount_folder("grandparent", None).mount(&server).await;
let files_api = FilesApi::new(&client);
let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
assert_eq!(
chain.folder_ids(),
vec![
"child".to_string(),
"parent".to_string(),
"grandparent".to_string(),
]
);
}
#[tokio::test]
async fn legacy_multi_parent_folder_walks_the_first_parent_only() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/child"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "child",
"name": "child",
"mimeType": "application/vnd.google-apps.folder",
"parents": ["first-parent", "second-parent"],
})),
)
.mount(&server)
.await;
mount_folder("first-parent", None).mount(&server).await;
let files_api = FilesApi::new(&client);
let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
assert_eq!(
chain.folder_ids(),
vec!["child".to_string(), "first-parent".to_string()]
);
}
#[tokio::test]
async fn ancestor_chain_fetch_failure_returns_err_not_a_truncated_chain() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("child", Some("parent")).mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let result = resolve_ancestor_chain(&files_api, "child").await;
assert!(
result.is_err(),
"a mid-walk fetch failure must be Err, not an Ok chain truncated at \"child\""
);
}
#[tokio::test]
async fn missing_start_folder_is_an_error() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/missing"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let result = resolve_ancestor_chain(&files_api, "missing").await;
assert!(result.is_err());
}
#[tokio::test]
async fn resolve_decision_stops_walking_once_a_rule_decides_it() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("child", Some("parent")).mount(&server).await;
let files_api = FilesApi::new(&client);
let rules = [FolderPermissionRule {
folder_id: Some("child".to_string()),
file_id: None,
recursive: false,
allow: std::iter::once(DriveOperation::Create).collect(),
deny: std::collections::HashSet::default(),
require_lease: true,
}];
let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &rules)
.await
.unwrap();
assert_eq!(decision.verdict, write_gate::Verdict::Allow);
}
#[tokio::test]
async fn resolve_decision_walks_to_root_when_nothing_matches() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("child", Some("parent")).mount(&server).await;
mount_folder("parent", Some("grandparent"))
.mount(&server)
.await;
mount_folder("grandparent", None).mount(&server).await;
let files_api = FilesApi::new(&client);
let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &[])
.await
.unwrap();
assert_eq!(decision.verdict, write_gate::Verdict::Deny);
assert_eq!(decision.decided_by, None);
}
#[tokio::test]
async fn resolve_decision_fetch_failure_returns_err_not_allow() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("child", Some("parent")).mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let result = resolve_decision(&files_api, "child", DriveOperation::Create, &[]).await;
assert!(result.is_err());
}
#[tokio::test]
async fn resolve_decision_from_never_refetches_the_supplied_start() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let files_api = FilesApi::new(&client);
let start = DriveFile {
id: "child".to_string(),
name: "child".to_string(),
mime_type: "application/vnd.google-apps.folder".to_string(),
parents: vec![],
..Default::default()
};
let rules = [FolderPermissionRule {
folder_id: Some("child".to_string()),
file_id: None,
recursive: false,
allow: std::iter::once(DriveOperation::Read).collect(),
deny: std::collections::HashSet::default(),
require_lease: true,
}];
let decision = resolve_decision_from(&files_api, start, DriveOperation::Read, &rules)
.await
.unwrap();
assert_eq!(decision.verdict, write_gate::Verdict::Allow);
}
#[tokio::test]
async fn resolve_decision_for_parents_empty_parents_uses_default_policy() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let files_api = FilesApi::new(&client);
let (decision, resolved_folder_id, requires_lease) =
resolve_decision_for_parents(&files_api, &[], DriveOperation::Edit, &[])
.await
.unwrap();
assert_eq!(decision.verdict, write_gate::Verdict::Deny);
assert_eq!(resolved_folder_id, None);
assert!(requires_lease, "no decided_by defaults to requiring one");
}
#[tokio::test]
async fn resolve_decision_for_parents_single_parent_reports_its_folder_id() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("parent-1", None).mount(&server).await;
let files_api = FilesApi::new(&client);
let rules = [FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: false,
allow: std::iter::once(DriveOperation::Edit).collect(),
deny: std::collections::HashSet::default(),
require_lease: true,
}];
let (decision, resolved_folder_id, _requires_lease) = resolve_decision_for_parents(
&files_api,
&["parent-1".to_string()],
DriveOperation::Edit,
&rules,
)
.await
.unwrap();
assert_eq!(decision.verdict, write_gate::Verdict::Allow);
assert_eq!(resolved_folder_id, Some("parent-1".to_string()));
}
#[tokio::test]
async fn resolve_decision_for_parents_deny_wins_across_parents_and_reports_no_single_folder() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("allow-parent", None).mount(&server).await;
mount_folder("deny-parent", None).mount(&server).await;
let files_api = FilesApi::new(&client);
let rules = [
FolderPermissionRule {
folder_id: Some("allow-parent".to_string()),
file_id: None,
recursive: false,
allow: std::iter::once(DriveOperation::Edit).collect(),
deny: std::collections::HashSet::default(),
require_lease: true,
},
FolderPermissionRule {
folder_id: Some("deny-parent".to_string()),
file_id: None,
recursive: false,
allow: std::collections::HashSet::default(),
deny: std::iter::once(DriveOperation::Edit).collect(),
require_lease: true,
},
];
let (decision, resolved_folder_id, _requires_lease) = resolve_decision_for_parents(
&files_api,
&["allow-parent".to_string(), "deny-parent".to_string()],
DriveOperation::Edit,
&rules,
)
.await
.unwrap();
assert_eq!(decision.verdict, write_gate::Verdict::Deny);
assert_eq!(resolved_folder_id, None);
}
#[tokio::test]
async fn resolve_decision_for_parents_lease_requirement_is_the_or_of_every_parent_even_though_only_one_decides_the_verdict(
) {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("strict-parent", None).mount(&server).await;
mount_folder("lenient-parent", None).mount(&server).await;
let files_api = FilesApi::new(&client);
let rules = [
FolderPermissionRule::folder("strict-parent").allowing([DriveOperation::Edit]),
FolderPermissionRule::folder("lenient-parent")
.allowing([DriveOperation::Edit])
.requiring_lease(false),
];
let (decision, _resolved_folder_id, requires_lease) = resolve_decision_for_parents(
&files_api,
&["strict-parent".to_string(), "lenient-parent".to_string()],
DriveOperation::Edit,
&rules,
)
.await
.unwrap();
assert_eq!(decision.verdict, write_gate::Verdict::Allow);
assert!(
requires_lease,
"strict-parent's lease requirement must survive even though \
lenient-parent's decision is the one the combined verdict reports"
);
}
fn target_file(id: &str, parents: &[&str]) -> DriveFile {
DriveFile {
id: id.to_string(),
name: format!("{id}-name"),
parents: parents.iter().map(|p| (*p).to_string()).collect(),
..DriveFile::default()
}
}
#[tokio::test]
async fn a_file_rule_decides_without_fetching_any_parent() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let files_api = FilesApi::new(&client);
let rules = [FolderPermissionRule::file("target").allowing([DriveOperation::SheetsWrite])];
let evaluated = resolve_decision_for_file_target(
&files_api,
&target_file("target", &["never-mounted"]),
DriveOperation::SheetsWrite,
&rules,
)
.await
.unwrap();
assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
assert_eq!(evaluated.source, DecisionSource::FileRule);
assert_eq!(evaluated.resolved_folder_id, None);
}
#[tokio::test]
async fn a_parentless_target_with_no_file_rule_reports_no_visible_parents() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let files_api = FilesApi::new(&client);
let evaluated = resolve_decision_for_file_target(
&files_api,
&target_file("orphan", &[]),
DriveOperation::SheetsWrite,
&[],
)
.await
.unwrap();
assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Deny);
assert_eq!(evaluated.decision.decided_by, None);
}
#[tokio::test]
async fn no_visible_parents_still_allows_read_by_default_policy() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let files_api = FilesApi::new(&client);
let evaluated = resolve_decision_for_file_target(
&files_api,
&target_file("orphan", &[]),
DriveOperation::Read,
&[],
)
.await
.unwrap();
assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
}
#[tokio::test]
async fn a_file_allow_beats_a_denying_parent() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let files_api = FilesApi::new(&client);
let rules = [
FolderPermissionRule::folder("deny-parent").denying([DriveOperation::Edit]),
FolderPermissionRule::file("target").allowing([DriveOperation::Edit]),
];
let evaluated = resolve_decision_for_file_target(
&files_api,
&target_file("target", &["deny-parent"]),
DriveOperation::Edit,
&rules,
)
.await
.unwrap();
assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
assert_eq!(evaluated.source, DecisionSource::FileRule);
}
#[tokio::test]
async fn a_target_with_parents_and_no_file_rule_walks_the_chain_as_before() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_folder("parent", None).mount(&server).await;
let files_api = FilesApi::new(&client);
let rules = [FolderPermissionRule::folder("parent").allowing([DriveOperation::Edit])];
let evaluated = resolve_decision_for_file_target(
&files_api,
&target_file("target", &["parent"]),
DriveOperation::Edit,
&rules,
)
.await
.unwrap();
assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
assert_eq!(evaluated.source, DecisionSource::FolderChain);
assert_eq!(evaluated.resolved_folder_id.as_deref(), Some("parent"));
}
}