use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::folder_ancestry::{self, DecisionSource};
use crate::drive::types::{classify_sheet_target, DriveFile, SheetTargetRefusal};
use crate::drive::write_gate::{
DecidingRule, Decision, DriveOperation, FolderPermissionRule, Verdict,
};
pub(crate) enum TargetGateOutcome {
MetadataFetchFailed {
detail: String,
},
Refused {
target: DriveFile,
refusal: SheetTargetRefusal,
},
GateFetchFailed {
target: DriveFile,
detail: String,
},
Gated {
target: DriveFile,
decision: Decision,
resolved_folder_id: Option<String>,
requires_lease: bool,
},
}
pub(crate) async fn resolve(
drive: &DriveClient,
spreadsheet_id: &str,
operation: DriveOperation,
rules: &[FolderPermissionRule],
) -> TargetGateOutcome {
let files_api = FilesApi::new(drive);
let target = match files_api.get_metadata(spreadsheet_id).await {
Ok(target) => target,
Err(err) => {
return TargetGateOutcome::MetadataFetchFailed {
detail: err.to_string(),
}
}
};
if let Err(refusal) = classify_sheet_target(&target) {
return TargetGateOutcome::Refused { target, refusal };
}
match folder_ancestry::resolve_decision_for_file_target(&files_api, &target, operation, rules)
.await
{
Ok(evaluated) if evaluated.source == DecisionSource::NoVisibleParents => {
TargetGateOutcome::Refused {
target,
refusal: SheetTargetRefusal::NoVisibleParents,
}
}
Ok(evaluated) => TargetGateOutcome::Gated {
target,
decision: evaluated.decision,
resolved_folder_id: evaluated.resolved_folder_id,
requires_lease: evaluated.requires_lease,
},
Err(err) => TargetGateOutcome::GateFetchFailed {
target,
detail: err.to_string(),
},
}
}
pub(crate) enum TargetGateUnionOutcome {
MetadataFetchFailed {
detail: String,
},
Refused {
target: DriveFile,
refusal: SheetTargetRefusal,
},
GateFetchFailed {
target: DriveFile,
detail: String,
},
Gated {
target: DriveFile,
verdict: Verdict,
denied: Option<(DriveOperation, Option<DecidingRule>)>,
resolved_folder_id: Option<String>,
requires_lease: bool,
},
}
pub(crate) async fn resolve_all(
drive: &DriveClient,
spreadsheet_id: &str,
operations: &[DriveOperation],
rules: &[FolderPermissionRule],
) -> TargetGateUnionOutcome {
let files_api = FilesApi::new(drive);
let target = match files_api.get_metadata(spreadsheet_id).await {
Ok(target) => target,
Err(err) => {
return TargetGateUnionOutcome::MetadataFetchFailed {
detail: err.to_string(),
}
}
};
if let Err(refusal) = classify_sheet_target(&target) {
return TargetGateUnionOutcome::Refused { target, refusal };
}
let mut resolved_folder_id = None;
let mut requires_lease = false;
let mut denied = None;
let mut all_no_visible_parents = true;
for &operation in operations {
let evaluated = match folder_ancestry::resolve_decision_for_file_target(
&files_api, &target, operation, rules,
)
.await
{
Ok(evaluated) => evaluated,
Err(err) => {
return TargetGateUnionOutcome::GateFetchFailed {
target,
detail: err.to_string(),
}
}
};
all_no_visible_parents &= evaluated.source == DecisionSource::NoVisibleParents;
requires_lease |= evaluated.requires_lease;
if evaluated.resolved_folder_id.is_some() {
resolved_folder_id = evaluated.resolved_folder_id;
}
if evaluated.decision.verdict == Verdict::Deny && denied.is_none() {
denied = Some((operation, evaluated.decision.decided_by));
}
}
if all_no_visible_parents {
return TargetGateUnionOutcome::Refused {
target,
refusal: SheetTargetRefusal::NoVisibleParents,
};
}
TargetGateUnionOutcome::Gated {
target,
verdict: if denied.is_none() {
Verdict::Allow
} else {
Verdict::Deny
},
denied,
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::types::GOOGLE_SHEET_MIME_TYPE;
use crate::drive::write_gate::Verdict;
use crate::utils::secret::Secret;
use std::collections::HashSet;
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_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
let parents: Vec<&str> = parents.to_vec();
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": mime_type, "parents": parents,
})),
)
}
fn mount_folder(id: &str) -> wiremock::Mock {
mount_file(id, "application/vnd.google-apps.folder", &[])
}
fn allow_rule(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some(folder.to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
deny: HashSet::default(),
require_lease: true,
}
}
#[tokio::test]
async fn a_metadata_fetch_failure_is_reported_distinctly() {
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 outcome = resolve(&client, "missing", DriveOperation::SheetsWrite, &[]).await;
assert!(matches!(
outcome,
TargetGateOutcome::MetadataFetchFailed { .. }
));
}
#[tokio::test]
async fn a_shortcut_is_refused_before_the_gate_runs() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.shortcut",
&["parent-1"],
)
.mount(&server)
.await;
let outcome = resolve(
&client,
"sheet-1",
DriveOperation::SheetsWrite,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome,
TargetGateOutcome::Refused {
refusal: SheetTargetRefusal::Shortcut,
..
}
));
}
#[tokio::test]
async fn a_gate_ancestor_fetch_failure_is_reported_distinctly_from_a_metadata_failure() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let outcome = resolve(
&client,
"sheet-1",
DriveOperation::SheetsWrite,
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome, TargetGateOutcome::GateFetchFailed { .. }));
}
#[tokio::test]
async fn a_resolvable_target_reaches_gated_with_the_right_verdict() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = resolve(
&client,
"sheet-1",
DriveOperation::SheetsWrite,
&[allow_rule("parent-1")],
)
.await;
let TargetGateOutcome::Gated {
target,
decision,
resolved_folder_id,
..
} = outcome
else {
panic!("expected Gated");
};
assert_eq!(target.id, "sheet-1");
assert_eq!(decision.verdict, Verdict::Allow);
assert_eq!(resolved_folder_id, Some("parent-1".to_string()));
}
#[tokio::test]
async fn a_target_with_no_visible_parents_and_no_file_rule_is_refused() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = resolve(&client, "sheet-1", DriveOperation::SheetsWrite, &[]).await;
assert!(matches!(
outcome,
TargetGateOutcome::Refused {
refusal: SheetTargetRefusal::NoVisibleParents,
..
}
));
}
#[tokio::test]
async fn a_file_rule_grants_a_target_with_no_visible_parents() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = resolve(
&client,
"sheet-1",
DriveOperation::SheetsWrite,
&[FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsWrite])],
)
.await;
let TargetGateOutcome::Gated {
decision,
resolved_folder_id,
..
} = outcome
else {
panic!("expected Gated");
};
assert_eq!(decision.verdict, Verdict::Allow);
assert_eq!(resolved_folder_id, None);
}
#[tokio::test]
async fn a_target_with_no_matching_rule_reaches_gated_with_the_default_policy() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = resolve(&client, "sheet-1", DriveOperation::SheetsWrite, &[]).await;
let TargetGateOutcome::Gated {
decision,
resolved_folder_id,
..
} = outcome
else {
panic!("expected Gated");
};
assert_eq!(decision.verdict, Verdict::Deny);
assert_eq!(decision.decided_by, None);
assert_eq!(resolved_folder_id, Some("parent-1".to_string()));
}
#[tokio::test]
async fn resolve_all_allows_only_when_every_operation_allows() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules = [FolderPermissionRule::folder("parent-1")
.allowing([DriveOperation::SheetsWrite, DriveOperation::SheetsStructure])];
let outcome = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&rules,
)
.await;
let TargetGateUnionOutcome::Gated {
verdict, denied, ..
} = outcome
else {
panic!("expected Gated"); };
assert_eq!(verdict, Verdict::Allow);
assert_eq!(denied, None);
}
#[tokio::test]
async fn resolve_all_denies_and_names_the_first_denying_operation() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules = [
FolderPermissionRule::folder("parent-1").allowing([DriveOperation::SheetsStructure])
];
let outcome = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&rules,
)
.await;
let TargetGateUnionOutcome::Gated {
verdict, denied, ..
} = outcome
else {
panic!("expected Gated"); };
assert_eq!(verdict, Verdict::Deny);
assert_eq!(denied.map(|(op, _)| op), Some(DriveOperation::SheetsWrite));
}
#[tokio::test]
async fn resolve_all_denies_when_only_the_second_operation_lacks_a_grant() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules =
[FolderPermissionRule::folder("parent-1").allowing([DriveOperation::SheetsWrite])];
let outcome = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&rules,
)
.await;
let TargetGateUnionOutcome::Gated {
verdict, denied, ..
} = outcome
else {
panic!("expected Gated"); };
assert_eq!(verdict, Verdict::Deny);
assert_eq!(
denied.map(|(op, _)| op),
Some(DriveOperation::SheetsStructure)
);
}
#[tokio::test]
async fn resolve_all_ors_the_lease_requirement_across_operations() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules = [
FolderPermissionRule::folder("parent-1")
.allowing([DriveOperation::SheetsWrite])
.requiring_lease(true),
FolderPermissionRule::folder("parent-1")
.allowing([DriveOperation::SheetsStructure])
.requiring_lease(false),
];
let outcome = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&rules,
)
.await;
let TargetGateUnionOutcome::Gated { requires_lease, .. } = outcome else {
panic!("expected Gated"); };
assert!(requires_lease);
}
#[tokio::test]
async fn resolve_all_fetches_the_targets_metadata_exactly_once() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.expect(1)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules = [FolderPermissionRule::folder("parent-1")
.allowing([DriveOperation::SheetsWrite, DriveOperation::SheetsStructure])];
let _ = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&rules,
)
.await;
}
#[tokio::test]
async fn resolve_all_refuses_no_visible_parents_only_when_every_operation_agrees() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&[],
)
.await;
assert!(matches!(
outcome,
TargetGateUnionOutcome::Refused {
refusal: SheetTargetRefusal::NoVisibleParents,
..
}
));
}
#[tokio::test]
async fn resolve_all_is_gated_not_refused_when_a_file_rule_covers_only_one_operation() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let rules = [FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsWrite])];
let outcome = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&rules,
)
.await;
let TargetGateUnionOutcome::Gated {
verdict, denied, ..
} = outcome
else {
panic!("expected Gated, got a Refused fold"); };
assert_eq!(verdict, Verdict::Deny);
assert_eq!(
denied.map(|(op, _)| op),
Some(DriveOperation::SheetsStructure)
);
}
#[tokio::test]
async fn resolve_all_reports_a_metadata_fetch_failure_distinctly() {
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 outcome = resolve_all(
&client,
"missing",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&[],
)
.await;
assert!(matches!(
outcome,
TargetGateUnionOutcome::MetadataFetchFailed { .. }
));
}
#[tokio::test]
async fn resolve_all_refuses_a_shortcut_before_the_gate_runs() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.shortcut",
&["parent-1"],
)
.mount(&server)
.await;
let outcome = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome,
TargetGateUnionOutcome::Refused {
refusal: SheetTargetRefusal::Shortcut,
..
}
));
}
#[tokio::test]
async fn resolve_all_reports_a_gate_ancestor_fetch_failure_distinctly() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let outcome = resolve_all(
&client,
"sheet-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome,
TargetGateUnionOutcome::GateFetchFailed { .. }
));
}
}