Skip to main content

omni_dev/drive/
folder_ancestry.rs

1//! Ancestor folder-chain resolution for `crate::drive::write_gate` (issue
2//! #1574).
3//!
4//! Impure sibling to `write_gate` — mirrors `crate::drive::file_move`'s
5//! role relative to `crate::drive::visibility`: this module does the
6//! fetching, `write_gate` does the (pure) classifying.
7
8use anyhow::Result;
9
10use crate::drive::files_api::FilesApi;
11use crate::drive::types::DriveFile;
12use crate::drive::write_gate::{self, Decision, DriveOperation, FolderPermissionRule};
13
14/// Defensive cap against a cyclic or pathological parent chain, mirroring
15/// `crate::drive::permissions_api::MAX_PERMISSIONS`'s rationale.
16const MAX_CHAIN_DEPTH: usize = 100;
17
18/// A target folder's resolved ancestor chain.
19///
20/// `folders[0]` is the target folder itself, `folders[1]` its parent, and
21/// so on up to Drive's root (whose `parents` is empty).
22#[derive(Debug, Clone)]
23pub struct AncestorChain {
24    /// The chain, in walk order (depth 0 first).
25    pub folders: Vec<DriveFile>,
26}
27
28impl AncestorChain {
29    /// The folder ids in walk order, ready for `write_gate::resolve`.
30    #[must_use]
31    pub fn folder_ids(&self) -> Vec<String> {
32        self.folders.iter().map(|f| f.id.clone()).collect()
33    }
34}
35
36/// Walks `start_folder_id` → parent → grandparent → ... via `files.get`,
37/// stopping at Drive's root (a folder with no `parents`).
38///
39/// **Any** failure — a `files.get` error, or exceeding [`MAX_CHAIN_DEPTH`]
40/// — is an `Err`, never a silently truncated `Ok` chain: a truncated chain
41/// could hide a deny/allow rule configured above the truncation point.
42/// This is the same hard invariant [ADR-0070](../../docs/adrs/adr-0070.md)
43/// §3 established for `permissions.list` fetch failures (no
44/// `unwrap_or_default()` empty-set fallback) — callers must turn this
45/// `Err` into a refusal, never a silent default-allow.
46///
47/// v1 boundary: a folder with a legacy multi-parent (rare — Drive no
48/// longer permits creating new multi-parent folders) walks its *first*
49/// parent only, beyond depth 0.
50pub 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(&current_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
74/// Resolves `op` against `start_folder_id`'s ancestor chain under `rules`,
75/// walking one `files.get` at a time and stopping as soon as some depth
76/// decides it.
77///
78/// [`write_gate::resolve`]'s "closest ancestor wins" tie-break means that
79/// once *any* depth in the chain has a matching rule, nothing farther up
80/// can ever produce a closer match — so unlike [`resolve_ancestor_chain`]
81/// (which a caller wanting the *full* chain, e.g. for display, should keep
82/// using), this never walks past the first decisive depth. Equivalent to
83/// `write_gate::resolve(&resolve_ancestor_chain(..).await?.folder_ids(), op, rules)`,
84/// but touches only as many ancestors as necessary instead of always
85/// walking to Drive's root. The same fetch-failure-is-always-`Err`
86/// invariant [`resolve_ancestor_chain`] documents applies here too.
87pub 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
97/// [`resolve_decision`], but for a caller that already has the starting
98/// folder's metadata in hand.
99///
100/// Avoids re-fetching `start` as this walk's own first `files.get`.
101/// Shared by [`resolve_decision_for_parents`] and `drive permissions
102/// check`'s folder-target path.
103pub 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        // Re-resolving against the whole chain fetched so far on every
114        // iteration is O(chain_len × rules_len) instead of O(rules_len)
115        // once — negligible against a typically-small configured rule
116        // list, and it's what lets this reuse `write_gate::resolve`
117        // verbatim rather than re-implementing its tie-break logic here.
118        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
136/// Resolves `op` against `parents` (a target's *current* parent ids).
137///
138/// Combines the per-parent [`Decision`]s via
139/// [`write_gate::combine_across_parents`] for a legacy multi-parent target
140/// (deny wins across parents). Also returns the single resolved folder id
141/// when there's exactly one parent — `None` for an orphan target or a
142/// multi-parent target, where no single folder id would be accurate.
143///
144/// Shared by `drive edit` (`crate::drive::content_edit`) and `drive
145/// permissions check` (`crate::cli::drive::permissions::check`) — the two
146/// callers whose target's chain starts at its *current* parent(s) rather
147/// than a caller-supplied `--parent`. Centralizing the combine loop here
148/// is what lets `permissions check` claim it can never drift from actual
149/// enforcement: previously only the primitives were shared and this
150/// orchestration was duplicated at both call sites.
151pub 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        // Deliberately no mock for "second-parent" — asserting it's never
272        // fetched proves the documented v1 boundary (first-parent-only).
273        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    /// The single highest-value test in this module (mirrors ADR-0070 §3's
283    /// `permissions.list`-failure precedent): a fetch failure mid-walk must
284    /// be an explicit `Err`, never a chain silently truncated at the point
285    /// of failure — a truncated chain could hide a deny/allow rule
286    /// configured on an ancestor above the failure point, manufacturing a
287    /// false "no rule applies here" reading.
288    #[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    // ── resolve_decision / resolve_decision_from ───────────────────────
323
324    #[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        // Deliberately no mock for "parent" — "child" already has a
330        // matching rule, so a correctly short-circuiting walk never fetches
331        // it; wiremock panics with "no matching mock" if it does.
332        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        // Deliberately no mock at all — proves resolve_decision_from never
385        // issues a files.get for the folder its caller already fetched.
386        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    // ── resolve_decision_for_parents ────────────────────────────────────
408
409    #[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}