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` —
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". Every current caller of
194    /// [`resolve_decision_for_file_target`] passes a lease-eligible `op`
195    /// hardcoded at the call site, so this hasn't mattered yet — but
196    /// `drive permissions check`, whose `op` is caller-supplied, applies
197    /// `ever_requires_lease` on top of this field rather than trusting
198    /// it alone. A future caller that also accepts an arbitrary `op`
199    /// must do the same.
200    pub requires_lease: bool,
201}
202
203/// Resolves `op` for a file target whose metadata the caller already has.
204///
205/// **This is the only entry point for a file target**, and the reason
206/// `resolve_decision_for_parents` below is private rather than `pub`: this
207/// function owns the ordering — a `file_id` rule first, then the
208/// current-parent chains, then the no-visible-parents case — so `drive
209/// sheets write`, `drive edit` and `drive permissions check` cannot drift
210/// apart on it.
211///
212/// The short-circuit is exact rather than an optimization: a file rule
213/// sits at depth −1, so no folder rule at any depth of any parent chain
214/// can beat it, and skipping the walk cannot change the verdict. That is
215/// the same argument that already licenses [`resolve_decision_from`]'s
216/// early return — and it is what makes a file **shared by link or email**
217/// grantable at all, since such a target has no chain to walk.
218///
219/// The fetch-failure-is-always-`Err` invariant [`resolve_ancestor_chain`]
220/// documents applies to the chain-walking branch.
221pub async fn resolve_decision_for_file_target(
222    files_api: &FilesApi<'_>,
223    target: &DriveFile,
224    op: DriveOperation,
225    rules: &[FolderPermissionRule],
226) -> Result<FileTargetDecision> {
227    if let Some(decision) = write_gate::resolve_file_rule(&target.id, op, rules) {
228        let requires_lease =
229            write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
230        return Ok(FileTargetDecision {
231            decision,
232            resolved_folder_id: None,
233            source: DecisionSource::FileRule,
234            requires_lease,
235        });
236    }
237    if target.parents.is_empty() {
238        let decision = write_gate::resolve(&[], op, rules);
239        let requires_lease =
240            write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
241        return Ok(FileTargetDecision {
242            decision,
243            resolved_folder_id: None,
244            source: DecisionSource::NoVisibleParents,
245            requires_lease,
246        });
247    }
248    let (decision, resolved_folder_id, requires_lease) =
249        resolve_decision_for_parents(files_api, &target.parents, op, rules).await?;
250    Ok(FileTargetDecision {
251        decision,
252        resolved_folder_id,
253        source: DecisionSource::FolderChain,
254        requires_lease,
255    })
256}
257
258/// Resolves `op` against `parents` (a target's *current* parent ids).
259///
260/// Combines the per-parent [`Decision`]s via
261/// [`write_gate::combine_across_parents`] for a legacy multi-parent target
262/// (deny wins across parents). Also returns the single resolved folder id
263/// when there's exactly one parent — `None` for an orphan target or a
264/// multi-parent target, where no single folder id would be accurate.
265///
266/// Also returns whether the combined decision needs a lease, computed as
267/// the OR of **every** parent's own `decided_rule_requires_lease` result —
268/// each evaluated against that parent's own `decided_by`, before
269/// [`write_gate::combine_across_parents`] discards every `decided_by` but
270/// the winning one. Re-deriving the requirement from only the combined
271/// `Decision` afterward would silently drop it whenever the surviving
272/// parent's rule happened to opt out, even though a losing (but still
273/// `Allow`) parent's rule required one — the safe direction this gate takes
274/// everywhere else.
275///
276/// **Private on purpose** (issue #1612): every file target must go through
277/// [`resolve_decision_for_file_target`], which consults `file_id` rules
278/// first. A caller reaching this primitive directly would skip that step
279/// and silently refuse a file that was explicitly granted — so the
280/// restriction is a compile error rather than a convention.
281async fn resolve_decision_for_parents(
282    files_api: &FilesApi<'_>,
283    parents: &[String],
284    op: DriveOperation,
285    rules: &[FolderPermissionRule],
286) -> Result<(Decision, Option<String>, bool)> {
287    let Some((first_parent, rest_parents)) = parents.split_first() else {
288        let decision = write_gate::resolve(&[], op, rules);
289        let requires_lease =
290            write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
291        return Ok((decision, None, requires_lease));
292    };
293    let mut combined = resolve_decision(files_api, first_parent, op, rules).await?;
294    let mut requires_lease =
295        write_gate::decided_rule_requires_lease(combined.decided_by.as_ref(), op, rules);
296    for parent_id in rest_parents {
297        let decision = resolve_decision(files_api, parent_id, op, rules).await?;
298        requires_lease |=
299            write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
300        combined = write_gate::combine_across_parents(combined, [decision]);
301    }
302    let resolved_folder_id = rest_parents.is_empty().then(|| first_parent.clone());
303    Ok((combined, resolved_folder_id, requires_lease))
304}
305
306#[cfg(test)]
307#[allow(clippy::unwrap_used, clippy::expect_used)]
308mod tests {
309    use super::*;
310    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
311    use crate::drive::client::DriveClient;
312    use crate::utils::secret::Secret;
313
314    fn test_credentials() -> DriveCredentials {
315        DriveCredentials {
316            client_id: "client-1".to_string(),
317            client_secret: Secret::new("secret-1"),
318            refresh_token: Secret::new("refresh-1"),
319            scope: DriveGrantedScopes::READONLY,
320        }
321    }
322
323    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
324        wiremock::Mock::given(wiremock::matchers::method("POST"))
325            .and(wiremock::matchers::path("/token"))
326            .respond_with(
327                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
328                    "access_token": "test-token",
329                    "expires_in": 3600,
330                })),
331            )
332            .mount(server)
333            .await;
334
335        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
336        crate::drive::client::test_support::replace_session(
337            &mut client,
338            &test_credentials(),
339            &format!("{}/token", server.uri()),
340        );
341        client
342    }
343
344    fn mount_folder(id: &str, parent: Option<&str>) -> wiremock::Mock {
345        let parents: Vec<&str> = parent.into_iter().collect();
346        wiremock::Mock::given(wiremock::matchers::method("GET"))
347            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
348            .respond_with(
349                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
350                    "id": id,
351                    "name": id,
352                    "mimeType": "application/vnd.google-apps.folder",
353                    "parents": parents,
354                })),
355            )
356    }
357
358    #[tokio::test]
359    async fn resolves_a_single_folder_with_no_parent_as_a_one_element_chain() {
360        let server = wiremock::MockServer::start().await;
361        let client = client_with_bootstrapped_token(&server).await;
362        mount_folder("root", None).mount(&server).await;
363        let files_api = FilesApi::new(&client);
364
365        let chain = resolve_ancestor_chain(&files_api, "root").await.unwrap();
366        assert_eq!(chain.folder_ids(), vec!["root".to_string()]);
367    }
368
369    #[tokio::test]
370    async fn walks_the_full_ancestor_chain_via_files_get() {
371        let server = wiremock::MockServer::start().await;
372        let client = client_with_bootstrapped_token(&server).await;
373        mount_folder("child", Some("parent")).mount(&server).await;
374        mount_folder("parent", Some("grandparent"))
375            .mount(&server)
376            .await;
377        mount_folder("grandparent", None).mount(&server).await;
378        let files_api = FilesApi::new(&client);
379
380        let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
381        assert_eq!(
382            chain.folder_ids(),
383            vec![
384                "child".to_string(),
385                "parent".to_string(),
386                "grandparent".to_string(),
387            ]
388        );
389    }
390
391    #[tokio::test]
392    async fn legacy_multi_parent_folder_walks_the_first_parent_only() {
393        let server = wiremock::MockServer::start().await;
394        let client = client_with_bootstrapped_token(&server).await;
395        wiremock::Mock::given(wiremock::matchers::method("GET"))
396            .and(wiremock::matchers::path("/drive/v3/files/child"))
397            .respond_with(
398                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
399                    "id": "child",
400                    "name": "child",
401                    "mimeType": "application/vnd.google-apps.folder",
402                    "parents": ["first-parent", "second-parent"],
403                })),
404            )
405            .mount(&server)
406            .await;
407        mount_folder("first-parent", None).mount(&server).await;
408        // Deliberately no mock for "second-parent" — asserting it's never
409        // fetched proves the documented v1 boundary (first-parent-only).
410        let files_api = FilesApi::new(&client);
411
412        let chain = resolve_ancestor_chain(&files_api, "child").await.unwrap();
413        assert_eq!(
414            chain.folder_ids(),
415            vec!["child".to_string(), "first-parent".to_string()]
416        );
417    }
418
419    /// The single highest-value test in this module (mirrors ADR-0070 §3's
420    /// `permissions.list`-failure precedent): a fetch failure mid-walk must
421    /// be an explicit `Err`, never a chain silently truncated at the point
422    /// of failure — a truncated chain could hide a deny/allow rule
423    /// configured on an ancestor above the failure point, manufacturing a
424    /// false "no rule applies here" reading.
425    #[tokio::test]
426    async fn ancestor_chain_fetch_failure_returns_err_not_a_truncated_chain() {
427        let server = wiremock::MockServer::start().await;
428        let client = client_with_bootstrapped_token(&server).await;
429        mount_folder("child", Some("parent")).mount(&server).await;
430        wiremock::Mock::given(wiremock::matchers::method("GET"))
431            .and(wiremock::matchers::path("/drive/v3/files/parent"))
432            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
433            .mount(&server)
434            .await;
435        let files_api = FilesApi::new(&client);
436
437        let result = resolve_ancestor_chain(&files_api, "child").await;
438        assert!(
439            result.is_err(),
440            "a mid-walk fetch failure must be Err, not an Ok chain truncated at \"child\""
441        );
442    }
443
444    #[tokio::test]
445    async fn missing_start_folder_is_an_error() {
446        let server = wiremock::MockServer::start().await;
447        let client = client_with_bootstrapped_token(&server).await;
448        wiremock::Mock::given(wiremock::matchers::method("GET"))
449            .and(wiremock::matchers::path("/drive/v3/files/missing"))
450            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
451            .mount(&server)
452            .await;
453        let files_api = FilesApi::new(&client);
454
455        let result = resolve_ancestor_chain(&files_api, "missing").await;
456        assert!(result.is_err());
457    }
458
459    // ── resolve_decision / resolve_decision_from ───────────────────────
460
461    #[tokio::test]
462    async fn resolve_decision_stops_walking_once_a_rule_decides_it() {
463        let server = wiremock::MockServer::start().await;
464        let client = client_with_bootstrapped_token(&server).await;
465        mount_folder("child", Some("parent")).mount(&server).await;
466        // Deliberately no mock for "parent" — "child" already has a
467        // matching rule, so a correctly short-circuiting walk never fetches
468        // it; wiremock panics with "no matching mock" if it does.
469        let files_api = FilesApi::new(&client);
470        let rules = [FolderPermissionRule {
471            folder_id: Some("child".to_string()),
472            file_id: None,
473            recursive: false,
474            allow: std::iter::once(DriveOperation::Create).collect(),
475            deny: std::collections::HashSet::default(),
476            require_lease: true,
477        }];
478
479        let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &rules)
480            .await
481            .unwrap();
482        assert_eq!(decision.verdict, write_gate::Verdict::Allow);
483    }
484
485    #[tokio::test]
486    async fn resolve_decision_walks_to_root_when_nothing_matches() {
487        let server = wiremock::MockServer::start().await;
488        let client = client_with_bootstrapped_token(&server).await;
489        mount_folder("child", Some("parent")).mount(&server).await;
490        mount_folder("parent", Some("grandparent"))
491            .mount(&server)
492            .await;
493        mount_folder("grandparent", None).mount(&server).await;
494        let files_api = FilesApi::new(&client);
495
496        let decision = resolve_decision(&files_api, "child", DriveOperation::Create, &[])
497            .await
498            .unwrap();
499        assert_eq!(decision.verdict, write_gate::Verdict::Deny);
500        assert_eq!(decision.decided_by, None);
501    }
502
503    #[tokio::test]
504    async fn resolve_decision_fetch_failure_returns_err_not_allow() {
505        let server = wiremock::MockServer::start().await;
506        let client = client_with_bootstrapped_token(&server).await;
507        mount_folder("child", Some("parent")).mount(&server).await;
508        wiremock::Mock::given(wiremock::matchers::method("GET"))
509            .and(wiremock::matchers::path("/drive/v3/files/parent"))
510            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
511            .mount(&server)
512            .await;
513        let files_api = FilesApi::new(&client);
514
515        let result = resolve_decision(&files_api, "child", DriveOperation::Create, &[]).await;
516        assert!(result.is_err());
517    }
518
519    #[tokio::test]
520    async fn resolve_decision_from_never_refetches_the_supplied_start() {
521        let server = wiremock::MockServer::start().await;
522        let client = client_with_bootstrapped_token(&server).await;
523        // Deliberately no mock at all — proves resolve_decision_from never
524        // issues a files.get for the folder its caller already fetched.
525        let files_api = FilesApi::new(&client);
526        let start = DriveFile {
527            id: "child".to_string(),
528            name: "child".to_string(),
529            mime_type: "application/vnd.google-apps.folder".to_string(),
530            parents: vec![],
531            ..Default::default()
532        };
533        let rules = [FolderPermissionRule {
534            folder_id: Some("child".to_string()),
535            file_id: None,
536            recursive: false,
537            allow: std::iter::once(DriveOperation::Read).collect(),
538            deny: std::collections::HashSet::default(),
539            require_lease: true,
540        }];
541
542        let decision = resolve_decision_from(&files_api, start, DriveOperation::Read, &rules)
543            .await
544            .unwrap();
545        assert_eq!(decision.verdict, write_gate::Verdict::Allow);
546    }
547
548    // ── resolve_decision_for_parents ────────────────────────────────────
549
550    #[tokio::test]
551    async fn resolve_decision_for_parents_empty_parents_uses_default_policy() {
552        let server = wiremock::MockServer::start().await;
553        let client = client_with_bootstrapped_token(&server).await;
554        let files_api = FilesApi::new(&client);
555
556        let (decision, resolved_folder_id, requires_lease) =
557            resolve_decision_for_parents(&files_api, &[], DriveOperation::Edit, &[])
558                .await
559                .unwrap();
560        assert_eq!(decision.verdict, write_gate::Verdict::Deny);
561        assert_eq!(resolved_folder_id, None);
562        assert!(requires_lease, "no decided_by defaults to requiring one");
563    }
564
565    #[tokio::test]
566    async fn resolve_decision_for_parents_single_parent_reports_its_folder_id() {
567        let server = wiremock::MockServer::start().await;
568        let client = client_with_bootstrapped_token(&server).await;
569        mount_folder("parent-1", None).mount(&server).await;
570        let files_api = FilesApi::new(&client);
571        let rules = [FolderPermissionRule {
572            folder_id: Some("parent-1".to_string()),
573            file_id: None,
574            recursive: false,
575            allow: std::iter::once(DriveOperation::Edit).collect(),
576            deny: std::collections::HashSet::default(),
577            require_lease: true,
578        }];
579
580        let (decision, resolved_folder_id, _requires_lease) = resolve_decision_for_parents(
581            &files_api,
582            &["parent-1".to_string()],
583            DriveOperation::Edit,
584            &rules,
585        )
586        .await
587        .unwrap();
588        assert_eq!(decision.verdict, write_gate::Verdict::Allow);
589        assert_eq!(resolved_folder_id, Some("parent-1".to_string()));
590    }
591
592    #[tokio::test]
593    async fn resolve_decision_for_parents_deny_wins_across_parents_and_reports_no_single_folder() {
594        let server = wiremock::MockServer::start().await;
595        let client = client_with_bootstrapped_token(&server).await;
596        mount_folder("allow-parent", None).mount(&server).await;
597        mount_folder("deny-parent", None).mount(&server).await;
598        let files_api = FilesApi::new(&client);
599        let rules = [
600            FolderPermissionRule {
601                folder_id: Some("allow-parent".to_string()),
602                file_id: None,
603                recursive: false,
604                allow: std::iter::once(DriveOperation::Edit).collect(),
605                deny: std::collections::HashSet::default(),
606                require_lease: true,
607            },
608            FolderPermissionRule {
609                folder_id: Some("deny-parent".to_string()),
610                file_id: None,
611                recursive: false,
612                allow: std::collections::HashSet::default(),
613                deny: std::iter::once(DriveOperation::Edit).collect(),
614                require_lease: true,
615            },
616        ];
617
618        let (decision, resolved_folder_id, _requires_lease) = resolve_decision_for_parents(
619            &files_api,
620            &["allow-parent".to_string(), "deny-parent".to_string()],
621            DriveOperation::Edit,
622            &rules,
623        )
624        .await
625        .unwrap();
626        assert_eq!(decision.verdict, write_gate::Verdict::Deny);
627        assert_eq!(resolved_folder_id, None);
628    }
629
630    #[tokio::test]
631    async fn resolve_decision_for_parents_lease_requirement_is_the_or_of_every_parent_even_though_only_one_decides_the_verdict(
632    ) {
633        // Regression test: `combine_across_parents` keeps only the winning
634        // parent's `decided_by` (issue #1664's multi-parent lease-bypass
635        // fix). Here both parents Allow — `lenient-parent` opts out of the
636        // lease and, because `combine_across_parents` folds left-to-right
637        // and keeps the *later* decision on an Allow/Allow tie, its
638        // decision is what the combined `Decision` reports. `strict-parent`
639        // still requires a lease, and it must not be silently dropped.
640        let server = wiremock::MockServer::start().await;
641        let client = client_with_bootstrapped_token(&server).await;
642        mount_folder("strict-parent", None).mount(&server).await;
643        mount_folder("lenient-parent", None).mount(&server).await;
644        let files_api = FilesApi::new(&client);
645        let rules = [
646            FolderPermissionRule::folder("strict-parent").allowing([DriveOperation::Edit]),
647            FolderPermissionRule::folder("lenient-parent")
648                .allowing([DriveOperation::Edit])
649                .requiring_lease(false),
650        ];
651
652        let (decision, _resolved_folder_id, requires_lease) = resolve_decision_for_parents(
653            &files_api,
654            &["strict-parent".to_string(), "lenient-parent".to_string()],
655            DriveOperation::Edit,
656            &rules,
657        )
658        .await
659        .unwrap();
660        assert_eq!(decision.verdict, write_gate::Verdict::Allow);
661        assert!(
662            requires_lease,
663            "strict-parent's lease requirement must survive even though \
664             lenient-parent's decision is the one the combined verdict reports"
665        );
666    }
667
668    // ── file targets (issue #1612) ────────────────────────────────────
669
670    fn target_file(id: &str, parents: &[&str]) -> DriveFile {
671        DriveFile {
672            id: id.to_string(),
673            name: format!("{id}-name"),
674            parents: parents.iter().map(|p| (*p).to_string()).collect(),
675            ..DriveFile::default()
676        }
677    }
678
679    #[tokio::test]
680    async fn a_file_rule_decides_without_fetching_any_parent() {
681        // The load-bearing test for issue #1612. The target names a parent
682        // that is deliberately NOT mounted: if the walk happens at all,
683        // wiremock panics with "no matching mock". That is what makes the
684        // short-circuit an assertion rather than a claim — and it is the
685        // same property that lets a link-shared file with *no* visible
686        // parent be granted.
687        let server = wiremock::MockServer::start().await;
688        let client = client_with_bootstrapped_token(&server).await;
689        let files_api = FilesApi::new(&client);
690        let rules = [FolderPermissionRule::file("target").allowing([DriveOperation::SheetsWrite])];
691
692        let evaluated = resolve_decision_for_file_target(
693            &files_api,
694            &target_file("target", &["never-mounted"]),
695            DriveOperation::SheetsWrite,
696            &rules,
697        )
698        .await
699        .unwrap();
700
701        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
702        assert_eq!(evaluated.source, DecisionSource::FileRule);
703        assert_eq!(evaluated.resolved_folder_id, None);
704    }
705
706    #[tokio::test]
707    async fn a_parentless_target_with_no_file_rule_reports_no_visible_parents() {
708        let server = wiremock::MockServer::start().await;
709        let client = client_with_bootstrapped_token(&server).await;
710        let files_api = FilesApi::new(&client);
711
712        let evaluated = resolve_decision_for_file_target(
713            &files_api,
714            &target_file("orphan", &[]),
715            DriveOperation::SheetsWrite,
716            &[],
717        )
718        .await
719        .unwrap();
720
721        assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
722        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Deny);
723        assert_eq!(evaluated.decision.decided_by, None);
724    }
725
726    #[tokio::test]
727    async fn no_visible_parents_still_allows_read_by_default_policy() {
728        // `NoVisibleParents` is a *why*, not a verdict: forcing it to deny
729        // would break reading a shared file, which defaults to allow.
730        let server = wiremock::MockServer::start().await;
731        let client = client_with_bootstrapped_token(&server).await;
732        let files_api = FilesApi::new(&client);
733
734        let evaluated = resolve_decision_for_file_target(
735            &files_api,
736            &target_file("orphan", &[]),
737            DriveOperation::Read,
738            &[],
739        )
740        .await
741        .unwrap();
742
743        assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
744        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
745    }
746
747    #[tokio::test]
748    async fn a_file_allow_beats_a_denying_parent() {
749        // `combine_across_parents`' deny-wins is a tie-break among peers.
750        // A file rule is not a peer — it sits at depth −1 — so it wins.
751        let server = wiremock::MockServer::start().await;
752        let client = client_with_bootstrapped_token(&server).await;
753        let files_api = FilesApi::new(&client);
754        let rules = [
755            FolderPermissionRule::folder("deny-parent").denying([DriveOperation::Edit]),
756            FolderPermissionRule::file("target").allowing([DriveOperation::Edit]),
757        ];
758
759        let evaluated = resolve_decision_for_file_target(
760            &files_api,
761            &target_file("target", &["deny-parent"]),
762            DriveOperation::Edit,
763            &rules,
764        )
765        .await
766        .unwrap();
767
768        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
769        assert_eq!(evaluated.source, DecisionSource::FileRule);
770    }
771
772    #[tokio::test]
773    async fn a_target_with_parents_and_no_file_rule_walks_the_chain_as_before() {
774        let server = wiremock::MockServer::start().await;
775        let client = client_with_bootstrapped_token(&server).await;
776        mount_folder("parent", None).mount(&server).await;
777        let files_api = FilesApi::new(&client);
778        let rules = [FolderPermissionRule::folder("parent").allowing([DriveOperation::Edit])];
779
780        let evaluated = resolve_decision_for_file_target(
781            &files_api,
782            &target_file("target", &["parent"]),
783            DriveOperation::Edit,
784            &rules,
785        )
786        .await
787        .unwrap();
788
789        assert_eq!(evaluated.decision.verdict, write_gate::Verdict::Allow);
790        assert_eq!(evaluated.source, DecisionSource::FolderChain);
791        assert_eq!(evaluated.resolved_folder_id.as_deref(), Some("parent"));
792    }
793}