1use anyhow::Result;
9
10use crate::drive::files_api::FilesApi;
11use crate::drive::types::DriveFile;
12use crate::drive::write_gate::{self, Decision, DriveOperation, FolderPermissionRule};
13
14const MAX_CHAIN_DEPTH: usize = 100;
17
18#[derive(Debug, Clone)]
23pub struct AncestorChain {
24 pub folders: Vec<DriveFile>,
26}
27
28impl AncestorChain {
29 #[must_use]
31 pub fn folder_ids(&self) -> Vec<String> {
32 self.folders.iter().map(|f| f.id.clone()).collect()
33 }
34}
35
36pub async fn resolve_ancestor_chain(
51 files_api: &FilesApi<'_>,
52 start_folder_id: &str,
53) -> Result<AncestorChain> {
54 let mut folders = Vec::new();
55 let mut current_id = start_folder_id.to_string();
56 loop {
57 anyhow::ensure!(
58 folders.len() < MAX_CHAIN_DEPTH,
59 "folder ancestry for '{start_folder_id}' exceeds {MAX_CHAIN_DEPTH} levels; \
60 refusing to resolve a possibly-cyclic or pathological chain rather than \
61 truncating it"
62 );
63 let folder = files_api.get_metadata(¤t_id).await?;
64 let next_parent = folder.parents.first().cloned();
65 folders.push(folder);
66 match next_parent {
67 Some(parent_id) => current_id = parent_id,
68 None => break,
69 }
70 }
71 Ok(AncestorChain { folders })
72}
73
74pub async fn resolve_decision(
88 files_api: &FilesApi<'_>,
89 start_folder_id: &str,
90 op: DriveOperation,
91 rules: &[FolderPermissionRule],
92) -> Result<Decision> {
93 let start = files_api.get_metadata(start_folder_id).await?;
94 resolve_decision_from(files_api, start, op, rules).await
95}
96
97pub async fn resolve_decision_from(
104 files_api: &FilesApi<'_>,
105 start: DriveFile,
106 op: DriveOperation,
107 rules: &[FolderPermissionRule],
108) -> Result<Decision> {
109 let start_id = start.id.clone();
110 let mut next_parent = start.parents.first().cloned();
111 let mut folder_ids = vec![start.id];
112 loop {
113 let decision = write_gate::resolve(&folder_ids, op, rules);
119 if decision.decided_by.is_some() {
120 return Ok(decision);
121 }
122 let Some(parent_id) = next_parent else {
123 return Ok(decision);
124 };
125 anyhow::ensure!(
126 folder_ids.len() < MAX_CHAIN_DEPTH,
127 "folder ancestry for '{start_id}' exceeds {MAX_CHAIN_DEPTH} levels; refusing to \
128 resolve a possibly-cyclic or pathological chain rather than truncating it"
129 );
130 let folder = files_api.get_metadata(&parent_id).await?;
131 next_parent = folder.parents.first().cloned();
132 folder_ids.push(folder.id);
133 }
134}
135
136pub async fn resolve_decision_for_parents(
152 files_api: &FilesApi<'_>,
153 parents: &[String],
154 op: DriveOperation,
155 rules: &[FolderPermissionRule],
156) -> Result<(Decision, Option<String>)> {
157 let Some((first_parent, rest_parents)) = parents.split_first() else {
158 return Ok((write_gate::resolve(&[], op, rules), None));
159 };
160 let mut combined = resolve_decision(files_api, first_parent, op, rules).await?;
161 for parent_id in rest_parents {
162 let decision = resolve_decision(files_api, parent_id, op, rules).await?;
163 combined = write_gate::combine_across_parents(combined, [decision]);
164 }
165 let resolved_folder_id = rest_parents.is_empty().then(|| first_parent.clone());
166 Ok((combined, resolved_folder_id))
167}
168
169#[cfg(test)]
170#[allow(clippy::unwrap_used, clippy::expect_used)]
171mod tests {
172 use super::*;
173 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
174 use crate::drive::client::DriveClient;
175 use crate::utils::secret::Secret;
176
177 fn test_credentials() -> DriveCredentials {
178 DriveCredentials {
179 client_id: "client-1".to_string(),
180 client_secret: Secret::new("secret-1"),
181 refresh_token: Secret::new("refresh-1"),
182 scope: DriveGrantedScopes::READONLY,
183 }
184 }
185
186 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
187 wiremock::Mock::given(wiremock::matchers::method("POST"))
188 .and(wiremock::matchers::path("/token"))
189 .respond_with(
190 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
191 "access_token": "test-token",
192 "expires_in": 3600,
193 })),
194 )
195 .mount(server)
196 .await;
197
198 let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
199 crate::drive::client::test_support::replace_session(
200 &mut client,
201 &test_credentials(),
202 &format!("{}/token", server.uri()),
203 );
204 client
205 }
206
207 fn mount_folder(id: &str, parent: Option<&str>) -> wiremock::Mock {
208 let parents: Vec<&str> = parent.into_iter().collect();
209 wiremock::Mock::given(wiremock::matchers::method("GET"))
210 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
211 .respond_with(
212 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
213 "id": id,
214 "name": id,
215 "mimeType": "application/vnd.google-apps.folder",
216 "parents": parents,
217 })),
218 )
219 }
220
221 #[tokio::test]
222 async fn resolves_a_single_folder_with_no_parent_as_a_one_element_chain() {
223 let server = wiremock::MockServer::start().await;
224 let client = client_with_bootstrapped_token(&server).await;
225 mount_folder("root", None).mount(&server).await;
226 let files_api = FilesApi::new(&client);
227
228 let chain = resolve_ancestor_chain(&files_api, "root").await.unwrap();
229 assert_eq!(chain.folder_ids(), vec!["root".to_string()]);
230 }
231
232 #[tokio::test]
233 async fn walks_the_full_ancestor_chain_via_files_get() {
234 let server = wiremock::MockServer::start().await;
235 let client = client_with_bootstrapped_token(&server).await;
236 mount_folder("child", Some("parent")).mount(&server).await;
237 mount_folder("parent", Some("grandparent"))
238 .mount(&server)
239 .await;
240 mount_folder("grandparent", None).mount(&server).await;
241 let files_api = FilesApi::new(&client);
242
243 let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
244 assert_eq!(
245 chain.folder_ids(),
246 vec![
247 "child".to_string(),
248 "parent".to_string(),
249 "grandparent".to_string(),
250 ]
251 );
252 }
253
254 #[tokio::test]
255 async fn legacy_multi_parent_folder_walks_the_first_parent_only() {
256 let server = wiremock::MockServer::start().await;
257 let client = client_with_bootstrapped_token(&server).await;
258 wiremock::Mock::given(wiremock::matchers::method("GET"))
259 .and(wiremock::matchers::path("/drive/v3/files/child"))
260 .respond_with(
261 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
262 "id": "child",
263 "name": "child",
264 "mimeType": "application/vnd.google-apps.folder",
265 "parents": ["first-parent", "second-parent"],
266 })),
267 )
268 .mount(&server)
269 .await;
270 mount_folder("first-parent", None).mount(&server).await;
271 let files_api = FilesApi::new(&client);
274
275 let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
276 assert_eq!(
277 chain.folder_ids(),
278 vec!["child".to_string(), "first-parent".to_string()]
279 );
280 }
281
282 #[tokio::test]
289 async fn ancestor_chain_fetch_failure_returns_err_not_a_truncated_chain() {
290 let server = wiremock::MockServer::start().await;
291 let client = client_with_bootstrapped_token(&server).await;
292 mount_folder("child", Some("parent")).mount(&server).await;
293 wiremock::Mock::given(wiremock::matchers::method("GET"))
294 .and(wiremock::matchers::path("/drive/v3/files/parent"))
295 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
296 .mount(&server)
297 .await;
298 let files_api = FilesApi::new(&client);
299
300 let result = resolve_ancestor_chain(&files_api, "child").await;
301 assert!(
302 result.is_err(),
303 "a mid-walk fetch failure must be Err, not an Ok chain truncated at \"child\""
304 );
305 }
306
307 #[tokio::test]
308 async fn missing_start_folder_is_an_error() {
309 let server = wiremock::MockServer::start().await;
310 let client = client_with_bootstrapped_token(&server).await;
311 wiremock::Mock::given(wiremock::matchers::method("GET"))
312 .and(wiremock::matchers::path("/drive/v3/files/missing"))
313 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
314 .mount(&server)
315 .await;
316 let files_api = FilesApi::new(&client);
317
318 let result = resolve_ancestor_chain(&files_api, "missing").await;
319 assert!(result.is_err());
320 }
321
322 #[tokio::test]
325 async fn resolve_decision_stops_walking_once_a_rule_decides_it() {
326 let server = wiremock::MockServer::start().await;
327 let client = client_with_bootstrapped_token(&server).await;
328 mount_folder("child", Some("parent")).mount(&server).await;
329 let files_api = FilesApi::new(&client);
333 let rules = [FolderPermissionRule {
334 folder_id: "child".to_string(),
335 recursive: false,
336 allow: std::iter::once(DriveOperation::Create).collect(),
337 deny: std::collections::HashSet::default(),
338 }];
339
340 let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &rules)
341 .await
342 .unwrap();
343 assert_eq!(decision.verdict, write_gate::Verdict::Allow);
344 }
345
346 #[tokio::test]
347 async fn resolve_decision_walks_to_root_when_nothing_matches() {
348 let server = wiremock::MockServer::start().await;
349 let client = client_with_bootstrapped_token(&server).await;
350 mount_folder("child", Some("parent")).mount(&server).await;
351 mount_folder("parent", Some("grandparent"))
352 .mount(&server)
353 .await;
354 mount_folder("grandparent", None).mount(&server).await;
355 let files_api = FilesApi::new(&client);
356
357 let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &[])
358 .await
359 .unwrap();
360 assert_eq!(decision.verdict, write_gate::Verdict::Deny);
361 assert_eq!(decision.decided_by, None);
362 }
363
364 #[tokio::test]
365 async fn resolve_decision_fetch_failure_returns_err_not_allow() {
366 let server = wiremock::MockServer::start().await;
367 let client = client_with_bootstrapped_token(&server).await;
368 mount_folder("child", Some("parent")).mount(&server).await;
369 wiremock::Mock::given(wiremock::matchers::method("GET"))
370 .and(wiremock::matchers::path("/drive/v3/files/parent"))
371 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
372 .mount(&server)
373 .await;
374 let files_api = FilesApi::new(&client);
375
376 let result = resolve_decision(&files_api, "child", DriveOperation::Create, &[]).await;
377 assert!(result.is_err());
378 }
379
380 #[tokio::test]
381 async fn resolve_decision_from_never_refetches_the_supplied_start() {
382 let server = wiremock::MockServer::start().await;
383 let client = client_with_bootstrapped_token(&server).await;
384 let files_api = FilesApi::new(&client);
387 let start = DriveFile {
388 id: "child".to_string(),
389 name: "child".to_string(),
390 mime_type: "application/vnd.google-apps.folder".to_string(),
391 parents: vec![],
392 ..Default::default()
393 };
394 let rules = [FolderPermissionRule {
395 folder_id: "child".to_string(),
396 recursive: false,
397 allow: std::iter::once(DriveOperation::Read).collect(),
398 deny: std::collections::HashSet::default(),
399 }];
400
401 let decision = resolve_decision_from(&files_api, start, DriveOperation::Read, &rules)
402 .await
403 .unwrap();
404 assert_eq!(decision.verdict, write_gate::Verdict::Allow);
405 }
406
407 #[tokio::test]
410 async fn resolve_decision_for_parents_empty_parents_uses_default_policy() {
411 let server = wiremock::MockServer::start().await;
412 let client = client_with_bootstrapped_token(&server).await;
413 let files_api = FilesApi::new(&client);
414
415 let (decision, resolved_folder_id) =
416 resolve_decision_for_parents(&files_api, &[], DriveOperation::Edit, &[])
417 .await
418 .unwrap();
419 assert_eq!(decision.verdict, write_gate::Verdict::Deny);
420 assert_eq!(resolved_folder_id, None);
421 }
422
423 #[tokio::test]
424 async fn resolve_decision_for_parents_single_parent_reports_its_folder_id() {
425 let server = wiremock::MockServer::start().await;
426 let client = client_with_bootstrapped_token(&server).await;
427 mount_folder("parent-1", None).mount(&server).await;
428 let files_api = FilesApi::new(&client);
429 let rules = [FolderPermissionRule {
430 folder_id: "parent-1".to_string(),
431 recursive: false,
432 allow: std::iter::once(DriveOperation::Edit).collect(),
433 deny: std::collections::HashSet::default(),
434 }];
435
436 let (decision, resolved_folder_id) = resolve_decision_for_parents(
437 &files_api,
438 &["parent-1".to_string()],
439 DriveOperation::Edit,
440 &rules,
441 )
442 .await
443 .unwrap();
444 assert_eq!(decision.verdict, write_gate::Verdict::Allow);
445 assert_eq!(resolved_folder_id, Some("parent-1".to_string()));
446 }
447
448 #[tokio::test]
449 async fn resolve_decision_for_parents_deny_wins_across_parents_and_reports_no_single_folder() {
450 let server = wiremock::MockServer::start().await;
451 let client = client_with_bootstrapped_token(&server).await;
452 mount_folder("allow-parent", None).mount(&server).await;
453 mount_folder("deny-parent", None).mount(&server).await;
454 let files_api = FilesApi::new(&client);
455 let rules = [
456 FolderPermissionRule {
457 folder_id: "allow-parent".to_string(),
458 recursive: false,
459 allow: std::iter::once(DriveOperation::Edit).collect(),
460 deny: std::collections::HashSet::default(),
461 },
462 FolderPermissionRule {
463 folder_id: "deny-parent".to_string(),
464 recursive: false,
465 allow: std::collections::HashSet::default(),
466 deny: std::iter::once(DriveOperation::Edit).collect(),
467 },
468 ];
469
470 let (decision, resolved_folder_id) = resolve_decision_for_parents(
471 &files_api,
472 &["allow-parent".to_string(), "deny-parent".to_string()],
473 DriveOperation::Edit,
474 &rules,
475 )
476 .await
477 .unwrap();
478 assert_eq!(decision.verdict, write_gate::Verdict::Deny);
479 assert_eq!(resolved_folder_id, None);
480 }
481}