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//!
8//! Despite the name it is not only about folders: since issue #1612 a rule
9//! may name a **file id**, and [`resolve_decision_for_file_target`] is the
10//! single entry point for a file target — it checks that rule before
11//! walking (or, for a target with no visible parents, instead of walking)
12//! any ancestor chain.
13
14use anyhow::Result;
15
16use crate::drive::files_api::FilesApi;
17use crate::drive::types::DriveFile;
18use crate::drive::write_gate::{self, Decision, DriveOperation, FolderPermissionRule};
19
20/// Defensive cap against a cyclic or pathological parent chain, mirroring
21/// `crate::drive::permissions_api::MAX_PERMISSIONS`'s rationale.
22const MAX_CHAIN_DEPTH: usize = 100;
23
24/// A target folder's resolved ancestor chain.
25///
26/// `folders[0]` is the target folder itself, `folders[1]` its parent, and
27/// so on up to Drive's root (whose `parents` is empty).
28#[derive(Debug, Clone)]
29pub struct AncestorChain {
30    /// The chain, in walk order (depth 0 first).
31    pub folders: Vec<DriveFile>,
32}
33
34impl AncestorChain {
35    /// The folder ids in walk order, ready for `write_gate::resolve`.
36    #[must_use]
37    pub fn folder_ids(&self) -> Vec<String> {
38        self.folders.iter().map(|f| f.id.clone()).collect()
39    }
40}
41
42/// Walks `start_folder_id` → parent → grandparent → ... via `files.get`,
43/// stopping at Drive's root (a folder with no `parents`).
44///
45/// **Any** failure — a `files.get` error, or exceeding [`MAX_CHAIN_DEPTH`]
46/// — is an `Err`, never a silently truncated `Ok` chain: a truncated chain
47/// could hide a deny/allow rule configured above the truncation point.
48/// This is the same hard invariant [ADR-0070](../../docs/adrs/adr-0070.md)
49/// §3 established for `permissions.list` fetch failures (no
50/// `unwrap_or_default()` empty-set fallback) — callers must turn this
51/// `Err` into a refusal, never a silent default-allow.
52///
53/// v1 boundary: a folder with a legacy multi-parent (rare — Drive no
54/// longer permits creating new multi-parent folders) walks its *first*
55/// parent only, beyond depth 0.
56pub 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(&current_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
80/// Resolves `op` against `start_folder_id`'s ancestor chain under `rules`,
81/// walking one `files.get` at a time and stopping as soon as some depth
82/// decides it.
83///
84/// [`write_gate::resolve`]'s "closest ancestor wins" tie-break means that
85/// once *any* depth in the chain has a matching rule, nothing farther up
86/// can ever produce a closer match — so unlike [`resolve_ancestor_chain`]
87/// (which a caller wanting the *full* chain, e.g. for display, should keep
88/// using), this never walks past the first decisive depth. Equivalent to
89/// `write_gate::resolve(&resolve_ancestor_chain(..).await?.folder_ids(), op, rules)`,
90/// but touches only as many ancestors as necessary instead of always
91/// walking to Drive's root. The same fetch-failure-is-always-`Err`
92/// invariant [`resolve_ancestor_chain`] documents applies here too.
93pub 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
103/// [`resolve_decision`], but for a caller that already has the starting
104/// folder's metadata in hand.
105///
106/// Avoids re-fetching `start` as this walk's own first `files.get`.
107/// Shared by the private `resolve_decision_for_parents` and `drive
108/// permissions check`'s folder-target path.
109pub 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        // Re-resolving against the whole chain fetched so far on every
120        // iteration is O(chain_len × rules_len) instead of O(rules_len)
121        // once — negligible against a typically-small configured rule
122        // list, and it's what lets this reuse `write_gate::resolve`
123        // verbatim rather than re-implementing its tie-break logic here.
124        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/// How a file target's verdict was reached.
143///
144/// Matched exhaustively by every caller, so a future source cannot be
145/// silently mishandled.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum DecisionSource {
148    /// A `file_id` rule named the target itself. No `files.get` on any
149    /// parent was issued — and none could have changed the verdict, since
150    /// a file rule sits at depth −1.
151    FileRule,
152    /// The target's current parent chain(s) decided it, or fell through to
153    /// the default policy with a chain actually resolved.
154    FolderChain,
155    /// The target has no parents this account can see **and** no file rule
156    /// named this operation — the one case where no folder id the operator
157    /// could name would ever help. `decision` is the bare default policy,
158    /// *not* a forced refusal: `Read` still defaults to allow, so a
159    /// caller that wants to refuse must check the verdict too.
160    NoVisibleParents,
161}
162
163/// The full evaluation of a **file** target.
164#[derive(Debug, Clone)]
165pub struct FileTargetDecision {
166    /// The verdict, and the rule that decided it.
167    pub decision: Decision,
168    /// The single folder the chain resolved through, when the target had
169    /// exactly one parent. Always `None` for `FileRule` and
170    /// `NoVisibleParents`, neither of which walks a chain.
171    pub resolved_folder_id: Option<String>,
172    /// How the verdict was reached.
173    pub source: DecisionSource,
174    /// Whether a write acting on `decision` needs a valid Drive write lease
175    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§13).
176    ///
177    /// Computed **here**, not by re-deriving it from `decision.decided_by`
178    /// after the fact: for a legacy multi-parent target,
179    /// [`write_gate::combine_across_parents`] keeps only the *winning*
180    /// parent's `decided_by`, discarding every other parent's — including
181    /// one whose rule required a lease. Re-querying
182    /// `decided_rule_requires_lease` against the single surviving
183    /// `decided_by` would silently drop that requirement whenever the
184    /// winning parent's own rule happened to opt out. This field is instead
185    /// the OR of each contributing parent's own requirement, computed
186    /// before its `decided_by` is folded away — see
187    /// [`resolve_decision_for_parents`].
188    ///
189    /// Meaningless on its own for an operation that can never take a
190    /// lease in the first place (`Read`/`Create`/`Upload`/`Trash` —
191    /// [`DriveOperation::ever_requires_lease`], issue #1917): this field
192    /// answers only "does a matching rule opt out", never "can this
193    /// operation be asked at all". The Trash engine ignores this field
194    /// under ADR-0092. `drive permissions check`, whose `op` is
195    /// caller-supplied, applies
196    /// `ever_requires_lease` on top of this field rather than trusting
197    /// it alone. A future caller that also accepts an arbitrary `op`
198    /// must do the same.
199    pub requires_lease: bool,
200}
201
202/// Resolves `op` for a file target whose metadata the caller already has.
203///
204/// **This is the only entry point for a file target**, and the reason
205/// `resolve_decision_for_parents` below is private rather than `pub`: this
206/// function owns the ordering — a `file_id` rule first, then the
207/// current-parent chains, then the no-visible-parents case — so `drive
208/// sheets write`, `drive edit` and `drive permissions check` cannot drift
209/// apart on it.
210///
211/// The short-circuit is exact rather than an optimization: a file rule
212/// sits at depth −1, so no folder rule at any depth of any parent chain
213/// can beat it, and skipping the walk cannot change the verdict. That is
214/// the same argument that already licenses [`resolve_decision_from`]'s
215/// early return — and it is what makes a file **shared by link or email**
216/// grantable at all, since such a target has no chain to walk.
217///
218/// The fetch-failure-is-always-`Err` invariant [`resolve_ancestor_chain`]
219/// documents applies to the chain-walking branch.
220pub async fn resolve_decision_for_file_target(
221    files_api: &FilesApi<'_>,
222    target: &DriveFile,
223    op: DriveOperation,
224    rules: &[FolderPermissionRule],
225) -> Result<FileTargetDecision> {
226    if let Some(decision) = write_gate::resolve_file_rule(&target.id, op, rules) {
227        let requires_lease =
228            write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
229        return Ok(FileTargetDecision {
230            decision,
231            resolved_folder_id: None,
232            source: DecisionSource::FileRule,
233            requires_lease,
234        });
235    }
236    if target.parents.is_empty() {
237        let decision = write_gate::resolve(&[], op, rules);
238        let requires_lease =
239            write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
240        return Ok(FileTargetDecision {
241            decision,
242            resolved_folder_id: None,
243            source: DecisionSource::NoVisibleParents,
244            requires_lease,
245        });
246    }
247    let (decision, resolved_folder_id, requires_lease) =
248        resolve_decision_for_parents(files_api, &target.parents, op, rules).await?;
249    Ok(FileTargetDecision {
250        decision,
251        resolved_folder_id,
252        source: DecisionSource::FolderChain,
253        requires_lease,
254    })
255}
256
257/// Resolves `op` against `parents` (a target's *current* parent ids).
258///
259/// Combines the per-parent [`Decision`]s via
260/// [`write_gate::combine_across_parents`] for a legacy multi-parent target
261/// (deny wins across parents). Also returns the single resolved folder id
262/// when there's exactly one parent — `None` for an orphan target or a
263/// multi-parent target, where no single folder id would be accurate.
264///
265/// Also returns whether the combined decision needs a lease, computed as
266/// the OR of **every** parent's own `decided_rule_requires_lease` result —
267/// each evaluated against that parent's own `decided_by`, before
268/// [`write_gate::combine_across_parents`] discards every `decided_by` but
269/// the winning one. Re-deriving the requirement from only the combined
270/// `Decision` afterward would silently drop it whenever the surviving
271/// parent's rule happened to opt out, even though a losing (but still
272/// `Allow`) parent's rule required one — the safe direction this gate takes
273/// everywhere else.
274///
275/// **Private on purpose** (issue #1612): every file target must go through
276/// [`resolve_decision_for_file_target`], which consults `file_id` rules
277/// first. A caller reaching this primitive directly would skip that step
278/// and silently refuse a file that was explicitly granted — so the
279/// restriction is a compile error rather than a convention.
280async fn resolve_decision_for_parents(
281    files_api: &FilesApi<'_>,
282    parents: &[String],
283    op: DriveOperation,
284    rules: &[FolderPermissionRule],
285) -> Result<(Decision, Option<String>, bool)> {
286    let Some((first_parent, rest_parents)) = parents.split_first() else {
287        let decision = write_gate::resolve(&[], op, rules);
288        let requires_lease =
289            write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
290        return Ok((decision, None, requires_lease));
291    };
292    let mut combined = resolve_decision(files_api, first_parent, op, rules).await?;
293    let mut requires_lease =
294        write_gate::decided_rule_requires_lease(combined.decided_by.as_ref(), op, rules);
295    for parent_id in rest_parents {
296        let decision = resolve_decision(files_api, parent_id, op, rules).await?;
297        requires_lease |=
298            write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
299        combined = write_gate::combine_across_parents(combined, [decision]);
300    }
301    let resolved_folder_id = rest_parents.is_empty().then(|| first_parent.clone());
302    Ok((combined, resolved_folder_id, requires_lease))
303}
304
305#[cfg(test)]
306#[allow(clippy::unwrap_used, clippy::expect_used)]
307mod tests {
308    use super::*;
309    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
310    use crate::drive::client::DriveClient;
311    use crate::utils::secret::Secret;
312
313    fn test_credentials() -> DriveCredentials {
314        DriveCredentials {
315            client_id: "client-1".to_string(),
316            client_secret: Secret::new("secret-1"),
317            refresh_token: Secret::new("refresh-1"),
318            scope: DriveGrantedScopes::READONLY,
319        }
320    }
321
322    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
323        wiremock::Mock::given(wiremock::matchers::method("POST"))
324            .and(wiremock::matchers::path("/token"))
325            .respond_with(
326                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
327                    "access_token": "test-token",
328                    "expires_in": 3600,
329                })),
330            )
331            .mount(server)
332            .await;
333
334        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
335        crate::drive::client::test_support::replace_session(
336            &mut client,
337            &test_credentials(),
338            &format!("{}/token", server.uri()),
339        );
340        client
341    }
342
343    fn mount_folder(id: &str, parent: Option<&str>) -> wiremock::Mock {
344        let parents: Vec<&str> = parent.into_iter().collect();
345        wiremock::Mock::given(wiremock::matchers::method("GET"))
346            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
347            .respond_with(
348                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
349                    "id": id,
350                    "name": id,
351                    "mimeType": "application/vnd.google-apps.folder",
352                    "parents": parents,
353                })),
354            )
355    }
356
357    #[tokio::test]
358    async fn resolves_a_single_folder_with_no_parent_as_a_one_element_chain() {
359        let server = wiremock::MockServer::start().await;
360        let client = client_with_bootstrapped_token(&server).await;
361        mount_folder("root", None).mount(&server).await;
362        let files_api = FilesApi::new(&client);
363
364        let chain = resolve_ancestor_chain(&files_api, "root").await.unwrap();
365        assert_eq!(chain.folder_ids(), vec!["root".to_string()]);
366    }
367
368    #[tokio::test]
369    async fn walks_the_full_ancestor_chain_via_files_get() {
370        let server = wiremock::MockServer::start().await;
371        let client = client_with_bootstrapped_token(&server).await;
372        mount_folder("child", Some("parent")).mount(&server).await;
373        mount_folder("parent", Some("grandparent"))
374            .mount(&server)
375            .await;
376        mount_folder("grandparent", None).mount(&server).await;
377        let files_api = FilesApi::new(&client);
378
379        let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
380        assert_eq!(
381            chain.folder_ids(),
382            vec![
383                "child".to_string(),
384                "parent".to_string(),
385                "grandparent".to_string(),
386            ]
387        );
388    }
389
390    #[tokio::test]
391    async fn legacy_multi_parent_folder_walks_the_first_parent_only() {
392        let server = wiremock::MockServer::start().await;
393        let client = client_with_bootstrapped_token(&server).await;
394        wiremock::Mock::given(wiremock::matchers::method("GET"))
395            .and(wiremock::matchers::path("/drive/v3/files/child"))
396            .respond_with(
397                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
398                    "id": "child",
399                    "name": "child",
400                    "mimeType": "application/vnd.google-apps.folder",
401                    "parents": ["first-parent", "second-parent"],
402                })),
403            )
404            .mount(&server)
405            .await;
406        mount_folder("first-parent", None).mount(&server).await;
407        // Deliberately no mock for "second-parent" — asserting it's never
408        // fetched proves the documented v1 boundary (first-parent-only).
409        let files_api = FilesApi::new(&client);
410
411        let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
412        assert_eq!(
413            chain.folder_ids(),
414            vec!["child".to_string(), "first-parent".to_string()]
415        );
416    }
417
418    /// The single highest-value test in this module (mirrors ADR-0070 §3's
419    /// `permissions.list`-failure precedent): a fetch failure mid-walk must
420    /// be an explicit `Err`, never a chain silently truncated at the point
421    /// of failure — a truncated chain could hide a deny/allow rule
422    /// configured on an ancestor above the failure point, manufacturing a
423    /// false "no rule applies here" reading.
424    #[tokio::test]
425    async fn ancestor_chain_fetch_failure_returns_err_not_a_truncated_chain() {
426        let server = wiremock::MockServer::start().await;
427        let client = client_with_bootstrapped_token(&server).await;
428        mount_folder("child", Some("parent")).mount(&server).await;
429        wiremock::Mock::given(wiremock::matchers::method("GET"))
430            .and(wiremock::matchers::path("/drive/v3/files/parent"))
431            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
432            .mount(&server)
433            .await;
434        let files_api = FilesApi::new(&client);
435
436        let result = resolve_ancestor_chain(&files_api, "child").await;
437        assert!(
438            result.is_err(),
439            "a mid-walk fetch failure must be Err, not an Ok chain truncated at \"child\""
440        );
441    }
442
443    #[tokio::test]
444    async fn missing_start_folder_is_an_error() {
445        let server = wiremock::MockServer::start().await;
446        let client = client_with_bootstrapped_token(&server).await;
447        wiremock::Mock::given(wiremock::matchers::method("GET"))
448            .and(wiremock::matchers::path("/drive/v3/files/missing"))
449            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
450            .mount(&server)
451            .await;
452        let files_api = FilesApi::new(&client);
453
454        let result = resolve_ancestor_chain(&files_api, "missing").await;
455        assert!(result.is_err());
456    }
457
458    // ── resolve_decision / resolve_decision_from ───────────────────────
459
460    #[tokio::test]
461    async fn resolve_decision_stops_walking_once_a_rule_decides_it() {
462        let server = wiremock::MockServer::start().await;
463        let client = client_with_bootstrapped_token(&server).await;
464        mount_folder("child", Some("parent")).mount(&server).await;
465        // Deliberately no mock for "parent" — "child" already has a
466        // matching rule, so a correctly short-circuiting walk never fetches
467        // it; wiremock panics with "no matching mock" if it does.
468        let files_api = FilesApi::new(&client);
469        let rules = [FolderPermissionRule {
470            folder_id: Some("child".to_string()),
471            file_id: None,
472            recursive: false,
473            allow: std::iter::once(DriveOperation::Create).collect(),
474            deny: std::collections::HashSet::default(),
475            require_lease: true,
476        }];
477
478        let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &rules)
479            .await
480            .unwrap();
481        assert_eq!(decision.verdict, write_gate::Verdict::Allow);
482    }
483
484    #[tokio::test]
485    async fn resolve_decision_walks_to_root_when_nothing_matches() {
486        let server = wiremock::MockServer::start().await;
487        let client = client_with_bootstrapped_token(&server).await;
488        mount_folder("child", Some("parent")).mount(&server).await;
489        mount_folder("parent", Some("grandparent"))
490            .mount(&server)
491            .await;
492        mount_folder("grandparent", None).mount(&server).await;
493        let files_api = FilesApi::new(&client);
494
495        let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &[])
496            .await
497            .unwrap();
498        assert_eq!(decision.verdict, write_gate::Verdict::Deny);
499        assert_eq!(decision.decided_by, None);
500    }
501
502    #[tokio::test]
503    async fn resolve_decision_fetch_failure_returns_err_not_allow() {
504        let server = wiremock::MockServer::start().await;
505        let client = client_with_bootstrapped_token(&server).await;
506        mount_folder("child", Some("parent")).mount(&server).await;
507        wiremock::Mock::given(wiremock::matchers::method("GET"))
508            .and(wiremock::matchers::path("/drive/v3/files/parent"))
509            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
510            .mount(&server)
511            .await;
512        let files_api = FilesApi::new(&client);
513
514        let result = resolve_decision(&files_api, "child", DriveOperation::Create, &[]).await;
515        assert!(result.is_err());
516    }
517
518    #[tokio::test]
519    async fn resolve_decision_from_never_refetches_the_supplied_start() {
520        let server = wiremock::MockServer::start().await;
521        let client = client_with_bootstrapped_token(&server).await;
522        // Deliberately no mock at all — proves resolve_decision_from never
523        // issues a files.get for the folder its caller already fetched.
524        let files_api = FilesApi::new(&client);
525        let start = DriveFile {
526            id: "child".to_string(),
527            name: "child".to_string(),
528            mime_type: "application/vnd.google-apps.folder".to_string(),
529            parents: vec![],
530            ..Default::default()
531        };
532        let rules = [FolderPermissionRule {
533            folder_id: Some("child".to_string()),
534            file_id: None,
535            recursive: false,
536            allow: std::iter::once(DriveOperation::Read).collect(),
537            deny: std::collections::HashSet::default(),
538            require_lease: true,
539        }];
540
541        let decision = resolve_decision_from(&files_api, start, DriveOperation::Read, &rules)
542            .await
543            .unwrap();
544        assert_eq!(decision.verdict, write_gate::Verdict::Allow);
545    }
546
547    // ── resolve_decision_for_parents ────────────────────────────────────
548
549    #[tokio::test]
550    async fn resolve_decision_for_parents_empty_parents_uses_default_policy() {
551        let server = wiremock::MockServer::start().await;
552        let client = client_with_bootstrapped_token(&server).await;
553        let files_api = FilesApi::new(&client);
554
555        let (decision, resolved_folder_id, requires_lease) =
556            resolve_decision_for_parents(&files_api, &[], DriveOperation::Edit, &[])
557                .await
558                .unwrap();
559        assert_eq!(decision.verdict, write_gate::Verdict::Deny);
560        assert_eq!(resolved_folder_id, None);
561        assert!(requires_lease, "no decided_by defaults to requiring one");
562    }
563
564    #[tokio::test]
565    async fn resolve_decision_for_parents_single_parent_reports_its_folder_id() {
566        let server = wiremock::MockServer::start().await;
567        let client = client_with_bootstrapped_token(&server).await;
568        mount_folder("parent-1", None).mount(&server).await;
569        let files_api = FilesApi::new(&client);
570        let rules = [FolderPermissionRule {
571            folder_id: Some("parent-1".to_string()),
572            file_id: None,
573            recursive: false,
574            allow: std::iter::once(DriveOperation::Edit).collect(),
575            deny: std::collections::HashSet::default(),
576            require_lease: true,
577        }];
578
579        let (decision, resolved_folder_id, _requires_lease) = resolve_decision_for_parents(
580            &files_api,
581            &["parent-1".to_string()],
582            DriveOperation::Edit,
583            &rules,
584        )
585        .await
586        .unwrap();
587        assert_eq!(decision.verdict, write_gate::Verdict::Allow);
588        assert_eq!(resolved_folder_id, Some("parent-1".to_string()));
589    }
590
591    #[tokio::test]
592    async fn resolve_decision_for_parents_deny_wins_across_parents_and_reports_no_single_folder() {
593        let server = wiremock::MockServer::start().await;
594        let client = client_with_bootstrapped_token(&server).await;
595        mount_folder("allow-parent", None).mount(&server).await;
596        mount_folder("deny-parent", None).mount(&server).await;
597        let files_api = FilesApi::new(&client);
598        let rules = [
599            FolderPermissionRule {
600                folder_id: Some("allow-parent".to_string()),
601                file_id: None,
602                recursive: false,
603                allow: std::iter::once(DriveOperation::Edit).collect(),
604                deny: std::collections::HashSet::default(),
605                require_lease: true,
606            },
607            FolderPermissionRule {
608                folder_id: Some("deny-parent".to_string()),
609                file_id: None,
610                recursive: false,
611                allow: std::collections::HashSet::default(),
612                deny: std::iter::once(DriveOperation::Edit).collect(),
613                require_lease: true,
614            },
615        ];
616
617        let (decision, resolved_folder_id, _requires_lease) = resolve_decision_for_parents(
618            &files_api,
619            &["allow-parent".to_string(), "deny-parent".to_string()],
620            DriveOperation::Edit,
621            &rules,
622        )
623        .await
624        .unwrap();
625        assert_eq!(decision.verdict, write_gate::Verdict::Deny);
626        assert_eq!(resolved_folder_id, None);
627    }
628
629    #[tokio::test]
630    async fn resolve_decision_for_parents_lease_requirement_is_the_or_of_every_parent_even_though_only_one_decides_the_verdict(
631    ) {
632        // Regression test: `combine_across_parents` keeps only the winning
633        // parent's `decided_by` (issue #1664's multi-parent lease-bypass
634        // fix). Here both parents Allow — `lenient-parent` opts out of the
635        // lease and, because `combine_across_parents` folds left-to-right
636        // and keeps the *later* decision on an Allow/Allow tie, its
637        // decision is what the combined `Decision` reports. `strict-parent`
638        // still requires a lease, and it must not be silently dropped.
639        let server = wiremock::MockServer::start().await;
640        let client = client_with_bootstrapped_token(&server).await;
641        mount_folder("strict-parent", None).mount(&server).await;
642        mount_folder("lenient-parent", None).mount(&server).await;
643        let files_api = FilesApi::new(&client);
644        let rules = [
645            FolderPermissionRule::folder("strict-parent").allowing([DriveOperation::Edit]),
646            FolderPermissionRule::folder("lenient-parent")
647                .allowing([DriveOperation::Edit])
648                .requiring_lease(false),
649        ];
650
651        let (decision, _resolved_folder_id, requires_lease) = resolve_decision_for_parents(
652            &files_api,
653            &["strict-parent".to_string(), "lenient-parent".to_string()],
654            DriveOperation::Edit,
655            &rules,
656        )
657        .await
658        .unwrap();
659        assert_eq!(decision.verdict, write_gate::Verdict::Allow);
660        assert!(
661            requires_lease,
662            "strict-parent's lease requirement must survive even though \
663             lenient-parent's decision is the one the combined verdict reports"
664        );
665    }
666
667    // ── file targets (issue #1612) ────────────────────────────────────
668
669    fn target_file(id: &str, parents: &[&str]) -> DriveFile {
670        DriveFile {
671            id: id.to_string(),
672            name: format!("{id}-name"),
673            parents: parents.iter().map(|p| (*p).to_string()).collect(),
674            ..DriveFile::default()
675        }
676    }
677
678    #[tokio::test]
679    async fn a_file_rule_decides_without_fetching_any_parent() {
680        // The load-bearing test for issue #1612. The target names a parent
681        // that is deliberately NOT mounted: if the walk happens at all,
682        // wiremock panics with "no matching mock". That is what makes the
683        // short-circuit an assertion rather than a claim — and it is the
684        // same property that lets a link-shared file with *no* visible
685        // parent be granted.
686        let server = wiremock::MockServer::start().await;
687        let client = client_with_bootstrapped_token(&server).await;
688        let files_api = FilesApi::new(&client);
689        let rules = [FolderPermissionRule::file("target").allowing([DriveOperation::SheetsWrite])];
690
691        let evaluated = resolve_decision_for_file_target(
692            &files_api,
693            &target_file("target", &["never-mounted"]),
694            DriveOperation::SheetsWrite,
695            &rules,
696        )
697        .await
698        .unwrap();
699
700        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
701        assert_eq!(evaluated.source, DecisionSource::FileRule);
702        assert_eq!(evaluated.resolved_folder_id, None);
703    }
704
705    #[tokio::test]
706    async fn a_parentless_target_with_no_file_rule_reports_no_visible_parents() {
707        let server = wiremock::MockServer::start().await;
708        let client = client_with_bootstrapped_token(&server).await;
709        let files_api = FilesApi::new(&client);
710
711        let evaluated = resolve_decision_for_file_target(
712            &files_api,
713            &target_file("orphan", &[]),
714            DriveOperation::SheetsWrite,
715            &[],
716        )
717        .await
718        .unwrap();
719
720        assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
721        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Deny);
722        assert_eq!(evaluated.decision.decided_by, None);
723    }
724
725    #[tokio::test]
726    async fn no_visible_parents_still_allows_read_by_default_policy() {
727        // `NoVisibleParents` is a *why*, not a verdict: forcing it to deny
728        // would break reading a shared file, which defaults to allow.
729        let server = wiremock::MockServer::start().await;
730        let client = client_with_bootstrapped_token(&server).await;
731        let files_api = FilesApi::new(&client);
732
733        let evaluated = resolve_decision_for_file_target(
734            &files_api,
735            &target_file("orphan", &[]),
736            DriveOperation::Read,
737            &[],
738        )
739        .await
740        .unwrap();
741
742        assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
743        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
744    }
745
746    #[tokio::test]
747    async fn a_file_allow_beats_a_denying_parent() {
748        // `combine_across_parents`' deny-wins is a tie-break among peers.
749        // A file rule is not a peer — it sits at depth −1 — so it wins.
750        let server = wiremock::MockServer::start().await;
751        let client = client_with_bootstrapped_token(&server).await;
752        let files_api = FilesApi::new(&client);
753        let rules = [
754            FolderPermissionRule::folder("deny-parent").denying([DriveOperation::Edit]),
755            FolderPermissionRule::file("target").allowing([DriveOperation::Edit]),
756        ];
757
758        let evaluated = resolve_decision_for_file_target(
759            &files_api,
760            &target_file("target", &["deny-parent"]),
761            DriveOperation::Edit,
762            &rules,
763        )
764        .await
765        .unwrap();
766
767        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
768        assert_eq!(evaluated.source, DecisionSource::FileRule);
769    }
770
771    #[tokio::test]
772    async fn a_target_with_parents_and_no_file_rule_walks_the_chain_as_before() {
773        let server = wiremock::MockServer::start().await;
774        let client = client_with_bootstrapped_token(&server).await;
775        mount_folder("parent", None).mount(&server).await;
776        let files_api = FilesApi::new(&client);
777        let rules = [FolderPermissionRule::folder("parent").allowing([DriveOperation::Edit])];
778
779        let evaluated = resolve_decision_for_file_target(
780            &files_api,
781            &target_file("target", &["parent"]),
782            DriveOperation::Edit,
783            &rules,
784        )
785        .await
786        .unwrap();
787
788        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
789        assert_eq!(evaluated.source, DecisionSource::FolderChain);
790        assert_eq!(evaluated.resolved_folder_id.as_deref(), Some("parent"));
791    }
792}